diff --git a/.editorconfig b/.editorconfig index a52bde9ce..d2207b9e5 100644 --- a/.editorconfig +++ b/.editorconfig @@ -4,23 +4,88 @@ root = true insert_final_newline = true [*.{kt,kts}] -ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL -# Disable wildcard imports entirely -ij_kotlin_name_count_to_use_star_import = 2147483647 -ij_kotlin_name_count_to_use_star_import_for_members = 2147483647 -ij_kotlin_packages_to_use_import_on_demand = unset +indent_style = space max_line_length = 100 -ktlint_code_style = android_studio -ktlint_standard = enabled -ktlint_experimental = disabled -ktlint_standard_wrapping = disabled -ktlint_standard_argument-list-wrapping = disabled -ktlint_standard_import-ordering = disabled -ktlint_standard_multiline-if-else = disabled -ktlint_standard_trailing-comma-on-call-site = disabled -ktlint_standard_trailing-comma-on-declaration-site = disabled -ktlint_standard_no-blank-line-before-rbrace = disabled -ktlint_standard_function-expression-body = disabled -ktlint_standard_class-signature = disabled -ktlint_standard_function-naming = disabled # for compose only -ktlint_standard_function-signature = disabled \ No newline at end of file +indent_size = 2 +ij_continuation_indent_size = 2 +ij_java_names_count_to_use_import_on_demand = 9999 +ij_kotlin_align_in_columns_case_branch = false +ij_kotlin_align_multiline_binary_operation = false +ij_kotlin_align_multiline_extends_list = false +ij_kotlin_align_multiline_method_parentheses = false +ij_kotlin_align_multiline_parameters = true +ij_kotlin_align_multiline_parameters_in_calls = false +ij_kotlin_allow_trailing_comma = true +ij_kotlin_allow_trailing_comma_on_call_site = true +ij_kotlin_assignment_wrap = normal +ij_kotlin_blank_lines_after_class_header = 0 +ij_kotlin_blank_lines_around_block_when_branches = 0 +ij_kotlin_blank_lines_before_declaration_with_comment_or_annotation_on_separate_line = 1 +ij_kotlin_block_comment_at_first_column = true +ij_kotlin_call_parameters_new_line_after_left_paren = true +ij_kotlin_call_parameters_right_paren_on_new_line = false +ij_kotlin_call_parameters_wrap = on_every_item +ij_kotlin_catch_on_new_line = false +ij_kotlin_class_annotation_wrap = split_into_lines +ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL +ij_kotlin_continuation_indent_for_chained_calls = true +ij_kotlin_continuation_indent_for_expression_bodies = true +ij_kotlin_continuation_indent_in_argument_lists = true +ij_kotlin_continuation_indent_in_elvis = false +ij_kotlin_continuation_indent_in_if_conditions = false +ij_kotlin_continuation_indent_in_parameter_lists = false +ij_kotlin_continuation_indent_in_supertype_lists = false +ij_kotlin_else_on_new_line = false +ij_kotlin_enum_constants_wrap = off +ij_kotlin_extends_list_wrap = normal +ij_kotlin_field_annotation_wrap = split_into_lines +ij_kotlin_finally_on_new_line = false +ij_kotlin_if_rparen_on_new_line = false +ij_kotlin_import_nested_classes = false +ij_kotlin_imports_layout = * +ij_kotlin_insert_whitespaces_in_simple_one_line_method = true +ij_kotlin_keep_blank_lines_before_right_brace = 2 +ij_kotlin_keep_blank_lines_in_code = 2 +ij_kotlin_keep_blank_lines_in_declarations = 2 +ij_kotlin_keep_first_column_comment = true +ij_kotlin_keep_indents_on_empty_lines = false +ij_kotlin_keep_line_breaks = true +ij_kotlin_lbrace_on_next_line = false +ij_kotlin_line_comment_add_space = false +ij_kotlin_line_comment_at_first_column = true +ij_kotlin_method_annotation_wrap = split_into_lines +ij_kotlin_method_call_chain_wrap = normal +ij_kotlin_method_parameters_new_line_after_left_paren = true +ij_kotlin_method_parameters_right_paren_on_new_line = true +ij_kotlin_method_parameters_wrap = on_every_item +ij_kotlin_name_count_to_use_star_import = 9999 +ij_kotlin_name_count_to_use_star_import_for_members = 9999 +ij_kotlin_parameter_annotation_wrap = off +ij_kotlin_space_after_comma = true +ij_kotlin_space_after_extend_colon = true +ij_kotlin_space_after_type_colon = true +ij_kotlin_space_before_catch_parentheses = true +ij_kotlin_space_before_comma = false +ij_kotlin_space_before_extend_colon = true +ij_kotlin_space_before_for_parentheses = true +ij_kotlin_space_before_if_parentheses = true +ij_kotlin_space_before_lambda_arrow = true +ij_kotlin_space_before_type_colon = false +ij_kotlin_space_before_when_parentheses = true +ij_kotlin_space_before_while_parentheses = true +ij_kotlin_spaces_around_additive_operators = true +ij_kotlin_spaces_around_assignment_operators = true +ij_kotlin_spaces_around_equality_operators = true +ij_kotlin_spaces_around_function_type_arrow = true +ij_kotlin_spaces_around_logical_operators = true +ij_kotlin_spaces_around_multiplicative_operators = true +ij_kotlin_spaces_around_range = false +ij_kotlin_spaces_around_relational_operators = true +ij_kotlin_spaces_around_unary_operator = false +ij_kotlin_spaces_around_when_arrow = true +ij_kotlin_variable_annotation_wrap = off +ij_kotlin_while_on_new_line = false +ij_kotlin_wrap_elvis_expressions = 1 +ij_kotlin_wrap_expression_body_functions = 1 +ij_kotlin_wrap_first_method_in_call_chain = false +ktfmt_trailing_comma_management_strategy = complete diff --git a/.gitignore b/.gitignore index 214d63f80..3862f236a 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ proguard/ # More IDE stuff .idea/* !.idea/icon.svg +!.idea/ktfmt.xml *.iml out .settings/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8ccc5ad06..44cce8033 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -152,7 +152,7 @@ app lint: # always report on lint errors to the build log - sed -i -e 's,textReport .*,textReport true,' legacy/build.gradle # the tasks "lint", "test", etc don't always include everything - - ./gradlew :app:lint :app:ktlintCheck :legacy:lint :legacy:ktlintCheck + - ./gradlew :app:lint :app:ktfmtCheck :legacy:lint legacy checkstyle: <<: *test-template @@ -168,7 +168,7 @@ legacy checkstyle: reports: codequality: gl-checkstyle.json -libs lint ktlintCheck: +libs lint: <<: *test-template stage: lint rules: @@ -176,7 +176,10 @@ libs lint ktlintCheck: - changes: - libs/**/* script: - - ./gradlew :libs:database:lint :libs:download:lint :libs:index:lint :libs:ktlintCheck checkLegacyAbi + - ./gradlew \ + :libs:core:lint :libs:database:lint :libs:download:lint :libs:index:lint :libs:sharedTest:lint \ + :libs:core:ktfmtCheck :libs:database:ktfmtCheck :libs:download:ktfmtCheck :libs:index:ktfmtCheck :libs:sharedTest:ktfmtCheck \ + checkLegacyAbi # Reference: https://gitlab.com/components/code-quality-oss/codequality-os-scanners-integration/-/blob/4121970daed111dda84cab4547e1f2951684653c/templates/pmd.yml#L52-92 legacy lint pmd: diff --git a/.idea/ktfmt.xml b/.idea/ktfmt.xml new file mode 100644 index 000000000..78ceb31f0 --- /dev/null +++ b/.idea/ktfmt.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/README.md b/README.md index 500bb87d8..84ba83116 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,13 @@ for Android. You can [download the application](https://f-droid.org/F-Droid.apk) directly from our site or [browse it in the repo](https://f-droid.org/app/org.fdroid.fdroid). +## Coding style + +This project uses [ktfmt](https://github.com/facebook/ktfmt) and enforces it via CI. +You can run the following to auto-format your changes: + + ./gradlew ktfmtFormat + ## Libraries Core F-Droid functionality is split into re-usable libraries diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ef9e470fe..3acafff00 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,187 +1,179 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { - alias(libs.plugins.android.application) - alias(libs.plugins.android.ksp) - alias(libs.plugins.android.hilt) - alias(libs.plugins.jetbrains.kotlin.android) - alias(libs.plugins.jetbrains.kotlin.plugin.serialization) - alias(libs.plugins.jetbrains.compose.compiler) - alias(libs.plugins.screenshot) + alias(libs.plugins.android.application) + alias(libs.plugins.android.ksp) + alias(libs.plugins.android.hilt) + alias(libs.plugins.jetbrains.kotlin.android) + alias(libs.plugins.jetbrains.kotlin.plugin.serialization) + alias(libs.plugins.jetbrains.compose.compiler) + alias(libs.plugins.screenshot) + alias(libs.plugins.ktfmt) } android { - namespace = "org.fdroid" - compileSdk = libs.versions.compileSdk.get().toInt() + namespace = "org.fdroid" + compileSdk = libs.versions.compileSdk.get().toInt() - defaultConfig { - applicationId = "org.fdroid" - minSdk = 24 - targetSdk = 36 - versionCode = 2000004 - versionName = "2.0-alpha4" + defaultConfig { + applicationId = "org.fdroid" + minSdk = 24 + targetSdk = 36 + versionCode = 2000004 + versionName = "2.0-alpha4" - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } - buildTypes { - all { - buildConfigField("String", "ACRA_REPORT_EMAIL", "\"reports@f-droid.org\"") - } - getByName("release") { - isMinifyEnabled = true - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro", - ) - } - getByName("debug") { - applicationIdSuffix = ".debug" - versionNameSuffix = "-debug" - isDebuggable = true - } + buildTypes { + all { buildConfigField("String", "ACRA_REPORT_EMAIL", "\"reports@f-droid.org\"") } + getByName("release") { + isMinifyEnabled = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } - flavorDimensions += listOf("variant", "release") - productFlavors { - create("basic") { - dimension = "variant" - applicationIdSuffix = ".basic" - } - create("full") { - dimension = "variant" - applicationIdSuffix = ".fdroid" - } - create("default") { - dimension = "release" - } - create("nightly") { - dimension = "release" - versionCode = (System.currentTimeMillis() / 1000 / 60).toInt() - versionNameSuffix = "-$gitHash" - applicationIdSuffix = ".nightly" - } + getByName("debug") { + applicationIdSuffix = ".debug" + versionNameSuffix = "-debug" + isDebuggable = true } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + } + flavorDimensions += listOf("variant", "release") + productFlavors { + create("basic") { + dimension = "variant" + applicationIdSuffix = ".basic" } - buildFeatures { - compose = true - buildConfig = true + create("full") { + dimension = "variant" + applicationIdSuffix = ".fdroid" } - packaging { - resources { - excludes += listOf("META-INF/LICENSE.md", "META-INF/LICENSE-notice.md") - } + create("default") { dimension = "release" } + create("nightly") { + dimension = "release" + versionCode = (System.currentTimeMillis() / 1000 / 60).toInt() + versionNameSuffix = "-$gitHash" + applicationIdSuffix = ".nightly" } - lint { - lintConfig = file("lint.xml") - textReport = true - } - @Suppress("UnstableApiUsage") - experimentalProperties["android.experimental.enableScreenshotTest"] = true + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + buildFeatures { + compose = true + buildConfig = true + } + packaging { + resources { excludes += listOf("META-INF/LICENSE.md", "META-INF/LICENSE-notice.md") } + } + lint { + lintConfig = file("lint.xml") + textReport = true + } + @Suppress("UnstableApiUsage") + experimentalProperties["android.experimental.enableScreenshotTest"] = true } androidComponents { - beforeVariants { variantBuilder -> - if (variantBuilder.buildType == "debug" && - variantBuilder.productFlavors.contains("release" to "nightly") - ) { - // no debug builds for nightly version, - // so we can test proguard minification in production - variantBuilder.enable = false - } + beforeVariants { variantBuilder -> + if ( + variantBuilder.buildType == "debug" && + variantBuilder.productFlavors.contains("release" to "nightly") + ) { + // no debug builds for nightly version, + // so we can test proguard minification in production + variantBuilder.enable = false } + } } dependencies { - implementation(project(":libs:index")) - implementation(project(":libs:database")) - implementation(project(":libs:download")) - implementation(libs.kotlinx.serialization.json) - implementation(platform(libs.androidx.compose.bom)) - implementation(libs.androidx.core.ktx) - implementation(libs.androidx.appcompat) - implementation(libs.androidx.lifecycle.runtime.ktx) - implementation(libs.androidx.work.runtime.ktx) - implementation(libs.androidx.hilt.work) - implementation(libs.androidx.activity.compose) - implementation(libs.androidx.ui) - implementation(libs.androidx.ui.graphics) - implementation(libs.androidx.compose.material.icons.extended) - implementation(libs.androidx.lifecycle.viewmodel.compose) - implementation(libs.androidx.compose.material3.adaptive.navigation3) - implementation(libs.androidx.compose.ui.tooling.preview) - implementation(libs.androidx.compose.material3) + implementation(project(":libs:index")) + implementation(project(":libs:database")) + implementation(project(":libs:download")) + implementation(libs.kotlinx.serialization.json) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.work.runtime.ktx) + implementation(libs.androidx.hilt.work) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.compose.material3.adaptive.navigation3) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) - implementation(libs.androidx.navigation3.ui) - implementation(libs.androidx.navigation3.runtime) - implementation(libs.androidx.lifecycle.viewmodel.navigation3) - implementation(libs.kotlinx.serialization.core) + implementation(libs.androidx.navigation3.ui) + implementation(libs.androidx.navigation3.runtime) + implementation(libs.androidx.lifecycle.viewmodel.navigation3) + implementation(libs.kotlinx.serialization.core) - implementation(libs.molecule.runtime) - implementation(libs.coil.compose) - implementation(libs.compose.hints) - implementation(libs.compose.preference) + implementation(libs.molecule.runtime) + implementation(libs.coil.compose) + implementation(libs.compose.hints) + implementation(libs.compose.preference) - implementation(libs.slf4j.api) - implementation(libs.logback.android) - implementation(libs.microutils.kotlin.logging) + implementation(libs.slf4j.api) + implementation(libs.logback.android) + implementation(libs.microutils.kotlin.logging) - implementation(libs.acra.mail) - implementation(libs.acra.dialog) - implementation("com.journeyapps:zxing-android-embedded:4.3.0") { isTransitive = false } - implementation(libs.zxing.core) + implementation(libs.acra.mail) + implementation(libs.acra.dialog) + implementation("com.journeyapps:zxing-android-embedded:4.3.0") { isTransitive = false } + implementation(libs.zxing.core) - implementation(libs.okhttp) + implementation(libs.okhttp) - implementation(libs.hilt.android) - implementation(libs.androidx.hilt.navigation.compose) - ksp(libs.hilt.android.compiler) - ksp(libs.androidx.hilt.compiler) - // https://github.com/google/dagger/issues/5001 - ksp("org.jetbrains.kotlin:kotlin-metadata-jvm:2.3.0") + implementation(libs.hilt.android) + implementation(libs.androidx.hilt.navigation.compose) + ksp(libs.hilt.android.compiler) + ksp(libs.androidx.hilt.compiler) + // https://github.com/google/dagger/issues/5001 + ksp("org.jetbrains.kotlin:kotlin-metadata-jvm:2.3.0") - debugImplementation(libs.androidx.compose.ui.tooling) - debugImplementation(libs.androidx.ui.test.manifest) + debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) - "fullImplementation"(libs.guardianproject.panic) + "fullImplementation"(libs.guardianproject.panic) - testImplementation(libs.junit) - testImplementation(kotlin("test")) - testImplementation(libs.mockk) - testImplementation(libs.robolectric) - testImplementation(libs.slf4j.simple) + testImplementation(libs.junit) + testImplementation(kotlin("test")) + testImplementation(libs.mockk) + testImplementation(libs.robolectric) + testImplementation(libs.slf4j.simple) - androidTestImplementation(libs.kotlin.test) - androidTestImplementation(libs.kotlin.reflect) - androidTestImplementation(libs.androidx.test.ext.junit) - androidTestImplementation(libs.androidx.espresso.core) - androidTestImplementation(platform(libs.androidx.compose.bom)) - androidTestImplementation(libs.androidx.ui.test.junit4) + androidTestImplementation(libs.kotlin.test) + androidTestImplementation(libs.kotlin.reflect) + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) - screenshotTestImplementation(libs.screenshot.validation.api) - screenshotTestImplementation(libs.androidx.ui.tooling) + screenshotTestImplementation(libs.screenshot.validation.api) + screenshotTestImplementation(libs.androidx.ui.tooling) } -kotlin { - compilerOptions { - jvmTarget = JvmTarget.JVM_17 - } -} +kotlin { compilerOptions { jvmTarget = JvmTarget.JVM_17 } } + +ktfmt { googleStyle() } composeCompiler { - reportsDestination = layout.buildDirectory.dir("compose_compiler") - metricsDestination = layout.buildDirectory.dir("compose_compiler") - stabilityConfigurationFiles.add(layout.projectDirectory.file("compose-stability.conf")) + reportsDestination = layout.buildDirectory.dir("compose_compiler") + metricsDestination = layout.buildDirectory.dir("compose_compiler") + stabilityConfigurationFiles.add(layout.projectDirectory.file("compose-stability.conf")) } val gitHash: String - get() { - val process = ProcessBuilder("git", "rev-parse", "--short=8", "HEAD") - .directory(rootDir) - .redirectErrorStream(true) - .start() - process.waitFor() // Ensure the command completes - return process.inputStream.use { it.readBytes().decodeToString().trim() } - } + get() { + val process = + ProcessBuilder("git", "rev-parse", "--short=8", "HEAD") + .directory(rootDir) + .redirectErrorStream(true) + .start() + process.waitFor() // Ensure the command completes + return process.inputStream.use { it.readBytes().decodeToString().trim() } + } diff --git a/app/src/androidTest/java/org/fdroid/database/PrimaryConstructorTest.kt b/app/src/androidTest/java/org/fdroid/database/PrimaryConstructorTest.kt index f3e9d7292..41cc5accd 100644 --- a/app/src/androidTest/java/org/fdroid/database/PrimaryConstructorTest.kt +++ b/app/src/androidTest/java/org/fdroid/database/PrimaryConstructorTest.kt @@ -1,31 +1,31 @@ package org.fdroid.database import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.junit.Test -import org.junit.runner.RunWith import kotlin.reflect.full.primaryConstructor import kotlin.test.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) internal class PrimaryConstructorTest { - private val classes = listOf( - AntiFeature::class, - Category::class, - ReleaseChannel::class, - // recent minification removes the primary constructor of CoreRepository - // so we need to ensure it is still there for our reflection diffing - Class.forName("org.fdroid.database.CoreRepository").kotlin, + private val classes = + listOf( + AntiFeature::class, + Category::class, + ReleaseChannel::class, + // recent minification removes the primary constructor of CoreRepository + // so we need to ensure it is still there for our reflection diffing + Class.forName("org.fdroid.database.CoreRepository").kotlin, ) - @Test - fun testPrimaryConstructor() { - classes.forEach { - assertNotNull( - actual = it.primaryConstructor, - message = "${it.simpleName} has no primary constructor", - ) - } + @Test + fun testPrimaryConstructor() { + classes.forEach { + assertNotNull( + actual = it.primaryConstructor, + message = "${it.simpleName} has no primary constructor", + ) } - + } } diff --git a/app/src/androidTest/java/org/fdroid/download/DnsCacheTest.kt b/app/src/androidTest/java/org/fdroid/download/DnsCacheTest.kt index 1d157b7d4..e0e9973f1 100644 --- a/app/src/androidTest/java/org/fdroid/download/DnsCacheTest.kt +++ b/app/src/androidTest/java/org/fdroid/download/DnsCacheTest.kt @@ -3,101 +3,101 @@ package org.fdroid.download import android.content.Context import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.fdroid.settings.SettingsManager -import org.junit.runner.RunWith import java.net.InetAddress import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue +import org.fdroid.settings.SettingsManager +import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class DnsCacheTest { - private val context = ApplicationProvider.getApplicationContext() - private val settings = SettingsManager(context) + private val context = ApplicationProvider.getApplicationContext() + private val settings = SettingsManager(context) - private val url1 = "locaihost" - private val url2 = "fdroid.org" - private val url3 = "fdroid.net" + private val url1 = "locaihost" + private val url2 = "fdroid.org" + private val url3 = "fdroid.net" - private val ip1 = InetAddress.getByName("127.0.0.1") - private val ip2 = InetAddress.getByName("127.0.0.2") - private val ip3 = InetAddress.getByName("127.0.0.3") + private val ip1 = InetAddress.getByName("127.0.0.1") + private val ip2 = InetAddress.getByName("127.0.0.2") + private val ip3 = InetAddress.getByName("127.0.0.3") - private val list1 = listOf(ip1, ip2, ip3) - private val list2 = listOf(ip2) - private val list3 = listOf(ip3) + private val list1 = listOf(ip1, ip2, ip3) + private val list2 = listOf(ip2) + private val list3 = listOf(ip3) - @Test - fun basicCacheTest() { - // test setup - settings.useDnsCache = true - val testObject = DnsCache(settings) + @Test + fun basicCacheTest() { + // test setup + settings.useDnsCache = true + val testObject = DnsCache(settings) - // populate cache - testObject.insert(url1, list1) - testObject.insert(url2, list2) - testObject.insert(url3, list3) + // populate cache + testObject.insert(url1, list1) + testObject.insert(url2, list2) + testObject.insert(url3, list3) - // check for cached lookup results - val testList1 = testObject.lookup(url1) - assertNotNull(testList1) - assertEquals(3, testList1.size.toLong()) - assertEquals(ip1.hostAddress, testList1[0].hostAddress) - assertEquals(ip2.hostAddress, testList1[1].hostAddress) - assertEquals(ip3.hostAddress, testList1[2].hostAddress) + // check for cached lookup results + val testList1 = testObject.lookup(url1) + assertNotNull(testList1) + assertEquals(3, testList1.size.toLong()) + assertEquals(ip1.hostAddress, testList1[0].hostAddress) + assertEquals(ip2.hostAddress, testList1[1].hostAddress) + assertEquals(ip3.hostAddress, testList1[2].hostAddress) - // toggle preference (false) - settings.useDnsCache = false + // toggle preference (false) + settings.useDnsCache = false - // attempt non-cached lookup - val testList2 = testObject.lookup(url1) - assertNull(testList2) + // attempt non-cached lookup + val testList2 = testObject.lookup(url1) + assertNull(testList2) - // toggle preference (true) - settings.useDnsCache = true + // toggle preference (true) + settings.useDnsCache = true - // confirm lookup results remain in cache - val testList3 = testObject.lookup(url2) - assertNotNull(testList3) - assertEquals(1, testList3.size.toLong()) - assertEquals(ip2.hostAddress, testList3[0].hostAddress) + // confirm lookup results remain in cache + val testList3 = testObject.lookup(url2) + assertNotNull(testList3) + assertEquals(1, testList3.size.toLong()) + assertEquals(ip2.hostAddress, testList3[0].hostAddress) - // test removal - testObject.remove(url2) + // test removal + testObject.remove(url2) - // confirm result was removed from cache - val testList4 = testObject.lookup(url2) - assertNull(testList4) - } + // confirm result was removed from cache + val testList4 = testObject.lookup(url2) + assertNull(testList4) + } - @Test - fun dnsRetryTest() { - // test setup - settings.useDnsCache = true - val testCache = DnsCache(settings) - val testObject = DnsWithCache(settings, testCache) + @Test + fun dnsRetryTest() { + // test setup + settings.useDnsCache = true + val testCache = DnsCache(settings) + val testObject = DnsWithCache(settings, testCache) - // insert dummy value into cache - testCache.insert(url2, list2) + // insert dummy value into cache + testCache.insert(url2, list2) - // check initial status - val testList1 = testObject.lookup(url2) - assertEquals(1, testList1.size.toLong()) - assertEquals(ip2.hostAddress, testList1[0].hostAddress) + // check initial status + val testList1 = testObject.lookup(url2) + assertEquals(1, testList1.size.toLong()) + assertEquals(ip2.hostAddress, testList1[0].hostAddress) - // mismatch with dummy value should require retry and clear cache - val testFlag = testObject.shouldRetryRequest(url2) - assertTrue(testFlag) - val testList2 = testCache.lookup(url2) - assertNull(testList2) + // mismatch with dummy value should require retry and clear cache + val testFlag = testObject.shouldRetryRequest(url2) + assertTrue(testFlag) + val testList2 = testCache.lookup(url2) + assertNull(testList2) - // subsequent lookup should cache actual dns result (not testing actual values) - val testList3 = testObject.lookup(url2) - assertNotNull(testList3) - val testList4 = testCache.lookup(url2) - assertNotNull(testList4) - } + // subsequent lookup should cache actual dns result (not testing actual values) + val testList3 = testObject.lookup(url2) + assertNotNull(testList3) + val testList4 = testCache.lookup(url2) + assertNotNull(testList4) + } } diff --git a/app/src/androidTest/java/org/fdroid/install/ApkFileProviderTest.kt b/app/src/androidTest/java/org/fdroid/install/ApkFileProviderTest.kt index 2247aef09..5fa25b72d 100644 --- a/app/src/androidTest/java/org/fdroid/install/ApkFileProviderTest.kt +++ b/app/src/androidTest/java/org/fdroid/install/ApkFileProviderTest.kt @@ -4,68 +4,72 @@ import android.content.pm.ApplicationInfo.FLAG_SYSTEM import android.provider.MediaStore.MediaColumns.DISPLAY_NAME import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry -import mu.KotlinLogging -import org.fdroid.install.ApkFileProvider.Companion.getIntent -import org.junit.Test -import org.junit.runner.RunWith import java.io.File import java.io.FileOutputStream import java.io.IOException import kotlin.test.assertEquals import kotlin.test.fail +import mu.KotlinLogging +import org.fdroid.install.ApkFileProvider.Companion.getIntent +import org.junit.Test +import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class ApkFileProviderTest { - private val log = KotlinLogging.logger {} - private val context = InstrumentationRegistry.getInstrumentation().targetContext - private val pm = context.packageManager + private val log = KotlinLogging.logger {} + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val pm = context.packageManager - private val packageInfoList - get() = pm.getInstalledPackages(0).filter { - val info = it.applicationInfo ?: return@filter false - (info.flags and FLAG_SYSTEM) == 0 - }.sortedBy { - val path = it.applicationInfo?.publicSourceDir ?: return@sortedBy Long.MAX_VALUE - File(path).length() - }.subList(0, 3) // just test with the three smallest apps - - /** - * Test whether reading installed APKs via our custom [android.content.ContentProvider] works. - * It also only copies max 3 apps so it doesn't take a long time to run. - */ - @Test - fun testCopyFromGetUri() { - for (packageInfo in packageInfoList) { - val applicationInfo = packageInfo.applicationInfo ?: fail() - val apk = File(applicationInfo.publicSourceDir) - val uri = getIntent(packageInfo.packageName).data ?: fail() - val test = FileOutputStream("/dev/null") - val numBytesCopied = context.contentResolver.openInputStream(uri)?.use { inputStream -> - inputStream.copyTo(test) - } ?: fail() - assertEquals(apk.length(), numBytesCopied) - log.info { - "${packageInfo.packageName} read $numBytesCopied bytes from ${apk.absolutePath}" - } + private val packageInfoList + get() = + pm + .getInstalledPackages(0) + .filter { + val info = it.applicationInfo ?: return@filter false + (info.flags and FLAG_SYSTEM) == 0 } - } - - /** - * Test whether querying the custom [android.content.ContentProvider] for installed APKs - * returns the right kind of data. - */ - @Test - @Throws(IOException::class) - fun testQuery() { - for (packageInfo in packageInfoList) { - val uri = getIntent(packageInfo.packageName).data ?: fail() - context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> - assertEquals(1, cursor.count) - cursor.moveToFirst() - val name = cursor.getString(cursor.getColumnIndex(DISPLAY_NAME)) - assertEquals("${packageInfo.packageName}.apk", name) - } ?: fail() + .sortedBy { + val path = it.applicationInfo?.publicSourceDir ?: return@sortedBy Long.MAX_VALUE + File(path).length() } + .subList(0, 3) // just test with the three smallest apps + + /** + * Test whether reading installed APKs via our custom [android.content.ContentProvider] works. It + * also only copies max 3 apps so it doesn't take a long time to run. + */ + @Test + fun testCopyFromGetUri() { + for (packageInfo in packageInfoList) { + val applicationInfo = packageInfo.applicationInfo ?: fail() + val apk = File(applicationInfo.publicSourceDir) + val uri = getIntent(packageInfo.packageName).data ?: fail() + val test = FileOutputStream("/dev/null") + val numBytesCopied = + context.contentResolver.openInputStream(uri)?.use { inputStream -> + inputStream.copyTo(test) + } ?: fail() + assertEquals(apk.length(), numBytesCopied) + log.info { "${packageInfo.packageName} read $numBytesCopied bytes from ${apk.absolutePath}" } } + } + + /** + * Test whether querying the custom [android.content.ContentProvider] for installed APKs returns + * the right kind of data. + */ + @Test + @Throws(IOException::class) + fun testQuery() { + for (packageInfo in packageInfoList) { + val uri = getIntent(packageInfo.packageName).data ?: fail() + context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + assertEquals(1, cursor.count) + cursor.moveToFirst() + val name = cursor.getString(cursor.getColumnIndex(DISPLAY_NAME)) + assertEquals("${packageInfo.packageName}.apk", name) + } ?: fail() + } + } } diff --git a/app/src/basic/kotlin/org/fdroid/download/DownloadRequestInterceptor.kt b/app/src/basic/kotlin/org/fdroid/download/DownloadRequestInterceptor.kt index 3e2f169d8..baaebabc0 100644 --- a/app/src/basic/kotlin/org/fdroid/download/DownloadRequestInterceptor.kt +++ b/app/src/basic/kotlin/org/fdroid/download/DownloadRequestInterceptor.kt @@ -5,5 +5,5 @@ import javax.inject.Singleton @Singleton class DownloadRequestInterceptor @Inject constructor() { - fun intercept(request: DownloadRequest): DownloadRequest = request + fun intercept(request: DownloadRequest): DownloadRequest = request } diff --git a/app/src/basic/kotlin/org/fdroid/ui/navigation/ExtraNavigationEntries.kt b/app/src/basic/kotlin/org/fdroid/ui/navigation/ExtraNavigationEntries.kt index be09e4dbb..eb281857b 100644 --- a/app/src/basic/kotlin/org/fdroid/ui/navigation/ExtraNavigationEntries.kt +++ b/app/src/basic/kotlin/org/fdroid/ui/navigation/ExtraNavigationEntries.kt @@ -3,7 +3,4 @@ package org.fdroid.ui.navigation import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey -fun EntryProviderScope.extraNavigationEntries( - navigator: Navigator, -) { -} +fun EntryProviderScope.extraNavigationEntries(navigator: Navigator) {} diff --git a/app/src/basic/kotlin/org/fdroid/ui/settings/ExtraSettings.kt b/app/src/basic/kotlin/org/fdroid/ui/settings/ExtraSettings.kt index 281fe76a0..df4a26ff0 100644 --- a/app/src/basic/kotlin/org/fdroid/ui/settings/ExtraSettings.kt +++ b/app/src/basic/kotlin/org/fdroid/ui/settings/ExtraSettings.kt @@ -3,8 +3,6 @@ package org.fdroid.ui.settings import android.content.Context import androidx.compose.foundation.lazy.LazyListScope -fun LazyListScope.extraPrivacySettings(context: android.content.Context) { -} +fun LazyListScope.extraPrivacySettings(context: android.content.Context) {} -fun LazyListScope.extraNetworkSettings(context: android.content.Context) { -} +fun LazyListScope.extraNetworkSettings(context: android.content.Context) {} diff --git a/app/src/full/kotlin/org/fdroid/download/DownloadRequestInterceptor.kt b/app/src/full/kotlin/org/fdroid/download/DownloadRequestInterceptor.kt index 67a05747b..798182504 100644 --- a/app/src/full/kotlin/org/fdroid/download/DownloadRequestInterceptor.kt +++ b/app/src/full/kotlin/org/fdroid/download/DownloadRequestInterceptor.kt @@ -1,26 +1,23 @@ package org.fdroid.download -import org.fdroid.ui.ipfs.IpfsManager import javax.inject.Inject import javax.inject.Singleton +import org.fdroid.ui.ipfs.IpfsManager @Singleton -class DownloadRequestInterceptor @Inject constructor( - private val ipfsManager: IpfsManager, -) { - fun intercept(request: DownloadRequest): DownloadRequest { - return if (request.indexFile.ipfsCidV1 != null && ipfsManager.enabled) { - // add IPFS gateways to mirrors, - // because have a CIDv1 and IPFS is enabled in preferences - val newMirrors = request.mirrors.toMutableList().apply { - val gatewayMirrors = ipfsManager.activeGateways.map { - Mirror(it, null, true) - } - addAll(gatewayMirrors) - } - request.copy(mirrors = newMirrors) - } else { - request +class DownloadRequestInterceptor @Inject constructor(private val ipfsManager: IpfsManager) { + fun intercept(request: DownloadRequest): DownloadRequest { + return if (request.indexFile.ipfsCidV1 != null && ipfsManager.enabled) { + // add IPFS gateways to mirrors, + // because have a CIDv1 and IPFS is enabled in preferences + val newMirrors = + request.mirrors.toMutableList().apply { + val gatewayMirrors = ipfsManager.activeGateways.map { Mirror(it, null, true) } + addAll(gatewayMirrors) } + request.copy(mirrors = newMirrors) + } else { + request } + } } diff --git a/app/src/full/kotlin/org/fdroid/ui/ipfs/AddGatewaysDialog.kt b/app/src/full/kotlin/org/fdroid/ui/ipfs/AddGatewaysDialog.kt index 99daa6dc1..98d5c062f 100644 --- a/app/src/full/kotlin/org/fdroid/ui/ipfs/AddGatewaysDialog.kt +++ b/app/src/full/kotlin/org/fdroid/ui/ipfs/AddGatewaysDialog.kt @@ -24,78 +24,65 @@ import org.fdroid.ui.utils.FDroidButton @Composable @OptIn(ExperimentalMaterial3Api::class) -fun AddGatewaysDialog( - onAddUserGateway: (url: String) -> Unit, - onDismissRequest: () -> Unit, -) { - val textState = remember { mutableStateOf(TextFieldValue()) } - var errorMsg by remember { mutableStateOf("") } - AlertDialog( - title = { - Text(text = stringResource(R.string.ipfsgw_add_title)) - }, - text = { - Column { - TextField( - value = textState.value, - minLines = 2, - onValueChange = { textState.value = it }, - isError = errorMsg.isNotEmpty(), - modifier = Modifier.fillMaxWidth() - ) - if (errorMsg.isNotEmpty()) { - Text( - text = errorMsg, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.error, - ) - } - } - }, - onDismissRequest = onDismissRequest, - confirmButton = { - FDroidButton( - text = stringResource(R.string.ipfsgw_add_add), - onClick = l@{ - errorMsg = "" - val inputUri = if (textState.value.text.endsWith("/")) { - textState.value.text - } else { - "${textState.value.text}/" - } - try { - val uri = inputUri.toUri() - if (!setOf("http", "https").contains(uri.scheme)) { - errorMsg = "IPFS gateway URL should start with `https://`" - return@l - } - } catch (e: Exception) { - errorMsg = "could not parse uri ($e)" - return@l - } - // no errors -> proceed to add the url - onAddUserGateway(inputUri) - onDismissRequest() - }, - ) - }, - dismissButton = { - TextButton( - onClick = onDismissRequest - ) { - Text(stringResource(R.string.cancel)) - } +fun AddGatewaysDialog(onAddUserGateway: (url: String) -> Unit, onDismissRequest: () -> Unit) { + val textState = remember { mutableStateOf(TextFieldValue()) } + var errorMsg by remember { mutableStateOf("") } + AlertDialog( + title = { Text(text = stringResource(R.string.ipfsgw_add_title)) }, + text = { + Column { + TextField( + value = textState.value, + minLines = 2, + onValueChange = { textState.value = it }, + isError = errorMsg.isNotEmpty(), + modifier = Modifier.fillMaxWidth(), + ) + if (errorMsg.isNotEmpty()) { + Text( + text = errorMsg, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error, + ) } - ) + } + }, + onDismissRequest = onDismissRequest, + confirmButton = { + FDroidButton( + text = stringResource(R.string.ipfsgw_add_add), + onClick = l@{ + errorMsg = "" + val inputUri = + if (textState.value.text.endsWith("/")) { + textState.value.text + } else { + "${textState.value.text}/" + } + try { + val uri = inputUri.toUri() + if (!setOf("http", "https").contains(uri.scheme)) { + errorMsg = "IPFS gateway URL should start with `https://`" + return@l + } + } catch (e: Exception) { + errorMsg = "could not parse uri ($e)" + return@l + } + // no errors -> proceed to add the url + onAddUserGateway(inputUri) + onDismissRequest() + }, + ) + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { Text(stringResource(R.string.cancel)) } + }, + ) } @Composable @Preview fun AddGatewaysScreenPreview() { - FDroidContent { - AddGatewaysDialog( - onAddUserGateway = {}, - onDismissRequest = {}, - ) - } + FDroidContent { AddGatewaysDialog(onAddUserGateway = {}, onDismissRequest = {}) } } diff --git a/app/src/full/kotlin/org/fdroid/ui/ipfs/IpfsGatewaySettingsActivity.kt b/app/src/full/kotlin/org/fdroid/ui/ipfs/IpfsGatewaySettingsActivity.kt index 314ccd62b..afa0664ee 100644 --- a/app/src/full/kotlin/org/fdroid/ui/ipfs/IpfsGatewaySettingsActivity.kt +++ b/app/src/full/kotlin/org/fdroid/ui/ipfs/IpfsGatewaySettingsActivity.kt @@ -6,26 +6,25 @@ import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.compose.collectAsStateWithLifecycle import dagger.hilt.android.AndroidEntryPoint -import org.fdroid.ui.FDroidContent import javax.inject.Inject +import org.fdroid.ui.FDroidContent @AndroidEntryPoint class IpfsGatewaySettingsActivity : AppCompatActivity() { - @Inject - lateinit var manager: IpfsManager + @Inject lateinit var manager: IpfsManager - override fun onCreate(savedInstanceState: Bundle?) { - enableEdgeToEdge() - super.onCreate(savedInstanceState) - setContent { - FDroidContent { - SettingsScreen( - prefs = manager.preferences.collectAsStateWithLifecycle().value, - actions = manager, - onBackClicked = { onBackPressedDispatcher.onBackPressed() }, - ) - } - } + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + setContent { + FDroidContent { + SettingsScreen( + prefs = manager.preferences.collectAsStateWithLifecycle().value, + actions = manager, + onBackClicked = { onBackPressedDispatcher.onBackPressed() }, + ) + } } + } } diff --git a/app/src/full/kotlin/org/fdroid/ui/ipfs/IpfsManager.kt b/app/src/full/kotlin/org/fdroid/ui/ipfs/IpfsManager.kt index a700904cc..ad783de63 100644 --- a/app/src/full/kotlin/org/fdroid/ui/ipfs/IpfsManager.kt +++ b/app/src/full/kotlin/org/fdroid/ui/ipfs/IpfsManager.kt @@ -1,129 +1,117 @@ package org.fdroid.ui.ipfs import androidx.core.content.edit +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import org.fdroid.settings.SettingsManager import org.json.JSONArray import org.json.JSONException -import javax.inject.Inject -import javax.inject.Singleton @Singleton -class IpfsManager @Inject constructor( - settingsManager: SettingsManager, -) : IpfsActions { +class IpfsManager @Inject constructor(settingsManager: SettingsManager) : IpfsActions { - companion object { - val DEFAULT_GATEWAYS = listOf( - "https://ipfs.io/ipfs/", - ) - const val PREF_USE_IPFS_GATEWAYS = "useIpfsGateways" - const val PREF_IPFSGW_DISABLED_DEFAULTS_LIST = "ipfsGwDisabledDefaultsList" - const val PREF_IPFSGW_USER_LIST = "ipfsGwUserList" + companion object { + val DEFAULT_GATEWAYS = listOf("https://ipfs.io/ipfs/") + const val PREF_USE_IPFS_GATEWAYS = "useIpfsGateways" + const val PREF_IPFSGW_DISABLED_DEFAULTS_LIST = "ipfsGwDisabledDefaultsList" + const val PREF_IPFSGW_USER_LIST = "ipfsGwUserList" + } + + private val prefs = settingsManager.prefs + + private val _preferences = MutableStateFlow(loadPreferences()) + val preferences = _preferences.asStateFlow() + + var enabled: Boolean + get() = prefs.getBoolean(PREF_USE_IPFS_GATEWAYS, false) + private set(value) { + prefs.edit { putBoolean(PREF_USE_IPFS_GATEWAYS, value) } } - private val prefs = settingsManager.prefs + private var disabledDefaultGateways: List + get() { + val list = prefs.getString(PREF_IPFSGW_DISABLED_DEFAULTS_LIST, "[]") ?: "[]" + return parseJsonStringArray(list) + } + set(value) { + prefs.edit { putString(PREF_IPFSGW_DISABLED_DEFAULTS_LIST, toJsonStringArray(value)) } + } - private val _preferences = MutableStateFlow(loadPreferences()) - val preferences = _preferences.asStateFlow() + private var userGateways: List + get() = parseJsonStringArray(prefs.getString(PREF_IPFSGW_USER_LIST, "[]") ?: "[]") + set(value) { + prefs.edit { putString(PREF_IPFSGW_USER_LIST, toJsonStringArray(value)) } + } - var enabled: Boolean - get() = prefs.getBoolean(PREF_USE_IPFS_GATEWAYS, false) - private set(value) { - prefs.edit { - putBoolean(PREF_USE_IPFS_GATEWAYS, value) - } - } - private var disabledDefaultGateways: List - get() { - val list = prefs.getString(PREF_IPFSGW_DISABLED_DEFAULTS_LIST, "[]") ?: "[]" - return parseJsonStringArray(list) - } - set(value) { - prefs.edit { - putString(PREF_IPFSGW_DISABLED_DEFAULTS_LIST, toJsonStringArray(value)) - } - } - private var userGateways: List - get() = parseJsonStringArray(prefs.getString(PREF_IPFSGW_USER_LIST, "[]") ?: "[]") - set(value) { - prefs.edit { - putString(PREF_IPFSGW_USER_LIST, toJsonStringArray(value)) - } - } - val activeGateways: List - get() = userGateways.toMutableList().apply { - for (gatewayUrl in DEFAULT_GATEWAYS) { - if (!disabledDefaultGateways.contains(gatewayUrl)) { - add(gatewayUrl) - } - } + val activeGateways: List + get() = + userGateways.toMutableList().apply { + for (gatewayUrl in DEFAULT_GATEWAYS) { + if (!disabledDefaultGateways.contains(gatewayUrl)) { + add(gatewayUrl) + } } + } - private fun loadPreferences() = IpfsPreferences( - isIpfsEnabled = prefs.getBoolean(PREF_USE_IPFS_GATEWAYS, false), - disabledDefaultGateways = parseJsonStringArray( - prefs.getString(PREF_IPFSGW_DISABLED_DEFAULTS_LIST, "[]") ?: "[]" - ), - userGateways = parseJsonStringArray(prefs.getString(PREF_IPFSGW_USER_LIST, "[]") ?: "[]"), + private fun loadPreferences() = + IpfsPreferences( + isIpfsEnabled = prefs.getBoolean(PREF_USE_IPFS_GATEWAYS, false), + disabledDefaultGateways = + parseJsonStringArray(prefs.getString(PREF_IPFSGW_DISABLED_DEFAULTS_LIST, "[]") ?: "[]"), + userGateways = parseJsonStringArray(prefs.getString(PREF_IPFSGW_USER_LIST, "[]") ?: "[]"), ) - override fun setIpfsEnabled(enabled: Boolean) { - _preferences.update { - it.copy(isIpfsEnabled = enabled) + override fun setIpfsEnabled(enabled: Boolean) { + _preferences.update { it.copy(isIpfsEnabled = enabled) } + } + + override fun setDefaultGatewayEnabled(url: String, enabled: Boolean) { + _preferences.update { + val newList = it.disabledDefaultGateways.toMutableList() + if (!enabled) { + newList.add(url) + } else { + newList.remove(url) + } + disabledDefaultGateways = newList + it.copy(disabledDefaultGateways = newList) + } + } + + override fun addUserGateway(url: String) { + // don't allow adding default gateways to the user gateways list + if (!DEFAULT_GATEWAYS.contains(url)) + _preferences.update { + if (it.userGateways.contains(url)) { + it // already has URL in list + } else { + val newList = it.userGateways.toMutableList().apply { add(url) } + userGateways = newList + it.copy(userGateways = newList) } + } + } + + override fun removeUserGateway(url: String) = + _preferences.update { + val newGateways = it.userGateways.toMutableList() + newGateways.remove(url) + userGateways = newGateways + it.copy(userGateways = newGateways) } - override fun setDefaultGatewayEnabled(url: String, enabled: Boolean) { - _preferences.update { - val newList = it.disabledDefaultGateways.toMutableList() - if (!enabled) { - newList.add(url) - } else { - newList.remove(url) - } - disabledDefaultGateways = newList - it.copy(disabledDefaultGateways = newList) - } + private fun parseJsonStringArray(json: String): List { + try { + val jsonArray = JSONArray(json) + return MutableList(jsonArray.length()) { i -> jsonArray.getString(i) } + } catch (_: JSONException) { + return emptyList() } + } - override fun addUserGateway(url: String) { - // don't allow adding default gateways to the user gateways list - if (!DEFAULT_GATEWAYS.contains(url)) _preferences.update { - if (it.userGateways.contains(url)) { - it // already has URL in list - } else { - val newList = it.userGateways.toMutableList().apply { - add(url) - } - userGateways = newList - it.copy(userGateways = newList) - } - } - } - - override fun removeUserGateway(url: String) = _preferences.update { - val newGateways = it.userGateways.toMutableList() - newGateways.remove(url) - userGateways = newGateways - it.copy(userGateways = newGateways) - } - - private fun parseJsonStringArray(json: String): List { - try { - val jsonArray = JSONArray(json) - return MutableList(jsonArray.length()) { i -> - jsonArray.getString(i) - } - } catch (_: JSONException) { - return emptyList() - } - } - - private fun toJsonStringArray(list: List): String = JSONArray().apply { - for (str in list) put(str) - }.toString() - + private fun toJsonStringArray(list: List): String = + JSONArray().apply { for (str in list) put(str) }.toString() } diff --git a/app/src/full/kotlin/org/fdroid/ui/ipfs/IpfsPreferences.kt b/app/src/full/kotlin/org/fdroid/ui/ipfs/IpfsPreferences.kt index 824a83728..9407b2102 100644 --- a/app/src/full/kotlin/org/fdroid/ui/ipfs/IpfsPreferences.kt +++ b/app/src/full/kotlin/org/fdroid/ui/ipfs/IpfsPreferences.kt @@ -1,14 +1,17 @@ package org.fdroid.ui.ipfs interface IpfsActions { - fun setIpfsEnabled(enabled: Boolean) - fun setDefaultGatewayEnabled(url: String, enabled: Boolean) - fun addUserGateway(url: String) - fun removeUserGateway(url: String) + fun setIpfsEnabled(enabled: Boolean) + + fun setDefaultGatewayEnabled(url: String, enabled: Boolean) + + fun addUserGateway(url: String) + + fun removeUserGateway(url: String) } data class IpfsPreferences( - val isIpfsEnabled: Boolean, - val disabledDefaultGateways: List, - val userGateways: List, + val isIpfsEnabled: Boolean, + val disabledDefaultGateways: List, + val userGateways: List, ) diff --git a/app/src/full/kotlin/org/fdroid/ui/ipfs/SettingsScreen.kt b/app/src/full/kotlin/org/fdroid/ui/ipfs/SettingsScreen.kt index bc835e956..a8f2a8e15 100644 --- a/app/src/full/kotlin/org/fdroid/ui/ipfs/SettingsScreen.kt +++ b/app/src/full/kotlin/org/fdroid/ui/ipfs/SettingsScreen.kt @@ -39,159 +39,131 @@ import org.fdroid.ui.ipfs.IpfsManager.Companion.DEFAULT_GATEWAYS @Composable @OptIn(ExperimentalMaterial3Api::class) -fun SettingsScreen( - prefs: IpfsPreferences, - actions: IpfsActions, - onBackClicked: () -> Unit, -) { - var showAddDialog by remember { mutableStateOf(false) } - Scaffold( - topBar = { - TopAppBar( - navigationIcon = { - IconButton(onClick = onBackClicked) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back)) - } - }, - title = { - Text( - text = stringResource(R.string.ipfsgw_title), - ) - }, - ) +fun SettingsScreen(prefs: IpfsPreferences, actions: IpfsActions, onBackClicked: () -> Unit) { + var showAddDialog by remember { mutableStateOf(false) } + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = onBackClicked) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back)) + } }, - floatingActionButton = { - // it doesn't seam to be supported to disable FABs, so just hide it for now. - if (prefs.isIpfsEnabled) { - FloatingActionButton( - onClick = { - showAddDialog = true - }, - ) { - Icon(Icons.Filled.Add, stringResource(id = R.string.ipfsgw_add_add)) - } - } - }, - ) { paddingValues -> - Box( - modifier = Modifier - .padding(paddingValues) - .verticalScroll(rememberScrollState()) - ) { - Column( - modifier = Modifier.padding(16.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = stringResource(id = R.string.ipfsgw_explainer), - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.weight(1f) - ) - Switch(checked = prefs.isIpfsEnabled, onCheckedChange = actions::setIpfsEnabled) - } - DefaultGatewaysSettings(prefs, actions) - UserGatewaysSettings(prefs, actions) - // make sure FAB doesn't occlude the delete button of the last user gateway - Spacer(modifier = Modifier.height(64.dp)) - } + title = { Text(text = stringResource(R.string.ipfsgw_title)) }, + ) + }, + floatingActionButton = { + // it doesn't seam to be supported to disable FABs, so just hide it for now. + if (prefs.isIpfsEnabled) { + FloatingActionButton(onClick = { showAddDialog = true }) { + Icon(Icons.Filled.Add, stringResource(id = R.string.ipfsgw_add_add)) } - if (showAddDialog) AddGatewaysDialog(actions::addUserGateway) { showAddDialog = false } + } + }, + ) { paddingValues -> + Box(modifier = Modifier.padding(paddingValues).verticalScroll(rememberScrollState())) { + Column(modifier = Modifier.padding(16.dp)) { + Row(modifier = Modifier.fillMaxWidth()) { + Text( + text = stringResource(id = R.string.ipfsgw_explainer), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + ) + Switch(checked = prefs.isIpfsEnabled, onCheckedChange = actions::setIpfsEnabled) + } + DefaultGatewaysSettings(prefs, actions) + UserGatewaysSettings(prefs, actions) + // make sure FAB doesn't occlude the delete button of the last user gateway + Spacer(modifier = Modifier.height(64.dp)) + } } + if (showAddDialog) AddGatewaysDialog(actions::addUserGateway) { showAddDialog = false } + } } @Composable fun DefaultGatewaysSettings(prefs: IpfsPreferences, actions: IpfsActions) { - Column { + Column { + Text( + text = stringResource(id = R.string.ipfsgw_caption_official_gateways), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(0.dp, 16.dp, 0.dp, 4.dp), + ) + for (gatewayUrl in DEFAULT_GATEWAYS) { + Row(modifier = Modifier.fillMaxWidth().padding(48.dp, 4.dp, 0.dp, 4.dp)) { Text( - text = stringResource(id = R.string.ipfsgw_caption_official_gateways), - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(0.dp, 16.dp, 0.dp, 4.dp) + text = gatewayUrl, + style = MaterialTheme.typography.bodyLarge, + modifier = + Modifier.weight(1f) + .align(Alignment.CenterVertically) + .alpha(if (prefs.isIpfsEnabled) 1f else 0.5f), ) - for (gatewayUrl in DEFAULT_GATEWAYS) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(48.dp, 4.dp, 0.dp, 4.dp) - ) { - Text( - text = gatewayUrl, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier - .weight(1f) - .align(Alignment.CenterVertically) - .alpha(if (prefs.isIpfsEnabled) 1f else 0.5f) - ) - Switch( - checked = !prefs.disabledDefaultGateways.contains(gatewayUrl), - onCheckedChange = { checked -> - actions.setDefaultGatewayEnabled(gatewayUrl, checked) - }, - enabled = prefs.isIpfsEnabled, - modifier = Modifier.align(Alignment.CenterVertically) - ) - } - } + Switch( + checked = !prefs.disabledDefaultGateways.contains(gatewayUrl), + onCheckedChange = { checked -> actions.setDefaultGatewayEnabled(gatewayUrl, checked) }, + enabled = prefs.isIpfsEnabled, + modifier = Modifier.align(Alignment.CenterVertically), + ) + } } + } } @Composable fun UserGatewaysSettings(prefs: IpfsPreferences, actions: IpfsActions) { - Column { - if (prefs.userGateways.isNotEmpty()) { - Text( - text = stringResource(id = R.string.ipfsgw_caption_custom_gateways), - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(0.dp, 16.dp, 0.dp, 4.dp) - ) - } - for (gatewayUrl in prefs.userGateways) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(48.dp, 4.dp, 0.dp, 4.dp) - ) { - Text( - text = gatewayUrl, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier - .weight(1f) - .align(Alignment.CenterVertically) - .alpha(if (prefs.isIpfsEnabled) 1f else 0.5f) - ) - IconButton( - onClick = { actions.removeUserGateway(gatewayUrl) }, - enabled = prefs.isIpfsEnabled, - modifier = Modifier.align(Alignment.CenterVertically), - ) { - Icon( - Icons.Default.DeleteForever, - contentDescription = "Localized description", - ) - } - } - } + Column { + if (prefs.userGateways.isNotEmpty()) { + Text( + text = stringResource(id = R.string.ipfsgw_caption_custom_gateways), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(0.dp, 16.dp, 0.dp, 4.dp), + ) } + for (gatewayUrl in prefs.userGateways) { + Row(modifier = Modifier.fillMaxWidth().padding(48.dp, 4.dp, 0.dp, 4.dp)) { + Text( + text = gatewayUrl, + style = MaterialTheme.typography.bodyLarge, + modifier = + Modifier.weight(1f) + .align(Alignment.CenterVertically) + .alpha(if (prefs.isIpfsEnabled) 1f else 0.5f), + ) + IconButton( + onClick = { actions.removeUserGateway(gatewayUrl) }, + enabled = prefs.isIpfsEnabled, + modifier = Modifier.align(Alignment.CenterVertically), + ) { + Icon(Icons.Default.DeleteForever, contentDescription = "Localized description") + } + } + } + } } @Composable @Preview fun SettingsScreenPreview() { - FDroidContent { - SettingsScreen( - prefs = IpfsPreferences( - isIpfsEnabled = true, - disabledDefaultGateways = listOf("https://4everland.io/ipfs/"), - userGateways = listOf("https://my.imaginary.gateway/ifps/") - ), - actions = object : IpfsActions { - override fun setIpfsEnabled(enabled: Boolean) {} - override fun setDefaultGatewayEnabled(url: String, enabled: Boolean) {} - override fun addUserGateway(url: String) {} - override fun removeUserGateway(url: String) {} - }, - onBackClicked = {}, - ) - } + FDroidContent { + SettingsScreen( + prefs = + IpfsPreferences( + isIpfsEnabled = true, + disabledDefaultGateways = listOf("https://4everland.io/ipfs/"), + userGateways = listOf("https://my.imaginary.gateway/ifps/"), + ), + actions = + object : IpfsActions { + override fun setIpfsEnabled(enabled: Boolean) {} + + override fun setDefaultGatewayEnabled(url: String, enabled: Boolean) {} + + override fun addUserGateway(url: String) {} + + override fun removeUserGateway(url: String) {} + }, + onBackClicked = {}, + ) + } } diff --git a/app/src/full/kotlin/org/fdroid/ui/navigation/ExtraNavigationEntries.kt b/app/src/full/kotlin/org/fdroid/ui/navigation/ExtraNavigationEntries.kt index be09e4dbb..eb281857b 100644 --- a/app/src/full/kotlin/org/fdroid/ui/navigation/ExtraNavigationEntries.kt +++ b/app/src/full/kotlin/org/fdroid/ui/navigation/ExtraNavigationEntries.kt @@ -3,7 +3,4 @@ package org.fdroid.ui.navigation import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey -fun EntryProviderScope.extraNavigationEntries( - navigator: Navigator, -) { -} +fun EntryProviderScope.extraNavigationEntries(navigator: Navigator) {} diff --git a/app/src/full/kotlin/org/fdroid/ui/panic/ExitActivity.kt b/app/src/full/kotlin/org/fdroid/ui/panic/ExitActivity.kt index 3b8dadaff..244bbc90b 100644 --- a/app/src/full/kotlin/org/fdroid/ui/panic/ExitActivity.kt +++ b/app/src/full/kotlin/org/fdroid/ui/panic/ExitActivity.kt @@ -6,25 +6,26 @@ import androidx.appcompat.app.AppCompatActivity import kotlin.system.exitProcess class ExitActivity : AppCompatActivity() { - companion object { - fun exitAndRemoveFromRecentApps(activity: AppCompatActivity) { - activity.runOnUiThread({ - val intent = Intent(activity, ExitActivity::class.java).apply { - addFlags( - Intent.FLAG_ACTIVITY_NEW_TASK - or Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS - or Intent.FLAG_ACTIVITY_CLEAR_TASK - or Intent.FLAG_ACTIVITY_NO_ANIMATION - ) - } - activity.startActivity(intent) - }) - } + companion object { + fun exitAndRemoveFromRecentApps(activity: AppCompatActivity) { + activity.runOnUiThread({ + val intent = + Intent(activity, ExitActivity::class.java).apply { + addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS or + Intent.FLAG_ACTIVITY_CLEAR_TASK or + Intent.FLAG_ACTIVITY_NO_ANIMATION + ) + } + activity.startActivity(intent) + }) } + } - public override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - finishAndRemoveTask() - exitProcess(0) - } + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + finishAndRemoveTask() + exitProcess(0) + } } diff --git a/app/src/full/kotlin/org/fdroid/ui/panic/PanicActivity.kt b/app/src/full/kotlin/org/fdroid/ui/panic/PanicActivity.kt index 85d6fc39c..de044292d 100644 --- a/app/src/full/kotlin/org/fdroid/ui/panic/PanicActivity.kt +++ b/app/src/full/kotlin/org/fdroid/ui/panic/PanicActivity.kt @@ -13,59 +13,57 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import dagger.hilt.android.AndroidEntryPoint import info.guardianproject.panic.PanicResponder +import javax.inject.Inject import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import mu.KotlinLogging import org.fdroid.settings.SettingsConstants.PREF_DEFAULT_DYNAMIC_COLORS import org.fdroid.settings.SettingsManager import org.fdroid.ui.FDroidContent -import javax.inject.Inject @AndroidEntryPoint class PanicActivity : AppCompatActivity() { - private val log = KotlinLogging.logger { } - private val viewModel: PanicSettingsViewModel by viewModels() + private val log = KotlinLogging.logger {} + private val viewModel: PanicSettingsViewModel by viewModels() - @Inject - lateinit var settingsManager: SettingsManager + @Inject lateinit var settingsManager: SettingsManager - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - // observe preventScreenshots setting and react to changes - lifecycleScope.launch { - // this flow doesn't change when we are paused, so we keep collecting it - settingsManager.preventScreenshotsFlow.collect { preventScreenshots -> - if (preventScreenshots) { - window?.addFlags(FLAG_SECURE) - } else { - window?.clearFlags(FLAG_SECURE) - } - } - } - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.appFlow.drop(1).collect { packageName -> - log.info { "Setting panic trigger package name..." } - PanicResponder.setTriggerPackageName(this@PanicActivity, packageName) - } - } - } - enableEdgeToEdge() - setContent { - val dynamicColors = settingsManager.dynamicColorFlow.collectAsStateWithLifecycle( - PREF_DEFAULT_DYNAMIC_COLORS - ).value - val viewModel = hiltViewModel() - FDroidContent( - dynamicColors = dynamicColors, - ) { - PanicSettings( - prefsFlow = viewModel.prefsFlow, - state = viewModel.state.collectAsStateWithLifecycle().value, - onBackClicked = { onBackPressedDispatcher.onBackPressed() }, - ) - } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // observe preventScreenshots setting and react to changes + lifecycleScope.launch { + // this flow doesn't change when we are paused, so we keep collecting it + settingsManager.preventScreenshotsFlow.collect { preventScreenshots -> + if (preventScreenshots) { + window?.addFlags(FLAG_SECURE) + } else { + window?.clearFlags(FLAG_SECURE) } + } } + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.appFlow.drop(1).collect { packageName -> + log.info { "Setting panic trigger package name..." } + PanicResponder.setTriggerPackageName(this@PanicActivity, packageName) + } + } + } + enableEdgeToEdge() + setContent { + val dynamicColors = + settingsManager.dynamicColorFlow + .collectAsStateWithLifecycle(PREF_DEFAULT_DYNAMIC_COLORS) + .value + val viewModel = hiltViewModel() + FDroidContent(dynamicColors = dynamicColors) { + PanicSettings( + prefsFlow = viewModel.prefsFlow, + state = viewModel.state.collectAsStateWithLifecycle().value, + onBackClicked = { onBackPressedDispatcher.onBackPressed() }, + ) + } + } + } } diff --git a/app/src/full/kotlin/org/fdroid/ui/panic/PanicResponderActivity.kt b/app/src/full/kotlin/org/fdroid/ui/panic/PanicResponderActivity.kt index 6d620ee4c..1a342dd00 100644 --- a/app/src/full/kotlin/org/fdroid/ui/panic/PanicResponderActivity.kt +++ b/app/src/full/kotlin/org/fdroid/ui/panic/PanicResponderActivity.kt @@ -10,50 +10,48 @@ import info.guardianproject.panic.PanicResponder import mu.KotlinLogging /** - * This [AppCompatActivity] is purely to run events in response to a panic trigger. - * It needs to be an `AppCompatActivity` rather than a [android.app.Service] - * so that it can fetch some of the required information about what sent the - * [Intent]. This is therefore an `AppCompatActivity` without any UI, which - * is a special case in Android. All the code must be in - * [onCreate] and [finish] must be called at the end of - * that method. + * This [AppCompatActivity] is purely to run events in response to a panic trigger. It needs to be + * an `AppCompatActivity` rather than a [android.app.Service] so that it can fetch some of the + * required information about what sent the [Intent]. This is therefore an `AppCompatActivity` + * without any UI, which is a special case in Android. All the code must be in [onCreate] and + * [finish] must be called at the end of that method. * * @see PanicResponder.receivedTriggerFromConnectedApp */ @AndroidEntryPoint class PanicResponderActivity : AppCompatActivity() { - private val log = KotlinLogging.logger { } - private val viewModel: PanicSettingsViewModel by viewModels() + private val log = KotlinLogging.logger {} + private val viewModel: PanicSettingsViewModel by viewModels() - public override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) - val intent = getIntent() - if (!Panic.isTriggerIntent(intent)) { - finish() - return - } - - // received intent from panic app - log.info { "Received Panic Trigger..." } - - // FIXME: we should ideally also check the signature of the connected app, - // not only the package name as it could be a fake app occupying the same package name - val receivedTriggerFromConnectedApp = PanicResponder.receivedTriggerFromConnectedApp(this) - - if (receivedTriggerFromConnectedApp) { - if (viewModel.resetRepos) { - viewModel.resetDb() - } - } - - // exit and clear, if not deactivated - if (viewModel.exitApp) { - ExitActivity.exitAndRemoveFromRecentApps(this) - finishAndRemoveTask() - } else { - finish() - } + val intent = getIntent() + if (!Panic.isTriggerIntent(intent)) { + finish() + return } + + // received intent from panic app + log.info { "Received Panic Trigger..." } + + // FIXME: we should ideally also check the signature of the connected app, + // not only the package name as it could be a fake app occupying the same package name + val receivedTriggerFromConnectedApp = PanicResponder.receivedTriggerFromConnectedApp(this) + + if (receivedTriggerFromConnectedApp) { + if (viewModel.resetRepos) { + viewModel.resetDb() + } + } + + // exit and clear, if not deactivated + if (viewModel.exitApp) { + ExitActivity.exitAndRemoveFromRecentApps(this) + finishAndRemoveTask() + } else { + finish() + } + } } diff --git a/app/src/full/kotlin/org/fdroid/ui/panic/PanicSettings.kt b/app/src/full/kotlin/org/fdroid/ui/panic/PanicSettings.kt index abc152ede..598d4cadb 100644 --- a/app/src/full/kotlin/org/fdroid/ui/panic/PanicSettings.kt +++ b/app/src/full/kotlin/org/fdroid/ui/panic/PanicSettings.kt @@ -37,95 +37,86 @@ import org.fdroid.ui.utils.BackButton @Composable @OptIn(ExperimentalMaterial3Api::class) fun PanicSettings( - prefsFlow: MutableStateFlow, - state: PanicSettingsState, - onBackClicked: () -> Unit, + prefsFlow: MutableStateFlow, + state: PanicSettingsState, + onBackClicked: () -> Unit, ) { - Scaffold( - topBar = { - TopAppBar( - navigationIcon = { - BackButton(onClick = onBackClicked) - }, - title = { - Text(stringResource(R.string.panic_settings)) - }, - ) - }, - ) { paddingValues -> - ProvidePreferenceLocals(prefsFlow) { - val res = LocalResources.current - LazyColumn( - modifier = Modifier - .padding(paddingValues) - .fillMaxSize() - ) { - switchPreference( - key = "pref_panic_exit", - defaultValue = true, - title = { Text(stringResource(R.string.panic_exit_title)) }, - summary = { Text(stringResource(R.string.panic_exit_summary)) }, - ) - preferenceCategory( - key = "pref_panic_destructive_actions", - title = { Text(stringResource(R.string.panic_destructive_actions)) }, - ) - listPreference( - key = "pref_panic_app", - defaultValue = null, - icon = { - if (state.selectedPanicApp == null) Icon( - imageVector = Icons.Default.Cancel, - contentDescription = null, - modifier = Modifier.semantics { hideFromAccessibility() }, - ) else AsyncShimmerImage( - model = state.selectedPanicApp.iconModel, - error = painterResource(R.drawable.ic_repo_app_default), - contentDescription = null, - modifier = Modifier - .size(32.dp) - .semantics { hideFromAccessibility() }, - ) - }, - title = { Text(stringResource(R.string.panic_app_setting_title)) }, - summary = { - if (state.selectedPanicApp == null) { - Text(stringResource(R.string.panic_app_setting_summary)) - } else { - Text(state.selectedPanicApp.name) - } - }, - values = state.panicApps.map { it?.packageName }, - valueToText = { v -> - val noApp = res.getString(R.string.panic_app_setting_none) - val s = state.panicApps.find { v == it?.packageName }?.name ?: noApp - AnnotatedString(s) - } - ) - switchPreference( - key = "pref_panic_reset_repos", - defaultValue = false, - enabled = { state.actionsEnabled }, - title = { Text(stringResource(R.string.panic_reset_repos_title)) }, - summary = { Text(stringResource(R.string.panic_reset_repos_summary)) }, - ) - } - } + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { BackButton(onClick = onBackClicked) }, + title = { Text(stringResource(R.string.panic_settings)) }, + ) } + ) { paddingValues -> + ProvidePreferenceLocals(prefsFlow) { + val res = LocalResources.current + LazyColumn(modifier = Modifier.padding(paddingValues).fillMaxSize()) { + switchPreference( + key = "pref_panic_exit", + defaultValue = true, + title = { Text(stringResource(R.string.panic_exit_title)) }, + summary = { Text(stringResource(R.string.panic_exit_summary)) }, + ) + preferenceCategory( + key = "pref_panic_destructive_actions", + title = { Text(stringResource(R.string.panic_destructive_actions)) }, + ) + listPreference( + key = "pref_panic_app", + defaultValue = null, + icon = { + if (state.selectedPanicApp == null) + Icon( + imageVector = Icons.Default.Cancel, + contentDescription = null, + modifier = Modifier.semantics { hideFromAccessibility() }, + ) + else + AsyncShimmerImage( + model = state.selectedPanicApp.iconModel, + error = painterResource(R.drawable.ic_repo_app_default), + contentDescription = null, + modifier = Modifier.size(32.dp).semantics { hideFromAccessibility() }, + ) + }, + title = { Text(stringResource(R.string.panic_app_setting_title)) }, + summary = { + if (state.selectedPanicApp == null) { + Text(stringResource(R.string.panic_app_setting_summary)) + } else { + Text(state.selectedPanicApp.name) + } + }, + values = state.panicApps.map { it?.packageName }, + valueToText = { v -> + val noApp = res.getString(R.string.panic_app_setting_none) + val s = state.panicApps.find { v == it?.packageName }?.name ?: noApp + AnnotatedString(s) + }, + ) + switchPreference( + key = "pref_panic_reset_repos", + defaultValue = false, + enabled = { state.actionsEnabled }, + title = { Text(stringResource(R.string.panic_reset_repos_title)) }, + summary = { Text(stringResource(R.string.panic_reset_repos_summary)) }, + ) + } + } + } } @Preview @Composable private fun Preview() { - FDroidContent { - val noApp = PanicApp( - packageName = Panic.PACKAGE_NAME_NONE, - name = LocalResources.current.getString(R.string.panic_app_setting_none), - ) - val state = PanicSettingsState( - panicApps = listOf(noApp), - selectedPanicApp = noApp, - ) - PanicSettings(MutableStateFlow(MapPreferences()), state, {}) - } + FDroidContent { + val noApp = + PanicApp( + packageName = Panic.PACKAGE_NAME_NONE, + name = LocalResources.current.getString(R.string.panic_app_setting_none), + ) + val state = PanicSettingsState(panicApps = listOf(noApp), selectedPanicApp = noApp) + PanicSettings(MutableStateFlow(MapPreferences()), state, {}) + } } diff --git a/app/src/full/kotlin/org/fdroid/ui/panic/PanicSettingsState.kt b/app/src/full/kotlin/org/fdroid/ui/panic/PanicSettingsState.kt index 3f0fcbab5..bb227a6af 100644 --- a/app/src/full/kotlin/org/fdroid/ui/panic/PanicSettingsState.kt +++ b/app/src/full/kotlin/org/fdroid/ui/panic/PanicSettingsState.kt @@ -3,15 +3,12 @@ package org.fdroid.ui.panic import org.fdroid.download.PackageName data class PanicSettingsState( - val panicApps: List = listOf(null), - val selectedPanicApp: PanicApp? = null, + val panicApps: List = listOf(null), + val selectedPanicApp: PanicApp? = null, ) { - val actionsEnabled: Boolean = selectedPanicApp != null + val actionsEnabled: Boolean = selectedPanicApp != null } -data class PanicApp( - val packageName: String, - val name: String, -) { - val iconModel = PackageName(packageName, null, true) +data class PanicApp(val packageName: String, val name: String) { + val iconModel = PackageName(packageName, null, true) } diff --git a/app/src/full/kotlin/org/fdroid/ui/panic/PanicSettingsViewModel.kt b/app/src/full/kotlin/org/fdroid/ui/panic/PanicSettingsViewModel.kt index 857b9c72b..ec81d9537 100644 --- a/app/src/full/kotlin/org/fdroid/ui/panic/PanicSettingsViewModel.kt +++ b/app/src/full/kotlin/org/fdroid/ui/panic/PanicSettingsViewModel.kt @@ -8,6 +8,7 @@ import androidx.lifecycle.application import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import info.guardianproject.panic.PanicResponder +import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -22,77 +23,75 @@ import org.fdroid.database.FDroidDatabase import org.fdroid.repo.RepoPreLoader import org.fdroid.settings.SettingsManager import org.fdroid.utils.IoDispatcher -import javax.inject.Inject @HiltViewModel -class PanicSettingsViewModel @Inject constructor( - app: Application, - private val db: FDroidDatabase, - private val repoPreLoader: RepoPreLoader, - private val settingsManager: SettingsManager, - @param:IoDispatcher private val ioScope: CoroutineScope, +class PanicSettingsViewModel +@Inject +constructor( + app: Application, + private val db: FDroidDatabase, + private val repoPreLoader: RepoPreLoader, + private val settingsManager: SettingsManager, + @param:IoDispatcher private val ioScope: CoroutineScope, ) : AndroidViewModel(app) { - private val log = KotlinLogging.logger {} + private val log = KotlinLogging.logger {} - val prefsFlow = settingsManager.prefsFlow - val appFlow = prefsFlow.map { it.get("pref_panic_app") }.distinctUntilChanged() - val resetRepos get() = settingsManager.prefs.getBoolean("pref_panic_reset_repos", false) - val exitApp get() = settingsManager.prefs.getBoolean("pref_panic_exit", true) + val prefsFlow = settingsManager.prefsFlow + val appFlow = prefsFlow.map { it.get("pref_panic_app") }.distinctUntilChanged() + val resetRepos + get() = settingsManager.prefs.getBoolean("pref_panic_reset_repos", false) - private val pm = app.packageManager - private val _state = MutableStateFlow(PanicSettingsState()) - val state = _state.asStateFlow() + val exitApp + get() = settingsManager.prefs.getBoolean("pref_panic_exit", true) - init { - ioScope.launch { - val apps = listOf(null) + PanicResponder.resolveTriggerApps(pm).map { info -> - info.activityInfo.toPanicApp() - } - val selected = PanicResponder.getTriggerPackageName(application) - _state.value = PanicSettingsState( - panicApps = apps, - selectedPanicApp = if (selected == null) { - null - } else { - getPanicApp(selected) - }, - ) - } - // react to panic app changes right away - viewModelScope.launch { - appFlow.drop(1).collect { packageName -> - _state.update { - it.copy(selectedPanicApp = getPanicApp(packageName)) - } - } - } + private val pm = app.packageManager + private val _state = MutableStateFlow(PanicSettingsState()) + val state = _state.asStateFlow() + + init { + ioScope.launch { + val apps = + listOf(null) + + PanicResponder.resolveTriggerApps(pm).map { info -> info.activityInfo.toPanicApp() } + val selected = PanicResponder.getTriggerPackageName(application) + _state.value = + PanicSettingsState( + panicApps = apps, + selectedPanicApp = + if (selected == null) { + null + } else { + getPanicApp(selected) + }, + ) } - - fun resetDb() { - val job = ioScope.launch { - db.getRepositoryDao().clearAll() - repoPreLoader.addPreloadedRepositories(db) - } - // hard wait for data to be cleared - runBlocking { - job.join() - } + // react to panic app changes right away + viewModelScope.launch { + appFlow.drop(1).collect { packageName -> + _state.update { it.copy(selectedPanicApp = getPanicApp(packageName)) } + } } + } - private fun getPanicApp(packageName: String?): PanicApp? { - if (packageName == null) return null - return pm.getPackageInfo(packageName, 0)?.applicationInfo?.toPanicApp() - } + fun resetDb() { + val job = + ioScope.launch { + db.getRepositoryDao().clearAll() + repoPreLoader.addPreloadedRepositories(db) + } + // hard wait for data to be cleared + runBlocking { job.join() } + } - private fun ApplicationInfo.toPanicApp() = PanicApp( - packageName = packageName, - name = loadLabel(pm).toString(), - ) + private fun getPanicApp(packageName: String?): PanicApp? { + if (packageName == null) return null + return pm.getPackageInfo(packageName, 0)?.applicationInfo?.toPanicApp() + } - private fun ActivityInfo.toPanicApp() = PanicApp( - packageName = packageName, - name = loadLabel(pm).toString(), - ) + private fun ApplicationInfo.toPanicApp() = + PanicApp(packageName = packageName, name = loadLabel(pm).toString()) + private fun ActivityInfo.toPanicApp() = + PanicApp(packageName = packageName, name = loadLabel(pm).toString()) } diff --git a/app/src/full/kotlin/org/fdroid/ui/settings/ExtraSettings.kt b/app/src/full/kotlin/org/fdroid/ui/settings/ExtraSettings.kt index 36b3a99de..6f300ec49 100644 --- a/app/src/full/kotlin/org/fdroid/ui/settings/ExtraSettings.kt +++ b/app/src/full/kotlin/org/fdroid/ui/settings/ExtraSettings.kt @@ -11,27 +11,27 @@ import org.fdroid.ui.ipfs.IpfsGatewaySettingsActivity import org.fdroid.ui.panic.PanicActivity fun LazyListScope.extraPrivacySettings(context: Context) { - preference( - key = "panic", - icon = {}, - title = { Text(stringResource(R.string.panic_settings)) }, - summary = { Text(stringResource(R.string.panic_settings_summary)) }, - onClick = { - val intent = Intent(context, PanicActivity::class.java) - context.startActivity(intent) - }, - ) + preference( + key = "panic", + icon = {}, + title = { Text(stringResource(R.string.panic_settings)) }, + summary = { Text(stringResource(R.string.panic_settings_summary)) }, + onClick = { + val intent = Intent(context, PanicActivity::class.java) + context.startActivity(intent) + }, + ) } fun LazyListScope.extraNetworkSettings(context: Context) { - preference( - key = "ipfsGateways", - icon = {}, - title = { Text(stringResource(R.string.ipfsgw_title)) }, - summary = { Text(stringResource(R.string.ipfsgw_summary_new)) }, - onClick = { - val intent = Intent(context, IpfsGatewaySettingsActivity::class.java) - context.startActivity(intent) - }, - ) + preference( + key = "ipfsGateways", + icon = {}, + title = { Text(stringResource(R.string.ipfsgw_title)) }, + summary = { Text(stringResource(R.string.ipfsgw_summary_new)) }, + onClick = { + val intent = Intent(context, IpfsGatewaySettingsActivity::class.java) + context.startActivity(intent) + }, + ) } diff --git a/app/src/main/kotlin/org/fdroid/App.kt b/app/src/main/kotlin/org/fdroid/App.kt index 3d8dc7af8..4571378a8 100644 --- a/app/src/main/kotlin/org/fdroid/App.kt +++ b/app/src/main/kotlin/org/fdroid/App.kt @@ -17,6 +17,7 @@ import coil3.memory.MemoryCache import coil3.request.crossfade import coil3.util.DebugLogger import dagger.hilt.android.HiltAndroidApp +import javax.inject.Inject import org.acra.ACRA import org.acra.ReportField import org.acra.config.dialog @@ -35,109 +36,95 @@ import org.fdroid.ui.crash.CrashActivity import org.fdroid.ui.crash.NoRetryPolicy import org.fdroid.ui.utils.applyNewTheme import org.fdroid.updates.AppUpdateWorker -import javax.inject.Inject @HiltAndroidApp class App : Application(), Configuration.Provider, SingletonImageLoader.Factory { - @Inject - lateinit var settingsManager: SettingsManager + @Inject lateinit var settingsManager: SettingsManager - @Inject - lateinit var workerFactory: HiltWorkerFactory + @Inject lateinit var workerFactory: HiltWorkerFactory - @Inject - lateinit var downloadRequestFetcherFactory: DownloadRequestFetcher.Factory + @Inject lateinit var downloadRequestFetcherFactory: DownloadRequestFetcher.Factory - override val workManagerConfiguration: Configuration - get() = Configuration.Builder() - .setWorkerFactory(workerFactory) - .build() + override val workManagerConfiguration: Configuration + get() = Configuration.Builder().setWorkerFactory(workerFactory).build() - override fun attachBaseContext(base: Context) { - super.attachBaseContext(base) - initAcra { - reportFormat = JSON - reportContent = listOf( - ReportField.USER_COMMENT, - ReportField.PACKAGE_NAME, - ReportField.APP_VERSION_NAME, - ReportField.ANDROID_VERSION, - ReportField.PRODUCT, - ReportField.BRAND, - ReportField.PHONE_MODEL, - ReportField.DISPLAY, - ReportField.TOTAL_MEM_SIZE, - ReportField.AVAILABLE_MEM_SIZE, - ReportField.CUSTOM_DATA, - ReportField.STACK_TRACE_HASH, - ReportField.STACK_TRACE, - ) - reportSendFailureToast = getString(R.string.crash_report_error) - // either sending via email intent works, or it doesn't, but don't keep trying - retryPolicyClass = NoRetryPolicy::class.java - sendReportsInDevMode = true - dialog { - reportDialogClass = CrashActivity::class.java - } - mailSender { - mailTo = BuildConfig.ACRA_REPORT_EMAIL - subject = "$APPLICATION_ID $VERSION_NAME: Crash Report" - reportFileName = "ACRA-report.stacktrace.json" - } - } + override fun attachBaseContext(base: Context) { + super.attachBaseContext(base) + initAcra { + reportFormat = JSON + reportContent = + listOf( + ReportField.USER_COMMENT, + ReportField.PACKAGE_NAME, + ReportField.APP_VERSION_NAME, + ReportField.ANDROID_VERSION, + ReportField.PRODUCT, + ReportField.BRAND, + ReportField.PHONE_MODEL, + ReportField.DISPLAY, + ReportField.TOTAL_MEM_SIZE, + ReportField.AVAILABLE_MEM_SIZE, + ReportField.CUSTOM_DATA, + ReportField.STACK_TRACE_HASH, + ReportField.STACK_TRACE, + ) + reportSendFailureToast = getString(R.string.crash_report_error) + // either sending via email intent works, or it doesn't, but don't keep trying + retryPolicyClass = NoRetryPolicy::class.java + sendReportsInDevMode = true + dialog { reportDialogClass = CrashActivity::class.java } + mailSender { + mailTo = BuildConfig.ACRA_REPORT_EMAIL + subject = "$APPLICATION_ID $VERSION_NAME: Crash Report" + reportFileName = "ACRA-report.stacktrace.json" + } } + } - @OptIn(ExperimentalComposeRuntimeApi::class) - override fun onCreate() { - super.onCreate() - if (BuildConfig.DEBUG) { - Composer.setDiagnosticStackTraceMode(ComposeStackTraceMode.SourceInformation) - } - applyNewTheme(settingsManager.theme) - // bail out here if we are the ACRA process to not initialize anything in crash process - if (isAcraProces()) return - - RepoUpdateWorker.scheduleOrCancel(applicationContext, settingsManager.repoUpdates) - AppUpdateWorker.scheduleOrCancel(applicationContext, settingsManager.autoUpdateApps) + @OptIn(ExperimentalComposeRuntimeApi::class) + override fun onCreate() { + super.onCreate() + if (BuildConfig.DEBUG) { + Composer.setDiagnosticStackTraceMode(ComposeStackTraceMode.SourceInformation) } + applyNewTheme(settingsManager.theme) + // bail out here if we are the ACRA process to not initialize anything in crash process + if (isAcraProces()) return - private fun isAcraProces(): Boolean { - return if (SDK_INT >= 28) { - val processName = getProcessName().split(':') - processName.size > 1 && processName[1] == "acra" - } else { - // FIXME this does disk I/O - ACRA.isACRASenderServiceProcess() - } + RepoUpdateWorker.scheduleOrCancel(applicationContext, settingsManager.repoUpdates) + AppUpdateWorker.scheduleOrCancel(applicationContext, settingsManager.autoUpdateApps) + } + + private fun isAcraProces(): Boolean { + return if (SDK_INT >= 28) { + val processName = getProcessName().split(':') + processName.size > 1 && processName[1] == "acra" + } else { + // FIXME this does disk I/O + ACRA.isACRASenderServiceProcess() } + } - override fun newImageLoader(context: Context): ImageLoader { - return ImageLoader.Builder(context) - .crossfade(true) - .components { - val downloadRequestKeyer = Keyer { data, _ -> data.getCacheKey() } - add(downloadRequestKeyer) - add(downloadRequestFetcherFactory) + override fun newImageLoader(context: Context): ImageLoader { + return ImageLoader.Builder(context) + .crossfade(true) + .components { + val downloadRequestKeyer = Keyer { data, _ -> data.getCacheKey() } + add(downloadRequestKeyer) + add(downloadRequestFetcherFactory) - val packageNameKeyer = Keyer { data, _ -> data.packageName } - add(packageNameKeyer) - add(LocalIconFetcher.Factory(context, downloadRequestFetcherFactory)) - } - .memoryCache { - MemoryCache.Builder() - .maxSizePercent(context, 0.25) - .build() - } - .diskCache { - DiskCache.Builder() - .directory(context.cacheDir.resolve("coil")) - .maxSizePercent(0.05) - .build() - } - .logger(if (BuildConfig.DEBUG) DebugLogger() else null) - .build() - } + val packageNameKeyer = Keyer { data, _ -> data.packageName } + add(packageNameKeyer) + add(LocalIconFetcher.Factory(context, downloadRequestFetcherFactory)) + } + .memoryCache { MemoryCache.Builder().maxSizePercent(context, 0.25).build() } + .diskCache { + DiskCache.Builder().directory(context.cacheDir.resolve("coil")).maxSizePercent(0.05).build() + } + .logger(if (BuildConfig.DEBUG) DebugLogger() else null) + .build() + } } fun DownloadRequest.getCacheKey() = indexFile.sha256 ?: (mirrors[0].baseUrl + indexFile.name) diff --git a/app/src/main/kotlin/org/fdroid/MainActivity.kt b/app/src/main/kotlin/org/fdroid/MainActivity.kt index 7786407bf..a8d993b65 100644 --- a/app/src/main/kotlin/org/fdroid/MainActivity.kt +++ b/app/src/main/kotlin/org/fdroid/MainActivity.kt @@ -12,49 +12,49 @@ import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject import kotlinx.coroutines.launch import org.fdroid.settings.SettingsManager import org.fdroid.ui.Main -import javax.inject.Inject // Using [AppCompatActivity] and not [ComponentActivity] seems to be needed // for automatic theme changes when calling AppCompatDelegate.setDefaultNightMode() @AndroidEntryPoint class MainActivity : AppCompatActivity() { - val requestPermissionLauncher = registerForActivityResult(RequestPermission()) {} + val requestPermissionLauncher = registerForActivityResult(RequestPermission()) {} - @Inject - lateinit var settingsManager: SettingsManager + @Inject lateinit var settingsManager: SettingsManager - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - // observe preventScreenshots setting and react to changes - lifecycleScope.launch { - // this flow doesn't change when we are paused, so we keep collecting it - settingsManager.preventScreenshotsFlow.collect { preventScreenshots -> - if (preventScreenshots) { - window?.addFlags(FLAG_SECURE) - } else { - window?.clearFlags(FLAG_SECURE) - } - } - } - enableEdgeToEdge() - setContent { - Main { - // inform OnNewIntentListeners about the initial intent (otherwise would be missed) - if (intent != null) { - onNewIntent(intent) - // set intent to null to avoid re-processing on configuration changes - intent = null - } - } - } - if (SDK_INT >= 33 && - ContextCompat.checkSelfPermission(this, POST_NOTIFICATIONS) != PERMISSION_GRANTED - ) { - requestPermissionLauncher.launch(POST_NOTIFICATIONS) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // observe preventScreenshots setting and react to changes + lifecycleScope.launch { + // this flow doesn't change when we are paused, so we keep collecting it + settingsManager.preventScreenshotsFlow.collect { preventScreenshots -> + if (preventScreenshots) { + window?.addFlags(FLAG_SECURE) + } else { + window?.clearFlags(FLAG_SECURE) } + } } + enableEdgeToEdge() + setContent { + Main { + // inform OnNewIntentListeners about the initial intent (otherwise would be missed) + if (intent != null) { + onNewIntent(intent) + // set intent to null to avoid re-processing on configuration changes + intent = null + } + } + } + if ( + SDK_INT >= 33 && + ContextCompat.checkSelfPermission(this, POST_NOTIFICATIONS) != PERMISSION_GRANTED + ) { + requestPermissionLauncher.launch(POST_NOTIFICATIONS) + } + } } diff --git a/app/src/main/kotlin/org/fdroid/NotificationManager.kt b/app/src/main/kotlin/org/fdroid/NotificationManager.kt index 62e1f85c0..4ed97784f 100644 --- a/app/src/main/kotlin/org/fdroid/NotificationManager.kt +++ b/app/src/main/kotlin/org/fdroid/NotificationManager.kt @@ -19,183 +19,181 @@ import androidx.core.app.NotificationManagerCompat.IMPORTANCE_DEFAULT import androidx.core.app.NotificationManagerCompat.IMPORTANCE_LOW import androidx.core.content.ContextCompat.checkSelfPermission import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton import mu.KotlinLogging import org.fdroid.install.InstallNotificationState import org.fdroid.ui.navigation.IntentRouter.Companion.ACTION_MY_APPS import org.fdroid.updates.UpdateNotificationState -import javax.inject.Inject -import javax.inject.Singleton @Singleton -class NotificationManager @Inject constructor( - @param:ApplicationContext private val context: Context, -) { +class NotificationManager +@Inject +constructor(@param:ApplicationContext private val context: Context) { - private val log = KotlinLogging.logger {} - private val nm = NotificationManagerCompat.from(context) - private var lastRepoUpdateNotification = 0L + private val log = KotlinLogging.logger {} + private val nm = NotificationManagerCompat.from(context) + private var lastRepoUpdateNotification = 0L - companion object { - const val NOTIFICATION_ID_REPO_UPDATE: Int = 0 - const val NOTIFICATION_ID_APP_INSTALLS: Int = 1 - const val NOTIFICATION_ID_APP_INSTALL_SUCCESS: Int = 2 - const val NOTIFICATION_ID_APP_UPDATES_AVAILABLE: Int = 3 - private const val CHANNEL_UPDATES = "update-channel" - private const val CHANNEL_INSTALLS = "install-channel" - private const val CHANNEL_INSTALL_SUCCESS = "install-success-channel" - private const val CHANNEL_UPDATES_AVAILABLE = "updates-available-channel" + companion object { + const val NOTIFICATION_ID_REPO_UPDATE: Int = 0 + const val NOTIFICATION_ID_APP_INSTALLS: Int = 1 + const val NOTIFICATION_ID_APP_INSTALL_SUCCESS: Int = 2 + const val NOTIFICATION_ID_APP_UPDATES_AVAILABLE: Int = 3 + private const val CHANNEL_UPDATES = "update-channel" + private const val CHANNEL_INSTALLS = "install-channel" + private const val CHANNEL_INSTALL_SUCCESS = "install-success-channel" + private const val CHANNEL_UPDATES_AVAILABLE = "updates-available-channel" + } + + init { + createNotificationChannels() + } + + private fun createNotificationChannels() { + val channels = + listOf( + NotificationChannelCompat.Builder(CHANNEL_UPDATES, IMPORTANCE_LOW) + .setName(s(R.string.notification_channel_updates_title)) + .setDescription(s(R.string.notification_channel_updates_description)) + .build(), + NotificationChannelCompat.Builder(CHANNEL_INSTALLS, IMPORTANCE_LOW) + .setName(s(R.string.notification_channel_installs_title)) + .setDescription(s(R.string.notification_channel_installs_description)) + .build(), + NotificationChannelCompat.Builder(CHANNEL_INSTALL_SUCCESS, IMPORTANCE_LOW) + .setName(s(R.string.notification_channel_install_success_title)) + .setDescription(s(R.string.notification_channel_install_success_description)) + .build(), + NotificationChannelCompat.Builder(CHANNEL_UPDATES_AVAILABLE, IMPORTANCE_DEFAULT) + .setName(s(R.string.notification_channel_updates_available_title)) + .setDescription(s(R.string.notification_channel_updates_available_description)) + .build(), + ) + nm.createNotificationChannelsCompat(channels) + } + + fun showUpdateRepoNotification(msg: String, throttle: Boolean = true, progress: Int? = null) { + if (!throttle || System.currentTimeMillis() - lastRepoUpdateNotification > 500) { + val n = getRepoUpdateNotification(msg, progress).build() + lastRepoUpdateNotification = System.currentTimeMillis() + if (checkSelfPermission(context, POST_NOTIFICATIONS) == PERMISSION_GRANTED) { + nm.notify(NOTIFICATION_ID_REPO_UPDATE, n) + } } + } - init { - createNotificationChannels() + fun cancelUpdateRepoNotification() { + nm.cancel(NOTIFICATION_ID_REPO_UPDATE) + } + + fun getRepoUpdateNotification(msg: String? = null, progress: Int? = null) = + NotificationCompat.Builder(context, CHANNEL_UPDATES) + .setSmallIcon(R.drawable.ic_refresh) + .setCategory(CATEGORY_SERVICE) + .setContentTitle(context.getString(R.string.banner_updating_repositories)) + .setContentText(msg) + .setContentIntent(getMainActivityPendingIntent(context)) + .setOngoing(true) + .setProgress(100, progress ?: 0, progress == null) + + fun showAppUpdatesAvailableNotification(notificationState: UpdateNotificationState) { + val n = getAppUpdatesAvailableNotification(notificationState).build() + if (checkSelfPermission(context, POST_NOTIFICATIONS) == PERMISSION_GRANTED) { + nm.notify(NOTIFICATION_ID_APP_UPDATES_AVAILABLE, n) } + } - private fun createNotificationChannels() { - val channels = listOf( - NotificationChannelCompat.Builder(CHANNEL_UPDATES, IMPORTANCE_LOW) - .setName(s(R.string.notification_channel_updates_title)) - .setDescription(s(R.string.notification_channel_updates_description)) - .build(), - NotificationChannelCompat.Builder(CHANNEL_INSTALLS, IMPORTANCE_LOW) - .setName(s(R.string.notification_channel_installs_title)) - .setDescription(s(R.string.notification_channel_installs_description)) - .build(), - NotificationChannelCompat.Builder(CHANNEL_INSTALL_SUCCESS, IMPORTANCE_LOW) - .setName(s(R.string.notification_channel_install_success_title)) - .setDescription(s(R.string.notification_channel_install_success_description)) - .build(), - NotificationChannelCompat.Builder(CHANNEL_UPDATES_AVAILABLE, IMPORTANCE_DEFAULT) - .setName(s(R.string.notification_channel_updates_available_title)) - .setDescription(s(R.string.notification_channel_updates_available_description)) - .build(), - ) - nm.createNotificationChannelsCompat(channels) + private fun getAppUpdatesAvailableNotification( + state: UpdateNotificationState + ): NotificationCompat.Builder { + val pi = getMyAppsPendingIntent(context) + return NotificationCompat.Builder(context, CHANNEL_UPDATES_AVAILABLE) + .setSmallIcon(R.drawable.ic_notification) + .setPriority(PRIORITY_HIGH) + .setContentTitle(state.getTitle(context)) + .setContentIntent(pi) + .setStyle(BigTextStyle().bigText(state.getBigText())) + .setOngoing(false) + .setAutoCancel(true) + } + + val isAppUpdatesAvailableNotificationShowing: Boolean + get() = + nm.activeNotifications.any { notification -> + notification.id == NOTIFICATION_ID_APP_UPDATES_AVAILABLE + } + + fun cancelAppUpdatesAvailableNotification() { + log.info { "cancel app updates available notification" } + nm.cancel(NOTIFICATION_ID_APP_UPDATES_AVAILABLE) + } + + fun showAppInstallNotification(installNotificationState: InstallNotificationState) { + // TODO we may need some throttling when many apps download at the same time + val n = getAppInstallNotification(installNotificationState).build() + if (checkSelfPermission(context, POST_NOTIFICATIONS) == PERMISSION_GRANTED) { + log.debug { "Show app install notification" } + nm.notify(NOTIFICATION_ID_APP_INSTALLS, n) } + } - fun showUpdateRepoNotification(msg: String, throttle: Boolean = true, progress: Int? = null) { - if (!throttle || System.currentTimeMillis() - lastRepoUpdateNotification > 500) { - val n = getRepoUpdateNotification(msg, progress).build() - lastRepoUpdateNotification = System.currentTimeMillis() - if (checkSelfPermission(context, POST_NOTIFICATIONS) == PERMISSION_GRANTED) { - nm.notify(NOTIFICATION_ID_REPO_UPDATE, n) - } - } - } - - fun cancelUpdateRepoNotification() { - nm.cancel(NOTIFICATION_ID_REPO_UPDATE) - } - - fun getRepoUpdateNotification( - msg: String? = null, - progress: Int? = null, - ) = NotificationCompat.Builder(context, CHANNEL_UPDATES) - .setSmallIcon(R.drawable.ic_refresh) + fun getAppInstallNotification(state: InstallNotificationState): NotificationCompat.Builder { + val pi = getMyAppsPendingIntent(context) + val builder = + NotificationCompat.Builder(context, CHANNEL_INSTALLS) + .setSmallIcon(R.drawable.ic_notification) .setCategory(CATEGORY_SERVICE) - .setContentTitle(context.getString(R.string.banner_updating_repositories)) - .setContentText(msg) - .setContentIntent(getMainActivityPendingIntent(context)) - .setOngoing(true) - .setProgress(100, progress ?: 0, progress == null) - - fun showAppUpdatesAvailableNotification(notificationState: UpdateNotificationState) { - val n = getAppUpdatesAvailableNotification(notificationState).build() - if (checkSelfPermission(context, POST_NOTIFICATIONS) == PERMISSION_GRANTED) { - nm.notify(NOTIFICATION_ID_APP_UPDATES_AVAILABLE, n) + .setContentTitle(state.getTitle(context)) + .setStyle(BigTextStyle().bigText(state.getBigText(context))) + .setContentIntent(pi) + .setOngoing(state.isInstallingSomeApp) + .apply { + if (state.isInstallingSomeApp) { + setProgress(100, state.percent ?: 0, state.percent == null) + } } - } + return builder + } - private fun getAppUpdatesAvailableNotification( - state: UpdateNotificationState, - ): NotificationCompat.Builder { - val pi = getMyAppsPendingIntent(context) - return NotificationCompat.Builder(context, CHANNEL_UPDATES_AVAILABLE) - .setSmallIcon(R.drawable.ic_notification) - .setPriority(PRIORITY_HIGH) - .setContentTitle(state.getTitle(context)) - .setContentIntent(pi) - .setStyle(BigTextStyle().bigText(state.getBigText())) - .setOngoing(false) - .setAutoCancel(true) - } + fun cancelAppInstallNotification() { + log.debug { "Cancel app install notification" } + nm.cancel(NOTIFICATION_ID_APP_INSTALLS) + } - val isAppUpdatesAvailableNotificationShowing: Boolean - get() = nm.activeNotifications.any { notification -> - notification.id == NOTIFICATION_ID_APP_UPDATES_AVAILABLE - } - - fun cancelAppUpdatesAvailableNotification() { - log.info { "cancel app updates available notification" } - nm.cancel(NOTIFICATION_ID_APP_UPDATES_AVAILABLE) + fun showInstallSuccessNotification(installNotificationState: InstallNotificationState) { + val n = getInstallSuccessNotification(installNotificationState).build() + if (checkSelfPermission(context, POST_NOTIFICATIONS) == PERMISSION_GRANTED) { + nm.notify(NOTIFICATION_ID_APP_INSTALL_SUCCESS, n) } + } - fun showAppInstallNotification(installNotificationState: InstallNotificationState) { - // TODO we may need some throttling when many apps download at the same time - val n = getAppInstallNotification(installNotificationState).build() - if (checkSelfPermission(context, POST_NOTIFICATIONS) == PERMISSION_GRANTED) { - log.debug { "Show app install notification" } - nm.notify(NOTIFICATION_ID_APP_INSTALLS, n) - } - } + fun getInstallSuccessNotification(state: InstallNotificationState): NotificationCompat.Builder { + val pi = getMyAppsPendingIntent(context) + val builder = + NotificationCompat.Builder(context, CHANNEL_INSTALL_SUCCESS) + .setSmallIcon(R.drawable.ic_notification) + .setCategory(CATEGORY_SERVICE) + .setContentTitle(state.getSuccessTitle(context)) + .setStyle(BigTextStyle().bigText(state.getSuccessBigText())) + .setContentIntent(pi) + .setAutoCancel(true) + return builder + } - fun getAppInstallNotification(state: InstallNotificationState): NotificationCompat.Builder { - val pi = getMyAppsPendingIntent(context) - val builder = NotificationCompat.Builder(context, CHANNEL_INSTALLS) - .setSmallIcon(R.drawable.ic_notification) - .setCategory(CATEGORY_SERVICE) - .setContentTitle(state.getTitle(context)) - .setStyle(BigTextStyle().bigText(state.getBigText(context))) - .setContentIntent(pi) - .setOngoing(state.isInstallingSomeApp) - .apply { - if (state.isInstallingSomeApp) { - setProgress(100, state.percent ?: 0, state.percent == null) - } - } - return builder - } + private fun getMainActivityPendingIntent(context: Context): PendingIntent { + val i = Intent(ACTION_MAIN).apply { setClass(context, MainActivity::class.java) } + val flags = FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE + return PendingIntent.getActivity(context, 0, i, flags) + } - fun cancelAppInstallNotification() { - log.debug { "Cancel app install notification" } - nm.cancel(NOTIFICATION_ID_APP_INSTALLS) - } + private fun getMyAppsPendingIntent(context: Context): PendingIntent { + val i = Intent(ACTION_MY_APPS).apply { setClass(context, MainActivity::class.java) } + val flags = FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE + return PendingIntent.getActivity(context, 0, i, flags) + } - fun showInstallSuccessNotification(installNotificationState: InstallNotificationState) { - val n = getInstallSuccessNotification(installNotificationState).build() - if (checkSelfPermission(context, POST_NOTIFICATIONS) == PERMISSION_GRANTED) { - nm.notify(NOTIFICATION_ID_APP_INSTALL_SUCCESS, n) - } - } - - fun getInstallSuccessNotification(state: InstallNotificationState): NotificationCompat.Builder { - val pi = getMyAppsPendingIntent(context) - val builder = NotificationCompat.Builder(context, CHANNEL_INSTALL_SUCCESS) - .setSmallIcon(R.drawable.ic_notification) - .setCategory(CATEGORY_SERVICE) - .setContentTitle(state.getSuccessTitle(context)) - .setStyle(BigTextStyle().bigText(state.getSuccessBigText())) - .setContentIntent(pi) - .setAutoCancel(true) - return builder - } - - private fun getMainActivityPendingIntent(context: Context): PendingIntent { - val i = Intent(ACTION_MAIN).apply { - setClass(context, MainActivity::class.java) - } - val flags = FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE - return PendingIntent.getActivity(context, 0, i, flags) - } - - private fun getMyAppsPendingIntent(context: Context): PendingIntent { - val i = Intent(ACTION_MY_APPS).apply { - setClass(context, MainActivity::class.java) - } - val flags = FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE - return PendingIntent.getActivity(context, 0, i, flags) - } - - private fun s(@StringRes id: Int): String { - return context.getString(id) - } + private fun s(@StringRes id: Int): String { + return context.getString(id) + } } diff --git a/app/src/main/kotlin/org/fdroid/db/DatabaseModule.kt b/app/src/main/kotlin/org/fdroid/db/DatabaseModule.kt index 80ae0f43a..52c9f9f6d 100644 --- a/app/src/main/kotlin/org/fdroid/db/DatabaseModule.kt +++ b/app/src/main/kotlin/org/fdroid/db/DatabaseModule.kt @@ -6,19 +6,19 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton import org.fdroid.database.FDroidDatabase import org.fdroid.database.FDroidDatabaseHolder -import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object DatabaseModule { - @Provides - @Singleton - fun provideFDroidDatabase( - @ApplicationContext context: Context, - initialData: InitialData, - ): FDroidDatabase { - return FDroidDatabaseHolder.getDb(context, "fdroid_db", initialData) - } + @Provides + @Singleton + fun provideFDroidDatabase( + @ApplicationContext context: Context, + initialData: InitialData, + ): FDroidDatabase { + return FDroidDatabaseHolder.getDb(context, "fdroid_db", initialData) + } } diff --git a/app/src/main/kotlin/org/fdroid/db/InitialData.kt b/app/src/main/kotlin/org/fdroid/db/InitialData.kt index bf8923ce9..b57f1080c 100644 --- a/app/src/main/kotlin/org/fdroid/db/InitialData.kt +++ b/app/src/main/kotlin/org/fdroid/db/InitialData.kt @@ -1,18 +1,16 @@ package org.fdroid.db +import javax.inject.Inject +import javax.inject.Singleton import org.fdroid.database.FDroidDatabase import org.fdroid.database.FDroidFixture import org.fdroid.repo.RepoPreLoader -import javax.inject.Inject -import javax.inject.Singleton @Singleton -class InitialData @Inject constructor( - private val repoPreLoader: RepoPreLoader, -) : FDroidFixture { - override fun prePopulateDb(db: FDroidDatabase) { - repoPreLoader.addPreloadedRepositories(db) - // we are kicking off the initial update from the UI, - // not here to account for metered connection - } +class InitialData @Inject constructor(private val repoPreLoader: RepoPreLoader) : FDroidFixture { + override fun prePopulateDb(db: FDroidDatabase) { + repoPreLoader.addPreloadedRepositories(db) + // we are kicking off the initial update from the UI, + // not here to account for metered connection + } } diff --git a/app/src/main/kotlin/org/fdroid/download/DnsCache.kt b/app/src/main/kotlin/org/fdroid/download/DnsCache.kt index fa8578e2f..513e52e3a 100644 --- a/app/src/main/kotlin/org/fdroid/download/DnsCache.kt +++ b/app/src/main/kotlin/org/fdroid/download/DnsCache.kt @@ -1,105 +1,103 @@ package org.fdroid.download -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import mu.KotlinLogging -import org.fdroid.settings.SettingsManager import java.net.InetAddress import java.net.UnknownHostException import javax.inject.Inject import javax.inject.Singleton import kotlin.concurrent.atomics.AtomicBoolean import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import mu.KotlinLogging +import org.fdroid.settings.SettingsManager @OptIn(ExperimentalAtomicApi::class) @Singleton -class DnsCache @Inject constructor( - private val settingsManager: SettingsManager -) { +class DnsCache @Inject constructor(private val settingsManager: SettingsManager) { - private val log = KotlinLogging.logger {} + private val log = KotlinLogging.logger {} - private var cache: MutableMap> - private var writeScheduled: AtomicBoolean = AtomicBoolean(false) + private var cache: MutableMap> + private var writeScheduled: AtomicBoolean = AtomicBoolean(false) - init { - cache = stringToIpMap(settingsManager.dnsCache).toMutableMap() + init { + cache = stringToIpMap(settingsManager.dnsCache).toMutableMap() + } + + fun insert(hostname: String, ipList: List) { + cache[hostname] = ipList + cacheWrite() + } + + fun remove(hostname: String) { + val ipList = cache.remove(hostname) + if (ipList != null) { + cacheWrite() } + } - fun insert(hostname: String, ipList: List) { - cache[hostname] = ipList - cacheWrite() + fun lookup(hostname: String): List? { + return if (settingsManager.useDnsCache) { + cache[hostname] + } else { + null } + } - fun remove(hostname: String) { - val ipList = cache.remove(hostname) - if (ipList != null) { - cacheWrite() + private fun cacheWrite() { + if (writeScheduled.compareAndSet(expectedValue = false, newValue = true)) { + MainScope().launch { + delay(1000L) + settingsManager.dnsCache = ipMapToString(cache) + writeScheduled.store(false) + } + } + } + + private fun ipMapToString(ipMap: Map>): String { + try { + var output = "" + ipMap.forEach { (key, addresses) -> + if (!output.isEmpty()) { + output += "\n" } - } - - fun lookup(hostname: String): List? { - return if (settingsManager.useDnsCache) { - cache[hostname] - } else { - null + output += key + for (item in addresses) { + if (!output.isEmpty()) { + output += " " + } + output += item.hostAddress } + } + return output + } catch (e: Exception) { + log.error(e) { "Error converting IP map to string, returning empty string: " } + return "" } + } - private fun cacheWrite() { - if (writeScheduled.compareAndSet(expectedValue = false, newValue = true)) { - MainScope().launch { - delay(1000L) - settingsManager.dnsCache = ipMapToString(cache) - writeScheduled.store(false) - } - } - } - - private fun ipMapToString(ipMap: Map>): String { - try { - var output = "" - ipMap.forEach { (key, addresses) -> - if (!output.isEmpty()) { - output += "\n" - } - output += key - for (item in addresses) { - if (!output.isEmpty()) { - output += " " - } - output += item.hostAddress - } - } - return output - } catch (e: Exception) { - log.error(e) { "Error converting IP map to string, returning empty string: " } - return "" - } - } - - private fun stringToIpMap(string: String): Map> { - try { - val output = mutableMapOf>() - for (line in string.split("\n")) { - val items = line.split(" ").toMutableList() - val key = items.removeAt(0) - val ipList = mutableListOf() - for (ip in items) { - try { - ipList.add(InetAddress.getByName(ip)) - } catch (e: UnknownHostException) { - // should not occur, if an ip address is supplied only the format is checked - log.error(e) { "Error parsing IP address, moving on to next item: " } - } - } - output[key] = ipList - } - return output - } catch (e: Exception) { - log.error(e) { "Error converting string to IP map, returning empty map: " } - return emptyMap() + private fun stringToIpMap(string: String): Map> { + try { + val output = mutableMapOf>() + for (line in string.split("\n")) { + val items = line.split(" ").toMutableList() + val key = items.removeAt(0) + val ipList = mutableListOf() + for (ip in items) { + try { + ipList.add(InetAddress.getByName(ip)) + } catch (e: UnknownHostException) { + // should not occur, if an ip address is supplied only the format is checked + log.error(e) { "Error parsing IP address, moving on to next item: " } + } } + output[key] = ipList + } + return output + } catch (e: Exception) { + log.error(e) { "Error converting string to IP map, returning empty map: " } + return emptyMap() } + } } diff --git a/app/src/main/kotlin/org/fdroid/download/DnsWithCache.kt b/app/src/main/kotlin/org/fdroid/download/DnsWithCache.kt index 084309099..7e3642566 100644 --- a/app/src/main/kotlin/org/fdroid/download/DnsWithCache.kt +++ b/app/src/main/kotlin/org/fdroid/download/DnsWithCache.kt @@ -1,59 +1,58 @@ package org.fdroid.download -import okhttp3.Dns -import org.fdroid.settings.SettingsManager import java.net.InetAddress import java.net.UnknownHostException import javax.inject.Inject import javax.inject.Singleton +import okhttp3.Dns +import org.fdroid.settings.SettingsManager @Singleton -class DnsWithCache @Inject constructor( - private val settingsManager: SettingsManager, - private val cache: DnsCache -) : Dns { +class DnsWithCache +@Inject +constructor(private val settingsManager: SettingsManager, private val cache: DnsCache) : Dns { - override fun lookup(hostname: String): List { - if (!settingsManager.useDnsCache) { - return Dns.SYSTEM.lookup(hostname) - } - var ipList = cache.lookup(hostname) - if (ipList == null) { - ipList = Dns.SYSTEM.lookup(hostname) - cache.insert(hostname, ipList) - } - return ipList + override fun lookup(hostname: String): List { + if (!settingsManager.useDnsCache) { + return Dns.SYSTEM.lookup(hostname) } + var ipList = cache.lookup(hostname) + if (ipList == null) { + ipList = Dns.SYSTEM.lookup(hostname) + cache.insert(hostname, ipList) + } + return ipList + } - /** - * in case a host is unreachable, check whether the cached dns result is different from - * the current result. if the cached result is different, remove that result from the - * cache. returns true if a cached result was removed, indicating that the connection - * should be retried, otherwise returns false. - */ - fun shouldRetryRequest(hostname: String): Boolean { - if (!settingsManager.useDnsCache) { - // the cache feature was not enabled, so a cached result didn't cause the failure - return false - } - // if no cached result was found, a cached result didn't cause the failure - val ipList = cache.lookup(hostname) ?: return false - try { - val dnsList = Dns.SYSTEM.lookup(hostname) - for (address in dnsList) { - if (!ipList.contains(address)) { - // the cached result doesn't match the current dns result, - // so the connection should be retried - cache.remove(hostname) - return true - } - } - // the cached result matches the current dns result, - // so a cached result didn't cause the failure - return false - } catch (_: UnknownHostException) { - // the url returned an unknown host exception, so there's no point in retrying - return false - } + /** + * in case a host is unreachable, check whether the cached dns result is different from the + * current result. if the cached result is different, remove that result from the cache. returns + * true if a cached result was removed, indicating that the connection should be retried, + * otherwise returns false. + */ + fun shouldRetryRequest(hostname: String): Boolean { + if (!settingsManager.useDnsCache) { + // the cache feature was not enabled, so a cached result didn't cause the failure + return false } + // if no cached result was found, a cached result didn't cause the failure + val ipList = cache.lookup(hostname) ?: return false + try { + val dnsList = Dns.SYSTEM.lookup(hostname) + for (address in dnsList) { + if (!ipList.contains(address)) { + // the cached result doesn't match the current dns result, + // so the connection should be retried + cache.remove(hostname) + return true + } + } + // the cached result matches the current dns result, + // so a cached result didn't cause the failure + return false + } catch (_: UnknownHostException) { + // the url returned an unknown host exception, so there's no point in retrying + return false + } + } } diff --git a/app/src/main/kotlin/org/fdroid/download/DownloadModule.kt b/app/src/main/kotlin/org/fdroid/download/DownloadModule.kt index 0d6d85a1d..7a230fde5 100644 --- a/app/src/main/kotlin/org/fdroid/download/DownloadModule.kt +++ b/app/src/main/kotlin/org/fdroid/download/DownloadModule.kt @@ -4,34 +4,33 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton import org.fdroid.BuildConfig import org.fdroid.settings.SettingsManager -import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object DownloadModule { - private const val USER_AGENT = "F-Droid ${BuildConfig.VERSION_NAME}" + private const val USER_AGENT = "F-Droid ${BuildConfig.VERSION_NAME}" - @Provides - @Singleton - fun provideHttpManager( - settingsManager: SettingsManager, - dns: DnsWithCache, - manager: FDroidMirrorParameterManager - ): HttpManager { - return HttpManager( - userAgent = USER_AGENT, - proxyConfig = settingsManager.proxyConfig, - customDns = dns, - mirrorParameterManager = manager - ) - } + @Provides + @Singleton + fun provideHttpManager( + settingsManager: SettingsManager, + dns: DnsWithCache, + manager: FDroidMirrorParameterManager, + ): HttpManager { + return HttpManager( + userAgent = USER_AGENT, + proxyConfig = settingsManager.proxyConfig, + customDns = dns, + mirrorParameterManager = manager, + ) + } - @Provides - @Singleton - fun provideDownloaderFactory( - downloaderFactoryImpl: DownloaderFactoryImpl, - ): DownloaderFactory = downloaderFactoryImpl + @Provides + @Singleton + fun provideDownloaderFactory(downloaderFactoryImpl: DownloaderFactoryImpl): DownloaderFactory = + downloaderFactoryImpl } diff --git a/app/src/main/kotlin/org/fdroid/download/DownloaderFactoryImpl.kt b/app/src/main/kotlin/org/fdroid/download/DownloaderFactoryImpl.kt index 08f2cf261..1fdc4e878 100644 --- a/app/src/main/kotlin/org/fdroid/download/DownloaderFactoryImpl.kt +++ b/app/src/main/kotlin/org/fdroid/download/DownloaderFactoryImpl.kt @@ -2,53 +2,56 @@ package org.fdroid.download import android.content.ContentResolver.SCHEME_FILE import android.net.Uri +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton import org.fdroid.IndexFile import org.fdroid.database.Repository import org.fdroid.index.IndexFormatVersion import org.fdroid.settings.SettingsManager -import java.io.File -import javax.inject.Inject -import javax.inject.Singleton @Singleton -class DownloaderFactoryImpl @Inject constructor( - private val httpManager: HttpManager, - private val settingsManager: SettingsManager, - private val interceptor: DownloadRequestInterceptor, +class DownloaderFactoryImpl +@Inject +constructor( + private val httpManager: HttpManager, + private val settingsManager: SettingsManager, + private val interceptor: DownloadRequestInterceptor, ) : DownloaderFactory() { - override fun create( - repo: Repository, - uri: Uri, - indexFile: IndexFile, - destFile: File - ): Downloader { - return create(repo, repo.getMirrors(), uri, indexFile, destFile, null) - } + override fun create( + repo: Repository, + uri: Uri, + indexFile: IndexFile, + destFile: File, + ): Downloader { + return create(repo, repo.getMirrors(), uri, indexFile, destFile, null) + } - override fun create( - repo: Repository, - mirrors: List, - uri: Uri, - indexFile: IndexFile, - destFile: File, - tryFirst: Mirror? - ): Downloader { - val request = DownloadRequest( - indexFile = indexFile, - mirrors = mirrors, - proxy = settingsManager.proxyConfig, - username = repo.username, - password = repo.password, - tryFirstMirror = tryFirst, - ) - val v1OrUnknown = repo.formatVersion == null || repo.formatVersion == IndexFormatVersion.ONE - return if (uri.scheme == SCHEME_FILE) { - LocalFileDownloader(uri, indexFile, destFile) - } else if (v1OrUnknown) { - @Suppress("DEPRECATION") // v1 only - HttpDownloader(httpManager, request, destFile) - } else { - HttpDownloaderV2(httpManager, interceptor.intercept(request), destFile) - } + override fun create( + repo: Repository, + mirrors: List, + uri: Uri, + indexFile: IndexFile, + destFile: File, + tryFirst: Mirror?, + ): Downloader { + val request = + DownloadRequest( + indexFile = indexFile, + mirrors = mirrors, + proxy = settingsManager.proxyConfig, + username = repo.username, + password = repo.password, + tryFirstMirror = tryFirst, + ) + val v1OrUnknown = repo.formatVersion == null || repo.formatVersion == IndexFormatVersion.ONE + return if (uri.scheme == SCHEME_FILE) { + LocalFileDownloader(uri, indexFile, destFile) + } else if (v1OrUnknown) { + @Suppress("DEPRECATION") // v1 only + HttpDownloader(httpManager, request, destFile) + } else { + HttpDownloaderV2(httpManager, interceptor.intercept(request), destFile) } + } } diff --git a/app/src/main/kotlin/org/fdroid/download/FDroidMirrorParameterManager.kt b/app/src/main/kotlin/org/fdroid/download/FDroidMirrorParameterManager.kt index 46465b433..01c0df4e3 100644 --- a/app/src/main/kotlin/org/fdroid/download/FDroidMirrorParameterManager.kt +++ b/app/src/main/kotlin/org/fdroid/download/FDroidMirrorParameterManager.kt @@ -4,39 +4,43 @@ import android.content.Context import android.telephony.TelephonyManager import androidx.core.os.LocaleListCompat import dagger.hilt.android.qualifiers.ApplicationContext -import org.fdroid.settings.SettingsConstants -import org.fdroid.settings.SettingsManager import java.util.Locale import javax.inject.Inject import javax.inject.Singleton import kotlin.concurrent.atomics.ExperimentalAtomicApi +import org.fdroid.settings.SettingsConstants +import org.fdroid.settings.SettingsManager @OptIn(ExperimentalAtomicApi::class) @Singleton -class FDroidMirrorParameterManager @Inject constructor( - @param:ApplicationContext private val context: Context, - private val settingsManager: SettingsManager, - private val dnsWithCache: DnsWithCache +class FDroidMirrorParameterManager +@Inject +constructor( + @param:ApplicationContext private val context: Context, + private val settingsManager: SettingsManager, + private val dnsWithCache: DnsWithCache, ) : MirrorParameterManager { - override fun shouldRetryRequest(mirrorUrl: String): Boolean { - return dnsWithCache.shouldRetryRequest(mirrorUrl) - } + override fun shouldRetryRequest(mirrorUrl: String): Boolean { + return dnsWithCache.shouldRetryRequest(mirrorUrl) + } - // TODO overhaul default MirrorChooser - override fun incrementMirrorErrorCount(mirrorUrl: String) {} + // TODO overhaul default MirrorChooser + override fun incrementMirrorErrorCount(mirrorUrl: String) {} - override fun getMirrorErrorCount(mirrorUrl: String): Int = 0 + override fun getMirrorErrorCount(mirrorUrl: String): Int = 0 - override fun preferForeignMirrors(): Boolean { - return settingsManager.mirrorChooser == SettingsConstants.MirrorChooserValues.PreferForeign - } + override fun preferForeignMirrors(): Boolean { + return settingsManager.mirrorChooser == SettingsConstants.MirrorChooserValues.PreferForeign + } - override fun getCurrentLocation(): String { - val tm = context.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager - return tm.simCountryIso ?: tm.networkCountryIso ?: run { - val localeList = LocaleListCompat.getDefault() - localeList.get(0)?.country ?: Locale.getDefault().country - } - } + override fun getCurrentLocation(): String { + val tm = context.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager + return tm.simCountryIso + ?: tm.networkCountryIso + ?: run { + val localeList = LocaleListCompat.getDefault() + localeList.get(0)?.country ?: Locale.getDefault().country + } + } } diff --git a/app/src/main/kotlin/org/fdroid/download/ImageModel.kt b/app/src/main/kotlin/org/fdroid/download/ImageModel.kt index df433a0ea..86404726f 100644 --- a/app/src/main/kotlin/org/fdroid/download/ImageModel.kt +++ b/app/src/main/kotlin/org/fdroid/download/ImageModel.kt @@ -7,37 +7,37 @@ import org.fdroid.IndexFile import org.fdroid.database.Repository fun IndexFile.getImageModel(repository: Repository?, proxyConfig: ProxyConfig?): Any? { - if (repository == null) return null - val address = repository.address - if (address.startsWith("content://") || address.startsWith("file://")) { - return getUri(address, this) - } - return DownloadRequest( - indexFile = this, - mirrors = repository.getMirrors(), - proxy = proxyConfig, - username = repository.username, - password = repository.password, - ) + if (repository == null) return null + val address = repository.address + if (address.startsWith("content://") || address.startsWith("file://")) { + return getUri(address, this) + } + return DownloadRequest( + indexFile = this, + mirrors = repository.getMirrors(), + proxy = proxyConfig, + username = repository.username, + password = repository.password, + ) } fun getUri(repoAddress: String, indexFile: IndexFile): Uri { - val pathElements = indexFile.name.split("/") - if (repoAddress.startsWith("content://")) { - // This is a hack that won't work with most ContentProviders - // as they don't expose the path in the Uri. - // However, it works for local file storage. - val result = StringBuilder(repoAddress) - for (element in pathElements) { - result.append("%2F") - result.append(element) - } - return result.toString().toUri() - } else { // Normal URL - val result = repoAddress.toUri().buildUpon() - for (element in pathElements) { - result.appendPath(element) - } - return result.build() + val pathElements = indexFile.name.split("/") + if (repoAddress.startsWith("content://")) { + // This is a hack that won't work with most ContentProviders + // as they don't expose the path in the Uri. + // However, it works for local file storage. + val result = StringBuilder(repoAddress) + for (element in pathElements) { + result.append("%2F") + result.append(element) } + return result.toString().toUri() + } else { // Normal URL + val result = repoAddress.toUri().buildUpon() + for (element in pathElements) { + result.appendPath(element) + } + return result.build() + } } diff --git a/app/src/main/kotlin/org/fdroid/download/LocalFileDownloader.kt b/app/src/main/kotlin/org/fdroid/download/LocalFileDownloader.kt index 1f04ca17a..7c5f61afc 100644 --- a/app/src/main/kotlin/org/fdroid/download/LocalFileDownloader.kt +++ b/app/src/main/kotlin/org/fdroid/download/LocalFileDownloader.kt @@ -1,49 +1,44 @@ package org.fdroid.download import android.net.Uri -import org.fdroid.IndexFile import java.io.File import java.io.FileNotFoundException import java.io.InputStream +import org.fdroid.IndexFile /** - * "Downloads" files from `file:///` [Uri]s. Even though it is - * obviously unnecessary to download a file that is locally available, this - * class is here so that the whole security-sensitive installation process is - * the same, no matter where the files are downloaded from. Also, for things - * like icons and graphics, it makes sense to have them copied to the cache so - * that they are available even after removable storage is no longer present. + * "Downloads" files from `file:///` [Uri]s. Even though it is obviously unnecessary to download a + * file that is locally available, this class is here so that the whole security-sensitive + * installation process is the same, no matter where the files are downloaded from. Also, for things + * like icons and graphics, it makes sense to have them copied to the cache so that they are + * available even after removable storage is no longer present. */ -class LocalFileDownloader( - uri: Uri, - indexFile: IndexFile, - destFile: File, -) : Downloader(indexFile, destFile) { - private val sourceFile: File = File(uri.path ?: error("Uri had no path")) +class LocalFileDownloader(uri: Uri, indexFile: IndexFile, destFile: File) : + Downloader(indexFile, destFile) { + private val sourceFile: File = File(uri.path ?: error("Uri had no path")) - override fun getInputStream(resumable: Boolean): InputStream = sourceFile.inputStream() + override fun getInputStream(resumable: Boolean): InputStream = sourceFile.inputStream() - override fun close() {} + override fun close() {} - @Deprecated("Only for v1 repos") - override fun hasChanged(): Boolean = true + @Deprecated("Only for v1 repos") override fun hasChanged(): Boolean = true - override fun totalDownloadSize(): Long = sourceFile.length() + override fun totalDownloadSize(): Long = sourceFile.length() - override fun download() { - if (!sourceFile.exists()) { - throw FileNotFoundException("$sourceFile does not exist") - } - var resumable = false - val contentLength = sourceFile.length() - val fileLength = outputFile.length() - if (fileLength > contentLength) { - outputFile.delete() - } else if (fileLength == contentLength && outputFile.isFile()) { - return // already have it! - } else if (fileLength > 0) { - resumable = true - } - downloadFromStream(resumable) + override fun download() { + if (!sourceFile.exists()) { + throw FileNotFoundException("$sourceFile does not exist") } + var resumable = false + val contentLength = sourceFile.length() + val fileLength = outputFile.length() + if (fileLength > contentLength) { + outputFile.delete() + } else if (fileLength == contentLength && outputFile.isFile()) { + return // already have it! + } else if (fileLength > 0) { + resumable = true + } + downloadFromStream(resumable) + } } diff --git a/app/src/main/kotlin/org/fdroid/download/LocalIconFetcher.kt b/app/src/main/kotlin/org/fdroid/download/LocalIconFetcher.kt index 2b4555c07..7330d5ed5 100644 --- a/app/src/main/kotlin/org/fdroid/download/LocalIconFetcher.kt +++ b/app/src/main/kotlin/org/fdroid/download/LocalIconFetcher.kt @@ -10,60 +10,59 @@ import coil3.fetch.FetchResult import coil3.fetch.Fetcher import coil3.fetch.ImageFetchResult import coil3.request.Options +import javax.inject.Inject import mu.KotlinLogging import org.fdroid.download.coil.DownloadRequestFetcher -import javax.inject.Inject data class PackageName( - val packageName: String, - val iconDownloadRequest: DownloadRequest?, - val warnOnError: Boolean = false, + val packageName: String, + val iconDownloadRequest: DownloadRequest?, + val warnOnError: Boolean = false, ) class LocalIconFetcher( - private val packageManager: PackageManager, - private val data: PackageName, - private val downloadRequestFetcher: Fetcher?, + private val packageManager: PackageManager, + private val data: PackageName, + private val downloadRequestFetcher: Fetcher?, ) : Fetcher { - private val log = KotlinLogging.logger { } + private val log = KotlinLogging.logger {} - override suspend fun fetch(): FetchResult? { - val drawable = try { - val info = packageManager.getApplicationInfo(data.packageName, 0) - info.loadUnbadgedIcon(packageManager) - } catch (e: PackageManager.NameNotFoundException) { - if (data.warnOnError) log.error(e) { "Error getting icon from packageManager: " } - return downloadRequestFetcher?.fetch() - } + override suspend fun fetch(): FetchResult? { + val drawable = + try { + val info = packageManager.getApplicationInfo(data.packageName, 0) + info.loadUnbadgedIcon(packageManager) + } catch (e: PackageManager.NameNotFoundException) { + if (data.warnOnError) log.error(e) { "Error getting icon from packageManager: " } + return downloadRequestFetcher?.fetch() + } - if (SDK_INT >= 30 && packageManager.isDefaultApplicationIcon(drawable)) { - log.warn { - "Could not extract image for ${data.packageName}" - } - return downloadRequestFetcher?.fetch() - } - return ImageFetchResult( - image = drawable.asImage(), - isSampled = false, - dataSource = DataSource.DISK, - ) + if (SDK_INT >= 30 && packageManager.isDefaultApplicationIcon(drawable)) { + log.warn { "Could not extract image for ${data.packageName}" } + return downloadRequestFetcher?.fetch() } + return ImageFetchResult( + image = drawable.asImage(), + isSampled = false, + dataSource = DataSource.DISK, + ) + } - class Factory @Inject constructor( - private val context: Context, - private val downloadRequestFetcherFactory: DownloadRequestFetcher.Factory, - ) : Fetcher.Factory { - override fun create( - data: PackageName, - options: Options, - imageLoader: ImageLoader, - ): Fetcher = LocalIconFetcher( - packageManager = context.packageManager, - data = data, - downloadRequestFetcher = data.iconDownloadRequest?.let { - downloadRequestFetcherFactory.create(it, options, imageLoader) - }, - ) - } + class Factory + @Inject + constructor( + private val context: Context, + private val downloadRequestFetcherFactory: DownloadRequestFetcher.Factory, + ) : Fetcher.Factory { + override fun create(data: PackageName, options: Options, imageLoader: ImageLoader): Fetcher = + LocalIconFetcher( + packageManager = context.packageManager, + data = data, + downloadRequestFetcher = + data.iconDownloadRequest?.let { + downloadRequestFetcherFactory.create(it, options, imageLoader) + }, + ) + } } diff --git a/app/src/main/kotlin/org/fdroid/download/NetworkMonitor.kt b/app/src/main/kotlin/org/fdroid/download/NetworkMonitor.kt index 12883f60a..3f4f648c1 100644 --- a/app/src/main/kotlin/org/fdroid/download/NetworkMonitor.kt +++ b/app/src/main/kotlin/org/fdroid/download/NetworkMonitor.kt @@ -7,55 +7,60 @@ import android.net.NetworkCapabilities import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_METERED import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import org.fdroid.settings.SettingsConstants.AutoUpdateValues import org.fdroid.settings.SettingsManager -import javax.inject.Inject -import javax.inject.Singleton @Singleton -class NetworkMonitor @Inject constructor( - @param:ApplicationContext private val context: Context, - private val settingsManager: SettingsManager, +class NetworkMonitor +@Inject +constructor( + @param:ApplicationContext private val context: Context, + private val settingsManager: SettingsManager, ) : ConnectivityManager.NetworkCallback() { - private val connectivityManager = - context.getSystemService(ConnectivityManager::class.java) as ConnectivityManager - private val neverMetered get() = settingsManager.autoUpdateApps == AutoUpdateValues.Always - private val _networkState = MutableStateFlow( - connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)?.let { - NetworkState(it, neverMetered) - } ?: NetworkState(isOnline = false, isMetered = false) + private val connectivityManager = + context.getSystemService(ConnectivityManager::class.java) as ConnectivityManager + private val neverMetered + get() = settingsManager.autoUpdateApps == AutoUpdateValues.Always + + private val _networkState = + MutableStateFlow( + connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)?.let { + NetworkState(it, neverMetered) + } ?: NetworkState(isOnline = false, isMetered = false) ) - val networkState = _networkState.asStateFlow() + val networkState = _networkState.asStateFlow() - init { - /** - * We are not using [ConnectivityManager.getActiveNetwork] or - * [ConnectivityManager.isActiveNetworkMetered], because often the active network is null. - * What we are doing instead is simpler and seems to work better. - */ - connectivityManager.registerDefaultNetworkCallback(this) - } + init { + /** + * We are not using [ConnectivityManager.getActiveNetwork] or + * [ConnectivityManager.isActiveNetworkMetered], because often the active network is null. What + * we are doing instead is simpler and seems to work better. + */ + connectivityManager.registerDefaultNetworkCallback(this) + } - override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) { - _networkState.update { NetworkState(networkCapabilities, neverMetered) } - } + override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) { + _networkState.update { NetworkState(networkCapabilities, neverMetered) } + } - override fun onLost(network: Network) { - _networkState.update { NetworkState(isOnline = false, isMetered = false) } - } + override fun onLost(network: Network) { + _networkState.update { NetworkState(isOnline = false, isMetered = false) } + } } -data class NetworkState( - val isOnline: Boolean, - val isMetered: Boolean, -) { - constructor(networkCapabilities: NetworkCapabilities, neverMetered: Boolean) : this( - isOnline = networkCapabilities.hasCapability(NET_CAPABILITY_INTERNET), - isMetered = if (neverMetered) false - else !networkCapabilities.hasCapability(NET_CAPABILITY_NOT_METERED), - ) +data class NetworkState(val isOnline: Boolean, val isMetered: Boolean) { + constructor( + networkCapabilities: NetworkCapabilities, + neverMetered: Boolean, + ) : this( + isOnline = networkCapabilities.hasCapability(NET_CAPABILITY_INTERNET), + isMetered = + if (neverMetered) false else !networkCapabilities.hasCapability(NET_CAPABILITY_NOT_METERED), + ) } diff --git a/app/src/main/kotlin/org/fdroid/history/HistoryEvent.kt b/app/src/main/kotlin/org/fdroid/history/HistoryEvent.kt index bbf81afee..b39a0fdad 100644 --- a/app/src/main/kotlin/org/fdroid/history/HistoryEvent.kt +++ b/app/src/main/kotlin/org/fdroid/history/HistoryEvent.kt @@ -5,25 +5,25 @@ import kotlinx.serialization.Serializable @Serializable sealed class HistoryEvent { - abstract val time: Long - abstract val packageName: String - abstract val name: String? + abstract val time: Long + abstract val packageName: String + abstract val name: String? } @Serializable @SerialName("InstallEvent") data class InstallEvent( - override val time: Long, - override val packageName: String, - override val name: String, - val versionName: String, - val oldVersionName: String?, + override val time: Long, + override val packageName: String, + override val name: String, + val versionName: String, + val oldVersionName: String?, ) : HistoryEvent() @Serializable @SerialName("UninstallEvent") data class UninstallEvent( - override val time: Long, - override val packageName: String, - override val name: String?, + override val time: Long, + override val packageName: String, + override val name: String?, ) : HistoryEvent() diff --git a/app/src/main/kotlin/org/fdroid/history/HistoryManager.kt b/app/src/main/kotlin/org/fdroid/history/HistoryManager.kt index 0980193fe..6b930e874 100644 --- a/app/src/main/kotlin/org/fdroid/history/HistoryManager.kt +++ b/app/src/main/kotlin/org/fdroid/history/HistoryManager.kt @@ -5,95 +5,93 @@ import android.content.Context.MODE_APPEND import android.content.Context.MODE_PRIVATE import androidx.annotation.WorkerThread import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.FileNotFoundException +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json import mu.KotlinLogging import org.fdroid.settings.SettingsManager -import java.io.FileNotFoundException -import javax.inject.Inject -import javax.inject.Singleton private const val HISTORY_FILE = "install_history.json" private const val MAX_EVENTS = 999 @Singleton class HistoryManager( - private val context: Context, - private val settingsManager: SettingsManager, - private val maxNumEvents: Int = MAX_EVENTS, + private val context: Context, + private val settingsManager: SettingsManager, + private val maxNumEvents: Int = MAX_EVENTS, ) { - @Inject - constructor( - @ApplicationContext context: Context, - settingsManager: SettingsManager, - ) : this(context, settingsManager, MAX_EVENTS) + @Inject + constructor( + @ApplicationContext context: Context, + settingsManager: SettingsManager, + ) : this(context, settingsManager, MAX_EVENTS) - private val log = KotlinLogging.logger { } + private val log = KotlinLogging.logger {} - @Synchronized - @WorkerThread - @OptIn(ExperimentalSerializationApi::class) - fun getEvents(): List { - return try { - context.openFileInput(HISTORY_FILE).use { inputStream -> - // history shouldn't become too large to fit into memory, hopefully... - val s = inputStream.readBytes().decodeToString().trimEnd(',') - Json.decodeFromString("[$s]") - } - } catch (e: Exception) { - if (e !is FileNotFoundException) { - log.error(e) { "Error getting events: " } - clearAll() - } - emptyList() - } + @Synchronized + @WorkerThread + @OptIn(ExperimentalSerializationApi::class) + fun getEvents(): List { + return try { + context.openFileInput(HISTORY_FILE).use { inputStream -> + // history shouldn't become too large to fit into memory, hopefully... + val s = inputStream.readBytes().decodeToString().trimEnd(',') + Json.decodeFromString("[$s]") + } + } catch (e: Exception) { + if (e !is FileNotFoundException) { + log.error(e) { "Error getting events: " } + clearAll() + } + emptyList() } + } - @Synchronized - @WorkerThread - fun append(event: HistoryEvent) { - if (!settingsManager.useInstallHistory) return - try { - context.openFileOutput(HISTORY_FILE, MODE_APPEND).use { outputStream -> - val s = Json.encodeToString(event) - outputStream.write(s.toByteArray()) - outputStream.write(",".toByteArray()) - } - } catch (e: Exception) { - log.error(e) { "Error appending $event: " } - } + @Synchronized + @WorkerThread + fun append(event: HistoryEvent) { + if (!settingsManager.useInstallHistory) return + try { + context.openFileOutput(HISTORY_FILE, MODE_APPEND).use { outputStream -> + val s = Json.encodeToString(event) + outputStream.write(s.toByteArray()) + outputStream.write(",".toByteArray()) + } + } catch (e: Exception) { + log.error(e) { "Error appending $event: " } } + } - @Synchronized - @WorkerThread - fun clearAll() { - try { - context.deleteFile(HISTORY_FILE) - } catch (e: Exception) { - log.error(e) { "Error deleting file: " } - } + @Synchronized + @WorkerThread + fun clearAll() { + try { + context.deleteFile(HISTORY_FILE) + } catch (e: Exception) { + log.error(e) { "Error deleting file: " } } + } - @Synchronized - @WorkerThread - fun pruneEvents() { - // only run if enabled and we have more events than we should have - if (!settingsManager.useInstallHistory) return - val events = getEvents() - val overhead = events.size - maxNumEvents - if (overhead <= 0) return + @Synchronized + @WorkerThread + fun pruneEvents() { + // only run if enabled and we have more events than we should have + if (!settingsManager.useInstallHistory) return + val events = getEvents() + val overhead = events.size - maxNumEvents + if (overhead <= 0) return - try { - val truncatedEvents = events.drop(overhead) - val serializedEvents = Json.encodeToString(truncatedEvents) - .trimStart('[') - .trimEnd(']') - context.openFileOutput(HISTORY_FILE, MODE_PRIVATE).use { outputStream -> - outputStream.write(serializedEvents.toByteArray()) - outputStream.write(",".toByteArray()) - } - } catch (e: Exception) { - log.error(e) { "Error pruning $overhead events: " } - } + try { + val truncatedEvents = events.drop(overhead) + val serializedEvents = Json.encodeToString(truncatedEvents).trimStart('[').trimEnd(']') + context.openFileOutput(HISTORY_FILE, MODE_PRIVATE).use { outputStream -> + outputStream.write(serializedEvents.toByteArray()) + outputStream.write(",".toByteArray()) + } + } catch (e: Exception) { + log.error(e) { "Error pruning $overhead events: " } } + } } diff --git a/app/src/main/kotlin/org/fdroid/install/ApkFileProvider.kt b/app/src/main/kotlin/org/fdroid/install/ApkFileProvider.kt index 9ce95c706..ef8d33b18 100644 --- a/app/src/main/kotlin/org/fdroid/install/ApkFileProvider.kt +++ b/app/src/main/kotlin/org/fdroid/install/ApkFileProvider.kt @@ -15,99 +15,105 @@ import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor.MODE_READ_ONLY import android.provider.MediaStore.MediaColumns import androidx.core.net.toUri -import mu.KotlinLogging -import org.fdroid.BuildConfig.APPLICATION_ID import java.io.File import java.io.FileNotFoundException import java.io.IOException +import mu.KotlinLogging +import org.fdroid.BuildConfig.APPLICATION_ID class ApkFileProvider : ContentProvider() { - companion object { - private const val AUTHORITY = "${APPLICATION_ID}.install.ApkFileProvider" - private const val MIME_TYPE = "application/vnd.android.package-archive" + companion object { + private const val AUTHORITY = "${APPLICATION_ID}.install.ApkFileProvider" + private const val MIME_TYPE = "application/vnd.android.package-archive" - private fun getUri(packageName: String): Uri { - return "content://$AUTHORITY/$packageName.apk".toUri() - } - - fun getIntent(packageName: String) = Intent(ACTION_SEND).apply { - setDataAndType(getUri(packageName), MIME_TYPE) - putExtra(EXTRA_STREAM, data) - setFlags(FLAG_GRANT_READ_URI_PERMISSION) - } + private fun getUri(packageName: String): Uri { + return "content://$AUTHORITY/$packageName.apk".toUri() } - private val log = KotlinLogging.logger {} + fun getIntent(packageName: String) = + Intent(ACTION_SEND).apply { + setDataAndType(getUri(packageName), MIME_TYPE) + putExtra(EXTRA_STREAM, data) + setFlags(FLAG_GRANT_READ_URI_PERMISSION) + } + } - override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? { - log.info { "openFile $uri $mode" } - if (mode != "r") return null + private val log = KotlinLogging.logger {} - val applicationInfo = getApplicationInfo(uri) ?: throw FileNotFoundException() - try { - val apkFile = File(applicationInfo.publicSourceDir) - return ParcelFileDescriptor.open(apkFile, MODE_READ_ONLY) - } catch (e: IOException) { - throw FileNotFoundException(e.localizedMessage) - } + override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? { + log.info { "openFile $uri $mode" } + if (mode != "r") return null + + val applicationInfo = getApplicationInfo(uri) ?: throw FileNotFoundException() + try { + val apkFile = File(applicationInfo.publicSourceDir) + return ParcelFileDescriptor.open(apkFile, MODE_READ_ONLY) + } catch (e: IOException) { + throw FileNotFoundException(e.localizedMessage) } + } - override fun query( - uri: Uri, - projection: Array?, - selection: String?, - selectionArgs: Array?, - sortOrder: String?, - ): Cursor? { - val packageName = uri.lastPathSegment ?: return null - val applicationInfo = getApplicationInfo(uri) ?: return null - // we don't care what they are asking for, just give them this - val columns = arrayOf( - MediaColumns.DISPLAY_NAME, - MediaColumns.MIME_TYPE, - MediaColumns.DATA, - MediaColumns.SIZE, + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String?, + ): Cursor? { + val packageName = uri.lastPathSegment ?: return null + val applicationInfo = getApplicationInfo(uri) ?: return null + // we don't care what they are asking for, just give them this + val columns = + arrayOf( + MediaColumns.DISPLAY_NAME, + MediaColumns.MIME_TYPE, + MediaColumns.DATA, + MediaColumns.SIZE, + ) + return MatrixCursor(columns).apply { + try { + addRow( + arrayOf( + packageName, + MIME_TYPE, + applicationInfo.publicSourceDir, + File(applicationInfo.publicSourceDir).length(), + ) ) - return MatrixCursor(columns).apply { - try { - addRow( - arrayOf( - packageName, - MIME_TYPE, - applicationInfo.publicSourceDir, - File(applicationInfo.publicSourceDir).length(), - ) - ) - } catch (e: Exception) { - log.error(e) { "Error returning cursor: " } - return null - } - } + } catch (e: Exception) { + log.error(e) { "Error returning cursor: " } + return null + } } + } - @Throws(PackageManager.NameNotFoundException::class) - private fun getApplicationInfo(uri: Uri): ApplicationInfo? { - val packageManager = context?.packageManager ?: return null - val packageName = uri.lastPathSegment?.removeSuffix(".apk") ?: return null - return try { - packageManager.getApplicationInfo(packageName, 0) - } catch (e: Exception) { - log.error(e) { "Error getting ApplicationInfo: " } - null - } + @Throws(PackageManager.NameNotFoundException::class) + private fun getApplicationInfo(uri: Uri): ApplicationInfo? { + val packageManager = context?.packageManager ?: return null + val packageName = uri.lastPathSegment?.removeSuffix(".apk") ?: return null + return try { + packageManager.getApplicationInfo(packageName, 0) + } catch (e: Exception) { + log.error(e) { "Error getting ApplicationInfo: " } + null } + } - override fun onCreate(): Boolean = true - override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0 - override fun getType(uri: Uri): String = MIME_TYPE - override fun getTypeAnonymous(uri: Uri): String = MIME_TYPE - override fun insert(uri: Uri, values: ContentValues?): Uri? = null + override fun onCreate(): Boolean = true - override fun update( - uri: Uri, - values: ContentValues?, - selection: String?, - selectionArgs: Array?, - ): Int = 0 + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0 + + override fun getType(uri: Uri): String = MIME_TYPE + + override fun getTypeAnonymous(uri: Uri): String = MIME_TYPE + + override fun insert(uri: Uri, values: ContentValues?): Uri? = null + + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array?, + ): Int = 0 } diff --git a/app/src/main/kotlin/org/fdroid/install/AppInstallListener.kt b/app/src/main/kotlin/org/fdroid/install/AppInstallListener.kt index ed5d9f7b6..6be0827bb 100644 --- a/app/src/main/kotlin/org/fdroid/install/AppInstallListener.kt +++ b/app/src/main/kotlin/org/fdroid/install/AppInstallListener.kt @@ -3,8 +3,11 @@ package org.fdroid.install import android.app.PendingIntent interface AppInstallListener { - fun onStartInstall(sessionId: Int) - fun onUserConfirmationNeeded(sessionId: Int, intent: PendingIntent) - fun onInstalled() - fun onInstallError(msg: String?) + fun onStartInstall(sessionId: Int) + + fun onUserConfirmationNeeded(sessionId: Int, intent: PendingIntent) + + fun onInstalled() + + fun onInstallError(msg: String?) } diff --git a/app/src/main/kotlin/org/fdroid/install/AppInstallManager.kt b/app/src/main/kotlin/org/fdroid/install/AppInstallManager.kt index 97c573c0e..aa425c2cc 100644 --- a/app/src/main/kotlin/org/fdroid/install/AppInstallManager.kt +++ b/app/src/main/kotlin/org/fdroid/install/AppInstallManager.kt @@ -16,6 +16,10 @@ import coil3.request.ImageRequest import coil3.size.Size import coil3.toBitmap import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.File +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred @@ -45,509 +49,490 @@ import org.fdroid.history.HistoryManager import org.fdroid.history.InstallEvent import org.fdroid.history.UninstallEvent import org.fdroid.utils.IoDispatcher -import java.io.File -import java.util.concurrent.ConcurrentHashMap -import javax.inject.Inject -import javax.inject.Singleton @Singleton -class AppInstallManager @Inject constructor( - @param:ApplicationContext private val context: Context, - private val downloaderFactory: DownloaderFactory, - private val sessionInstallManager: SessionInstallManager, - private val notificationManager: NotificationManager, - private val historyManager: HistoryManager, - @param:IoDispatcher private val scope: CoroutineScope, +class AppInstallManager +@Inject +constructor( + @param:ApplicationContext private val context: Context, + private val downloaderFactory: DownloaderFactory, + private val sessionInstallManager: SessionInstallManager, + private val notificationManager: NotificationManager, + private val historyManager: HistoryManager, + @param:IoDispatcher private val scope: CoroutineScope, ) { - private val log = KotlinLogging.logger { } - private val apps = MutableStateFlow>(emptyMap()) - private val jobs = ConcurrentHashMap() - val appInstallStates = apps.asStateFlow() - val installNotificationState: InstallNotificationState - get() { - val appStates = mutableListOf() - var numBytesDownloaded = 0L - var numTotalBytes = 0L - // go through all apps that have active state - apps.value.toMap().forEach { (packageName, state) -> - // assign a category to each in progress state - val appStateCategory = when (state) { - is InstallState.Installing, is InstallState.PreApproved, - is InstallState.Waiting, is InstallState.Starting -> AppStateCategory.INSTALLING - is InstallState.Downloading -> { - numBytesDownloaded += state.downloadedBytes - numTotalBytes += state.totalBytes - AppStateCategory.INSTALLING - } - is InstallState.Installed -> AppStateCategory.INSTALLED - is InstallState.UserConfirmationNeeded -> AppStateCategory.NEEDS_CONFIRMATION - else -> null - } - // track app state for in progress apps - val appState = appStateCategory?.let { - // all states that get a category above must be InstallStateWithInfo - state as InstallStateWithInfo - AppState( - packageName = packageName, - category = it, - name = state.name, - installVersionName = state.versionName, - currentVersionName = state.currentVersionName, - ) - } - if (appState != null) appStates.add(appState) - } - return InstallNotificationState( - apps = appStates, - numBytesDownloaded = numBytesDownloaded, - numTotalBytes = numTotalBytes, - ) - } - - fun getAppFlow(packageName: String): Flow { - return apps.map { it[packageName] ?: InstallState.Unknown } - } - - /** - * Installs the given [version]. - * - * @param canAskPreApprovalNow true if there will be only one approval dialog - * and the app is currently in the foreground. - * Reasoning: - * The system will swallow the second or third dialog we pop up - * before the user could respond to the first. - * Also, we are not allowed anymore to start other activities while in the background. - */ - @UiThread - suspend fun install( - appMetadata: AppMetadata, - version: AppVersion, - currentVersionName: String?, - repo: Repository, - iconModel: Any?, - canAskPreApprovalNow: Boolean, - ): InstallState { - val packageName = appMetadata.packageName - val currentState = apps.value[packageName] - if (currentState?.showProgress == true && currentState !is InstallState.Waiting) { - log.warn { "Attempted to install $packageName with install in progress: $currentState" } - return currentState - } - currentCoroutineContext().ensureActive() - val job = scope.async { - startInstall( - appMetadata = appMetadata, - version = version, - currentVersionName = currentVersionName, - repo = repo, - iconModel = iconModel, - canAskPreApprovalNow = canAskPreApprovalNow, - ) - } - // keep track of this job, in case we want to cancel it - return trackJob(packageName, job) - } - - private suspend fun trackJob(packageName: String, job: Deferred): InstallState { - jobs[packageName] = job - // wait for job to return - val result = try { - job.await() - } catch (_: CancellationException) { - InstallState.UserAborted - } finally { - // remove job as it has completed - jobs.remove(packageName) - } - apps.updateApp(packageName) { result } - onStatesUpdated() - if (result is InstallState.Installed) { - val event = InstallEvent( - time = System.currentTimeMillis(), - packageName = packageName, - name = result.name, - versionName = result.versionName, - oldVersionName = result.currentVersionName, - ) - scope.launch { - historyManager.append(event) - } - } - return result - } - - fun setWaitingState( - packageName: String, - name: String, - versionName: String, - currentVersionName: String, - lastUpdated: Long, - ) { - apps.updateApp(packageName) { - InstallState.Waiting(name, versionName, currentVersionName, lastUpdated) - } - onStatesUpdated() - } - - @WorkerThread - private suspend fun startInstall( - appMetadata: AppMetadata, - version: AppVersion, - currentVersionName: String?, - repo: Repository, - iconModel: Any?, - canAskPreApprovalNow: Boolean, - ): InstallState { - val startingState = InstallState.Starting( - name = appMetadata.name.getBestLocale(LocaleListCompat.getDefault()) ?: "Unknown", - versionName = version.versionName, - currentVersionName = currentVersionName, - lastUpdated = version.added, - iconModel = iconModel, - ) - apps.updateApp(appMetadata.packageName) { startingState } - log.info { "Started install of ${appMetadata.packageName}" } - onStatesUpdated() - currentCoroutineContext().ensureActive() - // request pre-approval from user (if available) - val preApprovalResult = sessionInstallManager.requestPreapproval( - app = appMetadata, - iconGetter = { getIcon(iconModel) }, - isUpdate = currentVersionName != null, - version = version, - canRequestUserConfirmationNow = canAskPreApprovalNow, - ) - log.info { "Got pre-approval result $preApprovalResult for ${appMetadata.packageName}" } - // continue depending on result, abort early if no approval was given - return when (preApprovalResult) { - is PreApprovalResult.UserAborted -> InstallState.UserAborted - is PreApprovalResult.Success, PreApprovalResult.NotSupported -> { - val newState = apps.checkAndUpdateApp(appMetadata.packageName) { - InstallState.PreApproved( - name = it.name, - versionName = it.versionName, - currentVersionName = it.currentVersionName, - lastUpdated = it.lastUpdated, - iconModel = it.iconModel, - result = preApprovalResult, - ) - } as InstallState.PreApproved - downloadAndInstall(newState, version, currentVersionName, repo, iconModel) - } - is PreApprovalResult.UserConfirmationRequired -> { - InstallState.PreApprovalConfirmationNeeded( - state = startingState, - version = version, - repo = repo, - sessionId = preApprovalResult.sessionId, - intent = preApprovalResult.intent, - ) - } - is PreApprovalResult.Error -> InstallState.Error( - msg = preApprovalResult.errorMsg, - s = startingState, - ) - } - } - - /** - * Request user confirmation for pre-approval and suspend until we get a result. - */ - @UiThread - suspend fun requestPreApprovalConfirmation( - packageName: String, - installState: InstallState.PreApprovalConfirmationNeeded, - ): InstallState? { - val state = apps.value[packageName] ?: error("No state for $packageName $installState") - if (state !is InstallState.PreApprovalConfirmationNeeded) { - log.error { "Unexpected state: $state" } - return null - } - log.info { "Requesting pre-approval confirmation for $packageName" } - val result = sessionInstallManager.requestUserConfirmation(installState) - log.info { "Pre-approval confirmation for $packageName $result" } - apps.updateApp(packageName) { result } - onStatesUpdated() - return if (result is InstallState.PreApproved) { - // move us off the UiThread, so we can download/install this app now - val job = scope.async { - downloadAndInstall( - state = result, - version = installState.version, - currentVersionName = installState.currentVersionName, - repo = installState.repo, - iconModel = installState.iconModel, - ) - } - // suspend/wait for this job and track it in case we want to cancel it - return trackJob(packageName, job) - } else result - } - - @WorkerThread - private suspend fun downloadAndInstall( - state: InstallState.PreApproved, - version: AppVersion, - currentVersionName: String?, - repo: Repository, - iconModel: Any?, - ): InstallState { - val sessionId = (state.result as? PreApprovalResult.Success)?.sessionId - val coroutineContext = currentCoroutineContext() - coroutineContext.ensureActive() - // download file - val file = File(context.cacheDir, version.file.sha256) - val uri = getUri(repo.address, version.file) - val downloader = downloaderFactory.create(repo, uri, version.file, file) - val now = System.currentTimeMillis() - downloader.setListener { bytesRead, totalBytes -> - coroutineContext.ensureActive() - apps.checkAndUpdateApp(version.packageName) { - InstallState.Downloading( - name = it.name, - versionName = it.versionName, - currentVersionName = it.currentVersionName, - lastUpdated = it.lastUpdated, - iconModel = it.iconModel, - downloadedBytes = bytesRead, - totalBytes = totalBytes, - startMillis = now, - ) - } - onStatesUpdated() - } - try { - downloader.download() - log.debug { "Download completed" } - } catch (e: Exception) { - if (e is CancellationException) throw e - log.error(e) { "Error downloading ${version.file}" } - val msg = "Download failed: ${e::class.java.simpleName} ${e.message}" - return InstallState.Error( - msg = msg, - name = state.name, - versionName = version.versionName, - currentVersionName = currentVersionName, - lastUpdated = version.added, - iconModel = iconModel, - ) - } - currentCoroutineContext().ensureActive() - val newState = apps.checkAndUpdateApp(version.packageName) { - InstallState.Installing( - name = it.name, - versionName = it.versionName, - currentVersionName = it.currentVersionName, - lastUpdated = it.lastUpdated, - iconModel = it.iconModel, - ) - } - val result = - sessionInstallManager.install(sessionId, version.packageName, newState, file) - log.debug { "Install result: $result" } - return if (result is InstallState.PreApproved && - result.result is PreApprovalResult.Error - ) { - // if pre-approval failed (e.g. due to app label mismatch), - // then try to install again, this time not using the pre-approved session - sessionInstallManager.install(null, version.packageName, newState, file) - } else { - result - } - } - - /** - * Request user confirmation for installation and suspend until we get a result. - */ - @UiThread - suspend fun requestUserConfirmation( - packageName: String, - installState: InstallState.UserConfirmationNeeded, - ): InstallState? { - val state = apps.value[packageName] ?: error("No state for $packageName $installState") - if (state !is InstallState.UserConfirmationNeeded) { - log.error { "Unexpected state: $state" } - return null - } - log.info { "Requesting user confirmation for $packageName" } - val job = scope.async { - sessionInstallManager.requestUserConfirmation(installState) - } - // keep track of this job, in case we need to cancel it - val result = trackJob(packageName, job) // updates app state - log.info { "User confirmation for $packageName $result" } - return result - } - - /** - * A workaround for Android 10, 11, 12 and 13 where tapping outside the confirmation dialog - * dismisses it without any feedback for us. - * So when our activity resumes while we are in state [InstallState.UserConfirmationNeeded] - * we need to call this method, so we can manually check if our session progressed or not. - * If it didn't progress and the state hasn't changed, we fire up the confirmation intent again. - */ - @UiThread - fun checkUserConfirmation( - packageName: String, - installState: InstallState.UserConfirmationNeeded, - ) { - val state = apps.value[packageName] ?: run { - // We run this method with some delay, so there's the unlikely, - // but possible scenario that state got cleaned up already when this code runs. - log.warn { "No more state for $packageName $installState" } - return - } - if (state !is InstallState.UserConfirmationNeeded) { - log.debug { "State has changed. Now: $state" } - return - } - val sessionInfo = - context.packageManager.packageInstaller.getSessionInfo(installState.sessionId) - ?: run { - log.error { "Session ${installState.sessionId} does not exist anymore" } - return - } - if (sessionInfo.progress <= installState.progress) { - log.info { - "Session did not progress: ${sessionInfo.progress} <= ${installState.progress}" - } - // we fire up intent again to force the user to do a proper yes/no decision, - // so our session and our coroutine above don't get stuck - installState.intent.send() - } else { - log.debug { "Session has progressed, doing nothing" } - } - } - - fun cancel(packageName: String) { - val job = jobs[packageName] - log.debug { "Canceling job for $packageName $job" } - job?.cancel() - } - - /** - * Must be called after receiving the result from the [ACTION_DELETE] uninstall Intent. - * - * Note: We are not using [android.content.pm.PackageInstaller.uninstall], - * because on Android 10 to 13 (at least) we don't get feedback - * when the user taps outside the confirmation dialog. - * Using this non-deprecated API ([ACTION_DELETE]) seems to work - * without issues everywhere. - */ - @UiThread - fun onUninstallResult( - packageName: String, - name: String?, - activityResult: ActivityResult, - ): InstallState { - val result = when (activityResult.resultCode) { - Activity.RESULT_OK -> InstallState.Uninstalled - Activity.RESULT_FIRST_USER -> InstallState.UserAborted - else -> InstallState.UserAborted - } - val code = activityResult.data?.getIntExtra("android.intent.extra.INSTALL_RESULT", -1) - log.info { "Uninstall result received: ${activityResult.resultCode} => $result ($code)" } - apps.updateApp(packageName) { result } - if (result == InstallState.Uninstalled) { - val event = UninstallEvent( - time = System.currentTimeMillis(), - packageName = packageName, - name = name, - ) - scope.launch { - historyManager.append(event) - } - } - return result - } - - @UiThread - fun cleanUp(packageName: String) { - val state = apps.value[packageName] ?: return - if (!state.showProgress) { - log.info { "Cleaning up state for $packageName $state" } - jobs.remove(packageName)?.cancel() - apps.update { oldApps -> - oldApps.toMutableMap().apply { - remove(packageName) - } - } - } - } - - private fun onStatesUpdated() { - val notificationState = installNotificationState - val serviceIntent = Intent(context, AppInstallService::class.java) - // stop foreground service, if no app is installing and it is still running - if (!notificationState.isInstallingSomeApp && AppInstallService.isServiceRunning) { - context.stopService(serviceIntent) - } - if (notificationState.isInProgress) { - // start foreground service if at least one app is installing and not already running - if (notificationState.isInstallingSomeApp && !AppInstallService.isServiceRunning) { - try { - context.startService(serviceIntent) - } catch (e: Exception) { - log.error { "Couldn't start service: $e ${e.message}" } - } - } - notificationManager.showAppInstallNotification(notificationState) - } else { - // cancel notification if no more apps are in progress - notificationManager.cancelAppInstallNotification() - } - } - - /** - * Gets icon for preapproval from memory cache. - * In the unlikely event, that the icon isn't in the cache, - * we download it with the given [iconModel]. - */ - private suspend fun getIcon(iconModel: Any?): Bitmap? { - return when (iconModel) { - is DownloadRequest -> { - // try memory cache first and download, if not found - val memoryCache = SingletonImageLoader.get(context).memoryCache - val key = iconModel.getCacheKey() - memoryCache?.get(MemoryCache.Key(key))?.image?.toBitmap() ?: run { - // not found in cache, download icon - val request = ImageRequest.Builder(context) - .data(iconModel) - .size(Size.ORIGINAL) - .build() - SingletonImageLoader.get(context).execute(request).image?.toBitmap() - } - } - is PackageName -> { - context.packageManager.getApplicationIcon(iconModel.packageName).toBitmap() + private val log = KotlinLogging.logger {} + private val apps = MutableStateFlow>(emptyMap()) + private val jobs = ConcurrentHashMap() + val appInstallStates = apps.asStateFlow() + val installNotificationState: InstallNotificationState + get() { + val appStates = mutableListOf() + var numBytesDownloaded = 0L + var numTotalBytes = 0L + // go through all apps that have active state + apps.value.toMap().forEach { (packageName, state) -> + // assign a category to each in progress state + val appStateCategory = + when (state) { + is InstallState.Installing, + is InstallState.PreApproved, + is InstallState.Waiting, + is InstallState.Starting -> AppStateCategory.INSTALLING + is InstallState.Downloading -> { + numBytesDownloaded += state.downloadedBytes + numTotalBytes += state.totalBytes + AppStateCategory.INSTALLING } + is InstallState.Installed -> AppStateCategory.INSTALLED + is InstallState.UserConfirmationNeeded -> AppStateCategory.NEEDS_CONFIRMATION else -> null + } + // track app state for in progress apps + val appState = + appStateCategory?.let { + // all states that get a category above must be InstallStateWithInfo + state as InstallStateWithInfo + AppState( + packageName = packageName, + category = it, + name = state.name, + installVersionName = state.versionName, + currentVersionName = state.currentVersionName, + ) + } + if (appState != null) appStates.add(appState) + } + return InstallNotificationState( + apps = appStates, + numBytesDownloaded = numBytesDownloaded, + numTotalBytes = numTotalBytes, + ) + } + + fun getAppFlow(packageName: String): Flow { + return apps.map { it[packageName] ?: InstallState.Unknown } + } + + /** + * Installs the given [version]. + * + * @param canAskPreApprovalNow true if there will be only one approval dialog and the app is + * currently in the foreground. Reasoning: The system will swallow the second or third dialog we + * pop up before the user could respond to the first. Also, we are not allowed anymore to start + * other activities while in the background. + */ + @UiThread + suspend fun install( + appMetadata: AppMetadata, + version: AppVersion, + currentVersionName: String?, + repo: Repository, + iconModel: Any?, + canAskPreApprovalNow: Boolean, + ): InstallState { + val packageName = appMetadata.packageName + val currentState = apps.value[packageName] + if (currentState?.showProgress == true && currentState !is InstallState.Waiting) { + log.warn { "Attempted to install $packageName with install in progress: $currentState" } + return currentState + } + currentCoroutineContext().ensureActive() + val job = + scope.async { + startInstall( + appMetadata = appMetadata, + version = version, + currentVersionName = currentVersionName, + repo = repo, + iconModel = iconModel, + canAskPreApprovalNow = canAskPreApprovalNow, + ) + } + // keep track of this job, in case we want to cancel it + return trackJob(packageName, job) + } + + private suspend fun trackJob(packageName: String, job: Deferred): InstallState { + jobs[packageName] = job + // wait for job to return + val result = + try { + job.await() + } catch (_: CancellationException) { + InstallState.UserAborted + } finally { + // remove job as it has completed + jobs.remove(packageName) + } + apps.updateApp(packageName) { result } + onStatesUpdated() + if (result is InstallState.Installed) { + val event = + InstallEvent( + time = System.currentTimeMillis(), + packageName = packageName, + name = result.name, + versionName = result.versionName, + oldVersionName = result.currentVersionName, + ) + scope.launch { historyManager.append(event) } + } + return result + } + + fun setWaitingState( + packageName: String, + name: String, + versionName: String, + currentVersionName: String, + lastUpdated: Long, + ) { + apps.updateApp(packageName) { + InstallState.Waiting(name, versionName, currentVersionName, lastUpdated) + } + onStatesUpdated() + } + + @WorkerThread + private suspend fun startInstall( + appMetadata: AppMetadata, + version: AppVersion, + currentVersionName: String?, + repo: Repository, + iconModel: Any?, + canAskPreApprovalNow: Boolean, + ): InstallState { + val startingState = + InstallState.Starting( + name = appMetadata.name.getBestLocale(LocaleListCompat.getDefault()) ?: "Unknown", + versionName = version.versionName, + currentVersionName = currentVersionName, + lastUpdated = version.added, + iconModel = iconModel, + ) + apps.updateApp(appMetadata.packageName) { startingState } + log.info { "Started install of ${appMetadata.packageName}" } + onStatesUpdated() + currentCoroutineContext().ensureActive() + // request pre-approval from user (if available) + val preApprovalResult = + sessionInstallManager.requestPreapproval( + app = appMetadata, + iconGetter = { getIcon(iconModel) }, + isUpdate = currentVersionName != null, + version = version, + canRequestUserConfirmationNow = canAskPreApprovalNow, + ) + log.info { "Got pre-approval result $preApprovalResult for ${appMetadata.packageName}" } + // continue depending on result, abort early if no approval was given + return when (preApprovalResult) { + is PreApprovalResult.UserAborted -> InstallState.UserAborted + is PreApprovalResult.Success, + PreApprovalResult.NotSupported -> { + val newState = + apps.checkAndUpdateApp(appMetadata.packageName) { + InstallState.PreApproved( + name = it.name, + versionName = it.versionName, + currentVersionName = it.currentVersionName, + lastUpdated = it.lastUpdated, + iconModel = it.iconModel, + result = preApprovalResult, + ) + } as InstallState.PreApproved + downloadAndInstall(newState, version, currentVersionName, repo, iconModel) + } + is PreApprovalResult.UserConfirmationRequired -> { + InstallState.PreApprovalConfirmationNeeded( + state = startingState, + version = version, + repo = repo, + sessionId = preApprovalResult.sessionId, + intent = preApprovalResult.intent, + ) + } + is PreApprovalResult.Error -> + InstallState.Error(msg = preApprovalResult.errorMsg, s = startingState) + } + } + + /** Request user confirmation for pre-approval and suspend until we get a result. */ + @UiThread + suspend fun requestPreApprovalConfirmation( + packageName: String, + installState: InstallState.PreApprovalConfirmationNeeded, + ): InstallState? { + val state = apps.value[packageName] ?: error("No state for $packageName $installState") + if (state !is InstallState.PreApprovalConfirmationNeeded) { + log.error { "Unexpected state: $state" } + return null + } + log.info { "Requesting pre-approval confirmation for $packageName" } + val result = sessionInstallManager.requestUserConfirmation(installState) + log.info { "Pre-approval confirmation for $packageName $result" } + apps.updateApp(packageName) { result } + onStatesUpdated() + return if (result is InstallState.PreApproved) { + // move us off the UiThread, so we can download/install this app now + val job = + scope.async { + downloadAndInstall( + state = result, + version = installState.version, + currentVersionName = installState.currentVersionName, + repo = installState.repo, + iconModel = installState.iconModel, + ) } - } + // suspend/wait for this job and track it in case we want to cancel it + return trackJob(packageName, job) + } else result + } - private fun MutableStateFlow>.updateApp( - packageName: String, - function: (InstallState) -> InstallState, - ) = update { oldMap -> - val newMap = oldMap.toMutableMap() - newMap[packageName] = function(newMap[packageName] ?: InstallState.Unknown) - newMap + @WorkerThread + private suspend fun downloadAndInstall( + state: InstallState.PreApproved, + version: AppVersion, + currentVersionName: String?, + repo: Repository, + iconModel: Any?, + ): InstallState { + val sessionId = (state.result as? PreApprovalResult.Success)?.sessionId + val coroutineContext = currentCoroutineContext() + coroutineContext.ensureActive() + // download file + val file = File(context.cacheDir, version.file.sha256) + val uri = getUri(repo.address, version.file) + val downloader = downloaderFactory.create(repo, uri, version.file, file) + val now = System.currentTimeMillis() + downloader.setListener { bytesRead, totalBytes -> + coroutineContext.ensureActive() + apps.checkAndUpdateApp(version.packageName) { + InstallState.Downloading( + name = it.name, + versionName = it.versionName, + currentVersionName = it.currentVersionName, + lastUpdated = it.lastUpdated, + iconModel = it.iconModel, + downloadedBytes = bytesRead, + totalBytes = totalBytes, + startMillis = now, + ) + } + onStatesUpdated() } - - private fun MutableStateFlow>.checkAndUpdateApp( - packageName: String, - function: (InstallStateWithInfo) -> InstallStateWithInfo, - ): InstallStateWithInfo { - return updateAndGet { oldMap -> - val oldState = oldMap[packageName] - check(oldState is InstallStateWithInfo) { - "State for $packageName was $oldState" - } - val newMap = oldMap.toMutableMap() - newMap[packageName] = function(oldState) - newMap - }[packageName] as InstallStateWithInfo + try { + downloader.download() + log.debug { "Download completed" } + } catch (e: Exception) { + if (e is CancellationException) throw e + log.error(e) { "Error downloading ${version.file}" } + val msg = "Download failed: ${e::class.java.simpleName} ${e.message}" + return InstallState.Error( + msg = msg, + name = state.name, + versionName = version.versionName, + currentVersionName = currentVersionName, + lastUpdated = version.added, + iconModel = iconModel, + ) } + currentCoroutineContext().ensureActive() + val newState = + apps.checkAndUpdateApp(version.packageName) { + InstallState.Installing( + name = it.name, + versionName = it.versionName, + currentVersionName = it.currentVersionName, + lastUpdated = it.lastUpdated, + iconModel = it.iconModel, + ) + } + val result = sessionInstallManager.install(sessionId, version.packageName, newState, file) + log.debug { "Install result: $result" } + return if (result is InstallState.PreApproved && result.result is PreApprovalResult.Error) { + // if pre-approval failed (e.g. due to app label mismatch), + // then try to install again, this time not using the pre-approved session + sessionInstallManager.install(null, version.packageName, newState, file) + } else { + result + } + } + /** Request user confirmation for installation and suspend until we get a result. */ + @UiThread + suspend fun requestUserConfirmation( + packageName: String, + installState: InstallState.UserConfirmationNeeded, + ): InstallState? { + val state = apps.value[packageName] ?: error("No state for $packageName $installState") + if (state !is InstallState.UserConfirmationNeeded) { + log.error { "Unexpected state: $state" } + return null + } + log.info { "Requesting user confirmation for $packageName" } + val job = scope.async { sessionInstallManager.requestUserConfirmation(installState) } + // keep track of this job, in case we need to cancel it + val result = trackJob(packageName, job) // updates app state + log.info { "User confirmation for $packageName $result" } + return result + } + + /** + * A workaround for Android 10, 11, 12 and 13 where tapping outside the confirmation dialog + * dismisses it without any feedback for us. So when our activity resumes while we are in state + * [InstallState.UserConfirmationNeeded] we need to call this method, so we can manually check if + * our session progressed or not. If it didn't progress and the state hasn't changed, we fire up + * the confirmation intent again. + */ + @UiThread + fun checkUserConfirmation( + packageName: String, + installState: InstallState.UserConfirmationNeeded, + ) { + val state = + apps.value[packageName] + ?: run { + // We run this method with some delay, so there's the unlikely, + // but possible scenario that state got cleaned up already when this code runs. + log.warn { "No more state for $packageName $installState" } + return + } + if (state !is InstallState.UserConfirmationNeeded) { + log.debug { "State has changed. Now: $state" } + return + } + val sessionInfo = + context.packageManager.packageInstaller.getSessionInfo(installState.sessionId) + ?: run { + log.error { "Session ${installState.sessionId} does not exist anymore" } + return + } + if (sessionInfo.progress <= installState.progress) { + log.info { "Session did not progress: ${sessionInfo.progress} <= ${installState.progress}" } + // we fire up intent again to force the user to do a proper yes/no decision, + // so our session and our coroutine above don't get stuck + installState.intent.send() + } else { + log.debug { "Session has progressed, doing nothing" } + } + } + + fun cancel(packageName: String) { + val job = jobs[packageName] + log.debug { "Canceling job for $packageName $job" } + job?.cancel() + } + + /** + * Must be called after receiving the result from the [ACTION_DELETE] uninstall Intent. + * + * Note: We are not using [android.content.pm.PackageInstaller.uninstall], because on Android 10 + * to 13 (at least) we don't get feedback when the user taps outside the confirmation dialog. + * Using this non-deprecated API ([ACTION_DELETE]) seems to work without issues everywhere. + */ + @UiThread + fun onUninstallResult( + packageName: String, + name: String?, + activityResult: ActivityResult, + ): InstallState { + val result = + when (activityResult.resultCode) { + Activity.RESULT_OK -> InstallState.Uninstalled + Activity.RESULT_FIRST_USER -> InstallState.UserAborted + else -> InstallState.UserAborted + } + val code = activityResult.data?.getIntExtra("android.intent.extra.INSTALL_RESULT", -1) + log.info { "Uninstall result received: ${activityResult.resultCode} => $result ($code)" } + apps.updateApp(packageName) { result } + if (result == InstallState.Uninstalled) { + val event = + UninstallEvent(time = System.currentTimeMillis(), packageName = packageName, name = name) + scope.launch { historyManager.append(event) } + } + return result + } + + @UiThread + fun cleanUp(packageName: String) { + val state = apps.value[packageName] ?: return + if (!state.showProgress) { + log.info { "Cleaning up state for $packageName $state" } + jobs.remove(packageName)?.cancel() + apps.update { oldApps -> oldApps.toMutableMap().apply { remove(packageName) } } + } + } + + private fun onStatesUpdated() { + val notificationState = installNotificationState + val serviceIntent = Intent(context, AppInstallService::class.java) + // stop foreground service, if no app is installing and it is still running + if (!notificationState.isInstallingSomeApp && AppInstallService.isServiceRunning) { + context.stopService(serviceIntent) + } + if (notificationState.isInProgress) { + // start foreground service if at least one app is installing and not already running + if (notificationState.isInstallingSomeApp && !AppInstallService.isServiceRunning) { + try { + context.startService(serviceIntent) + } catch (e: Exception) { + log.error { "Couldn't start service: $e ${e.message}" } + } + } + notificationManager.showAppInstallNotification(notificationState) + } else { + // cancel notification if no more apps are in progress + notificationManager.cancelAppInstallNotification() + } + } + + /** + * Gets icon for preapproval from memory cache. In the unlikely event, that the icon isn't in the + * cache, we download it with the given [iconModel]. + */ + private suspend fun getIcon(iconModel: Any?): Bitmap? { + return when (iconModel) { + is DownloadRequest -> { + // try memory cache first and download, if not found + val memoryCache = SingletonImageLoader.get(context).memoryCache + val key = iconModel.getCacheKey() + memoryCache?.get(MemoryCache.Key(key))?.image?.toBitmap() + ?: run { + // not found in cache, download icon + val request = ImageRequest.Builder(context).data(iconModel).size(Size.ORIGINAL).build() + SingletonImageLoader.get(context).execute(request).image?.toBitmap() + } + } + is PackageName -> { + context.packageManager.getApplicationIcon(iconModel.packageName).toBitmap() + } + else -> null + } + } + + private fun MutableStateFlow>.updateApp( + packageName: String, + function: (InstallState) -> InstallState, + ) = update { oldMap -> + val newMap = oldMap.toMutableMap() + newMap[packageName] = function(newMap[packageName] ?: InstallState.Unknown) + newMap + } + + private fun MutableStateFlow>.checkAndUpdateApp( + packageName: String, + function: (InstallStateWithInfo) -> InstallStateWithInfo, + ): InstallStateWithInfo { + return updateAndGet { oldMap -> + val oldState = oldMap[packageName] + check(oldState is InstallStateWithInfo) { "State for $packageName was $oldState" } + val newMap = oldMap.toMutableMap() + newMap[packageName] = function(oldState) + newMap + }[packageName] + as InstallStateWithInfo + } } diff --git a/app/src/main/kotlin/org/fdroid/install/AppInstallService.kt b/app/src/main/kotlin/org/fdroid/install/AppInstallService.kt index 34404d01d..6756f3f1d 100644 --- a/app/src/main/kotlin/org/fdroid/install/AppInstallService.kt +++ b/app/src/main/kotlin/org/fdroid/install/AppInstallService.kt @@ -7,54 +7,49 @@ import android.os.Build.VERSION.SDK_INT import android.os.IBinder import androidx.core.app.ServiceCompat import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject import mu.KotlinLogging import org.fdroid.NotificationManager import org.fdroid.NotificationManager.Companion.NOTIFICATION_ID_APP_INSTALLS -import javax.inject.Inject @AndroidEntryPoint class AppInstallService : Service() { - companion object { - var isServiceRunning = false - private set + companion object { + var isServiceRunning = false + private set + } + + private val log = KotlinLogging.logger {} + + @Inject lateinit var notificationManager: NotificationManager + + override fun onCreate() { + log.info { "onCreate" } + isServiceRunning = true + super.onCreate() // apparently importing for injection + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + log.info { "onStartCommand $intent" } + val notificationState = InstallNotificationState() + try { + ServiceCompat.startForeground( + this, + NOTIFICATION_ID_APP_INSTALLS, + notificationManager.getAppInstallNotification(notificationState).build(), + if (SDK_INT >= 29) FOREGROUND_SERVICE_TYPE_MANIFEST else 0, + ) + } catch (e: Exception) { + log.error(e) { "Error starting foreground service: " } } + return super.onStartCommand(intent, flags, startId) + } - private val log = KotlinLogging.logger { } + override fun onBind(intent: Intent): IBinder? = null - @Inject - lateinit var notificationManager: NotificationManager - - override fun onCreate() { - log.info { "onCreate" } - isServiceRunning = true - super.onCreate() // apparently importing for injection - } - - override fun onStartCommand( - intent: Intent?, - flags: Int, - startId: Int - ): Int { - log.info { "onStartCommand $intent" } - val notificationState = InstallNotificationState() - try { - ServiceCompat.startForeground( - this, - NOTIFICATION_ID_APP_INSTALLS, - notificationManager.getAppInstallNotification(notificationState).build(), - if (SDK_INT >= 29) FOREGROUND_SERVICE_TYPE_MANIFEST else 0, - ) - } catch (e: Exception) { - log.error(e) { "Error starting foreground service: " } - } - return super.onStartCommand(intent, flags, startId) - } - - override fun onBind(intent: Intent): IBinder? = null - - override fun onDestroy() { - log.info { "onDestroy" } - isServiceRunning = false - } + override fun onDestroy() { + log.info { "onDestroy" } + isServiceRunning = false + } } diff --git a/app/src/main/kotlin/org/fdroid/install/CacheCleaner.kt b/app/src/main/kotlin/org/fdroid/install/CacheCleaner.kt index 007650c66..eac190fa6 100644 --- a/app/src/main/kotlin/org/fdroid/install/CacheCleaner.kt +++ b/app/src/main/kotlin/org/fdroid/install/CacheCleaner.kt @@ -3,37 +3,35 @@ package org.fdroid.install import android.content.Context import androidx.annotation.WorkerThread import dagger.hilt.android.qualifiers.ApplicationContext -import mu.KotlinLogging import java.io.File import javax.inject.Inject import javax.inject.Singleton +import mu.KotlinLogging private const val DELETE_OLDER_THAN_MILLIS = 8_640_000 // 24h @Singleton -class CacheCleaner @Inject constructor( - @param:ApplicationContext private val context: Context, -) { - private val log = KotlinLogging.logger { } - private val shaRegex = "^[a-zA-Z0-9]{64}$".toRegex() +class CacheCleaner @Inject constructor(@param:ApplicationContext private val context: Context) { + private val log = KotlinLogging.logger {} + private val shaRegex = "^[a-zA-Z0-9]{64}$".toRegex() - @WorkerThread - fun clean() { - log.info { "Cleaning up old files..," } - try { - context.cacheDir.listFiles()?.forEach { file -> - if (file.isFile && shaRegex.matches(file.name) && file.isTooOld()) { - log.debug { "Deleting ${file.name}..." } - file.delete() - } - } ?: throw NullPointerException("listFiles() returned null") - } catch (e: Exception) { - log.error(e) { "Error deleting old cached files: " } + @WorkerThread + fun clean() { + log.info { "Cleaning up old files..," } + try { + context.cacheDir.listFiles()?.forEach { file -> + if (file.isFile && shaRegex.matches(file.name) && file.isTooOld()) { + log.debug { "Deleting ${file.name}..." } + file.delete() } + } ?: throw NullPointerException("listFiles() returned null") + } catch (e: Exception) { + log.error(e) { "Error deleting old cached files: " } } + } - private fun File.isTooOld(): Boolean { - val age = System.currentTimeMillis() - lastModified() - return age >= DELETE_OLDER_THAN_MILLIS - } + private fun File.isTooOld(): Boolean { + val age = System.currentTimeMillis() - lastModified() + return age >= DELETE_OLDER_THAN_MILLIS + } } diff --git a/app/src/main/kotlin/org/fdroid/install/InstallBroadcastReceiver.kt b/app/src/main/kotlin/org/fdroid/install/InstallBroadcastReceiver.kt index e8d16bb86..b37955d15 100644 --- a/app/src/main/kotlin/org/fdroid/install/InstallBroadcastReceiver.kt +++ b/app/src/main/kotlin/org/fdroid/install/InstallBroadcastReceiver.kt @@ -12,37 +12,28 @@ import androidx.core.content.IntentCompat.getParcelableExtra import mu.KotlinLogging class InstallBroadcastReceiver( - private val sessionId: Int, - private val listener: InstallBroadcastReceiver.( - status: Int, - confirmIntent: Intent?, - msg: String?, - ) -> Unit, + private val sessionId: Int, + private val listener: + InstallBroadcastReceiver.(status: Int, confirmIntent: Intent?, msg: String?) -> Unit, ) : BroadcastReceiver() { - private val log = KotlinLogging.logger { } + private val log = KotlinLogging.logger {} - override fun onReceive(context: Context, intent: Intent) { - val receivedSessionId = intent.getIntExtra(EXTRA_SESSION_ID, -1) - if (receivedSessionId != sessionId) { - log.warn { - "Received intent for session $receivedSessionId, but expected $sessionId" - } - return - } - val confirmIntent = getParcelableExtra(intent, EXTRA_INTENT, Intent::class.java) - val packageName = intent.getStringExtra(EXTRA_PACKAGE_NAME) - val status = intent.getIntExtra(EXTRA_STATUS, Int.MIN_VALUE) - val msg = intent.getStringExtra(EXTRA_STATUS_MESSAGE) - val warnings = intent.getStringArrayListExtra("android.content.pm.extra.WARNINGS") - log.info { - "Received broadcast for $packageName ($sessionId) $status: $msg" - } - if (warnings != null && warnings.isNotEmpty()) { - warnings.forEach { - log.warn { it } - } - } - listener(status, confirmIntent, msg) + override fun onReceive(context: Context, intent: Intent) { + val receivedSessionId = intent.getIntExtra(EXTRA_SESSION_ID, -1) + if (receivedSessionId != sessionId) { + log.warn { "Received intent for session $receivedSessionId, but expected $sessionId" } + return } + val confirmIntent = getParcelableExtra(intent, EXTRA_INTENT, Intent::class.java) + val packageName = intent.getStringExtra(EXTRA_PACKAGE_NAME) + val status = intent.getIntExtra(EXTRA_STATUS, Int.MIN_VALUE) + val msg = intent.getStringExtra(EXTRA_STATUS_MESSAGE) + val warnings = intent.getStringArrayListExtra("android.content.pm.extra.WARNINGS") + log.info { "Received broadcast for $packageName ($sessionId) $status: $msg" } + if (!warnings.isNullOrEmpty()) { + warnings.forEach { log.warn { it } } + } + listener(status, confirmIntent, msg) + } } diff --git a/app/src/main/kotlin/org/fdroid/install/InstallNotificationState.kt b/app/src/main/kotlin/org/fdroid/install/InstallNotificationState.kt index 4a1d8cd61..6a200956d 100644 --- a/app/src/main/kotlin/org/fdroid/install/InstallNotificationState.kt +++ b/app/src/main/kotlin/org/fdroid/install/InstallNotificationState.kt @@ -2,139 +2,135 @@ package org.fdroid.install import android.content.Context import androidx.annotation.StringRes -import org.fdroid.R import kotlin.math.roundToInt +import org.fdroid.R data class InstallNotificationState( - val apps: List, - val numBytesDownloaded: Long, - val numTotalBytes: Long, + val apps: List, + val numBytesDownloaded: Long, + val numTotalBytes: Long, ) { - constructor() : this(emptyList(), 0, 0) + constructor() : this(emptyList(), 0, 0) - val percent: Int? = if (numTotalBytes > 0) { - ((numBytesDownloaded.toFloat() / numTotalBytes) * 100).roundToInt() + val percent: Int? = + if (numTotalBytes > 0) { + ((numBytesDownloaded.toFloat() / numTotalBytes) * 100).roundToInt() } else { - null + null } - /** - * Returns true if there are apps that have an installation in progress which could be - * waiting for user confirmation or downloading, or waiting for system installer. - */ - val isInProgress: Boolean = apps.any { it.category != AppStateCategory.INSTALLED } + /** + * Returns true if there are apps that have an installation in progress which could be waiting for + * user confirmation or downloading, or waiting for system installer. + */ + val isInProgress: Boolean = apps.any { it.category != AppStateCategory.INSTALLED } - /** - * Returns true if there is at least one app either downloading for actually installing. - * If there are only apps that have been installed already or are waiting for user confirmation, - * this will return false. - */ - val isInstallingSomeApp: Boolean = apps.any { it.category == AppStateCategory.INSTALLING } + /** + * Returns true if there is at least one app either downloading for actually installing. If there + * are only apps that have been installed already or are waiting for user confirmation, this will + * return false. + */ + val isInstallingSomeApp: Boolean = apps.any { it.category == AppStateCategory.INSTALLING } - /** - * Returns true if *all* apps being installed are updates to existing apps. - */ - private val isUpdatingApps: Boolean = apps.all { it.currentVersionName != null } + /** Returns true if *all* apps being installed are updates to existing apps. */ + private val isUpdatingApps: Boolean = apps.all { it.currentVersionName != null } - val numInstalled: Int get() = apps.count { it.category == AppStateCategory.INSTALLED } + val numInstalled: Int + get() = apps.count { it.category == AppStateCategory.INSTALLED } - fun getTitle(context: Context): String { - // can briefly show as foreground service notification, before we update real state - if (apps.isEmpty()) return context.getString(R.string.installing) + fun getTitle(context: Context): String { + // can briefly show as foreground service notification, before we update real state + if (apps.isEmpty()) return context.getString(R.string.installing) - val titleRes = if (isUpdatingApps) { - R.plurals.notification_updating_title - } else { - R.plurals.notification_installing_title - } - val numActiveApps: Int = apps.count { it.category != AppStateCategory.INSTALLED } - val installTitle = context.resources.getQuantityString( - titleRes, - numActiveApps, - numActiveApps, - ) - val needsUserConfirmation = - apps.find { it.category == AppStateCategory.NEEDS_CONFIRMATION } != null - return if (needsUserConfirmation) { - val s = context.getString(R.string.notification_installing_confirmation) - "$s $installTitle" - } else { - installTitle - } + val titleRes = + if (isUpdatingApps) { + R.plurals.notification_updating_title + } else { + R.plurals.notification_installing_title + } + val numActiveApps: Int = apps.count { it.category != AppStateCategory.INSTALLED } + val installTitle = context.resources.getQuantityString(titleRes, numActiveApps, numActiveApps) + val needsUserConfirmation = + apps.find { it.category == AppStateCategory.NEEDS_CONFIRMATION } != null + return if (needsUserConfirmation) { + val s = context.getString(R.string.notification_installing_confirmation) + "$s $installTitle" + } else { + installTitle + } + } + + fun getBigText(context: Context): String { + // split app apps into their categories + val installing = mutableListOf() + val toConfirm = mutableListOf() + val installed = mutableListOf() + apps.forEach { appState -> + when (appState.category) { + AppStateCategory.INSTALLING -> installing.add(appState) + AppStateCategory.NEEDS_CONFIRMATION -> toConfirm.add(appState) + AppStateCategory.INSTALLED -> installed.add(appState) + } + } + val sb = StringBuilder() + fun printApps(@StringRes titleRes: Int, list: List, showTitle: Boolean = true) { + if (list.isEmpty()) return + if (showTitle) { + if (sb.isNotEmpty()) sb.append("\n⠀\n") + sb.append(context.getString(titleRes)) + } + sb.append("\n") + list.forEach { appState -> sb.append("• ").append(appState.displayStr).append("\n") } } - fun getBigText(context: Context): String { - // split app apps into their categories - val installing = mutableListOf() - val toConfirm = mutableListOf() - val installed = mutableListOf() - apps.forEach { appState -> - when (appState.category) { - AppStateCategory.INSTALLING -> installing.add(appState) - AppStateCategory.NEEDS_CONFIRMATION -> toConfirm.add(appState) - AppStateCategory.INSTALLED -> installed.add(appState) - } - } - val sb = StringBuilder() - fun printApps(@StringRes titleRes: Int, list: List, showTitle: Boolean = true) { - if (list.isEmpty()) return - if (showTitle) { - if (sb.isNotEmpty()) sb.append("\n⠀\n") - sb.append(context.getString(titleRes)) - } - sb.append("\n") - list.forEach { appState -> - sb.append("• ").append(appState.displayStr).append("\n") - } - } + val showInstallTitle = toConfirm.isNotEmpty() || installed.isNotEmpty() + printApps(R.string.notification_installing_section_confirmation, toConfirm) + printApps(R.string.notification_installing_section_installing, installing, showInstallTitle) + printApps(R.string.notification_installing_section_installed, installed) + return sb.toString() + } - val showInstallTitle = toConfirm.isNotEmpty() || installed.isNotEmpty() - printApps(R.string.notification_installing_section_confirmation, toConfirm) - printApps(R.string.notification_installing_section_installing, installing, showInstallTitle) - printApps(R.string.notification_installing_section_installed, installed) - return sb.toString() - } + fun getSuccessTitle(context: Context): String { + return context.resources.getQuantityString( + R.plurals.notification_update_success_title, + numInstalled, + numInstalled, + ) + } - fun getSuccessTitle(context: Context): String { - return context.resources.getQuantityString( - R.plurals.notification_update_success_title, - numInstalled, - numInstalled, - ) - } - - fun getSuccessBigText(): String { - val sb = StringBuilder() - apps.forEach { appState -> - if (appState.category == AppStateCategory.INSTALLED) { - sb.append(appState.displayStr).append("\n") - } - } - return sb.toString() + fun getSuccessBigText(): String { + val sb = StringBuilder() + apps.forEach { appState -> + if (appState.category == AppStateCategory.INSTALLED) { + sb.append(appState.displayStr).append("\n") + } } + return sb.toString() + } } data class AppState( - val packageName: String, - val category: AppStateCategory, - val name: String, - val installVersionName: String, - val currentVersionName: String?, + val packageName: String, + val category: AppStateCategory, + val name: String, + val installVersionName: String, + val currentVersionName: String?, ) { - val displayStr: String - get() { - val versionStr = if (currentVersionName == null) { - installVersionName - } else { - "$currentVersionName → $installVersionName" - } - return "$name $versionStr" + val displayStr: String + get() { + val versionStr = + if (currentVersionName == null) { + installVersionName + } else { + "$currentVersionName → $installVersionName" } + return "$name $versionStr" + } } enum class AppStateCategory { - INSTALLING, - NEEDS_CONFIRMATION, - INSTALLED + INSTALLING, + NEEDS_CONFIRMATION, + INSTALLED, } diff --git a/app/src/main/kotlin/org/fdroid/install/InstallState.kt b/app/src/main/kotlin/org/fdroid/install/InstallState.kt index d7ab1d964..45fa6aa3a 100644 --- a/app/src/main/kotlin/org/fdroid/install/InstallState.kt +++ b/app/src/main/kotlin/org/fdroid/install/InstallState.kt @@ -5,152 +5,156 @@ import org.fdroid.database.AppVersion import org.fdroid.database.Repository sealed class InstallState(val showProgress: Boolean) { - data object Unknown : InstallState(false) + data object Unknown : InstallState(false) - /** - * Used for our own app which will be updated last, - * so this is waiting for all other updates to complete. - */ - data class Waiting( - override val name: String, - override val versionName: String, - override val currentVersionName: String? = null, - override val lastUpdated: Long, - ) : InstallStateWithInfo(true) { - override val iconModel: Any? = null - } + /** + * Used for our own app which will be updated last, so this is waiting for all other updates to + * complete. + */ + data class Waiting( + override val name: String, + override val versionName: String, + override val currentVersionName: String? = null, + override val lastUpdated: Long, + ) : InstallStateWithInfo(true) { + override val iconModel: Any? = null + } - data class Starting( - override val name: String, - override val versionName: String, - override val currentVersionName: String? = null, - override val lastUpdated: Long, - override val iconModel: Any? = null, - ) : InstallStateWithInfo(true) + data class Starting( + override val name: String, + override val versionName: String, + override val currentVersionName: String? = null, + override val lastUpdated: Long, + override val iconModel: Any? = null, + ) : InstallStateWithInfo(true) - data class PreApprovalConfirmationNeeded( - private val state: InstallStateWithInfo, - val version: AppVersion, - val repo: Repository, - override val sessionId: Int, - override val creationTimeMillis: Long = System.currentTimeMillis(), - override val intent: PendingIntent, - ) : InstallConfirmationState() { - override val name: String = state.name - override val versionName: String = state.versionName - override val currentVersionName: String? = state.currentVersionName - override val lastUpdated: Long = state.lastUpdated - override val iconModel: Any? = state.iconModel - } + data class PreApprovalConfirmationNeeded( + private val state: InstallStateWithInfo, + val version: AppVersion, + val repo: Repository, + override val sessionId: Int, + override val creationTimeMillis: Long = System.currentTimeMillis(), + override val intent: PendingIntent, + ) : InstallConfirmationState() { + override val name: String = state.name + override val versionName: String = state.versionName + override val currentVersionName: String? = state.currentVersionName + override val lastUpdated: Long = state.lastUpdated + override val iconModel: Any? = state.iconModel + } - data class PreApproved( - override val name: String, - override val versionName: String, - override val currentVersionName: String?, - override val lastUpdated: Long, - override val iconModel: Any?, - val result: PreApprovalResult, - ) : InstallStateWithInfo(true) + data class PreApproved( + override val name: String, + override val versionName: String, + override val currentVersionName: String?, + override val lastUpdated: Long, + override val iconModel: Any?, + val result: PreApprovalResult, + ) : InstallStateWithInfo(true) - data class Downloading( - override val name: String, - override val versionName: String, - override val currentVersionName: String?, - override val lastUpdated: Long, - override val iconModel: Any?, - val downloadedBytes: Long, - val totalBytes: Long, - val startMillis: Long, - ) : InstallStateWithInfo(true) { - val progress: Float get() = downloadedBytes / totalBytes.toFloat() - } + data class Downloading( + override val name: String, + override val versionName: String, + override val currentVersionName: String?, + override val lastUpdated: Long, + override val iconModel: Any?, + val downloadedBytes: Long, + val totalBytes: Long, + val startMillis: Long, + ) : InstallStateWithInfo(true) { + val progress: Float + get() = downloadedBytes / totalBytes.toFloat() + } - data class Installing( - override val name: String, - override val versionName: String, - override val currentVersionName: String?, - override val lastUpdated: Long, - override val iconModel: Any?, - ) : InstallStateWithInfo(true) + data class Installing( + override val name: String, + override val versionName: String, + override val currentVersionName: String?, + override val lastUpdated: Long, + override val iconModel: Any?, + ) : InstallStateWithInfo(true) - data class UserConfirmationNeeded( - override val name: String, - override val versionName: String, - override val currentVersionName: String?, - override val lastUpdated: Long, - override val iconModel: Any?, - override val sessionId: Int, - override val intent: PendingIntent, - override val creationTimeMillis: Long, - val progress: Float, - ) : InstallConfirmationState() { - constructor( - state: InstallStateWithInfo, - sessionId: Int, - intent: PendingIntent, - progress: Float - ) : this( - name = state.name, - versionName = state.versionName, - currentVersionName = state.currentVersionName, - lastUpdated = state.lastUpdated, - iconModel = state.iconModel, - sessionId = sessionId, - intent = intent, - creationTimeMillis = System.currentTimeMillis(), - progress = progress - ) - } + data class UserConfirmationNeeded( + override val name: String, + override val versionName: String, + override val currentVersionName: String?, + override val lastUpdated: Long, + override val iconModel: Any?, + override val sessionId: Int, + override val intent: PendingIntent, + override val creationTimeMillis: Long, + val progress: Float, + ) : InstallConfirmationState() { + constructor( + state: InstallStateWithInfo, + sessionId: Int, + intent: PendingIntent, + progress: Float, + ) : this( + name = state.name, + versionName = state.versionName, + currentVersionName = state.currentVersionName, + lastUpdated = state.lastUpdated, + iconModel = state.iconModel, + sessionId = sessionId, + intent = intent, + creationTimeMillis = System.currentTimeMillis(), + progress = progress, + ) + } - data class Installed( - override val name: String, - override val versionName: String, - override val currentVersionName: String?, - override val lastUpdated: Long, - override val iconModel: Any?, - ) : InstallStateWithInfo(false) + data class Installed( + override val name: String, + override val versionName: String, + override val currentVersionName: String?, + override val lastUpdated: Long, + override val iconModel: Any?, + ) : InstallStateWithInfo(false) - data object UserAborted : InstallState(false) + data object UserAborted : InstallState(false) - data class Error( - val msg: String?, - override val name: String, - override val versionName: String, - override val currentVersionName: String?, - override val lastUpdated: Long, - override val iconModel: Any?, - ) : InstallStateWithInfo(false) { - constructor(msg: String?, s: InstallStateWithInfo) : this( - msg = msg, - name = s.name, - versionName = s.versionName, - currentVersionName = s.currentVersionName, - lastUpdated = s.lastUpdated, - iconModel = s.iconModel, - ) - } + data class Error( + val msg: String?, + override val name: String, + override val versionName: String, + override val currentVersionName: String?, + override val lastUpdated: Long, + override val iconModel: Any?, + ) : InstallStateWithInfo(false) { + constructor( + msg: String?, + s: InstallStateWithInfo, + ) : this( + msg = msg, + name = s.name, + versionName = s.versionName, + currentVersionName = s.currentVersionName, + lastUpdated = s.lastUpdated, + iconModel = s.iconModel, + ) + } - data object Uninstalled : InstallState(false) + data object Uninstalled : InstallState(false) } sealed class InstallStateWithInfo(showProgress: Boolean) : InstallState(showProgress) { - abstract val name: String - abstract val versionName: String - abstract val currentVersionName: String? - abstract val lastUpdated: Long - abstract val iconModel: Any? + abstract val name: String + abstract val versionName: String + abstract val currentVersionName: String? + abstract val lastUpdated: Long + abstract val iconModel: Any? } sealed class InstallConfirmationState() : InstallStateWithInfo(true) { - abstract val sessionId: Int + abstract val sessionId: Int - /** - * The epoch time in milliseconds when this state was created. - * This is used to get a stable ordering on apps that require user confirmation. - * The reason this is needed is that we can only show a single confirmation dialog at a time. - * If we show more than one, the second one gets silently swallowed by the system - * and we don't receive any feedback, so installation process of several apps gets stuck. - */ - abstract val creationTimeMillis: Long - abstract val intent: PendingIntent + /** + * The epoch time in milliseconds when this state was created. This is used to get a stable + * ordering on apps that require user confirmation. The reason this is needed is that we can only + * show a single confirmation dialog at a time. If we show more than one, the second one gets + * silently swallowed by the system and we don't receive any feedback, so installation process of + * several apps gets stuck. + */ + abstract val creationTimeMillis: Long + abstract val intent: PendingIntent } diff --git a/app/src/main/kotlin/org/fdroid/install/InstalledAppsCache.kt b/app/src/main/kotlin/org/fdroid/install/InstalledAppsCache.kt index 70b704a58..66fc2b023 100644 --- a/app/src/main/kotlin/org/fdroid/install/InstalledAppsCache.kt +++ b/app/src/main/kotlin/org/fdroid/install/InstalledAppsCache.kt @@ -10,6 +10,8 @@ import android.content.pm.PackageManager import android.content.pm.PackageManager.GET_SIGNATURES import androidx.annotation.UiThread import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -18,96 +20,91 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import mu.KotlinLogging import org.fdroid.utils.IoDispatcher -import javax.inject.Inject -import javax.inject.Singleton @Singleton -class InstalledAppsCache @Inject constructor( - @param:ApplicationContext private val context: Context, - @param:IoDispatcher private val ioScope: CoroutineScope, +class InstalledAppsCache +@Inject +constructor( + @param:ApplicationContext private val context: Context, + @param:IoDispatcher private val ioScope: CoroutineScope, ) : BroadcastReceiver() { - private val log = KotlinLogging.logger { } - private val packageManager = context.packageManager - private val _installedApps = MutableStateFlow>(emptyMap()) - val installedApps = _installedApps.asStateFlow() - private var loadJob: Job? = null + private val log = KotlinLogging.logger {} + private val packageManager = context.packageManager + private val _installedApps = MutableStateFlow>(emptyMap()) + val installedApps = _installedApps.asStateFlow() + private var loadJob: Job? = null - init { - val intentFilter = IntentFilter().apply { - addAction(Intent.ACTION_PACKAGE_ADDED) - addAction(Intent.ACTION_PACKAGE_REMOVED) - addDataScheme("package") - } - context.registerReceiver(this, intentFilter) - loadInstalledApps() + init { + val intentFilter = + IntentFilter().apply { + addAction(Intent.ACTION_PACKAGE_ADDED) + addAction(Intent.ACTION_PACKAGE_REMOVED) + addDataScheme("package") + } + context.registerReceiver(this, intentFilter) + loadInstalledApps() + } + + fun isInstalled(packageName: String): Boolean { + // TODO on first start this may have to wait for installed apps to load + return _installedApps.value.contains(packageName) + } + + @UiThread + private fun loadInstalledApps() { + if (loadJob?.isActive == true) { + // TODO this may give us a stale cache if an app was changed + // while the system had already assembled the data, but we didn't return yet + log.warn { "Already loading apps, not loading again." } + return } + loadJob = + ioScope.launch { + log.info { "Loading installed apps..." } + @Suppress("DEPRECATION") // we'll use this as long as it works, new one was broken + val installedPackages = packageManager.getInstalledPackages(GET_SIGNATURES) + _installedApps.update { installedPackages.associateBy { it.packageName } } + } + } - fun isInstalled(packageName: String): Boolean { - // TODO on first start this may have to wait for installed apps to load - return _installedApps.value.contains(packageName) + override fun onReceive(context: Context, intent: Intent) { + if (intent.`package` != null) { + // we have seen duplicate intents on Android 15, need to check other versions + log.warn { "Ignoring intent with package: $intent" } + return } - - @UiThread - private fun loadInstalledApps() { - if (loadJob?.isActive == true) { - // TODO this may give us a stale cache if an app was changed - // while the system had already assembled the data, but we didn't return yet - log.warn { "Already loading apps, not loading again." } - return - } - loadJob = ioScope.launch { - log.info { "Loading installed apps..." } - @Suppress("DEPRECATION") // we'll use this as long as it works, new one was broken - val installedPackages = packageManager.getInstalledPackages(GET_SIGNATURES) - _installedApps.update { installedPackages.associateBy { it.packageName } } - } + when (intent.action) { + Intent.ACTION_PACKAGE_ADDED -> onPackageAdded(intent) + Intent.ACTION_PACKAGE_REMOVED -> onPackageRemoved(intent) + else -> log.error { "Unknown broadcast received: $intent" } } + } - override fun onReceive(context: Context, intent: Intent) { - if (intent.`package` != null) { - // we have seen duplicate intents on Android 15, need to check other versions - log.warn { "Ignoring intent with package: $intent" } - return - } - when (intent.action) { - Intent.ACTION_PACKAGE_ADDED -> onPackageAdded(intent) - Intent.ACTION_PACKAGE_REMOVED -> onPackageRemoved(intent) - else -> log.error { "Unknown broadcast received: $intent" } - } + private fun onPackageAdded(intent: Intent) { + val replacing = intent.getBooleanExtra(EXTRA_REPLACING, false) + log.info { "onPackageAdded($intent) ${intent.data} replacing: $replacing" } + val packageName = + intent.data?.schemeSpecificPart ?: error("No package name in ACTION_PACKAGE_ADDED") + + try { + @Suppress("DEPRECATION") // we'll use this as long as it works, new one was broken + val packageInfo = packageManager.getPackageInfo(packageName, GET_SIGNATURES) + // even if the app got replaced, we need to update packageInfo for new version code + _installedApps.update { it.toMutableMap().apply { put(packageName, packageInfo) } } + } catch (e: PackageManager.NameNotFoundException) { + // Broadcasts don't always get delivered on time. So when this broadcast arrives, + // the user may already have uninstalled the app. + log.warn(e) { "Maybe broadcast was late? App not installed anymore: " } } + } - private fun onPackageAdded(intent: Intent) { - val replacing = intent.getBooleanExtra(EXTRA_REPLACING, false) - log.info { "onPackageAdded($intent) ${intent.data} replacing: $replacing" } - val packageName = intent.data?.schemeSpecificPart - ?: error("No package name in ACTION_PACKAGE_ADDED") - - try { - @Suppress("DEPRECATION") // we'll use this as long as it works, new one was broken - val packageInfo = packageManager.getPackageInfo(packageName, GET_SIGNATURES) - // even if the app got replaced, we need to update packageInfo for new version code - _installedApps.update { - it.toMutableMap().apply { - put(packageName, packageInfo) - } - } - } catch (e: PackageManager.NameNotFoundException) { - // Broadcasts don't always get delivered on time. So when this broadcast arrives, - // the user may already have uninstalled the app. - log.warn(e) { "Maybe broadcast was late? App not installed anymore: " } - } - } - - private fun onPackageRemoved(intent: Intent) { - val replacing = intent.getBooleanExtra(EXTRA_REPLACING, false) - log.info { "onPackageRemoved($intent) ${intent.data} replacing: $replacing" } - val packageName = intent.data?.schemeSpecificPart - ?: error("No package name in ACTION_PACKAGE_REMOVED") - if (!replacing) _installedApps.update { apps -> - apps.toMutableMap().apply { - remove(packageName) - } - } - } + private fun onPackageRemoved(intent: Intent) { + val replacing = intent.getBooleanExtra(EXTRA_REPLACING, false) + log.info { "onPackageRemoved($intent) ${intent.data} replacing: $replacing" } + val packageName = + intent.data?.schemeSpecificPart ?: error("No package name in ACTION_PACKAGE_REMOVED") + if (!replacing) + _installedApps.update { apps -> apps.toMutableMap().apply { remove(packageName) } } + } } diff --git a/app/src/main/kotlin/org/fdroid/install/PreApprovalResult.kt b/app/src/main/kotlin/org/fdroid/install/PreApprovalResult.kt index 3195c0a8b..1ca0ab5f3 100644 --- a/app/src/main/kotlin/org/fdroid/install/PreApprovalResult.kt +++ b/app/src/main/kotlin/org/fdroid/install/PreApprovalResult.kt @@ -3,13 +3,14 @@ package org.fdroid.install import android.app.PendingIntent sealed interface PreApprovalResult { - data object NotSupported : PreApprovalResult - data object UserAborted : PreApprovalResult - data class UserConfirmationRequired( - val sessionId: Int, - val intent: PendingIntent, - ) : PreApprovalResult + data object NotSupported : PreApprovalResult - data class Success(val sessionId: Int) : PreApprovalResult - data class Error(val errorMsg: String?) : PreApprovalResult + data object UserAborted : PreApprovalResult + + data class UserConfirmationRequired(val sessionId: Int, val intent: PendingIntent) : + PreApprovalResult + + data class Success(val sessionId: Int) : PreApprovalResult + + data class Error(val errorMsg: String?) : PreApprovalResult } diff --git a/app/src/main/kotlin/org/fdroid/install/SessionInstallManager.kt b/app/src/main/kotlin/org/fdroid/install/SessionInstallManager.kt index a2c0e0787..bd7f1e9a0 100644 --- a/app/src/main/kotlin/org/fdroid/install/SessionInstallManager.kt +++ b/app/src/main/kotlin/org/fdroid/install/SessionInstallManager.kt @@ -24,6 +24,10 @@ import androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED import androidx.core.content.ContextCompat.registerReceiver import androidx.core.os.LocaleListCompat import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine @@ -33,386 +37,376 @@ import org.fdroid.database.AppMetadata import org.fdroid.database.AppVersion import org.fdroid.ui.utils.isAppInForeground import org.fdroid.utils.IoDispatcher -import java.io.File -import javax.inject.Inject -import javax.inject.Singleton -import kotlin.coroutines.resume @Singleton -class SessionInstallManager @Inject constructor( - @param:ApplicationContext private val context: Context, - @param:IoDispatcher private val coroutineScope: CoroutineScope, +class SessionInstallManager +@Inject +constructor( + @param:ApplicationContext private val context: Context, + @param:IoDispatcher private val coroutineScope: CoroutineScope, ) { - private val log = KotlinLogging.logger { } - private val installer = context.packageManager.packageInstaller + private val log = KotlinLogging.logger {} + private val installer = context.packageManager.packageInstaller - companion object { - private const val ACTION_INSTALL = "org.fdroid.install.SessionInstallManager.install" - - /** - * If this returns true, we can use - * [SessionParams.setRequireUserAction] with false, - * thus updating the app with the given targetSdk without user action. - */ - fun isAutoUpdateSupported(targetSdk: Int): Boolean { - if (SDK_INT < 31) return false // not supported below Android 12 - - if (SDK_INT == 31 && targetSdk >= 29) return true - if (SDK_INT == 32 && targetSdk >= 29) return true - if (SDK_INT == 33 && targetSdk >= 30) return true - if (SDK_INT == 34 && targetSdk >= 31) return true - if (SDK_INT == 35 && targetSdk >= 33) return true - // This needs to be adjusted as new Android versions are released - // https://developer.android.com/reference/android/content/pm/PackageInstaller.SessionParams#setRequireUserAction(int) - // https://cs.android.com/android/platform/superproject/+/android-16.0.0_r2:frameworks/base/services/core/java/com/android/server/pm/PackageInstallerSession.java;l=329;drc=73caa0299d9196ddeefe4f659f557fb880f6536d - // current code requires targetSdk 34 on SDK 36+ - return SDK_INT >= 36 && targetSdk >= 34 - } - } - - init { - // abandon old sessions, because there's a limit - // that will throw IllegalStateException when we try to open new sessions - coroutineScope.launch { - for (session in installer.mySessions) { - log.debug { "Abandon session ${session.sessionId} for ${session.appPackageName}" } - try { - installer.abandonSession(session.sessionId) - } catch (e: SecurityException) { - log.error(e) { "Error abandoning session: " } - } - } - } - } + companion object { + private const val ACTION_INSTALL = "org.fdroid.install.SessionInstallManager.install" /** - * Requests installation pre-approval (if available on this device). + * If this returns true, we can use [SessionParams.setRequireUserAction] with false, thus + * updating the app with the given targetSdk without user action. */ - suspend fun requestPreapproval( - app: AppMetadata, - iconGetter: suspend () -> Bitmap?, - isUpdate: Boolean, - version: AppVersion, - canRequestUserConfirmationNow: Boolean, - ): PreApprovalResult { - return if (!context.isAppInForeground()) { - log.info { "App not in foreground, pre-approval for ${app.packageName} not supported." } - PreApprovalResult.NotSupported - } else if (isUpdate && canDoAutoUpdate(version)) { - // should not be needed, so we say not supported - log.info { "Can do auto-update pre-approval for ${app.packageName} not needed." } - PreApprovalResult.NotSupported - } else if (SDK_INT >= 34) { - log.info { "Requesting pre-approval for ${app.packageName}..." } - try { - val icon = iconGetter() - preapproval(app, icon, canRequestUserConfirmationNow) - } catch (e: Exception) { - log.error(e) { "Error requesting pre-approval for ${app.packageName}: " } - PreApprovalResult.Error("${e::class.java.simpleName} ${e.message}") - } - } else { - PreApprovalResult.NotSupported - } + fun isAutoUpdateSupported(targetSdk: Int): Boolean { + if (SDK_INT < 31) return false // not supported below Android 12 + + if (SDK_INT == 31 && targetSdk >= 29) return true + if (SDK_INT == 32 && targetSdk >= 29) return true + if (SDK_INT == 33 && targetSdk >= 30) return true + if (SDK_INT == 34 && targetSdk >= 31) return true + if (SDK_INT == 35 && targetSdk >= 33) return true + // This needs to be adjusted as new Android versions are released + // https://developer.android.com/reference/android/content/pm/PackageInstaller.SessionParams#setRequireUserAction(int) + // https://cs.android.com/android/platform/superproject/+/android-16.0.0_r2:frameworks/base/services/core/java/com/android/server/pm/PackageInstallerSession.java;l=329;drc=73caa0299d9196ddeefe4f659f557fb880f6536d + // current code requires targetSdk 34 on SDK 36+ + return SDK_INT >= 36 && targetSdk >= 34 } + } - @RequiresApi(34) - private suspend fun preapproval( - app: AppMetadata, - icon: Bitmap?, - canRequestUserConfirmationNow: Boolean, - ): PreApprovalResult = suspendCancellableCoroutine { cont -> - val params = getSessionParams(app.packageName) - val sessionId = installer.createSession(params) - log.info { "Opened session $sessionId for ${app.packageName}" } - val name = app.name.getBestLocale(LocaleListCompat.getDefault()) ?: "" - - val receiver = InstallBroadcastReceiver(sessionId) { status, intent, msg -> - when (status) { - PackageInstaller.STATUS_SUCCESS -> { - cont.resume(PreApprovalResult.Success(sessionId)) - context.unregisterReceiver(this) - } - PackageInstaller.STATUS_PENDING_USER_ACTION -> { - val flags = FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE - val pendingIntent = - PendingIntent.getActivity(context, sessionId, intent, flags) - // There should be no bugs on Android versions where this is supported, - // and we should be in the foreground right now, - // so fire up intent here and now. - if (canRequestUserConfirmationNow) { - log.info { "Sending pre-approval intent for ${app.packageName}: $intent" } - pendingIntent.send() - } else { - log.info { "Can not ask pre-approval for ${app.packageName}: $intent" } - val s = PreApprovalResult.UserConfirmationRequired(sessionId, pendingIntent) - cont.resume(s) - context.unregisterReceiver(this) - } - } - else -> { - val result = when (status) { - PackageInstaller.STATUS_FAILURE_ABORTED -> PreApprovalResult.UserAborted - PackageInstaller.STATUS_FAILURE_BLOCKED -> PreApprovalResult.NotSupported - else -> PreApprovalResult.Error(msg) - } - cont.resume(result) - context.unregisterReceiver(this) - } - } - } - registerReceiver( - context, - receiver, - IntentFilter(ACTION_INSTALL), - RECEIVER_NOT_EXPORTED - ) - cont.invokeOnCancellation { - log.info { "Pre-approval for ${app.packageName} cancelled." } - context.unregisterReceiver(receiver) - } - - installer.openSession(sessionId).use { session -> - log.info { "app name locales: ${app.name} using: ${ULocale.getDefault()}" } - val details = PackageInstaller.PreapprovalDetails.Builder() - .setPackageName(app.packageName) - .setLabel(name) - .setLocale(ULocale.getDefault()) // TODO get the real one used for label - .apply { if (icon != null) setIcon(icon) } - .build() - val sender = getInstallIntentSender(sessionId, app.packageName) - session.requestUserPreapproval(details, sender) - } - } - - @WorkerThread - @SuppressLint("RequestInstallPackagesPolicy") - suspend fun install( - sessionId: Int?, - packageName: String, - state: InstallStateWithInfo, - apkFile: File, - ): InstallState = suspendCancellableCoroutine { cont -> - val size = apkFile.length() - log.info { "Installing ${apkFile.name} with size $size bytes" } - - val sessionId = try { - if (sessionId == null) { - val params = getSessionParams(packageName, size) - installer.createSession(params) - } else { - sessionId - } - } catch (e: Exception) { - log.error(e) { "Error when creating session: " } - val s = InstallState.Error("${e::class.java.simpleName} ${e.message}", state) - cont.resume(s) - return@suspendCancellableCoroutine - } - // set-up receiver for install result - val receiver = InstallBroadcastReceiver(sessionId) { status, intent, msg -> - context.unregisterReceiver(this) - when (status) { - PackageInstaller.STATUS_SUCCESS -> { - val newState = InstallState.Installed( - name = state.name, - versionName = state.versionName, - currentVersionName = state.currentVersionName, - lastUpdated = state.lastUpdated, - iconModel = state.iconModel, - ) - cont.resume(newState) - } - PackageInstaller.STATUS_PENDING_USER_ACTION -> { - val flags = if (SDK_INT >= 31) { - FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE - } else { - FLAG_UPDATE_CURRENT - } - val pendingIntent = - PendingIntent.getActivity(context, sessionId, intent, flags) - val progress = installer.getSessionInfo(sessionId)?.progress - ?: error("No session info for $sessionId") - cont.resume( - InstallState.UserConfirmationNeeded( - state = state, - sessionId = sessionId, - intent = pendingIntent, - progress = progress, - ) - ) - } - else -> { - if (status == PackageInstaller.STATUS_FAILURE_ABORTED) { - cont.resume(InstallState.UserAborted) - } else if (status == PackageInstaller.STATUS_FAILURE && - msg != null && - msg.contains("PreapprovalDetails") - ) { - val newState = InstallState.PreApproved( - name = state.name, - versionName = state.versionName, - currentVersionName = state.currentVersionName, - lastUpdated = state.lastUpdated, - iconModel = state.iconModel, - result = PreApprovalResult.Error(msg), - ) - cont.resume(newState) - } else { - cont.resume(InstallState.Error(msg, state)) - } - } - } - } - registerReceiver( - context, - receiver, - IntentFilter(ACTION_INSTALL), - RECEIVER_NOT_EXPORTED - ) - cont.invokeOnCancellation { - log.info { "App installation was cancelled, unregistering broadcast receiver..." } - context.unregisterReceiver(receiver) - try { - installer.abandonSession(sessionId) - } catch (e: SecurityException) { - // this can happen if the cancellation came too late and session already concluded - log.warn(e) { "Error while abandoning session: " } - } - } - // do the actual installation + init { + // abandon old sessions, because there's a limit + // that will throw IllegalStateException when we try to open new sessions + coroutineScope.launch { + for (session in installer.mySessions) { + log.debug { "Abandon session ${session.sessionId} for ${session.appPackageName}" } try { - installer.openSession(sessionId).use { session -> - apkFile.inputStream().use { inputStream -> - session.openWrite(packageName, 0, size).use { outputStream -> - inputStream.copyTo(outputStream) - session.fsync(outputStream) - } - } - val sender = getInstallIntentSender(sessionId, packageName) - log.info { "Committing session..." } - session.commit(sender) - } - } catch (e: Exception) { - log.error(e) { "Error during install session: " } - cont.resume(InstallState.Error("${e::class.java.simpleName} ${e.message}", state)) + installer.abandonSession(session.sessionId) + } catch (e: SecurityException) { + log.error(e) { "Error abandoning session: " } } + } } + } - suspend fun requestUserConfirmation( - state: InstallConfirmationState, - ): InstallState = suspendCancellableCoroutine { cont -> - val isPreApproval = state is InstallState.PreApprovalConfirmationNeeded - val receiver = InstallBroadcastReceiver(state.sessionId) { status, _, msg -> + /** Requests installation pre-approval (if available on this device). */ + suspend fun requestPreapproval( + app: AppMetadata, + iconGetter: suspend () -> Bitmap?, + isUpdate: Boolean, + version: AppVersion, + canRequestUserConfirmationNow: Boolean, + ): PreApprovalResult { + return if (!context.isAppInForeground()) { + log.info { "App not in foreground, pre-approval for ${app.packageName} not supported." } + PreApprovalResult.NotSupported + } else if (isUpdate && canDoAutoUpdate(version)) { + // should not be needed, so we say not supported + log.info { "Can do auto-update pre-approval for ${app.packageName} not needed." } + PreApprovalResult.NotSupported + } else if (SDK_INT >= 34) { + log.info { "Requesting pre-approval for ${app.packageName}..." } + try { + val icon = iconGetter() + preapproval(app, icon, canRequestUserConfirmationNow) + } catch (e: Exception) { + log.error(e) { "Error requesting pre-approval for ${app.packageName}: " } + PreApprovalResult.Error("${e::class.java.simpleName} ${e.message}") + } + } else { + PreApprovalResult.NotSupported + } + } + + @RequiresApi(34) + private suspend fun preapproval( + app: AppMetadata, + icon: Bitmap?, + canRequestUserConfirmationNow: Boolean, + ): PreApprovalResult = suspendCancellableCoroutine { cont -> + val params = getSessionParams(app.packageName) + val sessionId = installer.createSession(params) + log.info { "Opened session $sessionId for ${app.packageName}" } + val name = app.name.getBestLocale(LocaleListCompat.getDefault()) ?: "" + + val receiver = + InstallBroadcastReceiver(sessionId) { status, intent, msg -> + when (status) { + PackageInstaller.STATUS_SUCCESS -> { + cont.resume(PreApprovalResult.Success(sessionId)) context.unregisterReceiver(this) - when (status) { - PackageInstaller.STATUS_SUCCESS -> { - val newState = if (isPreApproval) InstallState.PreApproved( - name = state.name, - versionName = state.versionName, - currentVersionName = state.currentVersionName, - lastUpdated = state.lastUpdated, - iconModel = state.iconModel, - result = PreApprovalResult.Success(state.sessionId), - ) else InstallState.Installed( - name = state.name, - versionName = state.versionName, - currentVersionName = state.currentVersionName, - lastUpdated = state.lastUpdated, - iconModel = state.iconModel, - ) - cont.resume(newState) - } - PackageInstaller.STATUS_PENDING_USER_ACTION -> { - error("Got STATUS_PENDING_USER_ACTION again") - } - else -> { - if (status == PackageInstaller.STATUS_FAILURE_ABORTED) { - cont.resume(InstallState.UserAborted) - } else { - cont.resume(InstallState.Error(msg, state)) - } - } - } - } - registerReceiver( - context, - receiver, - IntentFilter(ACTION_INSTALL), - RECEIVER_NOT_EXPORTED, - ) - cont.invokeOnCancellation { - context.unregisterReceiver(receiver) - } - state.intent.send() - } - - private fun getSessionParams(packageName: String, size: Long? = null): SessionParams { - val params = SessionParams(SessionParams.MODE_FULL_INSTALL) - params.setAppPackageName(packageName) - size?.let { params.setSize(it) } - params.setInstallLocation(PackageInfo.INSTALL_LOCATION_AUTO) - if (SDK_INT >= 26) { - params.setInstallReason(PackageManager.INSTALL_REASON_USER) - } - if (SDK_INT >= 31) { - params.setRequireUserAction(SessionParams.USER_ACTION_NOT_REQUIRED) - } - if (SDK_INT >= 33) { - params.setPackageSource(PackageInstaller.PACKAGE_SOURCE_STORE) - } - if (SDK_INT >= 34) { - // Once the update ownership enforcement is enabled, - // the other installers will need the user action to update the package - // even if the installers have been granted the INSTALL_PACKAGES permission. - // The update ownership enforcement can only be enabled on initial installation. - // Set this to true on package update is a no-op. - params.setRequestUpdateOwnership(true) - } - return params - } - - private fun canDoAutoUpdate(version: AppVersion): Boolean { - if (SDK_INT < 31) return false - val targetSdkVersion = version.manifest.targetSdkVersion ?: return false - // docs: https://developer.android.com/reference/android/content/pm/PackageInstaller.SessionParams#setRequireUserAction(int) - return if (isAutoUpdateSupported(targetSdkVersion)) { - val ourPackageName = context.packageName - if (ourPackageName == version.packageName) return true - val sourceInfo = try { - context.packageManager.getInstallSourceInfo(version.packageName) - } catch (e: Exception) { - log.error(e) { "Could not get package info: " } - return false - } - if (SDK_INT >= 34 && sourceInfo.updateOwnerPackageName == ourPackageName) { - true - } else if (sourceInfo.installingPackageName == ourPackageName) { - true + } + PackageInstaller.STATUS_PENDING_USER_ACTION -> { + val flags = FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE + val pendingIntent = PendingIntent.getActivity(context, sessionId, intent, flags) + // There should be no bugs on Android versions where this is supported, + // and we should be in the foreground right now, + // so fire up intent here and now. + if (canRequestUserConfirmationNow) { + log.info { "Sending pre-approval intent for ${app.packageName}: $intent" } + pendingIntent.send() } else { - false + log.info { "Can not ask pre-approval for ${app.packageName}: $intent" } + val s = PreApprovalResult.UserConfirmationRequired(sessionId, pendingIntent) + cont.resume(s) + context.unregisterReceiver(this) } - } else { - false + } + else -> { + val result = + when (status) { + PackageInstaller.STATUS_FAILURE_ABORTED -> PreApprovalResult.UserAborted + PackageInstaller.STATUS_FAILURE_BLOCKED -> PreApprovalResult.NotSupported + else -> PreApprovalResult.Error(msg) + } + cont.resume(result) + context.unregisterReceiver(this) + } } + } + registerReceiver(context, receiver, IntentFilter(ACTION_INSTALL), RECEIVER_NOT_EXPORTED) + cont.invokeOnCancellation { + log.info { "Pre-approval for ${app.packageName} cancelled." } + context.unregisterReceiver(receiver) } - private fun getInstallIntentSender( - sessionId: Int, - packageName: String, - ): IntentSender { - // Don't use a different action for preapproval and installation, - // because Android sometimes sends installation broadcasts to preapproval intent. - val broadcastIntent = Intent(ACTION_INSTALL).apply { - setPackage(context.packageName) - putExtra(EXTRA_SESSION_ID, sessionId) - putExtra(EXTRA_PACKAGE_NAME, packageName) - addFlags(Intent.FLAG_RECEIVER_FOREGROUND) - } - // intent flag needs to be mutable, otherwise the intent has no extras - val flags = if (SDK_INT >= 31) FLAG_UPDATE_CURRENT or FLAG_MUTABLE else FLAG_UPDATE_CURRENT - val pendingIntent = PendingIntent.getBroadcast(context, sessionId, broadcastIntent, flags) - return pendingIntent.intentSender + installer.openSession(sessionId).use { session -> + log.info { "app name locales: ${app.name} using: ${ULocale.getDefault()}" } + val details = + PackageInstaller.PreapprovalDetails.Builder() + .setPackageName(app.packageName) + .setLabel(name) + .setLocale(ULocale.getDefault()) // TODO get the real one used for label + .apply { if (icon != null) setIcon(icon) } + .build() + val sender = getInstallIntentSender(sessionId, app.packageName) + session.requestUserPreapproval(details, sender) } + } + + @WorkerThread + @SuppressLint("RequestInstallPackagesPolicy") + suspend fun install( + sessionId: Int?, + packageName: String, + state: InstallStateWithInfo, + apkFile: File, + ): InstallState = suspendCancellableCoroutine { cont -> + val size = apkFile.length() + log.info { "Installing ${apkFile.name} with size $size bytes" } + + val sessionId = + try { + if (sessionId == null) { + val params = getSessionParams(packageName, size) + installer.createSession(params) + } else { + sessionId + } + } catch (e: Exception) { + log.error(e) { "Error when creating session: " } + val s = InstallState.Error("${e::class.java.simpleName} ${e.message}", state) + cont.resume(s) + return@suspendCancellableCoroutine + } + // set-up receiver for install result + val receiver = + InstallBroadcastReceiver(sessionId) { status, intent, msg -> + context.unregisterReceiver(this) + when (status) { + PackageInstaller.STATUS_SUCCESS -> { + val newState = + InstallState.Installed( + name = state.name, + versionName = state.versionName, + currentVersionName = state.currentVersionName, + lastUpdated = state.lastUpdated, + iconModel = state.iconModel, + ) + cont.resume(newState) + } + PackageInstaller.STATUS_PENDING_USER_ACTION -> { + val flags = + if (SDK_INT >= 31) { + FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE + } else { + FLAG_UPDATE_CURRENT + } + val pendingIntent = PendingIntent.getActivity(context, sessionId, intent, flags) + val progress = + installer.getSessionInfo(sessionId)?.progress + ?: error("No session info for $sessionId") + cont.resume( + InstallState.UserConfirmationNeeded( + state = state, + sessionId = sessionId, + intent = pendingIntent, + progress = progress, + ) + ) + } + else -> { + if (status == PackageInstaller.STATUS_FAILURE_ABORTED) { + cont.resume(InstallState.UserAborted) + } else if ( + status == PackageInstaller.STATUS_FAILURE && + msg != null && + msg.contains("PreapprovalDetails") + ) { + val newState = + InstallState.PreApproved( + name = state.name, + versionName = state.versionName, + currentVersionName = state.currentVersionName, + lastUpdated = state.lastUpdated, + iconModel = state.iconModel, + result = PreApprovalResult.Error(msg), + ) + cont.resume(newState) + } else { + cont.resume(InstallState.Error(msg, state)) + } + } + } + } + registerReceiver(context, receiver, IntentFilter(ACTION_INSTALL), RECEIVER_NOT_EXPORTED) + cont.invokeOnCancellation { + log.info { "App installation was cancelled, unregistering broadcast receiver..." } + context.unregisterReceiver(receiver) + try { + installer.abandonSession(sessionId) + } catch (e: SecurityException) { + // this can happen if the cancellation came too late and session already concluded + log.warn(e) { "Error while abandoning session: " } + } + } + // do the actual installation + try { + installer.openSession(sessionId).use { session -> + apkFile.inputStream().use { inputStream -> + session.openWrite(packageName, 0, size).use { outputStream -> + inputStream.copyTo(outputStream) + session.fsync(outputStream) + } + } + val sender = getInstallIntentSender(sessionId, packageName) + log.info { "Committing session..." } + session.commit(sender) + } + } catch (e: Exception) { + log.error(e) { "Error during install session: " } + cont.resume(InstallState.Error("${e::class.java.simpleName} ${e.message}", state)) + } + } + + suspend fun requestUserConfirmation(state: InstallConfirmationState): InstallState = + suspendCancellableCoroutine { cont -> + val isPreApproval = state is InstallState.PreApprovalConfirmationNeeded + val receiver = + InstallBroadcastReceiver(state.sessionId) { status, _, msg -> + context.unregisterReceiver(this) + when (status) { + PackageInstaller.STATUS_SUCCESS -> { + val newState = + if (isPreApproval) + InstallState.PreApproved( + name = state.name, + versionName = state.versionName, + currentVersionName = state.currentVersionName, + lastUpdated = state.lastUpdated, + iconModel = state.iconModel, + result = PreApprovalResult.Success(state.sessionId), + ) + else + InstallState.Installed( + name = state.name, + versionName = state.versionName, + currentVersionName = state.currentVersionName, + lastUpdated = state.lastUpdated, + iconModel = state.iconModel, + ) + cont.resume(newState) + } + PackageInstaller.STATUS_PENDING_USER_ACTION -> { + error("Got STATUS_PENDING_USER_ACTION again") + } + else -> { + if (status == PackageInstaller.STATUS_FAILURE_ABORTED) { + cont.resume(InstallState.UserAborted) + } else { + cont.resume(InstallState.Error(msg, state)) + } + } + } + } + registerReceiver(context, receiver, IntentFilter(ACTION_INSTALL), RECEIVER_NOT_EXPORTED) + cont.invokeOnCancellation { context.unregisterReceiver(receiver) } + state.intent.send() + } + + private fun getSessionParams(packageName: String, size: Long? = null): SessionParams { + val params = SessionParams(SessionParams.MODE_FULL_INSTALL) + params.setAppPackageName(packageName) + size?.let { params.setSize(it) } + params.setInstallLocation(PackageInfo.INSTALL_LOCATION_AUTO) + if (SDK_INT >= 26) { + params.setInstallReason(PackageManager.INSTALL_REASON_USER) + } + if (SDK_INT >= 31) { + params.setRequireUserAction(SessionParams.USER_ACTION_NOT_REQUIRED) + } + if (SDK_INT >= 33) { + params.setPackageSource(PackageInstaller.PACKAGE_SOURCE_STORE) + } + if (SDK_INT >= 34) { + // Once the update ownership enforcement is enabled, + // the other installers will need the user action to update the package + // even if the installers have been granted the INSTALL_PACKAGES permission. + // The update ownership enforcement can only be enabled on initial installation. + // Set this to true on package update is a no-op. + params.setRequestUpdateOwnership(true) + } + return params + } + + private fun canDoAutoUpdate(version: AppVersion): Boolean { + if (SDK_INT < 31) return false + val targetSdkVersion = version.manifest.targetSdkVersion ?: return false + // docs: + // https://developer.android.com/reference/android/content/pm/PackageInstaller.SessionParams#setRequireUserAction(int) + return if (isAutoUpdateSupported(targetSdkVersion)) { + val ourPackageName = context.packageName + if (ourPackageName == version.packageName) return true + val sourceInfo = + try { + context.packageManager.getInstallSourceInfo(version.packageName) + } catch (e: Exception) { + log.error(e) { "Could not get package info: " } + return false + } + if (SDK_INT >= 34 && sourceInfo.updateOwnerPackageName == ourPackageName) { + true + } else if (sourceInfo.installingPackageName == ourPackageName) { + true + } else { + false + } + } else { + false + } + } + + private fun getInstallIntentSender(sessionId: Int, packageName: String): IntentSender { + // Don't use a different action for preapproval and installation, + // because Android sometimes sends installation broadcasts to preapproval intent. + val broadcastIntent = + Intent(ACTION_INSTALL).apply { + setPackage(context.packageName) + putExtra(EXTRA_SESSION_ID, sessionId) + putExtra(EXTRA_PACKAGE_NAME, packageName) + addFlags(Intent.FLAG_RECEIVER_FOREGROUND) + } + // intent flag needs to be mutable, otherwise the intent has no extras + val flags = if (SDK_INT >= 31) FLAG_UPDATE_CURRENT or FLAG_MUTABLE else FLAG_UPDATE_CURRENT + val pendingIntent = PendingIntent.getBroadcast(context, sessionId, broadcastIntent, flags) + return pendingIntent.intentSender + } } diff --git a/app/src/main/kotlin/org/fdroid/repo/RepoPreLoader.kt b/app/src/main/kotlin/org/fdroid/repo/RepoPreLoader.kt index da07783bd..9baed1fba 100644 --- a/app/src/main/kotlin/org/fdroid/repo/RepoPreLoader.kt +++ b/app/src/main/kotlin/org/fdroid/repo/RepoPreLoader.kt @@ -3,6 +3,9 @@ package org.fdroid.repo import android.content.Context import androidx.annotation.WorkerThread import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -10,67 +13,60 @@ import kotlinx.serialization.json.decodeFromStream import org.fdroid.database.FDroidDatabase import org.fdroid.database.InitialRepository import org.fdroid.database.RepositoryDao -import java.io.File -import javax.inject.Inject -import javax.inject.Singleton @Singleton -class RepoPreLoader @Inject constructor( - @param:ApplicationContext private val context: Context, -) { +class RepoPreLoader @Inject constructor(@param:ApplicationContext private val context: Context) { - @get:WorkerThread - val defaultRepoAddresses: Set by lazy { - getDefaultRepos().map { it.address }.toSet() + @get:WorkerThread + val defaultRepoAddresses: Set by lazy { getDefaultRepos().map { it.address }.toSet() } + + @WorkerThread + @OptIn(ExperimentalSerializationApi::class) + fun addPreloadedRepositories(db: FDroidDatabase) { + addRepositories(db.getRepositoryDao(), getDefaultRepos()) + // "system" can be removed when minSdk is 28 + for (root in listOf("/system", "/system_ext", "/product", "/vendor")) { + val romReposFile = File("$root/etc/${context.packageName}/additional_repos.json") + if (romReposFile.isFile) { + val romRepos = + romReposFile.inputStream().use { inputStream -> + Json.decodeFromStream>(inputStream) + } + addRepositories(db.getRepositoryDao(), romRepos) + } + } + } + + @WorkerThread + @OptIn(ExperimentalSerializationApi::class) + private fun getDefaultRepos() = + context.assets.open("default_repos.json").use { inputStream -> + Json.decodeFromStream>(inputStream) } - @WorkerThread - @OptIn(ExperimentalSerializationApi::class) - fun addPreloadedRepositories(db: FDroidDatabase) { - addRepositories(db.getRepositoryDao(), getDefaultRepos()) - // "system" can be removed when minSdk is 28 - for (root in listOf("/system", "/system_ext", "/product", "/vendor")) { - val romReposFile = File("$root/etc/${context.packageName}/additional_repos.json") - if (romReposFile.isFile) { - val romRepos = romReposFile.inputStream().use { inputStream -> - Json.decodeFromStream>(inputStream) - } - addRepositories(db.getRepositoryDao(), romRepos) - } - } - } - - @WorkerThread - @OptIn(ExperimentalSerializationApi::class) - private fun getDefaultRepos() = context.assets.open("default_repos.json").use { inputStream -> - Json.decodeFromStream>(inputStream) - } - - private fun addRepositories( - repositoryDao: RepositoryDao, - repositories: List - ) { - repositories.forEach { repository -> - val initialRepository = InitialRepository( - name = repository.name, - address = repository.address, - mirrors = repository.mirrors, - description = repository.description, - certificate = repository.certificate, - version = 1, - enabled = repository.enabled, - ) - repositoryDao.insert(initialRepository) - } + private fun addRepositories(repositoryDao: RepositoryDao, repositories: List) { + repositories.forEach { repository -> + val initialRepository = + InitialRepository( + name = repository.name, + address = repository.address, + mirrors = repository.mirrors, + description = repository.description, + certificate = repository.certificate, + version = 1, + enabled = repository.enabled, + ) + repositoryDao.insert(initialRepository) } + } } @Serializable data class DefaultRepository( - val name: String, - val address: String, - val mirrors: List = emptyList(), - val description: String, - val certificate: String, - val enabled: Boolean, + val name: String, + val address: String, + val mirrors: List = emptyList(), + val description: String, + val certificate: String, + val enabled: Boolean, ) diff --git a/app/src/main/kotlin/org/fdroid/repo/RepoUpdateManager.kt b/app/src/main/kotlin/org/fdroid/repo/RepoUpdateManager.kt index 9430699e8..e2d321bbc 100644 --- a/app/src/main/kotlin/org/fdroid/repo/RepoUpdateManager.kt +++ b/app/src/main/kotlin/org/fdroid/repo/RepoUpdateManager.kt @@ -8,6 +8,8 @@ import androidx.annotation.VisibleForTesting import androidx.annotation.WorkerThread import androidx.core.os.LocaleListCompat import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow @@ -29,267 +31,260 @@ import org.fdroid.index.RepoUpdater import org.fdroid.settings.SettingsManager import org.fdroid.ui.utils.addressForUi import org.fdroid.updates.UpdatesManager -import javax.inject.Inject -import javax.inject.Singleton private const val MIN_UPDATE_INTERVAL_MILLIS = 15_000 @Singleton -class RepoUpdateManager @VisibleForTesting internal constructor( - private val context: Context, - private val db: FDroidDatabase, - private val repoManager: RepoManager, - private val updatesManager: UpdatesManager, - private val settingsManager: SettingsManager, - private val downloaderFactory: DownloaderFactory, - private val notificationManager: NotificationManager, - private val compatibilityChecker: CompatibilityChecker = CompatibilityCheckerImpl( - packageManager = context.packageManager, - forceTouchApps = false, - ), - private val repoUpdateListener: RepoUpdateListener = - RepoUpdateListener(context, notificationManager), - private val repoUpdater: RepoUpdater = RepoUpdater( - tempDir = context.cacheDir, // FIXME this causes disk I/O - db = db, - downloaderFactory = downloaderFactory, - compatibilityChecker = compatibilityChecker, - listener = repoUpdateListener, +class RepoUpdateManager +@VisibleForTesting +internal constructor( + private val context: Context, + private val db: FDroidDatabase, + private val repoManager: RepoManager, + private val updatesManager: UpdatesManager, + private val settingsManager: SettingsManager, + private val downloaderFactory: DownloaderFactory, + private val notificationManager: NotificationManager, + private val compatibilityChecker: CompatibilityChecker = + CompatibilityCheckerImpl(packageManager = context.packageManager, forceTouchApps = false), + private val repoUpdateListener: RepoUpdateListener = + RepoUpdateListener(context, notificationManager), + private val repoUpdater: RepoUpdater = + RepoUpdater( + tempDir = context.cacheDir, // FIXME this causes disk I/O + db = db, + downloaderFactory = downloaderFactory, + compatibilityChecker = compatibilityChecker, + listener = repoUpdateListener, ), ) { - @Inject - constructor( - @ApplicationContext context: Context, - db: FDroidDatabase, - repositoryManager: RepoManager, - updatesManager: UpdatesManager, - settingsManager: SettingsManager, - downloaderFactory: DownloaderFactory, - notificationManager: NotificationManager, - ) : this( - context = context, - db = db, - repoManager = repositoryManager, - updatesManager = updatesManager, - settingsManager = settingsManager, - downloaderFactory = downloaderFactory, - notificationManager = notificationManager, - ) + @Inject + constructor( + @ApplicationContext context: Context, + db: FDroidDatabase, + repositoryManager: RepoManager, + updatesManager: UpdatesManager, + settingsManager: SettingsManager, + downloaderFactory: DownloaderFactory, + notificationManager: NotificationManager, + ) : this( + context = context, + db = db, + repoManager = repositoryManager, + updatesManager = updatesManager, + settingsManager = settingsManager, + downloaderFactory = downloaderFactory, + notificationManager = notificationManager, + ) - private val log = KotlinLogging.logger { } - private val _isUpdating = MutableStateFlow(false) - val isUpdating = _isUpdating.asStateFlow() - val repoUpdateState = repoUpdateListener.updateState + private val log = KotlinLogging.logger {} + private val _isUpdating = MutableStateFlow(false) + val isUpdating = _isUpdating.asStateFlow() + val repoUpdateState = repoUpdateListener.updateState - /** - * The time in milliseconds of the (earliest!) next automatic repo update check. - * This is [Long.MAX_VALUE], if no time is known. - */ - val nextUpdateFlow = RepoUpdateWorker.getAutoUpdateWorkInfo(context).map { workInfo -> - workInfo?.nextScheduleTimeMillis ?: Long.MAX_VALUE + /** + * The time in milliseconds of the (earliest!) next automatic repo update check. This is + * [Long.MAX_VALUE], if no time is known. + */ + val nextUpdateFlow = + RepoUpdateWorker.getAutoUpdateWorkInfo(context).map { workInfo -> + workInfo?.nextScheduleTimeMillis ?: Long.MAX_VALUE } - @WorkerThread - suspend fun updateRepos() { + @WorkerThread + suspend fun updateRepos() { + currentCoroutineContext().ensureActive() + if (isUpdating.value) { + // This is a workaround for what looks like a WorkManager bug. + // Sometimes it goes through scheduling/cancellation loops + // and then ends up running the same worker more than once. + log.warn { "Already updating repositories in updateRepos() not doing it again." } + return + } + val timeSinceLastCheck = System.currentTimeMillis() - settingsManager.lastRepoUpdate + if (timeSinceLastCheck < MIN_UPDATE_INTERVAL_MILLIS) { + // This is a workaround for a similar issue as above. + // We've seen WorkManager tell our worker to run in what looks like an endless loop. + log.info { "Not updating, only $timeSinceLastCheck ms since last check." } + return + } + _isUpdating.value = true + try { + currentCoroutineContext().ensureActive() + var reposUpdated = false + // always get repos fresh from DB, because + // * when an update is requested early at app start, + // the repos above might not be available, yet + // * when an update is requested when adding a new repo, + // it might not be in the FDroidApp list, yet + db.getRepositoryDao().getRepositories().forEach { repo -> + if (!repo.enabled) return@forEach currentCoroutineContext().ensureActive() - if (isUpdating.value) { - // This is a workaround for what looks like a WorkManager bug. - // Sometimes it goes through scheduling/cancellation loops - // and then ends up running the same worker more than once. - log.warn { "Already updating repositories in updateRepos() not doing it again." } - return - } - val timeSinceLastCheck = System.currentTimeMillis() - settingsManager.lastRepoUpdate - if (timeSinceLastCheck < MIN_UPDATE_INTERVAL_MILLIS) { - // This is a workaround for a similar issue as above. - // We've seen WorkManager tell our worker to run in what looks like an endless loop. - log.info { "Not updating, only $timeSinceLastCheck ms since last check." } - return - } - _isUpdating.value = true - try { - currentCoroutineContext().ensureActive() - var reposUpdated = false - // always get repos fresh from DB, because - // * when an update is requested early at app start, - // the repos above might not be available, yet - // * when an update is requested when adding a new repo, - // it might not be in the FDroidApp list, yet - db.getRepositoryDao().getRepositories().forEach { repo -> - if (!repo.enabled) return@forEach - currentCoroutineContext().ensureActive() - repoUpdateListener.onUpdateStarted(repo.repoId) - // show notification - val repoName = repo.getName(LocaleListCompat.getDefault()) - val msg = context.getString(R.string.notification_repo_update_default, repoName) - notificationManager.showUpdateRepoNotification(msg, throttle = false) - // update repo - val result = repoUpdater.update(repo) - log.info { "Update repo result: $result" } - repoUpdateListener.onUpdateFinished(repo.repoId, result) - if (result is IndexUpdateResult.Processed) reposUpdated = true - else if (result is IndexUpdateResult.Error) { - log.error(result.e) { "Error updating repository ${repo.address} " } - } - } - db.getRepositoryDao().walCheckpoint() - // don't update time on first start when repos failed to update - if (!settingsManager.isFirstStart || reposUpdated) { - settingsManager.lastRepoUpdate = System.currentTimeMillis() - } - if (reposUpdated) { - updatesManager.loadUpdates().join() - val numUpdates = updatesManager.numUpdates.value - if (numUpdates > 0) { - val states = updatesManager.notificationStates - notificationManager.showAppUpdatesAvailableNotification(states) - } - } - } finally { - notificationManager.cancelUpdateRepoNotification() - _isUpdating.value = false + repoUpdateListener.onUpdateStarted(repo.repoId) + // show notification + val repoName = repo.getName(LocaleListCompat.getDefault()) + val msg = context.getString(R.string.notification_repo_update_default, repoName) + notificationManager.showUpdateRepoNotification(msg, throttle = false) + // update repo + val result = repoUpdater.update(repo) + log.info { "Update repo result: $result" } + repoUpdateListener.onUpdateFinished(repo.repoId, result) + if (result is IndexUpdateResult.Processed) reposUpdated = true + else if (result is IndexUpdateResult.Error) { + log.error(result.e) { "Error updating repository ${repo.address} " } } + } + db.getRepositoryDao().walCheckpoint() + // don't update time on first start when repos failed to update + if (!settingsManager.isFirstStart || reposUpdated) { + settingsManager.lastRepoUpdate = System.currentTimeMillis() + } + if (reposUpdated) { + updatesManager.loadUpdates().join() + val numUpdates = updatesManager.numUpdates.value + if (numUpdates > 0) { + val states = updatesManager.notificationStates + notificationManager.showAppUpdatesAvailableNotification(states) + } + } + } finally { + notificationManager.cancelUpdateRepoNotification() + _isUpdating.value = false } + } - @WorkerThread - fun updateRepo(repoId: Long): IndexUpdateResult { - if (isUpdating.value) log.warn { "Already updating repositories: updateRepo($repoId)" } + @WorkerThread + fun updateRepo(repoId: Long): IndexUpdateResult { + if (isUpdating.value) log.warn { "Already updating repositories: updateRepo($repoId)" } - val repo = repoManager.getRepository(repoId) ?: return IndexUpdateResult.NotFound - _isUpdating.value = true - return try { - repoUpdateListener.onUpdateStarted(repo.repoId) - // show notification - val repoName = repo.getName(LocaleListCompat.getDefault()) - val msg = context.getString(R.string.notification_repo_update_default, repoName) - notificationManager.showUpdateRepoNotification(msg, throttle = false) - // update repo - val result = repoUpdater.update(repo) - log.info { "Update repo result: $result" } - repoUpdateListener.onUpdateFinished(repo.repoId, result) - if (result is IndexUpdateResult.Processed) { - updatesManager.loadUpdates() - } else if (result is IndexUpdateResult.Error) { - log.error(result.e) { "Error updating ${repo.address}: " } - } - result - } finally { - notificationManager.cancelUpdateRepoNotification() - _isUpdating.value = false - db.getRepositoryDao().walCheckpoint() - } + val repo = repoManager.getRepository(repoId) ?: return IndexUpdateResult.NotFound + _isUpdating.value = true + return try { + repoUpdateListener.onUpdateStarted(repo.repoId) + // show notification + val repoName = repo.getName(LocaleListCompat.getDefault()) + val msg = context.getString(R.string.notification_repo_update_default, repoName) + notificationManager.showUpdateRepoNotification(msg, throttle = false) + // update repo + val result = repoUpdater.update(repo) + log.info { "Update repo result: $result" } + repoUpdateListener.onUpdateFinished(repo.repoId, result) + if (result is IndexUpdateResult.Processed) { + updatesManager.loadUpdates() + } else if (result is IndexUpdateResult.Error) { + log.error(result.e) { "Error updating ${repo.address}: " } + } + result + } finally { + notificationManager.cancelUpdateRepoNotification() + _isUpdating.value = false + db.getRepositoryDao().walCheckpoint() } + } } @VisibleForTesting internal class RepoUpdateListener( - private val context: Context, - private val notificationManager: NotificationManager, + private val context: Context, + private val notificationManager: NotificationManager, ) : IndexUpdateListener { - private val log = KotlinLogging.logger { } - private val _updateState = MutableStateFlow(null) - val updateState = _updateState.asStateFlow() - private var lastUpdateProgress = 0L + private val log = KotlinLogging.logger {} + private val _updateState = MutableStateFlow(null) + val updateState = _updateState.asStateFlow() + private var lastUpdateProgress = 0L - fun onUpdateStarted(repoId: Long) { - _updateState.update { RepoUpdateProgress(repoId, true, 0) } + fun onUpdateStarted(repoId: Long) { + _updateState.update { RepoUpdateProgress(repoId, true, 0) } + } + + override fun onDownloadProgress(repo: Repository, bytesRead: Long, totalBytes: Long) { + log.debug { "Downloading ${repo.address} ($bytesRead/$totalBytes)" } + + val percent = getPercent(bytesRead, totalBytes) + val size = Formatter.formatFileSize(context, bytesRead) + notificationManager.showUpdateRepoNotification( + msg = + context.getString(R.string.notification_repo_update_downloading, size, repo.addressForUi), + throttle = bytesRead != totalBytes, + progress = percent, + ) + _updateState.update { RepoUpdateProgress(repo.repoId, true, percent) } + } + + /** + * If an updater is unable to know how many apps it has to process (i.e. it is streaming apps to + * the database or performing a large database query which touches all apps, but is unable to + * report progress), then it call this listener with [totalApps] = 0. Doing so will result in a + * message of "Saving app details" sent to the user. If you know how many apps you have processed, + * then a message of "Saving app details (x/total)" is displayed. + */ + override fun onUpdateProgress(repo: Repository, appsProcessed: Int, totalApps: Int) { + // Don't update progress, if we already have updated once within the last second + if (System.currentTimeMillis() - lastUpdateProgress < 1000 && appsProcessed != totalApps) { + return } + log.debug { "Committing ${repo.address} ($appsProcessed/$totalApps)" } - override fun onDownloadProgress(repo: Repository, bytesRead: Long, totalBytes: Long) { - log.debug { "Downloading ${repo.address} ($bytesRead/$totalBytes)" } - - val percent = getPercent(bytesRead, totalBytes) - val size = Formatter.formatFileSize(context, bytesRead) - notificationManager.showUpdateRepoNotification( - msg = context.getString( - R.string.notification_repo_update_downloading, - size, repo.addressForUi - ), - throttle = bytesRead != totalBytes, - progress = percent, - ) - _updateState.update { RepoUpdateProgress(repo.repoId, true, percent) } + val repoName = repo.getName(LocaleListCompat.getDefault()) + val msg = + context.resources.getQuantityString( + R.plurals.notification_repo_update_saving, + appsProcessed, + appsProcessed, + repoName, + ) + if (totalApps > 0) { + val percent = getPercent(appsProcessed.toLong(), totalApps.toLong()) + notificationManager.showUpdateRepoNotification( + msg = msg, + throttle = appsProcessed != totalApps, + progress = percent, + ) + _updateState.update { RepoUpdateProgress(repo.repoId, false, percent) } + } else { + notificationManager.showUpdateRepoNotification(msg) + _updateState.update { RepoUpdateProgress(repo.repoId, false, 0f) } } + lastUpdateProgress = System.currentTimeMillis() + } - /** - * If an updater is unable to know how many apps it has to process (i.e. it - * is streaming apps to the database or performing a large database query - * which touches all apps, but is unable to report progress), then it call - * this listener with [totalApps] = 0. Doing so will result in a message of - * "Saving app details" sent to the user. If you know how many apps you have - * processed, then a message of "Saving app details (x/total)" is displayed. - */ - override fun onUpdateProgress(repo: Repository, appsProcessed: Int, totalApps: Int) { - // Don't update progress, if we already have updated once within the last second - if (System.currentTimeMillis() - lastUpdateProgress < 1000 && appsProcessed != totalApps) { - return - } - log.debug { "Committing ${repo.address} ($appsProcessed/$totalApps)" } + fun onUpdateFinished(repoId: Long, result: IndexUpdateResult) { + _updateState.update { RepoUpdateFinished(repoId, result) } + } - val repoName = repo.getName(LocaleListCompat.getDefault()) - val msg = context.resources.getQuantityString( - R.plurals.notification_repo_update_saving, - appsProcessed, - appsProcessed, repoName, - ) - if (totalApps > 0) { - val percent = getPercent(appsProcessed.toLong(), totalApps.toLong()) - notificationManager.showUpdateRepoNotification( - msg = msg, - throttle = appsProcessed != totalApps, - progress = percent, - ) - _updateState.update { RepoUpdateProgress(repo.repoId, false, percent) } - } else { - notificationManager.showUpdateRepoNotification(msg) - _updateState.update { RepoUpdateProgress(repo.repoId, false, 0f) } - } - lastUpdateProgress = System.currentTimeMillis() - } - - fun onUpdateFinished(repoId: Long, result: IndexUpdateResult) { - _updateState.update { RepoUpdateFinished(repoId, result) } - } - - private fun getPercent(current: Long, total: Long): Int { - if (total <= 0) return 0 - return (100L * current / total).toInt() - } + private fun getPercent(current: Long, total: Long): Int { + if (total <= 0) return 0 + return (100L * current / total).toInt() + } } sealed interface RepoUpdateState { - val repoId: Long + val repoId: Long } /** - * There's two types of progress. First, there's the download, so [isDownloading] is true. - * Then there's inserting the repo data into the DB, there [isDownloading] is false. - * The [stepProgress] gets re-used for both. + * There's two types of progress. First, there's the download, so [isDownloading] is true. Then + * there's inserting the repo data into the DB, there [isDownloading] is false. The [stepProgress] + * gets re-used for both. * * An external unified view on that is given as [progress]. */ data class RepoUpdateProgress( - override val repoId: Long, - private val isDownloading: Boolean, - @param:FloatRange(from = 0.0, to = 1.0) private val stepProgress: Float, + override val repoId: Long, + private val isDownloading: Boolean, + @param:FloatRange(from = 0.0, to = 1.0) private val stepProgress: Float, ) : RepoUpdateState { - constructor( - repoId: Long, - isDownloading: Boolean, - @IntRange(from = 0, to = 100) percent: Int, - ) : this( - repoId = repoId, - isDownloading = isDownloading, - stepProgress = percent.toFloat() / 100, - ) + constructor( + repoId: Long, + isDownloading: Boolean, + @IntRange(from = 0, to = 100) percent: Int, + ) : this(repoId = repoId, isDownloading = isDownloading, stepProgress = percent.toFloat() / 100) - val progress: Float = if (isDownloading) stepProgress / 2 else 0.5f + stepProgress / 2 + val progress: Float = if (isDownloading) stepProgress / 2 else 0.5f + stepProgress / 2 } -data class RepoUpdateFinished( - override val repoId: Long, - val result: IndexUpdateResult, -) : RepoUpdateState +data class RepoUpdateFinished(override val repoId: Long, val result: IndexUpdateResult) : + RepoUpdateState diff --git a/app/src/main/kotlin/org/fdroid/repo/RepoUpdateWorker.kt b/app/src/main/kotlin/org/fdroid/repo/RepoUpdateWorker.kt index 44999be84..24614a68c 100644 --- a/app/src/main/kotlin/org/fdroid/repo/RepoUpdateWorker.kt +++ b/app/src/main/kotlin/org/fdroid/repo/RepoUpdateWorker.kt @@ -20,6 +20,8 @@ import androidx.work.WorkerParameters import androidx.work.workDataOf import dagger.assisted.Assisted import dagger.assisted.AssistedInject +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeUnit.MINUTES import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.Flow @@ -31,132 +33,131 @@ import org.fdroid.history.HistoryManager import org.fdroid.install.CacheCleaner import org.fdroid.settings.SettingsConstants.AutoUpdateValues import org.fdroid.ui.utils.canStartForegroundService -import java.util.concurrent.TimeUnit -import java.util.concurrent.TimeUnit.MINUTES private val TAG = RepoUpdateWorker::class.java.simpleName @HiltWorker -class RepoUpdateWorker @AssistedInject constructor( - @Assisted appContext: Context, - @Assisted workerParams: WorkerParameters, - private val repoUpdateManager: RepoUpdateManager, - private val cacheCleaner: CacheCleaner, - private val historyManager: HistoryManager, - private val nm: NotificationManager, +class RepoUpdateWorker +@AssistedInject +constructor( + @Assisted appContext: Context, + @Assisted workerParams: WorkerParameters, + private val repoUpdateManager: RepoUpdateManager, + private val cacheCleaner: CacheCleaner, + private val historyManager: HistoryManager, + private val nm: NotificationManager, ) : CoroutineWorker(appContext, workerParams) { - companion object { - private const val UNIQUE_WORK_NAME_REPO_AUTO_UPDATE = "repoAutoUpdate" + companion object { + private const val UNIQUE_WORK_NAME_REPO_AUTO_UPDATE = "repoAutoUpdate" - /** - * Use this to trigger a manual repo update if the app is currently in the foreground. - * - * @param repoId The optional ID of the repo to update. - * If no ID is given, all (enabled) repos will be updated. - * Also triggers a clean cache job if no ID is given - */ - @UiThread - @JvmStatic - @JvmOverloads - fun updateNow(context: Context, repoId: Long = -1) { - val request = OneTimeWorkRequestBuilder() - .setExpedited(RUN_AS_NON_EXPEDITED_WORK_REQUEST) - .apply { - if (repoId >= 0) setInputData(workDataOf("repoId" to repoId)) - } - .build() - WorkManager.getInstance(context) - .enqueue(request) - } - - @JvmStatic - fun scheduleOrCancel(context: Context, autoUpdate: AutoUpdateValues) { - val workManager = WorkManager.getInstance(context) - if (autoUpdate != AutoUpdateValues.Never) { - Log.i(TAG, "scheduleOrCancel: enqueueUniquePeriodicWork") - val networkType = if (autoUpdate == AutoUpdateValues.Always) { - NetworkType.CONNECTED - } else { - NetworkType.UNMETERED - } - val constraints = Constraints.Builder() - .setRequiresBatteryNotLow(true) - .setRequiresStorageNotLow(true) - .setRequiredNetworkType(networkType) - .build() - val workRequest = PeriodicWorkRequestBuilder( - repeatInterval = 4, - repeatIntervalTimeUnit = TimeUnit.HOURS, - flexTimeInterval = 15, - flexTimeIntervalUnit = MINUTES, - ) - .setConstraints(constraints) - .build() - workManager.enqueueUniquePeriodicWork( - UNIQUE_WORK_NAME_REPO_AUTO_UPDATE, - UPDATE, - workRequest, - ) - } else { - Log.w(TAG, "Cancelling job due to settings!") - workManager.cancelUniqueWork(UNIQUE_WORK_NAME_REPO_AUTO_UPDATE) - } - } - - fun getAutoUpdateWorkInfo(context: Context): Flow { - return WorkManager.getInstance(context).getWorkInfosForUniqueWorkFlow( - UNIQUE_WORK_NAME_REPO_AUTO_UPDATE - ).map { it.getOrNull(0) } - } + /** + * Use this to trigger a manual repo update if the app is currently in the foreground. + * + * @param repoId The optional ID of the repo to update. If no ID is given, all (enabled) repos + * will be updated. Also triggers a clean cache job if no ID is given + */ + @UiThread + @JvmStatic + @JvmOverloads + fun updateNow(context: Context, repoId: Long = -1) { + val request = + OneTimeWorkRequestBuilder() + .setExpedited(RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .apply { if (repoId >= 0) setInputData(workDataOf("repoId" to repoId)) } + .build() + WorkManager.getInstance(context).enqueue(request) } - private val log = KotlinLogging.logger { } - - override suspend fun doWork(): Result { - log.info { - if (SDK_INT >= 31) { - "Starting RepoUpdateWorker... $this stopReason: ${this.stopReason} $runAttemptCount" - } else { - "Starting RepoUpdateWorker... $this $runAttemptCount" - } - } - try { - if (canStartForegroundService(applicationContext)) setForeground(getForegroundInfo()) - } catch (e: Exception) { - log.error(e) { "Error while running setForeground: " } - } - val repoId = inputData.getLong("repoId", -1) - return try { - currentCoroutineContext().ensureActive() - if (repoId >= 0) repoUpdateManager.updateRepo(repoId) - else repoUpdateManager.updateRepos() - // use opportunity to clean up cached APKs - cacheCleaner.clean() - historyManager.pruneEvents() - // return result - Result.success() - } catch (e: Exception) { - log.error(e) { "Error updating repos" } - if (runAttemptCount <= 3) { - Result.retry() - } else { - log.warn { "Not retrying, already tried $runAttemptCount times." } - Result.failure() - } - } finally { - log.info { - if (SDK_INT >= 31) "finished doWork $this (stopReason: ${this.stopReason})" - else "finished doWork $this" - } - } - } - - override suspend fun getForegroundInfo(): ForegroundInfo { - return ForegroundInfo( - NOTIFICATION_ID_REPO_UPDATE, - nm.getRepoUpdateNotification().build(), - if (SDK_INT >= 29) FOREGROUND_SERVICE_TYPE_MANIFEST else 0 + @JvmStatic + fun scheduleOrCancel(context: Context, autoUpdate: AutoUpdateValues) { + val workManager = WorkManager.getInstance(context) + if (autoUpdate != AutoUpdateValues.Never) { + Log.i(TAG, "scheduleOrCancel: enqueueUniquePeriodicWork") + val networkType = + if (autoUpdate == AutoUpdateValues.Always) { + NetworkType.CONNECTED + } else { + NetworkType.UNMETERED + } + val constraints = + Constraints.Builder() + .setRequiresBatteryNotLow(true) + .setRequiresStorageNotLow(true) + .setRequiredNetworkType(networkType) + .build() + val workRequest = + PeriodicWorkRequestBuilder( + repeatInterval = 4, + repeatIntervalTimeUnit = TimeUnit.HOURS, + flexTimeInterval = 15, + flexTimeIntervalUnit = MINUTES, + ) + .setConstraints(constraints) + .build() + workManager.enqueueUniquePeriodicWork( + UNIQUE_WORK_NAME_REPO_AUTO_UPDATE, + UPDATE, + workRequest, ) + } else { + Log.w(TAG, "Cancelling job due to settings!") + workManager.cancelUniqueWork(UNIQUE_WORK_NAME_REPO_AUTO_UPDATE) + } } + + fun getAutoUpdateWorkInfo(context: Context): Flow { + return WorkManager.getInstance(context) + .getWorkInfosForUniqueWorkFlow(UNIQUE_WORK_NAME_REPO_AUTO_UPDATE) + .map { it.getOrNull(0) } + } + } + + private val log = KotlinLogging.logger {} + + override suspend fun doWork(): Result { + log.info { + if (SDK_INT >= 31) { + "Starting RepoUpdateWorker... $this stopReason: ${this.stopReason} $runAttemptCount" + } else { + "Starting RepoUpdateWorker... $this $runAttemptCount" + } + } + try { + if (canStartForegroundService(applicationContext)) setForeground(getForegroundInfo()) + } catch (e: Exception) { + log.error(e) { "Error while running setForeground: " } + } + val repoId = inputData.getLong("repoId", -1) + return try { + currentCoroutineContext().ensureActive() + if (repoId >= 0) repoUpdateManager.updateRepo(repoId) else repoUpdateManager.updateRepos() + // use opportunity to clean up cached APKs + cacheCleaner.clean() + historyManager.pruneEvents() + // return result + Result.success() + } catch (e: Exception) { + log.error(e) { "Error updating repos" } + if (runAttemptCount <= 3) { + Result.retry() + } else { + log.warn { "Not retrying, already tried $runAttemptCount times." } + Result.failure() + } + } finally { + log.info { + if (SDK_INT >= 31) "finished doWork $this (stopReason: ${this.stopReason})" + else "finished doWork $this" + } + } + } + + override suspend fun getForegroundInfo(): ForegroundInfo { + return ForegroundInfo( + NOTIFICATION_ID_REPO_UPDATE, + nm.getRepoUpdateNotification().build(), + if (SDK_INT >= 29) FOREGROUND_SERVICE_TYPE_MANIFEST else 0, + ) + } } diff --git a/app/src/main/kotlin/org/fdroid/repo/RepositoryModule.kt b/app/src/main/kotlin/org/fdroid/repo/RepositoryModule.kt index 353aae642..02321029e 100644 --- a/app/src/main/kotlin/org/fdroid/repo/RepositoryModule.kt +++ b/app/src/main/kotlin/org/fdroid/repo/RepositoryModule.kt @@ -6,29 +6,30 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton import org.fdroid.CompatibilityChecker import org.fdroid.database.FDroidDatabase import org.fdroid.download.DownloaderFactory import org.fdroid.download.HttpManager import org.fdroid.index.RepoManager -import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object RepositoryModule { - @Provides - @Singleton - fun provideRepoManager( - @ApplicationContext context: Context, - db: FDroidDatabase, - downloaderFactory: DownloaderFactory, - httpManager: HttpManager, - compatibilityChecker: CompatibilityChecker, - ): RepoManager = RepoManager( - context = context, - db = db, - downloaderFactory = downloaderFactory, - httpManager = httpManager, - compatibilityChecker = compatibilityChecker, + @Provides + @Singleton + fun provideRepoManager( + @ApplicationContext context: Context, + db: FDroidDatabase, + downloaderFactory: DownloaderFactory, + httpManager: HttpManager, + compatibilityChecker: CompatibilityChecker, + ): RepoManager = + RepoManager( + context = context, + db = db, + downloaderFactory = downloaderFactory, + httpManager = httpManager, + compatibilityChecker = compatibilityChecker, ) } diff --git a/app/src/main/kotlin/org/fdroid/settings/OnboardingManager.kt b/app/src/main/kotlin/org/fdroid/settings/OnboardingManager.kt index 0bfa07ccb..26d9fa574 100644 --- a/app/src/main/kotlin/org/fdroid/settings/OnboardingManager.kt +++ b/app/src/main/kotlin/org/fdroid/settings/OnboardingManager.kt @@ -5,71 +5,66 @@ import android.content.Context.MODE_PRIVATE import android.content.SharedPreferences import androidx.core.content.edit import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update -import javax.inject.Inject -import javax.inject.Singleton @Singleton -class OnboardingManager @Inject constructor( - @param:ApplicationContext private val context: Context, -) { +class OnboardingManager +@Inject +constructor(@param:ApplicationContext private val context: Context) { - private companion object { - const val KEY_FILTER = "appFilter" - const val KEY_REPO_LIST = "repoList" - const val KEY_REPO_DETAILS = "repoDetails" - const val KEY_APP_ISSUE_HINT = "appIssueHint" - } + private companion object { + const val KEY_FILTER = "appFilter" + const val KEY_REPO_LIST = "repoList" + const val KEY_REPO_DETAILS = "repoDetails" + const val KEY_APP_ISSUE_HINT = "appIssueHint" + } - private val prefs = context.getSharedPreferences("onboarding", MODE_PRIVATE) + private val prefs = context.getSharedPreferences("onboarding", MODE_PRIVATE) - private val _showFilterOnboarding = Onboarding(KEY_FILTER, prefs) - val showFilterOnboarding = _showFilterOnboarding.flow + private val _showFilterOnboarding = Onboarding(KEY_FILTER, prefs) + val showFilterOnboarding = _showFilterOnboarding.flow - private val _showRepositoriesOnboarding = Onboarding(KEY_REPO_LIST, prefs) - val showRepositoriesOnboarding = _showRepositoriesOnboarding.flow + private val _showRepositoriesOnboarding = Onboarding(KEY_REPO_LIST, prefs) + val showRepositoriesOnboarding = _showRepositoriesOnboarding.flow - private val _showRepoDetailsOnboarding = Onboarding(KEY_REPO_DETAILS, prefs) - val showRepoDetailsOnboarding = _showRepoDetailsOnboarding.flow + private val _showRepoDetailsOnboarding = Onboarding(KEY_REPO_DETAILS, prefs) + val showRepoDetailsOnboarding = _showRepoDetailsOnboarding.flow - private val _showAppIssueHint = Onboarding(KEY_APP_ISSUE_HINT, prefs) - val showAppIssueHint = _showAppIssueHint.flow + private val _showAppIssueHint = Onboarding(KEY_APP_ISSUE_HINT, prefs) + val showAppIssueHint = _showAppIssueHint.flow - fun onFilterOnboardingSeen() { - _showFilterOnboarding.onSeen(prefs) - } + fun onFilterOnboardingSeen() { + _showFilterOnboarding.onSeen(prefs) + } - fun onRepositoriesOnboardingSeen() { - _showRepositoriesOnboarding.onSeen(prefs) - } + fun onRepositoriesOnboardingSeen() { + _showRepositoriesOnboarding.onSeen(prefs) + } - fun onRepoDetailsOnboardingSeen() { - _showRepoDetailsOnboarding.onSeen(prefs) - } + fun onRepoDetailsOnboardingSeen() { + _showRepoDetailsOnboarding.onSeen(prefs) + } - fun onAppIssueHintSeen() { - _showAppIssueHint.onSeen(prefs) - } + fun onAppIssueHintSeen() { + _showAppIssueHint.onSeen(prefs) + } } -private data class Onboarding( - val key: String, - private val _flow: MutableStateFlow, -) { - constructor(key: String, prefs: SharedPreferences) : this( - key = key, - _flow = MutableStateFlow(prefs.getBoolean(key, true)), - ) +private data class Onboarding(val key: String, private val _flow: MutableStateFlow) { + constructor( + key: String, + prefs: SharedPreferences, + ) : this(key = key, _flow = MutableStateFlow(prefs.getBoolean(key, true))) - val flow: StateFlow = _flow.asStateFlow() + val flow: StateFlow = _flow.asStateFlow() - fun onSeen(prefs: SharedPreferences) { - _flow.update { false } - prefs.edit { - putBoolean(key, false) - } - } + fun onSeen(prefs: SharedPreferences) { + _flow.update { false } + prefs.edit { putBoolean(key, false) } + } } diff --git a/app/src/main/kotlin/org/fdroid/settings/SettingsConstants.kt b/app/src/main/kotlin/org/fdroid/settings/SettingsConstants.kt index f73f592fa..ec95111f2 100644 --- a/app/src/main/kotlin/org/fdroid/settings/SettingsConstants.kt +++ b/app/src/main/kotlin/org/fdroid/settings/SettingsConstants.kt @@ -8,84 +8,90 @@ import org.fdroid.settings.SettingsConstants.MirrorChooserValues object SettingsConstants { - const val PREF_KEY_LAST_UPDATE_CHECK = "lastUpdateCheck" - const val PREF_DEFAULT_LAST_UPDATE_CHECK = -1L + const val PREF_KEY_LAST_UPDATE_CHECK = "lastUpdateCheck" + const val PREF_DEFAULT_LAST_UPDATE_CHECK = -1L - const val PREF_KEY_THEME = "theme" - const val PREF_DEFAULT_THEME = "followSystem" + const val PREF_KEY_THEME = "theme" + const val PREF_DEFAULT_THEME = "followSystem" - const val PREF_KEY_DYNAMIC_COLORS = "dynamicColors" - const val PREF_DEFAULT_DYNAMIC_COLORS = false + const val PREF_KEY_DYNAMIC_COLORS = "dynamicColors" + const val PREF_DEFAULT_DYNAMIC_COLORS = false - enum class AutoUpdateValues { OnlyWifi, Always, Never } + enum class AutoUpdateValues { + OnlyWifi, + Always, + Never, + } - const val PREF_KEY_REPO_UPDATES = "repoAutoUpdates" - val PREF_DEFAULT_REPO_UPDATES = AutoUpdateValues.OnlyWifi.name + const val PREF_KEY_REPO_UPDATES = "repoAutoUpdates" + val PREF_DEFAULT_REPO_UPDATES = AutoUpdateValues.OnlyWifi.name - const val PREF_KEY_AUTO_UPDATES = "appAutoUpdates" - val PREF_DEFAULT_AUTO_UPDATES = AutoUpdateValues.OnlyWifi.name + const val PREF_KEY_AUTO_UPDATES = "appAutoUpdates" + val PREF_DEFAULT_AUTO_UPDATES = AutoUpdateValues.OnlyWifi.name - enum class MirrorChooserValues { - Random { - override val res = R.string.pref_mirror_chooser_summary_random - }, - PreferForeign { - override val res = R.string.pref_mirror_chooser_summary_prefer_foreign - }; + enum class MirrorChooserValues { + Random { + override val res = R.string.pref_mirror_chooser_summary_random + }, + PreferForeign { + override val res = R.string.pref_mirror_chooser_summary_prefer_foreign + }; - @get:StringRes - abstract val res: Int + @get:StringRes abstract val res: Int + } + + const val PREF_KEY_MIRROR_CHOOSER = "mirrorChooser" + val PREF_DEFAULT_MIRROR_CHOOSER = MirrorChooserValues.Random.name + + const val PREF_KEY_PROXY = "proxy" + const val PREF_DEFAULT_PROXY = "" + + const val PREF_USE_DNS_CACHE = "useDnsCache" + const val PREF_USE_DNS_CACHE_DEFAULT = false + + const val PREF_DNS_CACHE = "dnsCache" + const val PREF_DNS_CACHE_DEFAULT = "" + + const val PREF_KEY_PREVENT_SCREENSHOTS = "preventScreenshots" + const val PREF_DEFAULT_PREVENT_SCREENSHOTS = false + + const val PREF_KEY_SHOW_INCOMPATIBLE = "incompatibleVersions" + const val PREF_DEFAULT_SHOW_INCOMPATIBLE = true + + const val PREF_KEY_APP_LIST_SORT_ORDER = "appListSortOrder" + const val PREF_DEFAULT_APP_LIST_SORT_ORDER = "lastUpdated" + + fun getAppListSortOrder(s: String?) = + when (s) { + "name" -> AppListSortOrder.NAME + else -> AppListSortOrder.LAST_UPDATED } - const val PREF_KEY_MIRROR_CHOOSER = "mirrorChooser" - val PREF_DEFAULT_MIRROR_CHOOSER = MirrorChooserValues.Random.name - - const val PREF_KEY_PROXY = "proxy" - const val PREF_DEFAULT_PROXY = "" - - const val PREF_USE_DNS_CACHE = "useDnsCache" - const val PREF_USE_DNS_CACHE_DEFAULT = false - - const val PREF_DNS_CACHE = "dnsCache" - const val PREF_DNS_CACHE_DEFAULT = "" - - const val PREF_KEY_PREVENT_SCREENSHOTS = "preventScreenshots" - const val PREF_DEFAULT_PREVENT_SCREENSHOTS = false - - const val PREF_KEY_SHOW_INCOMPATIBLE = "incompatibleVersions" - const val PREF_DEFAULT_SHOW_INCOMPATIBLE = true - - const val PREF_KEY_APP_LIST_SORT_ORDER = "appListSortOrder" - const val PREF_DEFAULT_APP_LIST_SORT_ORDER = "lastUpdated" - fun getAppListSortOrder(s: String?) = when (s) { - "name" -> AppListSortOrder.NAME - else -> AppListSortOrder.LAST_UPDATED + fun AppListSortOrder.toSettings() = + when (this) { + AppListSortOrder.LAST_UPDATED -> "lastUpdated" + AppListSortOrder.NAME -> "name" } - fun AppListSortOrder.toSettings() = when (this) { - AppListSortOrder.LAST_UPDATED -> "lastUpdated" - AppListSortOrder.NAME -> "name" - } + const val PREF_KEY_MY_APPS_SORT_ORDER = "myAppsSortOrder" + const val PREF_DEFAULT_MY_APPS_SORT_ORDER = "name" - const val PREF_KEY_MY_APPS_SORT_ORDER = "myAppsSortOrder" - const val PREF_DEFAULT_MY_APPS_SORT_ORDER = "name" + const val PREF_KEY_IGNORED_APP_ISSUES = "ignoredAppIssues" - const val PREF_KEY_IGNORED_APP_ISSUES = "ignoredAppIssues" - - const val PREF_KEY_INSTALL_HISTORY = "keepInstallHistory" - const val PREF_DEFAULT_INSTALL_HISTORY = false + const val PREF_KEY_INSTALL_HISTORY = "keepInstallHistory" + const val PREF_DEFAULT_INSTALL_HISTORY = false } -fun String?.toAutoUpdateValue() = try { - if (this == null) AutoUpdateValues.OnlyWifi - else AutoUpdateValues.valueOf(this) -} catch (_: IllegalArgumentException) { +fun String?.toAutoUpdateValue() = + try { + if (this == null) AutoUpdateValues.OnlyWifi else AutoUpdateValues.valueOf(this) + } catch (_: IllegalArgumentException) { AutoUpdateValues.OnlyWifi -} + } -fun String?.toMirrorChooserValue() = try { - if (this == null) MirrorChooserValues.Random - else MirrorChooserValues.valueOf(this) -} catch (_: IllegalArgumentException) { +fun String?.toMirrorChooserValue() = + try { + if (this == null) MirrorChooserValues.Random else MirrorChooserValues.valueOf(this) + } catch (_: IllegalArgumentException) { MirrorChooserValues.Random -} + } diff --git a/app/src/main/kotlin/org/fdroid/settings/SettingsManager.kt b/app/src/main/kotlin/org/fdroid/settings/SettingsManager.kt index de86d962d..67ee1657e 100644 --- a/app/src/main/kotlin/org/fdroid/settings/SettingsManager.kt +++ b/app/src/main/kotlin/org/fdroid/settings/SettingsManager.kt @@ -7,6 +7,10 @@ import androidx.annotation.UiThread import androidx.core.content.edit import dagger.hilt.android.qualifiers.ApplicationContext import io.ktor.client.engine.ProxyConfig +import java.net.InetSocketAddress +import java.net.Proxy +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -48,157 +52,161 @@ import org.fdroid.settings.SettingsConstants.PREF_USE_DNS_CACHE import org.fdroid.settings.SettingsConstants.PREF_USE_DNS_CACHE_DEFAULT import org.fdroid.settings.SettingsConstants.getAppListSortOrder import org.fdroid.settings.SettingsConstants.toSettings -import java.net.InetSocketAddress -import java.net.Proxy -import javax.inject.Inject -import javax.inject.Singleton @Singleton -class SettingsManager @Inject constructor( - @param:ApplicationContext private val context: Context, -) { +class SettingsManager @Inject constructor(@param:ApplicationContext private val context: Context) { - private val log = KotlinLogging.logger {} + private val log = KotlinLogging.logger {} - val prefs: SharedPreferences by lazy { - context.getSharedPreferences("${context.packageName}_preferences", MODE_PRIVATE) + val prefs: SharedPreferences by lazy { + context.getSharedPreferences("${context.packageName}_preferences", MODE_PRIVATE) + } + + /** This is mutable, so the settings UI can make changes to it. */ + val prefsFlow by lazy { + isDefaultPreferenceFlowLongSupportEnabled = true + createPreferenceFlow(prefs) + } + val theme + get() = prefs.getString(PREF_KEY_THEME, PREF_DEFAULT_THEME)!! + + val themeFlow = prefsFlow.map { it.get(PREF_KEY_THEME) }.distinctUntilChanged() + val dynamicColorFlow: Flow = + prefsFlow + .map { it.get(PREF_KEY_DYNAMIC_COLORS) ?: PREF_DEFAULT_DYNAMIC_COLORS } + .distinctUntilChanged() + val repoUpdates + get() = prefs.getString(PREF_KEY_REPO_UPDATES, PREF_DEFAULT_REPO_UPDATES).toAutoUpdateValue() + + val repoUpdatesFlow + get() = + prefsFlow + .map { it.get(PREF_KEY_REPO_UPDATES).toAutoUpdateValue() } + .distinctUntilChanged() + + val autoUpdateApps + get() = prefs.getString(PREF_KEY_AUTO_UPDATES, PREF_DEFAULT_AUTO_UPDATES).toAutoUpdateValue() + + val autoUpdateAppsFlow + get() = + prefsFlow + .map { it.get(PREF_KEY_AUTO_UPDATES).toAutoUpdateValue() } + .distinctUntilChanged() + + var lastRepoUpdate: Long + get() = + try { + val t = prefs.getLong(PREF_KEY_LAST_UPDATE_CHECK, PREF_DEFAULT_LAST_UPDATE_CHECK) + // TODO for the stable release of 2.0 we can remove the migration code below + if (t == PREF_DEFAULT_LAST_UPDATE_CHECK) { + prefs.getLong("lastRepoUpdateCheck", PREF_DEFAULT_LAST_UPDATE_CHECK) + } else t + } catch (_: Exception) { + PREF_DEFAULT_LAST_UPDATE_CHECK + } + set(value) { + prefs.edit { putLong(PREF_KEY_LAST_UPDATE_CHECK, value) } + _lastRepoUpdateFlow.update { value } } - /** - * This is mutable, so the settings UI can make changes to it. - */ - val prefsFlow by lazy { - isDefaultPreferenceFlowLongSupportEnabled = true - createPreferenceFlow(prefs) - } - val theme get() = prefs.getString(PREF_KEY_THEME, PREF_DEFAULT_THEME)!! - val themeFlow = prefsFlow.map { it.get(PREF_KEY_THEME) }.distinctUntilChanged() - val dynamicColorFlow: Flow = prefsFlow.map { - it.get(PREF_KEY_DYNAMIC_COLORS) ?: PREF_DEFAULT_DYNAMIC_COLORS - }.distinctUntilChanged() - val repoUpdates - get() = prefs.getString(PREF_KEY_REPO_UPDATES, PREF_DEFAULT_REPO_UPDATES) - .toAutoUpdateValue() - val repoUpdatesFlow - get() = prefsFlow.map { - it.get(PREF_KEY_REPO_UPDATES).toAutoUpdateValue() - }.distinctUntilChanged() - val autoUpdateApps - get() = prefs.getString(PREF_KEY_AUTO_UPDATES, PREF_DEFAULT_AUTO_UPDATES) - .toAutoUpdateValue() - val autoUpdateAppsFlow - get() = prefsFlow.map { - it.get(PREF_KEY_AUTO_UPDATES).toAutoUpdateValue() - }.distinctUntilChanged() - var lastRepoUpdate: Long - get() = try { - val t = prefs.getLong(PREF_KEY_LAST_UPDATE_CHECK, PREF_DEFAULT_LAST_UPDATE_CHECK) - // TODO for the stable release of 2.0 we can remove the migration code below - if (t == PREF_DEFAULT_LAST_UPDATE_CHECK) { - prefs.getLong("lastRepoUpdateCheck", PREF_DEFAULT_LAST_UPDATE_CHECK) - } else t - } catch (_: Exception) { - PREF_DEFAULT_LAST_UPDATE_CHECK - } - set(value) { - prefs.edit { putLong(PREF_KEY_LAST_UPDATE_CHECK, value) } - _lastRepoUpdateFlow.update { value } - } - - var useInstallHistory: Boolean - get() = prefs.getBoolean(PREF_KEY_INSTALL_HISTORY, PREF_DEFAULT_INSTALL_HISTORY) - set(value) { - prefs.edit { putBoolean(PREF_KEY_INSTALL_HISTORY, value) } - _useInstallHistoryFlow.update { value } - } - private val _useInstallHistoryFlow = MutableStateFlow(useInstallHistory) - val useInstallHistoryFlow = _useInstallHistoryFlow.asStateFlow() - - private val _lastRepoUpdateFlow = MutableStateFlow(lastRepoUpdate) - val lastRepoUpdateFlow = _lastRepoUpdateFlow.asStateFlow() - val isFirstStart get() = lastRepoUpdate <= PREF_DEFAULT_LAST_UPDATE_CHECK - - /** - * A set of package name for which we should not show app issues. - */ - var ignoredAppIssues: Map - get() = try { - prefs.getStringSet(PREF_KEY_IGNORED_APP_ISSUES, emptySet())?.associate { - val (packageName, versionCode) = it.split('|') - Pair(packageName, versionCode.toLong()) - } ?: emptyMap() - } catch (e: Exception) { - log.error(e) { "Error parsing ignored app issues: " } - emptyMap() - } - private set(value) { - val newValue = value.map { (packageName, versionCode) -> "$packageName|$versionCode" } - prefs.edit { putStringSet(PREF_KEY_IGNORED_APP_ISSUES, newValue.toSet()) } - } - - val mirrorChooser - get() = prefs.getString(PREF_KEY_MIRROR_CHOOSER, PREF_DEFAULT_MIRROR_CHOOSER) - .toMirrorChooserValue() - val proxyConfig: ProxyConfig? - @UiThread - get() { - val proxyStr = prefs.getString(PREF_KEY_PROXY, PREF_DEFAULT_PROXY) - return if (proxyStr.isNullOrBlank()) null - else { - val (host, port) = proxyStr.split(':') - // don't resolve hostname here, or we get NetworkOnMainThreadException - val address = InetSocketAddress.createUnresolved(host, port.toInt()) - Proxy(Proxy.Type.SOCKS, address) - } - } - val preventScreenshotsFlow - get() = prefsFlow.map { - it.get(PREF_KEY_PREVENT_SCREENSHOTS) ?: PREF_DEFAULT_PREVENT_SCREENSHOTS - }.distinctUntilChanged() - - var useDnsCache: Boolean - get() { - return prefs.getBoolean(PREF_USE_DNS_CACHE, PREF_USE_DNS_CACHE_DEFAULT) - } - set(value) { - return prefs.edit { putBoolean(PREF_USE_DNS_CACHE, value) } - } - - var dnsCache: String - get() { - return prefs.getString(PREF_DNS_CACHE, null) ?: PREF_DNS_CACHE_DEFAULT - } - set(value) { - return prefs.edit { putString(PREF_DNS_CACHE, value) } - } - - val filterIncompatible: Boolean - get() = !prefs.getBoolean(PREF_KEY_SHOW_INCOMPATIBLE, PREF_DEFAULT_SHOW_INCOMPATIBLE) - val appListSortOrder: AppListSortOrder - get() { - val s = prefs.getString(PREF_KEY_APP_LIST_SORT_ORDER, PREF_DEFAULT_APP_LIST_SORT_ORDER) - return getAppListSortOrder(s) - } - var myAppsSortOrder: AppListSortOrder - get() { - val s = prefs.getString(PREF_KEY_MY_APPS_SORT_ORDER, PREF_DEFAULT_MY_APPS_SORT_ORDER) - return getAppListSortOrder(s) - } - set(value) { - prefs.edit { putString(PREF_KEY_MY_APPS_SORT_ORDER, value.toSettings()) } - } - - fun saveAppListFilter(sortOrder: AppListSortOrder, filterIncompatible: Boolean) { - prefs.edit { - putBoolean(PREF_KEY_SHOW_INCOMPATIBLE, !filterIncompatible) - putString(PREF_KEY_APP_LIST_SORT_ORDER, sortOrder.toSettings()) - } + var useInstallHistory: Boolean + get() = prefs.getBoolean(PREF_KEY_INSTALL_HISTORY, PREF_DEFAULT_INSTALL_HISTORY) + set(value) { + prefs.edit { putBoolean(PREF_KEY_INSTALL_HISTORY, value) } + _useInstallHistoryFlow.update { value } } - fun ignoreAppIssue(packageName: String, versionCode: Long) { - val newMap = ignoredAppIssues.toMutableMap().apply { - put(packageName, versionCode) - } - ignoredAppIssues = newMap + private val _useInstallHistoryFlow = MutableStateFlow(useInstallHistory) + val useInstallHistoryFlow = _useInstallHistoryFlow.asStateFlow() + + private val _lastRepoUpdateFlow = MutableStateFlow(lastRepoUpdate) + val lastRepoUpdateFlow = _lastRepoUpdateFlow.asStateFlow() + val isFirstStart + get() = lastRepoUpdate <= PREF_DEFAULT_LAST_UPDATE_CHECK + + /** A set of package name for which we should not show app issues. */ + var ignoredAppIssues: Map + get() = + try { + prefs.getStringSet(PREF_KEY_IGNORED_APP_ISSUES, emptySet())?.associate { + val (packageName, versionCode) = it.split('|') + Pair(packageName, versionCode.toLong()) + } ?: emptyMap() + } catch (e: Exception) { + log.error(e) { "Error parsing ignored app issues: " } + emptyMap() + } + private set(value) { + val newValue = value.map { (packageName, versionCode) -> "$packageName|$versionCode" } + prefs.edit { putStringSet(PREF_KEY_IGNORED_APP_ISSUES, newValue.toSet()) } } + + val mirrorChooser + get() = + prefs.getString(PREF_KEY_MIRROR_CHOOSER, PREF_DEFAULT_MIRROR_CHOOSER).toMirrorChooserValue() + + val proxyConfig: ProxyConfig? + @UiThread + get() { + val proxyStr = prefs.getString(PREF_KEY_PROXY, PREF_DEFAULT_PROXY) + return if (proxyStr.isNullOrBlank()) null + else { + val (host, port) = proxyStr.split(':') + // don't resolve hostname here, or we get NetworkOnMainThreadException + val address = InetSocketAddress.createUnresolved(host, port.toInt()) + Proxy(Proxy.Type.SOCKS, address) + } + } + + val preventScreenshotsFlow + get() = + prefsFlow + .map { it.get(PREF_KEY_PREVENT_SCREENSHOTS) ?: PREF_DEFAULT_PREVENT_SCREENSHOTS } + .distinctUntilChanged() + + var useDnsCache: Boolean + get() { + return prefs.getBoolean(PREF_USE_DNS_CACHE, PREF_USE_DNS_CACHE_DEFAULT) + } + set(value) { + return prefs.edit { putBoolean(PREF_USE_DNS_CACHE, value) } + } + + var dnsCache: String + get() { + return prefs.getString(PREF_DNS_CACHE, null) ?: PREF_DNS_CACHE_DEFAULT + } + set(value) { + return prefs.edit { putString(PREF_DNS_CACHE, value) } + } + + val filterIncompatible: Boolean + get() = !prefs.getBoolean(PREF_KEY_SHOW_INCOMPATIBLE, PREF_DEFAULT_SHOW_INCOMPATIBLE) + + val appListSortOrder: AppListSortOrder + get() { + val s = prefs.getString(PREF_KEY_APP_LIST_SORT_ORDER, PREF_DEFAULT_APP_LIST_SORT_ORDER) + return getAppListSortOrder(s) + } + + var myAppsSortOrder: AppListSortOrder + get() { + val s = prefs.getString(PREF_KEY_MY_APPS_SORT_ORDER, PREF_DEFAULT_MY_APPS_SORT_ORDER) + return getAppListSortOrder(s) + } + set(value) { + prefs.edit { putString(PREF_KEY_MY_APPS_SORT_ORDER, value.toSettings()) } + } + + fun saveAppListFilter(sortOrder: AppListSortOrder, filterIncompatible: Boolean) { + prefs.edit { + putBoolean(PREF_KEY_SHOW_INCOMPATIBLE, !filterIncompatible) + putString(PREF_KEY_APP_LIST_SORT_ORDER, sortOrder.toSettings()) + } + } + + fun ignoreAppIssue(packageName: String, versionCode: Long) { + val newMap = ignoredAppIssues.toMutableMap().apply { put(packageName, versionCode) } + ignoredAppIssues = newMap + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/About.kt b/app/src/main/kotlin/org/fdroid/ui/About.kt index 556fca405..56e2464b5 100644 --- a/app/src/main/kotlin/org/fdroid/ui/About.kt +++ b/app/src/main/kotlin/org/fdroid/ui/About.kt @@ -54,155 +54,138 @@ import org.fdroid.ui.utils.openUriSafe @Composable @OptIn(ExperimentalMaterial3Api::class) fun About(onBackClicked: (() -> Unit)?) { - val scrollBehavior = enterAlwaysScrollBehavior(rememberTopAppBarState()) - Scaffold( - topBar = { - TopAppBar( - navigationIcon = { - if (onBackClicked != null) IconButton(onClick = onBackClicked) { - Icon( - imageVector = Icons.AutoMirrored.Default.ArrowBack, - contentDescription = stringResource(R.string.back), - ) - } - }, - title = { - Text(stringResource(R.string.about_title_full)) - }, - scrollBehavior = scrollBehavior, - ) + val scrollBehavior = enterAlwaysScrollBehavior(rememberTopAppBarState()) + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + if (onBackClicked != null) + IconButton(onClick = onBackClicked) { + Icon( + imageVector = Icons.AutoMirrored.Default.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } }, - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - ) { paddingValues -> - AboutContent(Modifier.fillMaxSize(), paddingValues) - } + title = { Text(stringResource(R.string.about_title_full)) }, + scrollBehavior = scrollBehavior, + ) + }, + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + ) { paddingValues -> + AboutContent(Modifier.fillMaxSize(), paddingValues) + } } @Composable fun AboutContent(modifier: Modifier = Modifier, paddingValues: PaddingValues = PaddingValues()) { - val scrollableState = rememberScrollState() - Box( - modifier = modifier.verticalScroll(scrollableState) + val scrollableState = rememberScrollState() + Box(modifier = modifier.verticalScroll(scrollableState)) { + Column( + verticalArrangement = spacedBy(8.dp), + modifier = Modifier.padding(paddingValues).padding(16.dp), ) { - Column( - verticalArrangement = spacedBy(8.dp), - modifier = Modifier - .padding(paddingValues) - .padding(16.dp) - ) { - AboutHeader() - AboutText() - } + AboutHeader() + AboutText() } + } } @Composable private fun AboutHeader(modifier: Modifier = Modifier) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = modifier.fillMaxWidth() - ) { - Image( - painter = painterResource(id = R.drawable.ic_launcher_foreground), - contentDescription = null, // decorative element - modifier = Modifier - .fillMaxWidth(0.25f) - .aspectRatio(1f) - .semantics { hideFromAccessibility() } - ) - SelectionContainer { - Text( - text = "${stringResource(R.string.about_version)} $VERSION_NAME", - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier - .padding(top = 16.dp) - .alpha(0.75f) - ) - } + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier.fillMaxWidth()) { + Image( + painter = painterResource(id = R.drawable.ic_launcher_foreground), + contentDescription = null, // decorative element + modifier = Modifier.fillMaxWidth(0.25f).aspectRatio(1f).semantics { hideFromAccessibility() }, + ) + SelectionContainer { + Text( + text = "${stringResource(R.string.about_version)} $VERSION_NAME", + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(top = 16.dp).alpha(0.75f), + ) } + } } @Composable private fun AboutText() { - SelectionContainer { - Text( - text = stringResource(R.string.about_text), - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(top = 16.dp), - ) - } - val uriHandler = LocalUriHandler.current + SelectionContainer { Text( - text = stringResource(R.string.links), - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(top = 8.dp) - ) - AboutLink( - text = stringResource(R.string.menu_website), - icon = Icons.Default.Home, - onClick = { uriHandler.openUriSafe("https://f-droid.org") }, - ) - AboutLink( - text = stringResource(R.string.about_forum), - icon = Icons.Default.Forum, - onClick = { uriHandler.openUriSafe("https://forum.f-droid.org") }, - ) - AboutLink( - text = stringResource(R.string.menu_translation), - icon = Icons.Default.Translate, - onClick = { - uriHandler.openUriSafe("https://f-droid.org/en/docs/Translation_and_Localization/") - }, - ) - AboutLink( - text = stringResource(R.string.donate_title), - icon = Icons.Default.MonetizationOn, - onClick = { uriHandler.openUriSafe("https://f-droid.org/donate/") }, - ) - AboutLink( - text = stringResource(R.string.about_source), - icon = Icons.Default.Code, - onClick = { uriHandler.openUriSafe("https://gitlab.com/fdroid/fdroidclient") }, + text = stringResource(R.string.about_text), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(top = 16.dp), ) + } + val uriHandler = LocalUriHandler.current + Text( + text = stringResource(R.string.links), + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(top = 8.dp), + ) + AboutLink( + text = stringResource(R.string.menu_website), + icon = Icons.Default.Home, + onClick = { uriHandler.openUriSafe("https://f-droid.org") }, + ) + AboutLink( + text = stringResource(R.string.about_forum), + icon = Icons.Default.Forum, + onClick = { uriHandler.openUriSafe("https://forum.f-droid.org") }, + ) + AboutLink( + text = stringResource(R.string.menu_translation), + icon = Icons.Default.Translate, + onClick = { + uriHandler.openUriSafe("https://f-droid.org/en/docs/Translation_and_Localization/") + }, + ) + AboutLink( + text = stringResource(R.string.donate_title), + icon = Icons.Default.MonetizationOn, + onClick = { uriHandler.openUriSafe("https://f-droid.org/donate/") }, + ) + AboutLink( + text = stringResource(R.string.about_source), + icon = Icons.Default.Code, + onClick = { uriHandler.openUriSafe("https://gitlab.com/fdroid/fdroidclient") }, + ) + Text( + text = stringResource(R.string.about_license), + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.bodyLarge, + ) + SelectionContainer { Text( - text = stringResource(R.string.about_license), - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.bodyLarge, + text = stringResource(R.string.about_license_text), + style = MaterialTheme.typography.bodyLarge, ) - SelectionContainer { - Text( - text = stringResource(R.string.about_license_text), - style = MaterialTheme.typography.bodyLarge, - ) - } + } } @Composable private fun AboutLink(text: String, icon: ImageVector, onClick: () -> Unit) { - TextButton(onClick = onClick) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.semantics { hideFromAccessibility() } - ) - Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) - Text(text = text) - } + TextButton(onClick = onClick) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.semantics { hideFromAccessibility() }, + ) + Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) + Text(text = text) + } } @Preview(showBackground = true) @Composable private fun AboutPreview() { - FDroidContent { - About {} - } + FDroidContent { About {} } } @Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun AboutPreviewDark() { - FDroidContent { - About(null) - } + FDroidContent { About(null) } } diff --git a/app/src/main/kotlin/org/fdroid/ui/Main.kt b/app/src/main/kotlin/org/fdroid/ui/Main.kt index 4b3251ca4..79216b96b 100644 --- a/app/src/main/kotlin/org/fdroid/ui/Main.kt +++ b/app/src/main/kotlin/org/fdroid/ui/Main.kt @@ -42,103 +42,98 @@ import org.fdroid.ui.settings.SettingsViewModel @OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable fun Main(onListeningForIntent: () -> Unit = {}) { - val navigationState = rememberNavigationState( - startRoute = NavigationKey.Discover, - topLevelRoutes = topLevelRoutes, - ) - val navigator = remember { Navigator(navigationState) } - // set up intent routing by listening to new intents from activity - val activity = (LocalActivity.current as ComponentActivity) - DisposableEffect(navigator) { - val intentListener = IntentRouter(navigator) - activity.addOnNewIntentListener(intentListener) - onListeningForIntent() // call this to get informed about initial intents we have missed - onDispose { activity.removeOnNewIntentListener(intentListener) } + val navigationState = + rememberNavigationState(startRoute = NavigationKey.Discover, topLevelRoutes = topLevelRoutes) + val navigator = remember { Navigator(navigationState) } + // set up intent routing by listening to new intents from activity + val activity = (LocalActivity.current as ComponentActivity) + DisposableEffect(navigator) { + val intentListener = IntentRouter(navigator) + activity.addOnNewIntentListener(intentListener) + onListeningForIntent() // call this to get informed about initial intents we have missed + onDispose { activity.removeOnNewIntentListener(intentListener) } + } + // Override the defaults so that there isn't a horizontal space between the panes. + val windowAdaptiveInfo = currentWindowAdaptiveInfo() + val directive = + remember(windowAdaptiveInfo) { + calculatePaneScaffoldDirective(windowAdaptiveInfo).copy(horizontalPartitionSpacerSize = 2.dp) } - // Override the defaults so that there isn't a horizontal space between the panes. - val windowAdaptiveInfo = currentWindowAdaptiveInfo() - val directive = remember(windowAdaptiveInfo) { - calculatePaneScaffoldDirective(windowAdaptiveInfo) - .copy(horizontalPartitionSpacerSize = 2.dp) - } - val isBigScreen = directive.maxHorizontalPartitions > 1 - val listDetailStrategy = rememberListDetailSceneStrategy(directive = directive) + val isBigScreen = directive.maxHorizontalPartitions > 1 + val listDetailStrategy = rememberListDetailSceneStrategy(directive = directive) - val entryProvider: (NavKey) -> NavEntry = entryProvider { - discoverEntry(navigator) - myAppsEntry(navigator, isBigScreen) - appDetailsEntry(navigator, isBigScreen) - appListEntry(navigator, isBigScreen) - repoEntry(navigator, isBigScreen) - entry( - metadata = ListDetailSceneStrategy.listPane("appdetails") { - NoAppSelected() - }, - ) { - val viewModel = hiltViewModel() - ExpandedSearch( - textFieldState = viewModel.textFieldState, - searchResults = viewModel.searchResults.collectAsStateWithLifecycle().value, - onSearch = viewModel::search, - onNav = { navKey -> navigator.navigate(navKey) }, - onBack = { navigator.goBack() }, - onSearchCleared = viewModel::onSearchCleared, - ) - } - entry(NavigationKey.Settings) { - val viewModel = hiltViewModel() - Settings( - model = viewModel.model, - onSaveLogcat = { - viewModel.onSaveLogcat(it) - navigator.goBack() - }, - onBackClicked = { navigator.goBack() }, - ) - } - entry(NavigationKey.InstallationHistory) { - val viewModel = hiltViewModel() - History( - items = viewModel.items.collectAsStateWithLifecycle().value, - enabled = viewModel.useInstallHistory.collectAsStateWithLifecycle(null).value, - onEnabled = viewModel::useInstallHistory, - onDeleteAll = viewModel::deleteHistory, - onBackClicked = { navigator.goBack() }, - ) - } - entry( - key = NavigationKey.About, - metadata = ListDetailSceneStrategy.detailPane("appdetails"), - ) { - About( - onBackClicked = if (isBigScreen) null else { - { navigator.goBack() } - }, - ) - } - // flavor specific navigation destinations go here - extraNavigationEntries(navigator) - } - val showBottomBar = !isBigScreen && navigator.last is MainNavKey - val viewModel = hiltViewModel() - val dynamicColors = - viewModel.dynamicColors.collectAsStateWithLifecycle(PREF_DEFAULT_DYNAMIC_COLORS).value - val numUpdates = viewModel.numUpdates.collectAsStateWithLifecycle().value - val hasAppIssues = viewModel.hasAppIssues.collectAsStateWithLifecycle(false).value - MainContent( - isBigScreen = isBigScreen, - dynamicColors = dynamicColors, - showBottomBar = showBottomBar, - currentNavKey = navigationState.topLevelRoute, - numUpdates = numUpdates, - hasAppIssues = hasAppIssues, + val entryProvider: (NavKey) -> NavEntry = entryProvider { + discoverEntry(navigator) + myAppsEntry(navigator, isBigScreen) + appDetailsEntry(navigator, isBigScreen) + appListEntry(navigator, isBigScreen) + repoEntry(navigator, isBigScreen) + entry( + metadata = ListDetailSceneStrategy.listPane("appdetails") { NoAppSelected() } + ) { + val viewModel = hiltViewModel() + ExpandedSearch( + textFieldState = viewModel.textFieldState, + searchResults = viewModel.searchResults.collectAsStateWithLifecycle().value, + onSearch = viewModel::search, onNav = { navKey -> navigator.navigate(navKey) }, - ) { modifier -> - NavDisplay( - entries = navigationState.toEntries(entryProvider), - sceneStrategy = listDetailStrategy, - onBack = { navigator.goBack() }, - modifier = modifier, - ) + onBack = { navigator.goBack() }, + onSearchCleared = viewModel::onSearchCleared, + ) } + entry(NavigationKey.Settings) { + val viewModel = hiltViewModel() + Settings( + model = viewModel.model, + onSaveLogcat = { + viewModel.onSaveLogcat(it) + navigator.goBack() + }, + onBackClicked = { navigator.goBack() }, + ) + } + entry(NavigationKey.InstallationHistory) { + val viewModel = hiltViewModel() + History( + items = viewModel.items.collectAsStateWithLifecycle().value, + enabled = viewModel.useInstallHistory.collectAsStateWithLifecycle(null).value, + onEnabled = viewModel::useInstallHistory, + onDeleteAll = viewModel::deleteHistory, + onBackClicked = { navigator.goBack() }, + ) + } + entry(key = NavigationKey.About, metadata = ListDetailSceneStrategy.detailPane("appdetails")) { + About( + onBackClicked = + if (isBigScreen) null + else { + { navigator.goBack() } + } + ) + } + // flavor specific navigation destinations go here + extraNavigationEntries(navigator) + } + val showBottomBar = !isBigScreen && navigator.last is MainNavKey + val viewModel = hiltViewModel() + val dynamicColors = + viewModel.dynamicColors.collectAsStateWithLifecycle(PREF_DEFAULT_DYNAMIC_COLORS).value + val numUpdates = viewModel.numUpdates.collectAsStateWithLifecycle().value + val hasAppIssues = viewModel.hasAppIssues.collectAsStateWithLifecycle(false).value + MainContent( + isBigScreen = isBigScreen, + dynamicColors = dynamicColors, + showBottomBar = showBottomBar, + currentNavKey = navigationState.topLevelRoute, + numUpdates = numUpdates, + hasAppIssues = hasAppIssues, + onNav = { navKey -> navigator.navigate(navKey) }, + ) { modifier -> + NavDisplay( + entries = navigationState.toEntries(entryProvider), + sceneStrategy = listDetailStrategy, + onBack = { navigator.goBack() }, + modifier = modifier, + ) + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/MainContent.kt b/app/src/main/kotlin/org/fdroid/ui/MainContent.kt index 307bde60d..b63710b7c 100644 --- a/app/src/main/kotlin/org/fdroid/ui/MainContent.kt +++ b/app/src/main/kotlin/org/fdroid/ui/MainContent.kt @@ -19,57 +19,59 @@ import org.fdroid.ui.navigation.NavigationRail @Composable fun MainContent( - isBigScreen: Boolean, - dynamicColors: Boolean, - showBottomBar: Boolean, - currentNavKey: NavKey, - numUpdates: Int, - hasAppIssues: Boolean, - onNav: (MainNavKey) -> Unit, - content: @Composable (Modifier) -> Unit, -) = FDroidContent(dynamicColors = dynamicColors) { + isBigScreen: Boolean, + dynamicColors: Boolean, + showBottomBar: Boolean, + currentNavKey: NavKey, + numUpdates: Int, + hasAppIssues: Boolean, + onNav: (MainNavKey) -> Unit, + content: @Composable (Modifier) -> Unit, +) = + FDroidContent(dynamicColors = dynamicColors) { HintHost { - Scaffold( - bottomBar = if (showBottomBar) { - { - BottomBar( - numUpdates = numUpdates, - hasIssues = hasAppIssues, - currentNavKey = currentNavKey, - onNav = onNav, - ) - } - } else { - {} - }, - ) { paddingValues -> - Row { - // show nav rail only on big screen (at least two partitions) - if (isBigScreen) NavigationRail( - numUpdates = numUpdates, - hasIssues = hasAppIssues, - currentNavKey = currentNavKey, - onNav = onNav, - ) - val modifier = if (isBigScreen) { - // need to consume start insets or some phones leave a lot of space there - Modifier.consumeWindowInsets(PaddingValues(start = 64.dp)) - } else if (showBottomBar) { - // we only apply the bottom padding here, so content stays above bottom bar, - // but we need to consume the navigation bar height manually - val bottom = with(LocalDensity.current) { - WindowInsets.navigationBars.getBottom(this).toDp() - } - Modifier - .consumeWindowInsets(PaddingValues(bottom = bottom)) - .padding(bottom = paddingValues.calculateBottomPadding()) - } else { - Modifier - } - // this needs to a have a fixed place or state saving breaks, - // so all moving pieces with conditionals are above - content(modifier) + Scaffold( + bottomBar = + if (showBottomBar) { + { + BottomBar( + numUpdates = numUpdates, + hasIssues = hasAppIssues, + currentNavKey = currentNavKey, + onNav = onNav, + ) } + } else { + {} + } + ) { paddingValues -> + Row { + // show nav rail only on big screen (at least two partitions) + if (isBigScreen) + NavigationRail( + numUpdates = numUpdates, + hasIssues = hasAppIssues, + currentNavKey = currentNavKey, + onNav = onNav, + ) + val modifier = + if (isBigScreen) { + // need to consume start insets or some phones leave a lot of space there + Modifier.consumeWindowInsets(PaddingValues(start = 64.dp)) + } else if (showBottomBar) { + // we only apply the bottom padding here, so content stays above bottom bar, + // but we need to consume the navigation bar height manually + val bottom = + with(LocalDensity.current) { WindowInsets.navigationBars.getBottom(this).toDp() } + Modifier.consumeWindowInsets(PaddingValues(bottom = bottom)) + .padding(bottom = paddingValues.calculateBottomPadding()) + } else { + Modifier + } + // this needs to a have a fixed place or state saving breaks, + // so all moving pieces with conditionals are above + content(modifier) } + } } -} + } diff --git a/app/src/main/kotlin/org/fdroid/ui/MainViewModel.kt b/app/src/main/kotlin/org/fdroid/ui/MainViewModel.kt index 2331218fc..18a50b52c 100644 --- a/app/src/main/kotlin/org/fdroid/ui/MainViewModel.kt +++ b/app/src/main/kotlin/org/fdroid/ui/MainViewModel.kt @@ -2,17 +2,16 @@ package org.fdroid.ui import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.flow.map import org.fdroid.settings.SettingsManager import org.fdroid.updates.UpdatesManager -import javax.inject.Inject @HiltViewModel -class MainViewModel @Inject constructor( - settingsManager: SettingsManager, - updatesManager: UpdatesManager, -) : ViewModel() { - val dynamicColors = settingsManager.dynamicColorFlow - val numUpdates = updatesManager.numUpdates - val hasAppIssues = updatesManager.appsWithIssues.map { !it.isNullOrEmpty() } +class MainViewModel +@Inject +constructor(settingsManager: SettingsManager, updatesManager: UpdatesManager) : ViewModel() { + val dynamicColors = settingsManager.dynamicColorFlow + val numUpdates = updatesManager.numUpdates + val hasAppIssues = updatesManager.appsWithIssues.map { !it.isNullOrEmpty() } } diff --git a/app/src/main/kotlin/org/fdroid/ui/Theme.kt b/app/src/main/kotlin/org/fdroid/ui/Theme.kt index 815c2f7ef..639ae12bd 100644 --- a/app/src/main/kotlin/org/fdroid/ui/Theme.kt +++ b/app/src/main/kotlin/org/fdroid/ui/Theme.kt @@ -15,7 +15,8 @@ import androidx.compose.ui.platform.LocalContext // https://www.figma.com/community/plugin/1034969338659738588 // Unused code are and themes with contrast are removed -private val lightScheme = lightColorScheme( +private val lightScheme = + lightColorScheme( primary = primaryLight, onPrimary = onPrimaryLight, primaryContainer = primaryContainerLight, @@ -51,9 +52,10 @@ private val lightScheme = lightColorScheme( surfaceContainer = surfaceContainerLight, surfaceContainerHigh = surfaceContainerHighLight, surfaceContainerHighest = surfaceContainerHighestLight, -) + ) -private val darkScheme = darkColorScheme( +private val darkScheme = + darkColorScheme( primary = primaryDark, onPrimary = onPrimaryDark, primaryContainer = primaryContainerDark, @@ -89,27 +91,24 @@ private val darkScheme = darkColorScheme( surfaceContainer = surfaceContainerDark, surfaceContainerHigh = surfaceContainerHighDark, surfaceContainerHighest = surfaceContainerHighestDark, -) + ) @Composable fun FDroidContent( - darkTheme: Boolean = isSystemInDarkTheme(), - dynamicColors: Boolean = false, - content: @Composable () -> Unit + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColors: Boolean = false, + content: @Composable () -> Unit, ) { - val colorScheme = when { - SDK_INT >= 31 && dynamicColors && darkTheme -> { - dynamicDarkColorScheme(LocalContext.current) - } - SDK_INT >= 31 && dynamicColors && !darkTheme -> { - dynamicLightColorScheme(LocalContext.current) - } - darkTheme -> darkScheme - else -> lightScheme - } - MaterialTheme( - colorScheme = colorScheme, - ) { - Surface(content = content) + val colorScheme = + when { + SDK_INT >= 31 && dynamicColors && darkTheme -> { + dynamicDarkColorScheme(LocalContext.current) + } + SDK_INT >= 31 && dynamicColors && !darkTheme -> { + dynamicLightColorScheme(LocalContext.current) + } + darkTheme -> darkScheme + else -> lightScheme } + MaterialTheme(colorScheme = colorScheme) { Surface(content = content) } } diff --git a/app/src/main/kotlin/org/fdroid/ui/apps/IgnoreIssueDialog.kt b/app/src/main/kotlin/org/fdroid/ui/apps/IgnoreIssueDialog.kt index 0cc6dbadd..d01491c2e 100644 --- a/app/src/main/kotlin/org/fdroid/ui/apps/IgnoreIssueDialog.kt +++ b/app/src/main/kotlin/org/fdroid/ui/apps/IgnoreIssueDialog.kt @@ -10,26 +10,20 @@ import org.fdroid.R @Composable fun IgnoreIssueDialog(appName: String, onIgnore: () -> Unit, onDismiss: () -> Unit) { - AlertDialog( - title = { - Text(text = stringResource(R.string.my_apps_ignore_dialog_title)) - }, - text = { - Text(text = stringResource(R.string.my_apps_ignore_dialog_text, appName)) - }, - onDismissRequest = onDismiss, - confirmButton = { - TextButton(onClick = onIgnore) { - Text( - text = stringResource(R.string.my_apps_ignore_dialog_button), - color = MaterialTheme.colorScheme.error, - ) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(android.R.string.cancel)) - } - } - ) + AlertDialog( + title = { Text(text = stringResource(R.string.my_apps_ignore_dialog_title)) }, + text = { Text(text = stringResource(R.string.my_apps_ignore_dialog_text, appName)) }, + onDismissRequest = onDismiss, + confirmButton = { + TextButton(onClick = onIgnore) { + Text( + text = stringResource(R.string.my_apps_ignore_dialog_button), + color = MaterialTheme.colorScheme.error, + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } + }, + ) } diff --git a/app/src/main/kotlin/org/fdroid/ui/apps/InstalledAppRow.kt b/app/src/main/kotlin/org/fdroid/ui/apps/InstalledAppRow.kt index 680cb75b4..f83b45e7d 100644 --- a/app/src/main/kotlin/org/fdroid/ui/apps/InstalledAppRow.kt +++ b/app/src/main/kotlin/org/fdroid/ui/apps/InstalledAppRow.kt @@ -29,68 +29,68 @@ import org.fdroid.ui.utils.Names @Composable fun InstalledAppRow( - app: MyInstalledAppItem, - isSelected: Boolean, - modifier: Modifier = Modifier, - appIssue: AppIssue? = null, + app: MyInstalledAppItem, + isSelected: Boolean, + modifier: Modifier = Modifier, + appIssue: AppIssue? = null, ) { - ListItem( - leadingContent = { - BadgedBox(badge = { - if (appIssue != null) BadgeIcon( - icon = Icons.Filled.Error, - color = if (appIssue is UpdateInOtherRepo) { - MaterialTheme.colorScheme.inverseSurface - } else { - MaterialTheme.colorScheme.error - }, - contentDescription = - stringResource(R.string.my_apps_header_apps_with_issue), - ) - }) { - AsyncShimmerImage( - model = app.iconModel, - error = painterResource(R.drawable.ic_repo_app_default), - contentDescription = null, - modifier = Modifier - .size(48.dp) - .semantics { hideFromAccessibility() }, - ) - } - }, - headlineContent = { - Text(app.name) - }, - supportingContent = { - Text(app.installedVersionName) - }, - colors = ListItemDefaults.colors( - containerColor = if (isSelected) { - MaterialTheme.colorScheme.surfaceVariant - } else { - Color.Transparent - } - ), - modifier = modifier, - ) + ListItem( + leadingContent = { + BadgedBox( + badge = { + if (appIssue != null) + BadgeIcon( + icon = Icons.Filled.Error, + color = + if (appIssue is UpdateInOtherRepo) { + MaterialTheme.colorScheme.inverseSurface + } else { + MaterialTheme.colorScheme.error + }, + contentDescription = stringResource(R.string.my_apps_header_apps_with_issue), + ) + } + ) { + AsyncShimmerImage( + model = app.iconModel, + error = painterResource(R.drawable.ic_repo_app_default), + contentDescription = null, + modifier = Modifier.size(48.dp).semantics { hideFromAccessibility() }, + ) + } + }, + headlineContent = { Text(app.name) }, + supportingContent = { Text(app.installedVersionName) }, + colors = + ListItemDefaults.colors( + containerColor = + if (isSelected) { + MaterialTheme.colorScheme.surfaceVariant + } else { + Color.Transparent + } + ), + modifier = modifier, + ) } @Preview @Composable fun InstalledAppRowPreview() { - val app = InstalledAppItem( - packageName = "", - name = Names.randomName, - installedVersionName = "1.0.1", - installedVersionCode = 10001, - lastUpdated = System.currentTimeMillis() - 5000, + val app = + InstalledAppItem( + packageName = "", + name = Names.randomName, + installedVersionName = "1.0.1", + installedVersionCode = 10001, + lastUpdated = System.currentTimeMillis() - 5000, ) - FDroidContent { - Column { - InstalledAppRow(app, false) - InstalledAppRow(app, true) - InstalledAppRow(app, false, appIssue = UpdateInOtherRepo(2L)) - InstalledAppRow(app, false, appIssue = NoCompatibleSigner()) - } + FDroidContent { + Column { + InstalledAppRow(app, false) + InstalledAppRow(app, true) + InstalledAppRow(app, false, appIssue = UpdateInOtherRepo(2L)) + InstalledAppRow(app, false, appIssue = NoCompatibleSigner()) } + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/apps/InstallingAppRow.kt b/app/src/main/kotlin/org/fdroid/ui/apps/InstallingAppRow.kt index b02fb0e97..91919f1d0 100644 --- a/app/src/main/kotlin/org/fdroid/ui/apps/InstallingAppRow.kt +++ b/app/src/main/kotlin/org/fdroid/ui/apps/InstallingAppRow.kt @@ -25,108 +25,110 @@ import org.fdroid.ui.FDroidContent import org.fdroid.ui.utils.AsyncShimmerImage @Composable -fun InstallingAppRow( - app: InstallingAppItem, - isSelected: Boolean, - modifier: Modifier = Modifier -) { - ListItem( - leadingContent = { - AsyncShimmerImage( - model = app.iconModel, - error = painterResource(R.drawable.ic_repo_app_default), - contentDescription = null, - modifier = Modifier.size(48.dp), - ) - }, - headlineContent = { - Text(app.name) - }, - supportingContent = { - val currentVersionName = app.installState.currentVersionName - if (currentVersionName == null) { - Text(app.installState.versionName) - } else { - Text("$currentVersionName → ${app.installState.versionName}") - } - }, - trailingContent = { - if (app.installState is InstallState.Installed) { - Icon( - imageVector = Icons.Default.CheckCircle, - contentDescription = stringResource(R.string.app_installed), - tint = MaterialTheme.colorScheme.secondary, - modifier = Modifier.padding(8.dp) - ) - } else if (app.installState is InstallState.Error) { - val desc = stringResource(R.string.notification_title_summary_install_error) - Icon( - imageVector = Icons.Default.ErrorOutline, - contentDescription = desc, - tint = MaterialTheme.colorScheme.error, - modifier = Modifier.padding(8.dp) - ) - } else { - if (app.installState is InstallState.Downloading) { - CircularProgressIndicator(progress = { app.installState.progress }) - } else { - CircularProgressIndicator() - } - } - }, - colors = ListItemDefaults.colors( - containerColor = if (isSelected) { - MaterialTheme.colorScheme.surfaceVariant - } else { - Color.Transparent - } - ), - modifier = modifier, - ) +fun InstallingAppRow(app: InstallingAppItem, isSelected: Boolean, modifier: Modifier = Modifier) { + ListItem( + leadingContent = { + AsyncShimmerImage( + model = app.iconModel, + error = painterResource(R.drawable.ic_repo_app_default), + contentDescription = null, + modifier = Modifier.size(48.dp), + ) + }, + headlineContent = { Text(app.name) }, + supportingContent = { + val currentVersionName = app.installState.currentVersionName + if (currentVersionName == null) { + Text(app.installState.versionName) + } else { + Text("$currentVersionName → ${app.installState.versionName}") + } + }, + trailingContent = { + if (app.installState is InstallState.Installed) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = stringResource(R.string.app_installed), + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier.padding(8.dp), + ) + } else if (app.installState is InstallState.Error) { + val desc = stringResource(R.string.notification_title_summary_install_error) + Icon( + imageVector = Icons.Default.ErrorOutline, + contentDescription = desc, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(8.dp), + ) + } else { + if (app.installState is InstallState.Downloading) { + CircularProgressIndicator(progress = { app.installState.progress }) + } else { + CircularProgressIndicator() + } + } + }, + colors = + ListItemDefaults.colors( + containerColor = + if (isSelected) { + MaterialTheme.colorScheme.surfaceVariant + } else { + Color.Transparent + } + ), + modifier = modifier, + ) } @Preview @Composable private fun Preview() { - val installingApp1 = InstallingAppItem( - packageName = "A1", - installState = InstallState.Downloading( - name = "Installing App 1", - versionName = "1.0.4", - currentVersionName = null, - lastUpdated = 23, - iconModel = null, - downloadedBytes = 25, - totalBytes = 100, - startMillis = System.currentTimeMillis(), - ) + val installingApp1 = + InstallingAppItem( + packageName = "A1", + installState = + InstallState.Downloading( + name = "Installing App 1", + versionName = "1.0.4", + currentVersionName = null, + lastUpdated = 23, + iconModel = null, + downloadedBytes = 25, + totalBytes = 100, + startMillis = System.currentTimeMillis(), + ), ) - val installingApp2 = InstallingAppItem( - packageName = "A2", - installState = InstallState.Installed( - name = "Installing App 2", - versionName = "2.0.1", - currentVersionName = null, - lastUpdated = 13, - iconModel = null, - ) + val installingApp2 = + InstallingAppItem( + packageName = "A2", + installState = + InstallState.Installed( + name = "Installing App 2", + versionName = "2.0.1", + currentVersionName = null, + lastUpdated = 13, + iconModel = null, + ), ) - val installingApp3 = InstallingAppItem( - packageName = "A3", - installState = InstallState.Error( - msg = "error msg", - name = "Installing App 2", - versionName = "0.0.4", - currentVersionName = null, - lastUpdated = 13, - iconModel = null, - ) + val installingApp3 = + InstallingAppItem( + packageName = "A3", + installState = + InstallState.Error( + msg = "error msg", + name = "Installing App 2", + versionName = "0.0.4", + currentVersionName = null, + lastUpdated = 13, + iconModel = null, + ), ) - FDroidContent { - Column { - InstallingAppRow(installingApp1, false) - InstallingAppRow(installingApp2, true) - InstallingAppRow(installingApp3, false) - } + FDroidContent { + Column { + InstallingAppRow(installingApp1, false) + InstallingAppRow(installingApp2, true) + InstallingAppRow(installingApp3, false) } + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/apps/MyAppItem.kt b/app/src/main/kotlin/org/fdroid/ui/apps/MyAppItem.kt index 3baa1f212..77f041486 100644 --- a/app/src/main/kotlin/org/fdroid/ui/apps/MyAppItem.kt +++ b/app/src/main/kotlin/org/fdroid/ui/apps/MyAppItem.kt @@ -7,53 +7,52 @@ import org.fdroid.index.v2.PackageVersion import org.fdroid.install.InstallStateWithInfo sealed class MyAppItem { - abstract val packageName: String - abstract val name: String - abstract val lastUpdated: Long - abstract val iconModel: Any? + abstract val packageName: String + abstract val name: String + abstract val lastUpdated: Long + abstract val iconModel: Any? } data class InstallingAppItem( - override val packageName: String, - val installState: InstallStateWithInfo, + override val packageName: String, + val installState: InstallStateWithInfo, ) : MyAppItem() { - override val name: String = installState.name - override val lastUpdated: Long = installState.lastUpdated - override val iconModel: Any = - PackageName(packageName, installState.iconModel as? DownloadRequest) + override val name: String = installState.name + override val lastUpdated: Long = installState.lastUpdated + override val iconModel: Any = PackageName(packageName, installState.iconModel as? DownloadRequest) } data class AppUpdateItem( - val repoId: Long, - override val packageName: String, - override val name: String, - val installedVersionName: String, - val update: PackageVersion, - val whatsNew: String?, - override val iconModel: Any? = null, + val repoId: Long, + override val packageName: String, + override val name: String, + val installedVersionName: String, + val update: PackageVersion, + val whatsNew: String?, + override val iconModel: Any? = null, ) : MyAppItem() { - override val lastUpdated: Long = update.added + override val lastUpdated: Long = update.added } data class AppWithIssueItem( - override val packageName: String, - override val name: String, - override val installedVersionName: String, - val installedVersionCode: Long, - val issue: AppIssue, - override val lastUpdated: Long, - override val iconModel: Any? = null, + override val packageName: String, + override val name: String, + override val installedVersionName: String, + val installedVersionCode: Long, + val issue: AppIssue, + override val lastUpdated: Long, + override val iconModel: Any? = null, ) : MyInstalledAppItem() data class InstalledAppItem( - override val packageName: String, - override val name: String, - override val installedVersionName: String, - val installedVersionCode: Long, - override val lastUpdated: Long, - override val iconModel: Any? = null, + override val packageName: String, + override val name: String, + override val installedVersionName: String, + val installedVersionCode: Long, + override val lastUpdated: Long, + override val iconModel: Any? = null, ) : MyInstalledAppItem() abstract class MyInstalledAppItem : MyAppItem() { - abstract val installedVersionName: String + abstract val installedVersionName: String } diff --git a/app/src/main/kotlin/org/fdroid/ui/apps/MyApps.kt b/app/src/main/kotlin/org/fdroid/ui/apps/MyApps.kt index 1e398a136..e4d6fdfcd 100644 --- a/app/src/main/kotlin/org/fdroid/ui/apps/MyApps.kt +++ b/app/src/main/kotlin/org/fdroid/ui/apps/MyApps.kt @@ -55,196 +55,186 @@ import org.fdroid.ui.utils.myAppsModel @Composable @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) fun MyApps( - myAppsInfo: MyAppsInfo, - currentPackageName: String?, - onAppItemClick: (String) -> Unit, - onNav: (NavKey) -> Unit, - modifier: Modifier = Modifier, + myAppsInfo: MyAppsInfo, + currentPackageName: String?, + onAppItemClick: (String) -> Unit, + onNav: (NavKey) -> Unit, + modifier: Modifier = Modifier, ) { - val myAppsModel = myAppsInfo.model - // Ask user to confirm appToConfirm whenever it changes and we are in STARTED state. - // In tests, waiting for RESUME didn't work, because the LaunchedEffect ran before. - val lifecycleOwner = LocalLifecycleOwner.current - LaunchedEffect(myAppsModel.appToConfirm) { - val app = myAppsModel.appToConfirm - if (app != null && lifecycleOwner.lifecycle.currentState.isAtLeast(STARTED)) { - val state = app.installState as InstallConfirmationState - myAppsInfo.actions.confirmAppInstall(app.packageName, state) + val myAppsModel = myAppsInfo.model + // Ask user to confirm appToConfirm whenever it changes and we are in STARTED state. + // In tests, waiting for RESUME didn't work, because the LaunchedEffect ran before. + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(myAppsModel.appToConfirm) { + val app = myAppsModel.appToConfirm + if (app != null && lifecycleOwner.lifecycle.currentState.isAtLeast(STARTED)) { + val state = app.installState as InstallConfirmationState + myAppsInfo.actions.confirmAppInstall(app.packageName, state) + } + } + val installingApps = myAppsModel.installingApps + val updatableApps = myAppsModel.appUpdates + val appsWithIssue = myAppsModel.appsWithIssue + val installedApps = myAppsModel.installedApps + val scrollBehavior = enterAlwaysScrollBehavior(rememberTopAppBarState()) + var searchActive by rememberSaveable { mutableStateOf(false) } + val onSearchCleared = { myAppsInfo.actions.search("") } + // when search bar is shown, back button closes it again + BackHandler(enabled = searchActive) { + searchActive = false + onSearchCleared() + } + val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher + Scaffold( + topBar = { + if (searchActive) { + TopSearchBar(onSearch = myAppsInfo.actions::search, onSearchCleared = onSearchCleared) { + onBackPressedDispatcher?.onBackPressed() } - } - val installingApps = myAppsModel.installingApps - val updatableApps = myAppsModel.appUpdates - val appsWithIssue = myAppsModel.appsWithIssue - val installedApps = myAppsModel.installedApps - val scrollBehavior = enterAlwaysScrollBehavior(rememberTopAppBarState()) - var searchActive by rememberSaveable { mutableStateOf(false) } - val onSearchCleared = { myAppsInfo.actions.search("") } - // when search bar is shown, back button closes it again - BackHandler(enabled = searchActive) { - searchActive = false - onSearchCleared() - } - val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher - Scaffold( - topBar = { - if (searchActive) { - TopSearchBar( - onSearch = myAppsInfo.actions::search, - onSearchCleared = onSearchCleared, - ) { - onBackPressedDispatcher?.onBackPressed() - } - } else TopAppBar( - title = { - Text(stringResource(R.string.menu_apps_my)) - }, - actions = { - TopAppBarButton( - imageVector = Icons.Filled.Search, - contentDescription = stringResource(R.string.menu_search), - onClick = { searchActive = true }, - ) - var sortByMenuExpanded by remember { mutableStateOf(false) } - TopAppBarButton( - imageVector = Icons.AutoMirrored.Default.Sort, - contentDescription = stringResource(R.string.sort_search), - onClick = { sortByMenuExpanded = !sortByMenuExpanded }, - ) - DropdownMenu( - expanded = sortByMenuExpanded, - onDismissRequest = { sortByMenuExpanded = false }, - ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.sort_by_name)) }, - leadingIcon = { - Icon(Icons.Filled.SortByAlpha, null) - }, - trailingIcon = { - RadioButton( - selected = myAppsModel.sortOrder == AppListSortOrder.NAME, - onClick = null, - ) - }, - onClick = { - myAppsInfo.actions.changeSortOrder(AppListSortOrder.NAME) - sortByMenuExpanded = false - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.sort_by_latest)) }, - leadingIcon = { - Icon(Icons.Filled.AccessTime, null) - }, - trailingIcon = { - RadioButton( - selected = myAppsModel.sortOrder == LAST_UPDATED, - onClick = null, - ) - }, - onClick = { - myAppsInfo.actions.changeSortOrder(LAST_UPDATED) - sortByMenuExpanded = false - }, - ) - } - TopAppBarOverflowButton { onDismissRequest -> - MyAppsOverFlowMenu( - onInstallHistory = { onNav(NavigationKey.InstallationHistory) }, - onExportInstalledApps = myAppsInfo.actions::exportInstalledApps, - onDismissRequest = onDismissRequest, - ) - } - }, - scrollBehavior = scrollBehavior, + } else + TopAppBar( + title = { Text(stringResource(R.string.menu_apps_my)) }, + actions = { + TopAppBarButton( + imageVector = Icons.Filled.Search, + contentDescription = stringResource(R.string.menu_search), + onClick = { searchActive = true }, ) - }, - modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - ) { paddingValues -> - val lazyListState = rememberLazyListState() - if (updatableApps == null && installedApps == null) BigLoadingIndicator() - else if (installingApps.isEmpty() && - updatableApps.isNullOrEmpty() && - appsWithIssue.isNullOrEmpty() && - installedApps.isNullOrEmpty() - ) { - Text( - text = if (searchActive) { - stringResource(R.string.search_my_apps_no_results) - } else { - val appName = stringResource(R.string.app_name) - stringResource(R.string.my_apps_empty, appName) + var sortByMenuExpanded by remember { mutableStateOf(false) } + TopAppBarButton( + imageVector = Icons.AutoMirrored.Default.Sort, + contentDescription = stringResource(R.string.sort_search), + onClick = { sortByMenuExpanded = !sortByMenuExpanded }, + ) + DropdownMenu( + expanded = sortByMenuExpanded, + onDismissRequest = { sortByMenuExpanded = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.sort_by_name)) }, + leadingIcon = { Icon(Icons.Filled.SortByAlpha, null) }, + trailingIcon = { + RadioButton( + selected = myAppsModel.sortOrder == AppListSortOrder.NAME, + onClick = null, + ) }, - textAlign = TextAlign.Center, - modifier = Modifier - .padding(paddingValues) - .fillMaxSize() - .padding(16.dp), - ) - } else { - MyAppsList( - myAppsInfo = myAppsInfo, - currentPackageName = currentPackageName, - lazyListState = lazyListState, - onAppItemClick = onAppItemClick, - paddingValues = paddingValues, - ) - } + onClick = { + myAppsInfo.actions.changeSortOrder(AppListSortOrder.NAME) + sortByMenuExpanded = false + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.sort_by_latest)) }, + leadingIcon = { Icon(Icons.Filled.AccessTime, null) }, + trailingIcon = { + RadioButton(selected = myAppsModel.sortOrder == LAST_UPDATED, onClick = null) + }, + onClick = { + myAppsInfo.actions.changeSortOrder(LAST_UPDATED) + sortByMenuExpanded = false + }, + ) + } + TopAppBarOverflowButton { onDismissRequest -> + MyAppsOverFlowMenu( + onInstallHistory = { onNav(NavigationKey.InstallationHistory) }, + onExportInstalledApps = myAppsInfo.actions::exportInstalledApps, + onDismissRequest = onDismissRequest, + ) + } + }, + scrollBehavior = scrollBehavior, + ) + }, + modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + ) { paddingValues -> + val lazyListState = rememberLazyListState() + if (updatableApps == null && installedApps == null) BigLoadingIndicator() + else if ( + installingApps.isEmpty() && + updatableApps.isNullOrEmpty() && + appsWithIssue.isNullOrEmpty() && + installedApps.isNullOrEmpty() + ) { + Text( + text = + if (searchActive) { + stringResource(R.string.search_my_apps_no_results) + } else { + val appName = stringResource(R.string.app_name) + stringResource(R.string.my_apps_empty, appName) + }, + textAlign = TextAlign.Center, + modifier = Modifier.padding(paddingValues).fillMaxSize().padding(16.dp), + ) + } else { + MyAppsList( + myAppsInfo = myAppsInfo, + currentPackageName = currentPackageName, + lazyListState = lazyListState, + onAppItemClick = onAppItemClick, + paddingValues = paddingValues, + ) } + } } @Preview @Composable fun MyAppsLoadingPreview() { - val model = MyAppsModel( - installingApps = emptyList(), - appUpdates = null, - installedApps = null, - showAppIssueHint = false, - sortOrder = AppListSortOrder.NAME, - networkState = NetworkState(isOnline = false, isMetered = false), + val model = + MyAppsModel( + installingApps = emptyList(), + appUpdates = null, + installedApps = null, + showAppIssueHint = false, + sortOrder = AppListSortOrder.NAME, + networkState = NetworkState(isOnline = false, isMetered = false), ) - FDroidContent { - MyApps( - myAppsInfo = getMyAppsInfo(model), - currentPackageName = null, - onAppItemClick = {}, - onNav = {}, - ) - } + FDroidContent { + MyApps( + myAppsInfo = getMyAppsInfo(model), + currentPackageName = null, + onAppItemClick = {}, + onNav = {}, + ) + } } @Preview @Composable @RestrictTo(RestrictTo.Scope.TESTS) fun MyAppsPreview() { - FDroidContent { - MyApps( - myAppsInfo = getMyAppsInfo(myAppsModel), - currentPackageName = null, - onAppItemClick = {}, - onNav = {}, - ) - } + FDroidContent { + MyApps( + myAppsInfo = getMyAppsInfo(myAppsModel), + currentPackageName = null, + onAppItemClick = {}, + onNav = {}, + ) + } } @Preview @Composable @RestrictTo(RestrictTo.Scope.TESTS) fun MyAppsEmptyPreview() { - FDroidContent { - val model = MyAppsModel( - installingApps = emptyList(), - appUpdates = emptyList(), - installedApps = emptyList(), - showAppIssueHint = false, - sortOrder = AppListSortOrder.NAME, - networkState = NetworkState(isOnline = false, isMetered = false), - ) - MyApps( - myAppsInfo = getMyAppsInfo(model), - currentPackageName = null, - onAppItemClick = {}, - onNav = {}, - ) - } + FDroidContent { + val model = + MyAppsModel( + installingApps = emptyList(), + appUpdates = emptyList(), + installedApps = emptyList(), + showAppIssueHint = false, + sortOrder = AppListSortOrder.NAME, + networkState = NetworkState(isOnline = false, isMetered = false), + ) + MyApps( + myAppsInfo = getMyAppsInfo(model), + currentPackageName = null, + onAppItemClick = {}, + onNav = {}, + ) + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/apps/MyAppsEntry.kt b/app/src/main/kotlin/org/fdroid/ui/apps/MyAppsEntry.kt index b38919167..56e86938e 100644 --- a/app/src/main/kotlin/org/fdroid/ui/apps/MyAppsEntry.kt +++ b/app/src/main/kotlin/org/fdroid/ui/apps/MyAppsEntry.kt @@ -10,32 +10,29 @@ import org.fdroid.ui.navigation.NavigationKey import org.fdroid.ui.navigation.Navigator @OptIn(ExperimentalMaterial3AdaptiveApi::class) -fun EntryProviderScope.myAppsEntry( - navigator: Navigator, - isBigScreen: Boolean, -) { - entry( - metadata = ListDetailSceneStrategy.listPane("appdetails"), - ) { - val myAppsViewModel = hiltViewModel() - val myAppsInfo = object : MyAppsInfo { - override val model = myAppsViewModel.myAppsModel.collectAsStateWithLifecycle().value - override val actions: MyAppsActions = myAppsViewModel +fun EntryProviderScope.myAppsEntry(navigator: Navigator, isBigScreen: Boolean) { + entry(metadata = ListDetailSceneStrategy.listPane("appdetails")) { + val myAppsViewModel = hiltViewModel() + val myAppsInfo = + object : MyAppsInfo { + override val model = myAppsViewModel.myAppsModel.collectAsStateWithLifecycle().value + override val actions: MyAppsActions = myAppsViewModel + } + MyApps( + myAppsInfo = myAppsInfo, + currentPackageName = + if (isBigScreen) { + (navigator.last as? NavigationKey.AppDetails)?.packageName + } else null, + onAppItemClick = { + val new = NavigationKey.AppDetails(it) + if (navigator.last is NavigationKey.AppDetails) { + navigator.replaceLast(new) + } else { + navigator.navigate(new) } - MyApps( - myAppsInfo = myAppsInfo, - currentPackageName = if (isBigScreen) { - (navigator.last as? NavigationKey.AppDetails)?.packageName - } else null, - onAppItemClick = { - val new = NavigationKey.AppDetails(it) - if (navigator.last is NavigationKey.AppDetails) { - navigator.replaceLast(new) - } else { - navigator.navigate(new) - } - }, - onNav = { navKey -> navigator.navigate(navKey) }, - ) - } + }, + onNav = { navKey -> navigator.navigate(navKey) }, + ) + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/apps/MyAppsInfo.kt b/app/src/main/kotlin/org/fdroid/ui/apps/MyAppsInfo.kt index 1574de848..78c00c2f8 100644 --- a/app/src/main/kotlin/org/fdroid/ui/apps/MyAppsInfo.kt +++ b/app/src/main/kotlin/org/fdroid/ui/apps/MyAppsInfo.kt @@ -5,28 +5,34 @@ import org.fdroid.download.NetworkState import org.fdroid.install.InstallConfirmationState interface MyAppsInfo { - val model: MyAppsModel - val actions: MyAppsActions + val model: MyAppsModel + val actions: MyAppsActions } data class MyAppsModel( - val appToConfirm: InstallingAppItem? = null, - val appUpdates: List? = null, - val installingApps: List, - val appsWithIssue: List? = null, - val installedApps: List? = null, - val showAppIssueHint: Boolean, - val sortOrder: AppListSortOrder = AppListSortOrder.NAME, - val networkState: NetworkState, - val appUpdatesBytes: Long? = null, + val appToConfirm: InstallingAppItem? = null, + val appUpdates: List? = null, + val installingApps: List, + val appsWithIssue: List? = null, + val installedApps: List? = null, + val showAppIssueHint: Boolean, + val sortOrder: AppListSortOrder = AppListSortOrder.NAME, + val networkState: NetworkState, + val appUpdatesBytes: Long? = null, ) interface MyAppsActions { - fun updateAll() - fun changeSortOrder(sort: AppListSortOrder) - fun search(query: String) - fun confirmAppInstall(packageName: String, state: InstallConfirmationState) - fun ignoreAppIssue(item: AppWithIssueItem) - fun onAppIssueHintSeen() - fun exportInstalledApps() + fun updateAll() + + fun changeSortOrder(sort: AppListSortOrder) + + fun search(query: String) + + fun confirmAppInstall(packageName: String, state: InstallConfirmationState) + + fun ignoreAppIssue(item: AppWithIssueItem) + + fun onAppIssueHintSeen() + + fun exportInstalledApps() } diff --git a/app/src/main/kotlin/org/fdroid/ui/apps/MyAppsList.kt b/app/src/main/kotlin/org/fdroid/ui/apps/MyAppsList.kt index aa0c75053..9a819abb3 100644 --- a/app/src/main/kotlin/org/fdroid/ui/apps/MyAppsList.kt +++ b/app/src/main/kotlin/org/fdroid/ui/apps/MyAppsList.kt @@ -41,252 +41,212 @@ import org.fdroid.ui.utils.myAppsModel @Composable fun MyAppsList( - myAppsInfo: MyAppsInfo, - currentPackageName: String?, - lazyListState: LazyListState, - onAppItemClick: (String) -> Unit, - paddingValues: PaddingValues, - modifier: Modifier = Modifier, + myAppsInfo: MyAppsInfo, + currentPackageName: String?, + lazyListState: LazyListState, + onAppItemClick: (String) -> Unit, + paddingValues: PaddingValues, + modifier: Modifier = Modifier, ) { - val updatableApps = myAppsInfo.model.appUpdates - val installingApps = myAppsInfo.model.installingApps - val appsWithIssue = myAppsInfo.model.appsWithIssue - val installedApps = myAppsInfo.model.installedApps - // scroll to top if new updatable apps were added - var previousNumUpdates by remember { mutableIntStateOf(0) } - LaunchedEffect(updatableApps) { - if (updatableApps != null && updatableApps.isNotEmpty()) { - if (updatableApps.size > previousNumUpdates) { - lazyListState.animateScrollToItem(0) - } - previousNumUpdates = updatableApps.size - } + val updatableApps = myAppsInfo.model.appUpdates + val installingApps = myAppsInfo.model.installingApps + val appsWithIssue = myAppsInfo.model.appsWithIssue + val installedApps = myAppsInfo.model.installedApps + // scroll to top if new updatable apps were added + var previousNumUpdates by remember { mutableIntStateOf(0) } + LaunchedEffect(updatableApps) { + if (updatableApps != null && updatableApps.isNotEmpty()) { + if (updatableApps.size > previousNumUpdates) { + lazyListState.animateScrollToItem(0) + } + previousNumUpdates = updatableApps.size } - // allow us to hide "update all" button to avoid user pressing it twice - var showUpdateAllButton by remember(updatableApps) { - mutableStateOf(true) + } + // allow us to hide "update all" button to avoid user pressing it twice + var showUpdateAllButton by remember(updatableApps) { mutableStateOf(true) } + var showMeteredDialog by remember { mutableStateOf<(() -> Unit)?>(null) } + var showIssueIgnoreDialog by remember { mutableStateOf(null) } + LazyColumn( + state = lazyListState, + contentPadding = paddingValues, + modifier = + modifier.then(if (currentPackageName == null) Modifier else Modifier.selectableGroup()), + ) { + // Updates header with Update all button + if (!updatableApps.isNullOrEmpty()) { + if (!myAppsInfo.model.networkState.isOnline) { + item(key = "OfflineBar", contentType = "offlineBar") { OfflineBar() } + } + item(key = "A", contentType = "header") { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.updates), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(16.dp).weight(1f), + ) + if (showUpdateAllButton) + Button( + onClick = { + val installLambda = { + myAppsInfo.actions.updateAll() + showUpdateAllButton = false + } + if (myAppsInfo.model.networkState.isMetered) { + showMeteredDialog = installLambda + } else { + installLambda() + } + }, + modifier = Modifier.padding(end = 16.dp), + ) { + Text(stringResource(R.string.update_all)) + } + } + } + // List of updatable apps + items(items = updatableApps, key = { it.packageName }, contentType = { "A" }) { app -> + val isSelected = app.packageName == currentPackageName + val interactionModifier = + if (currentPackageName == null) { + Modifier.clickable(onClick = { onAppItemClick(app.packageName) }) + } else { + Modifier.selectable( + selected = isSelected, + onClick = { onAppItemClick(app.packageName) }, + ) + } + val modifier = Modifier.animateItem().then(interactionModifier) + UpdatableAppRow(app, isSelected, modifier) + } } - var showMeteredDialog by remember { mutableStateOf<(() -> Unit)?>(null) } - var showIssueIgnoreDialog by remember { mutableStateOf(null) } - LazyColumn( - state = lazyListState, - contentPadding = paddingValues, - modifier = modifier - .then( - if (currentPackageName == null) Modifier - else Modifier.selectableGroup() - ), - ) { - // Updates header with Update all button - if (!updatableApps.isNullOrEmpty()) { - if (!myAppsInfo.model.networkState.isOnline) { - item(key = "OfflineBar", contentType = "offlineBar") { - OfflineBar() - } - } - item(key = "A", contentType = "header") { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = stringResource(R.string.updates), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier - .padding(16.dp) - .weight(1f), - ) - if (showUpdateAllButton) Button( - onClick = { - val installLambda = { - myAppsInfo.actions.updateAll() - showUpdateAllButton = false - } - if (myAppsInfo.model.networkState.isMetered) { - showMeteredDialog = installLambda - } else { - installLambda() - } - }, - modifier = Modifier.padding(end = 16.dp), - ) { - Text(stringResource(R.string.update_all)) - } - } - } - // List of updatable apps - items( - items = updatableApps, - key = { it.packageName }, - contentType = { "A" }, - ) { app -> - val isSelected = app.packageName == currentPackageName - val interactionModifier = if (currentPackageName == null) { - Modifier.clickable( - onClick = { onAppItemClick(app.packageName) } - ) - } else { - Modifier.selectable( - selected = isSelected, - onClick = { onAppItemClick(app.packageName) } - ) - } - val modifier = Modifier - .animateItem() - .then(interactionModifier) - UpdatableAppRow(app, isSelected, modifier) - } - } - // Apps currently installing header - if (installingApps.isNotEmpty()) { - item(key = "B", contentType = "header") { - Text( - text = stringResource(R.string.notification_title_summary_installing), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier - .padding(16.dp) - ) - } - // List of currently installing apps - items( - items = installingApps, - key = { it.packageName }, - contentType = { "B" }, - ) { app -> - val isSelected = app.packageName == currentPackageName - val interactionModifier = if (currentPackageName == null) { - Modifier.clickable( - onClick = { onAppItemClick(app.packageName) } - ) - } else { - Modifier.selectable( - selected = isSelected, - onClick = { onAppItemClick(app.packageName) } - ) - } - val modifier = Modifier.Companion - .animateItem() - .then(interactionModifier) - InstallingAppRow(app, isSelected, modifier) - } - } - // Apps with issues - if (!appsWithIssue.isNullOrEmpty()) { - // header - item(key = "C", contentType = "header") { - Text( - text = stringResource(R.string.my_apps_header_apps_with_issue), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(16.dp), - ) - } - if (myAppsInfo.model.showAppIssueHint) item(key = "C-hint", contentType = "hint") { - Card( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp, start = 16.dp, end = 16.dp), - ) { - Column( - modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp) - ) { - Text( - text = stringResource(R.string.app_issue_hint), - style = MaterialTheme.typography.bodyMedium, - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - TextButton(onClick = { myAppsInfo.actions.onAppIssueHintSeen() }) { - Text(stringResource(R.string.got_it)) - } - } - } - } - } - // list of apps with issues - items( - items = appsWithIssue, - key = { it.packageName }, - contentType = { "C" }, - ) { app -> - val isSelected = app.packageName == currentPackageName - var showNotAvailableDialog by remember { mutableStateOf(false) } - val onClick = { - if (app.issue is NotAvailable) { - showNotAvailableDialog = true - } else { - onAppItemClick(app.packageName) - } - } - val interactionModifier = if (currentPackageName == null) { - Modifier.combinedClickable( - onClick = onClick, - onLongClick = { showIssueIgnoreDialog = app }, - onLongClickLabel = stringResource(R.string.my_apps_ignore_dialog_title), - ) - } else { - Modifier.selectable( - selected = isSelected, - onClick = onClick, - ) - } - val modifier = Modifier - .animateItem() - .then(interactionModifier) - InstalledAppRow(app, isSelected, modifier, app.issue) - // Dialogs - val appToIgnore = showIssueIgnoreDialog - if (appToIgnore != null) IgnoreIssueDialog( - appName = appToIgnore.name, - onIgnore = { - myAppsInfo.actions.ignoreAppIssue(appToIgnore) - showIssueIgnoreDialog = null - }, - onDismiss = { showIssueIgnoreDialog = null }, - ) else if (showNotAvailableDialog) NotAvailableDialog(app.packageName) { - showNotAvailableDialog = false - } - } - } - // Installed apps header (only show when we have non-empty lists above) - val aboveNonEmpty = installingApps.isNotEmpty() || - !updatableApps.isNullOrEmpty() || - !appsWithIssue.isNullOrEmpty() - if (aboveNonEmpty && !installedApps.isNullOrEmpty()) { - item(key = "D", contentType = "header") { - Text( - text = stringResource(R.string.installed_apps__activity_title), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(16.dp), - ) - } - } - // List of installed apps - if (installedApps != null) items( - items = installedApps, - key = { it.packageName }, - contentType = { "D" }, - ) { app -> - val isSelected = app.packageName == currentPackageName - val interactionModifier = if (currentPackageName == null) { - Modifier.clickable( - onClick = { onAppItemClick(app.packageName) } - ) - } else { - Modifier.selectable( - selected = isSelected, - onClick = { onAppItemClick(app.packageName) } - ) - } - val modifier = Modifier - .animateItem() - .then(interactionModifier) - InstalledAppRow(app, isSelected, modifier) - } + // Apps currently installing header + if (installingApps.isNotEmpty()) { + item(key = "B", contentType = "header") { + Text( + text = stringResource(R.string.notification_title_summary_installing), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(16.dp), + ) + } + // List of currently installing apps + items(items = installingApps, key = { it.packageName }, contentType = { "B" }) { app -> + val isSelected = app.packageName == currentPackageName + val interactionModifier = + if (currentPackageName == null) { + Modifier.clickable(onClick = { onAppItemClick(app.packageName) }) + } else { + Modifier.selectable( + selected = isSelected, + onClick = { onAppItemClick(app.packageName) }, + ) + } + val modifier = Modifier.Companion.animateItem().then(interactionModifier) + InstallingAppRow(app, isSelected, modifier) + } } - val meteredLambda = showMeteredDialog - if (meteredLambda != null) MeteredConnectionDialog( - numBytes = myAppsInfo.model.appUpdatesBytes, - onConfirm = { meteredLambda() }, - onDismiss = { showMeteredDialog = null }, + // Apps with issues + if (!appsWithIssue.isNullOrEmpty()) { + // header + item(key = "C", contentType = "header") { + Text( + text = stringResource(R.string.my_apps_header_apps_with_issue), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(16.dp), + ) + } + if (myAppsInfo.model.showAppIssueHint) + item(key = "C-hint", contentType = "hint") { + Card( + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp, start = 16.dp, end = 16.dp) + ) { + Column(modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp)) { + Text( + text = stringResource(R.string.app_issue_hint), + style = MaterialTheme.typography.bodyMedium, + ) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = { myAppsInfo.actions.onAppIssueHintSeen() }) { + Text(stringResource(R.string.got_it)) + } + } + } + } + } + // list of apps with issues + items(items = appsWithIssue, key = { it.packageName }, contentType = { "C" }) { app -> + val isSelected = app.packageName == currentPackageName + var showNotAvailableDialog by remember { mutableStateOf(false) } + val onClick = { + if (app.issue is NotAvailable) { + showNotAvailableDialog = true + } else { + onAppItemClick(app.packageName) + } + } + val interactionModifier = + if (currentPackageName == null) { + Modifier.combinedClickable( + onClick = onClick, + onLongClick = { showIssueIgnoreDialog = app }, + onLongClickLabel = stringResource(R.string.my_apps_ignore_dialog_title), + ) + } else { + Modifier.selectable(selected = isSelected, onClick = onClick) + } + val modifier = Modifier.animateItem().then(interactionModifier) + InstalledAppRow(app, isSelected, modifier, app.issue) + // Dialogs + val appToIgnore = showIssueIgnoreDialog + if (appToIgnore != null) + IgnoreIssueDialog( + appName = appToIgnore.name, + onIgnore = { + myAppsInfo.actions.ignoreAppIssue(appToIgnore) + showIssueIgnoreDialog = null + }, + onDismiss = { showIssueIgnoreDialog = null }, + ) + else if (showNotAvailableDialog) + NotAvailableDialog(app.packageName) { showNotAvailableDialog = false } + } + } + // Installed apps header (only show when we have non-empty lists above) + val aboveNonEmpty = + installingApps.isNotEmpty() || + !updatableApps.isNullOrEmpty() || + !appsWithIssue.isNullOrEmpty() + if (aboveNonEmpty && !installedApps.isNullOrEmpty()) { + item(key = "D", contentType = "header") { + Text( + text = stringResource(R.string.installed_apps__activity_title), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(16.dp), + ) + } + } + // List of installed apps + if (installedApps != null) + items(items = installedApps, key = { it.packageName }, contentType = { "D" }) { app -> + val isSelected = app.packageName == currentPackageName + val interactionModifier = + if (currentPackageName == null) { + Modifier.clickable(onClick = { onAppItemClick(app.packageName) }) + } else { + Modifier.selectable( + selected = isSelected, + onClick = { onAppItemClick(app.packageName) }, + ) + } + val modifier = Modifier.animateItem().then(interactionModifier) + InstalledAppRow(app, isSelected, modifier) + } + } + val meteredLambda = showMeteredDialog + if (meteredLambda != null) + MeteredConnectionDialog( + numBytes = myAppsInfo.model.appUpdatesBytes, + onConfirm = { meteredLambda() }, + onDismiss = { showMeteredDialog = null }, ) } @@ -294,12 +254,12 @@ fun MyAppsList( @Composable @RestrictTo(RestrictTo.Scope.TESTS) private fun MyAppsListPreview() { - FDroidContent { - MyApps( - myAppsInfo = getMyAppsInfo(myAppsModel), - currentPackageName = null, - onAppItemClick = {}, - onNav = {}, - ) - } + FDroidContent { + MyApps( + myAppsInfo = getMyAppsInfo(myAppsModel), + currentPackageName = null, + onAppItemClick = {}, + onNav = {}, + ) + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/apps/MyAppsOverflowMenu.kt b/app/src/main/kotlin/org/fdroid/ui/apps/MyAppsOverflowMenu.kt index e32eb838b..616765dc4 100644 --- a/app/src/main/kotlin/org/fdroid/ui/apps/MyAppsOverflowMenu.kt +++ b/app/src/main/kotlin/org/fdroid/ui/apps/MyAppsOverflowMenu.kt @@ -17,36 +17,36 @@ import org.fdroid.R @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun MyAppsOverFlowMenu( - onInstallHistory: () -> Unit, - onExportInstalledApps: () -> Unit, - onDismissRequest: () -> Unit, + onInstallHistory: () -> Unit, + onExportInstalledApps: () -> Unit, + onDismissRequest: () -> Unit, ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.install_history)) }, - onClick = { - onInstallHistory() - onDismissRequest() - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.History, - contentDescription = null, - modifier = Modifier.semantics { hideFromAccessibility() }, - ) - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.my_apps_export_installed_apps)) }, - onClick = { - onExportInstalledApps() - onDismissRequest() - }, - leadingIcon = { - Icon( - imageVector = Icons.Filled.UploadFile, - contentDescription = null, - modifier = Modifier.semantics { hideFromAccessibility() }, - ) - } - ) + DropdownMenuItem( + text = { Text(stringResource(R.string.install_history)) }, + onClick = { + onInstallHistory() + onDismissRequest() + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.History, + contentDescription = null, + modifier = Modifier.semantics { hideFromAccessibility() }, + ) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.my_apps_export_installed_apps)) }, + onClick = { + onExportInstalledApps() + onDismissRequest() + }, + leadingIcon = { + Icon( + imageVector = Icons.Filled.UploadFile, + contentDescription = null, + modifier = Modifier.semantics { hideFromAccessibility() }, + ) + }, + ) } diff --git a/app/src/main/kotlin/org/fdroid/ui/apps/MyAppsPresenter.kt b/app/src/main/kotlin/org/fdroid/ui/apps/MyAppsPresenter.kt index cb2ac6c12..a07b97130 100644 --- a/app/src/main/kotlin/org/fdroid/ui/apps/MyAppsPresenter.kt +++ b/app/src/main/kotlin/org/fdroid/ui/apps/MyAppsPresenter.kt @@ -1,9 +1,9 @@ -@file:Suppress("ktlint:standard:filename") - package org.fdroid.ui.apps import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState +import java.text.Collator +import java.util.Locale import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import org.fdroid.database.AppListSortOrder @@ -12,106 +12,110 @@ import org.fdroid.install.InstallConfirmationState import org.fdroid.install.InstallState import org.fdroid.install.InstallStateWithInfo import org.fdroid.ui.utils.normalize -import java.text.Collator -import java.util.Locale // TODO add tests for this, similar to DetailsPresenter @Composable fun MyAppsPresenter( - appUpdatesFlow: StateFlow?>, - appInstallStatesFlow: StateFlow>, - appsWithIssuesFlow: StateFlow?>, - installedAppsFlow: Flow>, - showAppIssueHintFlow: StateFlow, - searchQueryFlow: StateFlow, - sortOrderFlow: StateFlow, - networkStateFlow: StateFlow, + appUpdatesFlow: StateFlow?>, + appInstallStatesFlow: StateFlow>, + appsWithIssuesFlow: StateFlow?>, + installedAppsFlow: Flow>, + showAppIssueHintFlow: StateFlow, + searchQueryFlow: StateFlow, + sortOrderFlow: StateFlow, + networkStateFlow: StateFlow, ): MyAppsModel { - val appUpdates = appUpdatesFlow.collectAsState().value - val appInstallStates = appInstallStatesFlow.collectAsState().value - val appsWithIssues = appsWithIssuesFlow.collectAsState().value - val installedApps = installedAppsFlow.collectAsState(null).value - val searchQuery = searchQueryFlow.collectAsState().value.normalize() - val sortOrder = sortOrderFlow.collectAsState().value - val processedPackageNames = mutableSetOf() + val appUpdates = appUpdatesFlow.collectAsState().value + val appInstallStates = appInstallStatesFlow.collectAsState().value + val appsWithIssues = appsWithIssuesFlow.collectAsState().value + val installedApps = installedAppsFlow.collectAsState(null).value + val searchQuery = searchQueryFlow.collectAsState().value.normalize() + val sortOrder = sortOrderFlow.collectAsState().value + val processedPackageNames = mutableSetOf() - // we want to show apps currently installing/updating even if they have updates available, - // so we need to handle those first - val installingApps = appInstallStates.mapNotNull { (packageName, state) -> - if (state is InstallStateWithInfo) { - val keep = searchQuery.isBlank() || - state.name.normalize().contains(searchQuery, ignoreCase = true) - if (keep) { - processedPackageNames.add(packageName) - InstallingAppItem(packageName, state) - } else null - } else { - null - } + // we want to show apps currently installing/updating even if they have updates available, + // so we need to handle those first + val installingApps = + appInstallStates.mapNotNull { (packageName, state) -> + if (state is InstallStateWithInfo) { + val keep = + searchQuery.isBlank() || state.name.normalize().contains(searchQuery, ignoreCase = true) + if (keep) { + processedPackageNames.add(packageName) + InstallingAppItem(packageName, state) + } else null + } else { + null + } } - val updates = appUpdates?.filter { - val keep = if (searchQuery.isBlank()) { - it.packageName !in processedPackageNames - } else { - it.packageName !in processedPackageNames && - it.name.normalize().contains(searchQuery, ignoreCase = true) - } - if (keep) processedPackageNames.add(it.packageName) - keep - } - val withIssues = appsWithIssues?.filter { - val keep = if (searchQuery.isBlank()) { - it.packageName !in processedPackageNames - } else { - it.packageName !in processedPackageNames && - it.name.normalize().contains(searchQuery, ignoreCase = true) - } - if (keep) processedPackageNames.add(it.packageName) - keep - } - val installed = installedApps?.filter { + val updates = + appUpdates?.filter { + val keep = if (searchQuery.isBlank()) { - it.packageName !in processedPackageNames + it.packageName !in processedPackageNames } else { - it.packageName !in processedPackageNames && - it.name.normalize().contains(searchQuery, ignoreCase = true) + it.packageName !in processedPackageNames && + it.name.normalize().contains(searchQuery, ignoreCase = true) } + if (keep) processedPackageNames.add(it.packageName) + keep } - var updateBytes: Long? = 0L - updates?.forEach { - val size = it.update.size - if (size == null) { - // when we don't know the size of one update, we can't provide a total, so say null - updateBytes = null - return@forEach + val withIssues = + appsWithIssues?.filter { + val keep = + if (searchQuery.isBlank()) { + it.packageName !in processedPackageNames } else { - updateBytes = updateBytes?.plus(size) + it.packageName !in processedPackageNames && + it.name.normalize().contains(searchQuery, ignoreCase = true) } - } ?: run { updateBytes = null } - return MyAppsModel( - appToConfirm = installingApps.filter { - it.installState is InstallConfirmationState - }.minByOrNull { - (it.installState as InstallConfirmationState).creationTimeMillis - }, - installingApps = installingApps.sort(sortOrder), - appUpdates = updates?.sort(sortOrder), - appsWithIssue = withIssues?.sort(sortOrder), - installedApps = installed?.sort(sortOrder), - showAppIssueHint = showAppIssueHintFlow.collectAsState().value, - sortOrder = sortOrder, - networkState = networkStateFlow.collectAsState().value, - appUpdatesBytes = updateBytes, - ) + if (keep) processedPackageNames.add(it.packageName) + keep + } + val installed = + installedApps?.filter { + if (searchQuery.isBlank()) { + it.packageName !in processedPackageNames + } else { + it.packageName !in processedPackageNames && + it.name.normalize().contains(searchQuery, ignoreCase = true) + } + } + var updateBytes: Long? = 0L + updates?.forEach { + val size = it.update.size + if (size == null) { + // when we don't know the size of one update, we can't provide a total, so say null + updateBytes = null + return@forEach + } else { + updateBytes = updateBytes?.plus(size) + } + } ?: run { updateBytes = null } + return MyAppsModel( + appToConfirm = + installingApps + .filter { it.installState is InstallConfirmationState } + .minByOrNull { (it.installState as InstallConfirmationState).creationTimeMillis }, + installingApps = installingApps.sort(sortOrder), + appUpdates = updates?.sort(sortOrder), + appsWithIssue = withIssues?.sort(sortOrder), + installedApps = installed?.sort(sortOrder), + showAppIssueHint = showAppIssueHintFlow.collectAsState().value, + sortOrder = sortOrder, + networkState = networkStateFlow.collectAsState().value, + appUpdatesBytes = updateBytes, + ) } private fun List.sort(sortOrder: AppListSortOrder): List { - val collator = Collator.getInstance(Locale.getDefault()) - return when (sortOrder) { - AppListSortOrder.NAME -> sortedWith { a1, a2 -> - // storing collator.getCollationKey() and using that could be an optimization - collator.compare(a1.name, a2.name) - } - AppListSortOrder.LAST_UPDATED -> sortedByDescending { it.lastUpdated } - } + val collator = Collator.getInstance(Locale.getDefault()) + return when (sortOrder) { + AppListSortOrder.NAME -> + sortedWith { a1, a2 -> + // storing collator.getCollationKey() and using that could be an optimization + collator.compare(a1.name, a2.name) + } + AppListSortOrder.LAST_UPDATED -> sortedByDescending { it.lastUpdated } + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/apps/MyAppsViewModel.kt b/app/src/main/kotlin/org/fdroid/ui/apps/MyAppsViewModel.kt index 6d4607f01..6db150a63 100644 --- a/app/src/main/kotlin/org/fdroid/ui/apps/MyAppsViewModel.kt +++ b/app/src/main/kotlin/org/fdroid/ui/apps/MyAppsViewModel.kt @@ -13,6 +13,7 @@ import app.cash.molecule.AndroidUiDispatcher import app.cash.molecule.RecompositionMode.ContextClock import app.cash.molecule.launchMolecule import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -41,126 +42,128 @@ import org.fdroid.settings.SettingsManager import org.fdroid.ui.utils.startActivitySafe import org.fdroid.updates.UpdatesManager import org.fdroid.utils.IoDispatcher -import javax.inject.Inject @HiltViewModel -class MyAppsViewModel @Inject constructor( - app: Application, - @param:IoDispatcher private val scope: CoroutineScope, - savedStateHandle: SavedStateHandle, - private val db: FDroidDatabase, - private val settingsManager: SettingsManager, - installedAppsCache: InstalledAppsCache, - private val onboardingManager: OnboardingManager, - private val appInstallManager: AppInstallManager, - private val networkMonitor: NetworkMonitor, - private val updatesManager: UpdatesManager, - private val repoManager: RepoManager, +class MyAppsViewModel +@Inject +constructor( + app: Application, + @param:IoDispatcher private val scope: CoroutineScope, + savedStateHandle: SavedStateHandle, + private val db: FDroidDatabase, + private val settingsManager: SettingsManager, + installedAppsCache: InstalledAppsCache, + private val onboardingManager: OnboardingManager, + private val appInstallManager: AppInstallManager, + private val networkMonitor: NetworkMonitor, + private val updatesManager: UpdatesManager, + private val repoManager: RepoManager, ) : AndroidViewModel(app), MyAppsActions { - private val log = KotlinLogging.logger { } - private val localeList = LocaleListCompat.getDefault() - private val moleculeScope = - CoroutineScope(viewModelScope.coroutineContext + AndroidUiDispatcher.Main) + private val log = KotlinLogging.logger {} + private val localeList = LocaleListCompat.getDefault() + private val moleculeScope = + CoroutineScope(viewModelScope.coroutineContext + AndroidUiDispatcher.Main) - private val updates = updatesManager.updates + private val updates = updatesManager.updates - @OptIn(ExperimentalCoroutinesApi::class) - private val installedAppItems = - installedAppsCache.installedApps.flatMapLatest { installedApps -> - val proxyConfig = settingsManager.proxyConfig - db.getAppDao().getInstalledAppListItems(installedApps).map { list -> - list.map { app -> - val backupModel = repoManager.getRepository(app.repoId)?.let { repo -> - app.getIcon(localeList)?.getImageModel(repo, proxyConfig) - } as? DownloadRequest - InstalledAppItem( - packageName = app.packageName, - name = app.name ?: "Unknown app", - installedVersionName = app.installedVersionName ?: "???", - installedVersionCode = app.installedVersionCode ?: 0, - lastUpdated = app.lastUpdated, - iconModel = PackageName(app.packageName, backupModel), - ) - } - } + @OptIn(ExperimentalCoroutinesApi::class) + private val installedAppItems = + installedAppsCache.installedApps.flatMapLatest { installedApps -> + val proxyConfig = settingsManager.proxyConfig + db.getAppDao().getInstalledAppListItems(installedApps).map { list -> + list.map { app -> + val backupModel = + repoManager.getRepository(app.repoId)?.let { repo -> + app.getIcon(localeList)?.getImageModel(repo, proxyConfig) + } as? DownloadRequest + InstalledAppItem( + packageName = app.packageName, + name = app.name ?: "Unknown app", + installedVersionName = app.installedVersionName ?: "???", + installedVersionCode = app.installedVersionCode ?: 0, + lastUpdated = app.lastUpdated, + iconModel = PackageName(app.packageName, backupModel), + ) } + } + } - private val searchQuery = savedStateHandle.getMutableStateFlow("query", "") - private val sortOrder = MutableStateFlow(settingsManager.myAppsSortOrder) - val myAppsModel: StateFlow by lazy(LazyThreadSafetyMode.NONE) { - moleculeScope.launchMolecule(mode = ContextClock) { - MyAppsPresenter( - appUpdatesFlow = updates, - appInstallStatesFlow = appInstallManager.appInstallStates, - appsWithIssuesFlow = updatesManager.appsWithIssues, - installedAppsFlow = installedAppItems, - showAppIssueHintFlow = onboardingManager.showAppIssueHint, - searchQueryFlow = searchQuery, - sortOrderFlow = sortOrder, - networkStateFlow = networkMonitor.networkState, - ) + private val searchQuery = savedStateHandle.getMutableStateFlow("query", "") + private val sortOrder = MutableStateFlow(settingsManager.myAppsSortOrder) + val myAppsModel: StateFlow by + lazy(LazyThreadSafetyMode.NONE) { + moleculeScope.launchMolecule(mode = ContextClock) { + MyAppsPresenter( + appUpdatesFlow = updates, + appInstallStatesFlow = appInstallManager.appInstallStates, + appsWithIssuesFlow = updatesManager.appsWithIssues, + installedAppsFlow = installedAppItems, + showAppIssueHintFlow = onboardingManager.showAppIssueHint, + searchQueryFlow = searchQuery, + sortOrderFlow = sortOrder, + networkStateFlow = networkMonitor.networkState, + ) + } + } + + override fun updateAll() { + scope.launch { updatesManager.updateAll(true) } + } + + override fun search(query: String) { + searchQuery.value = query + } + + override fun changeSortOrder(sort: AppListSortOrder) { + sortOrder.value = sort + settingsManager.myAppsSortOrder = sort + } + + override fun confirmAppInstall(packageName: String, state: InstallConfirmationState) { + log.info { "Asking user to confirm install of $packageName..." } + scope.launch(Dispatchers.Main) { + when (state) { + is InstallState.PreApprovalConfirmationNeeded -> { + appInstallManager.requestPreApprovalConfirmation(packageName, state) } - } - - override fun updateAll() { - scope.launch { - updatesManager.updateAll(true) + is InstallState.UserConfirmationNeeded -> { + appInstallManager.requestUserConfirmation(packageName, state) } + } } + } - override fun search(query: String) { - searchQuery.value = query - } + override fun ignoreAppIssue(item: AppWithIssueItem) { + settingsManager.ignoreAppIssue(item.packageName, item.installedVersionCode) + updatesManager.loadUpdates() + } - override fun changeSortOrder(sort: AppListSortOrder) { - sortOrder.value = sort - settingsManager.myAppsSortOrder = sort - } + override fun onAppIssueHintSeen() = onboardingManager.onAppIssueHintSeen() - override fun confirmAppInstall(packageName: String, state: InstallConfirmationState) { - log.info { "Asking user to confirm install of $packageName..." } - scope.launch(Dispatchers.Main) { - when (state) { - is InstallState.PreApprovalConfirmationNeeded -> { - appInstallManager.requestPreApprovalConfirmation(packageName, state) - } - is InstallState.UserConfirmationNeeded -> { - appInstallManager.requestUserConfirmation(packageName, state) - } - } + override fun exportInstalledApps() { + scope.launch { + val stringBuilder = + StringBuilder().apply { + append("packageName,versionCode,versionName\n") + for (app in installedAppItems.first()) { + append(app.packageName).append(',') + append(app.installedVersionCode).append(',') + append(app.installedVersionName).append('\n') + } } - } - - override fun ignoreAppIssue(item: AppWithIssueItem) { - settingsManager.ignoreAppIssue(item.packageName, item.installedVersionCode) - updatesManager.loadUpdates() - } - - override fun onAppIssueHintSeen() = onboardingManager.onAppIssueHintSeen() - - override fun exportInstalledApps() { - scope.launch { - val stringBuilder = StringBuilder().apply { - append("packageName,versionCode,versionName\n") - for (app in installedAppItems.first()) { - append(app.packageName).append(',') - append(app.installedVersionCode).append(',') - append(app.installedVersionName).append('\n') - } - } - val title = application.resources.getString(R.string.send_installed_apps) - val intentBuilder = ShareCompat.IntentBuilder(application) - .setSubject(title) - .setChooserTitle(title) - .setText(stringBuilder.toString()) - .setType("text/csv") - val chooserIntent = Intent.createChooser(intentBuilder.getIntent(), title).apply { - addFlags(FLAG_ACTIVITY_NEW_TASK) - } - withContext(Dispatchers.Main) { - application.startActivitySafe(chooserIntent) - } + val title = application.resources.getString(R.string.send_installed_apps) + val intentBuilder = + ShareCompat.IntentBuilder(application) + .setSubject(title) + .setChooserTitle(title) + .setText(stringBuilder.toString()) + .setType("text/csv") + val chooserIntent = + Intent.createChooser(intentBuilder.getIntent(), title).apply { + addFlags(FLAG_ACTIVITY_NEW_TASK) } + withContext(Dispatchers.Main) { application.startActivitySafe(chooserIntent) } } + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/apps/NotAvailableDialog.kt b/app/src/main/kotlin/org/fdroid/ui/apps/NotAvailableDialog.kt index d9cf84763..3f94cd196 100644 --- a/app/src/main/kotlin/org/fdroid/ui/apps/NotAvailableDialog.kt +++ b/app/src/main/kotlin/org/fdroid/ui/apps/NotAvailableDialog.kt @@ -24,40 +24,33 @@ import org.fdroid.ui.utils.startActivitySafe @Composable fun NotAvailableDialog(packageName: String, onDismiss: () -> Unit) { - val context = LocalContext.current - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(text = stringResource(R.string.app_issue_not_available_title)) }, - text = { - Column(verticalArrangement = spacedBy(8.dp)) { - Text(text = stringResource(R.string.app_issue_not_available_text)) - OutlinedButton( - onClick = { - val intent: Intent = Intent(ACTION_APPLICATION_DETAILS_SETTINGS).apply { - setData(Uri.fromParts("package", packageName, null)) - } - context.startActivitySafe(intent) - }, - modifier = Modifier.align(CenterHorizontally) - ) { - Text(stringResource(R.string.app_issue_not_available_button)) - } - } - }, - confirmButton = { - TextButton( - onClick = onDismiss, - ) { Text(stringResource(R.string.ok)) } - }, - ) + val context = LocalContext.current + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(text = stringResource(R.string.app_issue_not_available_title)) }, + text = { + Column(verticalArrangement = spacedBy(8.dp)) { + Text(text = stringResource(R.string.app_issue_not_available_text)) + OutlinedButton( + onClick = { + val intent: Intent = + Intent(ACTION_APPLICATION_DETAILS_SETTINGS).apply { + setData(Uri.fromParts("package", packageName, null)) + } + context.startActivitySafe(intent) + }, + modifier = Modifier.align(CenterHorizontally), + ) { + Text(stringResource(R.string.app_issue_not_available_button)) + } + } + }, + confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.ok)) } }, + ) } @Preview @Composable private fun Preview() { - FDroidContent { - Box(modifier = Modifier.fillMaxSize()) { - NotAvailableDialog("foo.bar") {} - } - } + FDroidContent { Box(modifier = Modifier.fillMaxSize()) { NotAvailableDialog("foo.bar") {} } } } diff --git a/app/src/main/kotlin/org/fdroid/ui/apps/UpdatableAppRow.kt b/app/src/main/kotlin/org/fdroid/ui/apps/UpdatableAppRow.kt index e742807c5..6e94e2d1d 100644 --- a/app/src/main/kotlin/org/fdroid/ui/apps/UpdatableAppRow.kt +++ b/app/src/main/kotlin/org/fdroid/ui/apps/UpdatableAppRow.kt @@ -41,121 +41,109 @@ import org.fdroid.ui.utils.ExpandIconArrow import org.fdroid.ui.utils.getPreviewVersion @Composable -fun UpdatableAppRow( - app: AppUpdateItem, - isSelected: Boolean, - modifier: Modifier = Modifier -) { - var isExpanded by remember { mutableStateOf(false) } - Column(modifier = modifier) { - ListItem( - leadingContent = { - BadgedBox( - badge = { - BadgeIcon( - icon = Icons.Filled.NewReleases, - color = MaterialTheme.colorScheme.secondary, - contentDescription = - stringResource(R.string.notification_title_single_update_available), - ) - }, - ) { - AsyncShimmerImage( - model = app.iconModel, - error = painterResource(R.drawable.ic_repo_app_default), - contentDescription = null, - modifier = Modifier - .size(48.dp) - .semantics { hideFromAccessibility() }, - ) - } - }, - headlineContent = { - Text(app.name) - }, - supportingContent = { - val size = app.update.size?.let { - Formatter.formatFileSize(LocalContext.current, it) - } - val text = if (LocalLayoutDirection.current == LayoutDirection.Ltr) { - "${app.installedVersionName} → ${app.update.versionName} • $size" - } else { - "$size • ${app.update.versionName} ← ${app.installedVersionName}" - } - Text(text) - }, - trailingContent = { - if (app.whatsNew != null) IconButton(onClick = { isExpanded = !isExpanded }) { - ExpandIconArrow(isExpanded) - } - }, - colors = ListItemDefaults.colors( - containerColor = if (isSelected) { - MaterialTheme.colorScheme.surfaceVariant - } else { - Color.Transparent - } - ), - ) - AnimatedVisibility( - visible = isExpanded, - modifier = Modifier - .padding(8.dp) - .semantics { liveRegion = LiveRegionMode.Polite } +fun UpdatableAppRow(app: AppUpdateItem, isSelected: Boolean, modifier: Modifier = Modifier) { + var isExpanded by remember { mutableStateOf(false) } + Column(modifier = modifier) { + ListItem( + leadingContent = { + BadgedBox( + badge = { + BadgeIcon( + icon = Icons.Filled.NewReleases, + color = MaterialTheme.colorScheme.secondary, + contentDescription = + stringResource(R.string.notification_title_single_update_available), + ) + } ) { - Card(modifier = Modifier.fillMaxWidth()) { - Text( - text = app.whatsNew ?: "", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier - .padding(8.dp) - ) - } + AsyncShimmerImage( + model = app.iconModel, + error = painterResource(R.drawable.ic_repo_app_default), + contentDescription = null, + modifier = Modifier.size(48.dp).semantics { hideFromAccessibility() }, + ) } + }, + headlineContent = { Text(app.name) }, + supportingContent = { + val size = app.update.size?.let { Formatter.formatFileSize(LocalContext.current, it) } + val text = + if (LocalLayoutDirection.current == LayoutDirection.Ltr) { + "${app.installedVersionName} → ${app.update.versionName} • $size" + } else { + "$size • ${app.update.versionName} ← ${app.installedVersionName}" + } + Text(text) + }, + trailingContent = { + if (app.whatsNew != null) + IconButton(onClick = { isExpanded = !isExpanded }) { ExpandIconArrow(isExpanded) } + }, + colors = + ListItemDefaults.colors( + containerColor = + if (isSelected) { + MaterialTheme.colorScheme.surfaceVariant + } else { + Color.Transparent + } + ), + ) + AnimatedVisibility( + visible = isExpanded, + modifier = Modifier.padding(8.dp).semantics { liveRegion = LiveRegionMode.Polite }, + ) { + Card(modifier = Modifier.fillMaxWidth()) { + Text( + text = app.whatsNew ?: "", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(8.dp), + ) + } } + } } @Preview @Composable fun UpdatableAppRowPreview() { - val app1 = AppUpdateItem( - repoId = 1, - packageName = "A", - name = "App Update 123", - installedVersionName = "1.0.1", - update = getPreviewVersion("1.1.0", 123456789), - whatsNew = "This is new, all is new, nothing old.", + val app1 = + AppUpdateItem( + repoId = 1, + packageName = "A", + name = "App Update 123", + installedVersionName = "1.0.1", + update = getPreviewVersion("1.1.0", 123456789), + whatsNew = "This is new, all is new, nothing old.", ) - val app2 = AppUpdateItem( - repoId = 2, - packageName = "B", - name = "App Update 456", - installedVersionName = "1.0.1", - update = getPreviewVersion("1.1.0", 123456789), - whatsNew = "This is new, all is new, nothing old.", + val app2 = + AppUpdateItem( + repoId = 2, + packageName = "B", + name = "App Update 456", + installedVersionName = "1.0.1", + update = getPreviewVersion("1.1.0", 123456789), + whatsNew = "This is new, all is new, nothing old.", ) - FDroidContent { - Column { - UpdatableAppRow(app1, false) - UpdatableAppRow(app2, true) - } + FDroidContent { + Column { + UpdatableAppRow(app1, false) + UpdatableAppRow(app2, true) } + } } @Preview(locale = "fa") @Composable private fun UpdatableAppRowRtl() { - val app1 = AppUpdateItem( - repoId = 1, - packageName = "A", - name = "App Update 123", - installedVersionName = "1.0.1", - update = getPreviewVersion("1.1.0", 123456789), - whatsNew = "This is new, all is new, nothing old.", + val app1 = + AppUpdateItem( + repoId = 1, + packageName = "A", + name = "App Update 123", + installedVersionName = "1.0.1", + update = getPreviewVersion("1.1.0", 123456789), + whatsNew = "This is new, all is new, nothing old.", ) - FDroidContent { - Column { - UpdatableAppRow(app1, false) - } - } + FDroidContent { Column { UpdatableAppRow(app1, false) } } } diff --git a/app/src/main/kotlin/org/fdroid/ui/categories/CategoryChip.kt b/app/src/main/kotlin/org/fdroid/ui/categories/CategoryChip.kt index f0fb82048..d1848b3da 100644 --- a/app/src/main/kotlin/org/fdroid/ui/categories/CategoryChip.kt +++ b/app/src/main/kotlin/org/fdroid/ui/categories/CategoryChip.kt @@ -24,85 +24,58 @@ import org.fdroid.ui.FDroidContent @Composable fun CategoryChip( - categoryItem: CategoryItem, - onSelected: () -> Unit, - modifier: Modifier = Modifier, - selected: Boolean = false, + categoryItem: CategoryItem, + onSelected: () -> Unit, + modifier: Modifier = Modifier, + selected: Boolean = false, ) { - FilterChip( - onClick = onSelected, - leadingIcon = { - if (selected) Icon( - imageVector = Icons.Default.Check, - contentDescription = stringResource(R.string.filter_selected), - ) else Icon( - imageVector = categoryItem.imageVector, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.semantics { hideFromAccessibility() }, - ) - }, - label = { - Text( - categoryItem.name, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - }, - selected = selected, - modifier = modifier.height(chipHeight) - ) + FilterChip( + onClick = onSelected, + leadingIcon = { + if (selected) + Icon( + imageVector = Icons.Default.Check, + contentDescription = stringResource(R.string.filter_selected), + ) + else + Icon( + imageVector = categoryItem.imageVector, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.semantics { hideFromAccessibility() }, + ) + }, + label = { Text(categoryItem.name, maxLines = 2, overflow = TextOverflow.Ellipsis) }, + selected = selected, + modifier = modifier.height(chipHeight), + ) } @Composable -fun CategoryChip( - categoryItem: CategoryItem, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - AssistChip( - onClick = onClick, - leadingIcon = { - Icon( - imageVector = categoryItem.imageVector, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.semantics { hideFromAccessibility() }, - ) - }, - label = { - Text( - text = categoryItem.name, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - }, - modifier = modifier.height(chipHeight) - ) +fun CategoryChip(categoryItem: CategoryItem, onClick: () -> Unit, modifier: Modifier = Modifier) { + AssistChip( + onClick = onClick, + leadingIcon = { + Icon( + imageVector = categoryItem.imageVector, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.semantics { hideFromAccessibility() }, + ) + }, + label = { Text(text = categoryItem.name, maxLines = 2, overflow = TextOverflow.Ellipsis) }, + modifier = modifier.height(chipHeight), + ) } @Preview @Composable fun CategoryCardPreview() { - FDroidContent { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.padding(8.dp) - ) { - CategoryChip( - CategoryItem("VPN & Proxy", "VPN & Proxy"), - selected = true, - onSelected = {}, - ) - CategoryChip( - CategoryItem("VPN & Proxy", "VPN & Proxy"), - selected = false, - onSelected = {}, - ) - CategoryChip( - CategoryItem("VPN & Proxy", "VPN & Proxy"), - onClick = {}, - ) - } + FDroidContent { + Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(8.dp)) { + CategoryChip(CategoryItem("VPN & Proxy", "VPN & Proxy"), selected = true, onSelected = {}) + CategoryChip(CategoryItem("VPN & Proxy", "VPN & Proxy"), selected = false, onSelected = {}) + CategoryChip(CategoryItem("VPN & Proxy", "VPN & Proxy"), onClick = {}) } + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/categories/CategoryGroup.kt b/app/src/main/kotlin/org/fdroid/ui/categories/CategoryGroup.kt index 1a9b49d6c..fd22601d2 100644 --- a/app/src/main/kotlin/org/fdroid/ui/categories/CategoryGroup.kt +++ b/app/src/main/kotlin/org/fdroid/ui/categories/CategoryGroup.kt @@ -3,21 +3,17 @@ package org.fdroid.ui.categories import androidx.annotation.StringRes import org.fdroid.R -data class CategoryGroup( - val id: String, - @get:StringRes - val name: Int, -) +data class CategoryGroup(val id: String, @get:StringRes val name: Int) object CategoryGroups { - val productivity = CategoryGroup("productivity", R.string.category_group_productivity) - val tools = CategoryGroup("tools", R.string.category_group_tools) - val wallets = CategoryGroup("wallets", R.string.category_group_wallets) - val media = CategoryGroup("media", R.string.category_group_media) - val communication = CategoryGroup("communication", R.string.category_group_communication) - val device = CategoryGroup("device", R.string.category_group_device) - val network = CategoryGroup("network", R.string.category_group_network) - val storage = CategoryGroup("storage", R.string.category_group_storage) - val interests = CategoryGroup("interests", R.string.category_group_interests) - val misc = CategoryGroup("misc", R.string.category_group_misc) + val productivity = CategoryGroup("productivity", R.string.category_group_productivity) + val tools = CategoryGroup("tools", R.string.category_group_tools) + val wallets = CategoryGroup("wallets", R.string.category_group_wallets) + val media = CategoryGroup("media", R.string.category_group_media) + val communication = CategoryGroup("communication", R.string.category_group_communication) + val device = CategoryGroup("device", R.string.category_group_device) + val network = CategoryGroup("network", R.string.category_group_network) + val storage = CategoryGroup("storage", R.string.category_group_storage) + val interests = CategoryGroup("interests", R.string.category_group_interests) + val misc = CategoryGroup("misc", R.string.category_group_misc) } diff --git a/app/src/main/kotlin/org/fdroid/ui/categories/CategoryItem.kt b/app/src/main/kotlin/org/fdroid/ui/categories/CategoryItem.kt index 51d3ef0a3..a0fdb5838 100644 --- a/app/src/main/kotlin/org/fdroid/ui/categories/CategoryItem.kt +++ b/app/src/main/kotlin/org/fdroid/ui/categories/CategoryItem.kt @@ -72,150 +72,153 @@ import androidx.compose.material.icons.filled.WbSunny import androidx.compose.ui.graphics.vector.ImageVector data class CategoryItem(val id: String, val name: String) { - val imageVector: ImageVector - get() = when (id) { - "AI Chat" -> Icons.Default.VoiceChat - "App Manager" -> Icons.Default.Apps - "App Store & Updater" -> Icons.Default.Storefront - "Battery" -> Icons.Default.BatteryChargingFull - "Bookmark" -> Icons.Default.Bookmarks - "Browser" -> Icons.Default.OpenInBrowser - "Calculator" -> Icons.Default.Calculate - "Calendar & Agenda" -> Icons.Default.CalendarMonth - "Clock" -> Icons.Default.AccessTime - "Cloud Storage & File Sync" -> Icons.Default.Cloud - "Connectivity" -> Icons.Default.SignalCellularAlt - "Development" -> Icons.Default.DeveloperMode - "DNS & Hosts" -> Icons.Default.Dns - "Draw" -> Icons.Default.Draw - "Ebook Reader" -> AutoMirrored.Default.MenuBook - "Email" -> Icons.Default.AlternateEmail - "File Encryption & Vault" -> Icons.Default.EnhancedEncryption - "File Transfer" -> Icons.Default.UploadFile - "Finance Manager" -> Icons.Default.MonetizationOn - "Firewall" -> Icons.Default.AppBlocking - "Flashlight" -> Icons.Default.FlashlightOn - "Forum" -> Icons.Default.Image - "Gallery" -> Icons.Default.PhotoSizeSelectActual - "Games" -> Icons.Default.Games - "Graphics" -> Icons.Default.Brush - "Habit Tracker" -> Icons.Default.TrackChanges - "Icon Pack" -> Icons.Default.Collections - "Internet" -> Icons.Default.Language - "Inventory" -> Icons.Default.AllInbox - "Keyboard & IME" -> Icons.Default.Keyboard - "Launcher" -> Icons.Default.Home - "Local Media Player" -> Icons.Default.LocalPlay - "Location Tracker & Sharer" -> Icons.Default.MyLocation - "Messaging" -> AutoMirrored.Default.Message - "Money" -> Icons.Default.Money - "Multimedia" -> Icons.Default.MusicVideo - "Music Practice Tool" -> Icons.Default.MusicNote - "Navigation" -> Icons.Default.Navigation - "Network Analyzer" -> Icons.Default.NetworkCheck - "News" -> Icons.Default.Newspaper - "Note" -> Icons.Default.NoteAlt - "Online Media Player" -> Icons.Default.Airplay - "Pass Wallet" -> Icons.Default.AccountBalanceWallet - "Password & 2FA" -> Icons.Default.Password - "Phone & SMS" -> Icons.Default.PermPhoneMsg - "Podcast" -> Icons.Default.Podcasts - "Public Transport" -> Icons.Default.DirectionsBus - "Reading" -> AutoMirrored.Default.MenuBook - "Recipe Manager" -> Icons.Default.RestaurantMenu - "Religion" -> Icons.Default.Church - "Science & Education" -> Icons.Default.Science - "Security" -> Icons.Default.Security - "Shopping List" -> Icons.Default.ShoppingCart - "Social Network" -> Icons.Default.Groups - "Sports & Health" -> Icons.Default.HealthAndSafety - "System" -> Icons.Default.Settings - "Task" -> Icons.Default.TaskAlt - "Text Editor" -> Icons.Default.EditNote - "Theming" -> Icons.Default.Style - "Time" -> Icons.Default.AccessTime - "Translation & Dictionary" -> Icons.Default.Translate - "Voice & Video Chat" -> Icons.Default.VideoChat - "Unit Convertor" -> Icons.Default.CurrencyExchange - "VPN & Proxy" -> Icons.Default.VpnLock - "Wallet" -> Icons.Default.Wallet - "Wallpaper" -> Icons.Default.Wallpaper - "Weather" -> Icons.Default.WbSunny - "Workout" -> Icons.Default.FitnessCenter - "Writing" -> Icons.Default.EditNote - else -> Icons.Default.Category - } - val group: CategoryGroup - get() = when (id) { - "AI Chat" -> CategoryGroups.tools - "App Manager" -> CategoryGroups.device - "App Store & Updater" -> CategoryGroups.device - "Battery" -> CategoryGroups.device - "Bookmark" -> CategoryGroups.storage - "Browser" -> CategoryGroups.productivity - "Calculator" -> CategoryGroups.tools - "Calendar & Agenda" -> CategoryGroups.productivity - "Clock" -> CategoryGroups.productivity - "Cloud Storage & File Sync" -> CategoryGroups.storage - "Connectivity" -> CategoryGroups.network - "Development" -> CategoryGroups.interests - "DNS & Hosts" -> CategoryGroups.network - "Draw" -> CategoryGroups.interests - "Ebook Reader" -> CategoryGroups.media - "Email" -> CategoryGroups.communication - "File Encryption & Vault" -> CategoryGroups.storage - "File Transfer" -> CategoryGroups.storage - "Finance Manager" -> CategoryGroups.wallets - "Firewall" -> CategoryGroups.device - "Flashlight" -> CategoryGroups.tools - "Forum" -> CategoryGroups.communication - "Gallery" -> CategoryGroups.storage - "Games" -> CategoryGroups.media - "Graphics" -> CategoryGroups.interests - "Habit Tracker" -> CategoryGroups.productivity - "Icon Pack" -> CategoryGroups.device - "Internet" -> CategoryGroups.productivity - "Inventory" -> CategoryGroups.tools - "Keyboard & IME" -> CategoryGroups.device - "Launcher" -> CategoryGroups.device - "Local Media Player" -> CategoryGroups.media - "Location Tracker & Sharer" -> CategoryGroups.tools - "Messaging" -> CategoryGroups.communication - "Money" -> CategoryGroups.wallets - "Multimedia" -> CategoryGroups.media - "Music Practice Tool" -> CategoryGroups.interests - "Navigation" -> CategoryGroups.tools - "Network Analyzer" -> CategoryGroups.tools - "News" -> CategoryGroups.interests - "Note" -> CategoryGroups.storage - "Online Media Player" -> CategoryGroups.media - "Pass Wallet" -> CategoryGroups.wallets - "Password & 2FA" -> CategoryGroups.device - "Phone & SMS" -> CategoryGroups.communication - "Podcast" -> CategoryGroups.media - "Public Transport" -> CategoryGroups.tools - "Reading" -> CategoryGroups.media - "Recipe Manager" -> CategoryGroups.interests - "Religion" -> CategoryGroups.interests - "Science & Education" -> CategoryGroups.interests - "Security" -> CategoryGroups.device - "Shopping List" -> CategoryGroups.tools - "Social Network" -> CategoryGroups.communication - "Sports & Health" -> CategoryGroups.interests - "System" -> CategoryGroups.device - "Task" -> CategoryGroups.productivity - "Text Editor" -> CategoryGroups.productivity - "Theming" -> CategoryGroups.device - "Time" -> CategoryGroups.productivity - "Translation & Dictionary" -> CategoryGroups.tools - "Voice & Video Chat" -> CategoryGroups.communication - "Unit Convertor" -> CategoryGroups.tools - "VPN & Proxy" -> CategoryGroups.network - "Wallet" -> CategoryGroups.wallets - "Wallpaper" -> CategoryGroups.device - "Weather" -> CategoryGroups.tools - "Workout" -> CategoryGroups.interests - "Writing" -> CategoryGroups.productivity - else -> CategoryGroups.misc - } + val imageVector: ImageVector + get() = + when (id) { + "AI Chat" -> Icons.Default.VoiceChat + "App Manager" -> Icons.Default.Apps + "App Store & Updater" -> Icons.Default.Storefront + "Battery" -> Icons.Default.BatteryChargingFull + "Bookmark" -> Icons.Default.Bookmarks + "Browser" -> Icons.Default.OpenInBrowser + "Calculator" -> Icons.Default.Calculate + "Calendar & Agenda" -> Icons.Default.CalendarMonth + "Clock" -> Icons.Default.AccessTime + "Cloud Storage & File Sync" -> Icons.Default.Cloud + "Connectivity" -> Icons.Default.SignalCellularAlt + "Development" -> Icons.Default.DeveloperMode + "DNS & Hosts" -> Icons.Default.Dns + "Draw" -> Icons.Default.Draw + "Ebook Reader" -> AutoMirrored.Default.MenuBook + "Email" -> Icons.Default.AlternateEmail + "File Encryption & Vault" -> Icons.Default.EnhancedEncryption + "File Transfer" -> Icons.Default.UploadFile + "Finance Manager" -> Icons.Default.MonetizationOn + "Firewall" -> Icons.Default.AppBlocking + "Flashlight" -> Icons.Default.FlashlightOn + "Forum" -> Icons.Default.Image + "Gallery" -> Icons.Default.PhotoSizeSelectActual + "Games" -> Icons.Default.Games + "Graphics" -> Icons.Default.Brush + "Habit Tracker" -> Icons.Default.TrackChanges + "Icon Pack" -> Icons.Default.Collections + "Internet" -> Icons.Default.Language + "Inventory" -> Icons.Default.AllInbox + "Keyboard & IME" -> Icons.Default.Keyboard + "Launcher" -> Icons.Default.Home + "Local Media Player" -> Icons.Default.LocalPlay + "Location Tracker & Sharer" -> Icons.Default.MyLocation + "Messaging" -> AutoMirrored.Default.Message + "Money" -> Icons.Default.Money + "Multimedia" -> Icons.Default.MusicVideo + "Music Practice Tool" -> Icons.Default.MusicNote + "Navigation" -> Icons.Default.Navigation + "Network Analyzer" -> Icons.Default.NetworkCheck + "News" -> Icons.Default.Newspaper + "Note" -> Icons.Default.NoteAlt + "Online Media Player" -> Icons.Default.Airplay + "Pass Wallet" -> Icons.Default.AccountBalanceWallet + "Password & 2FA" -> Icons.Default.Password + "Phone & SMS" -> Icons.Default.PermPhoneMsg + "Podcast" -> Icons.Default.Podcasts + "Public Transport" -> Icons.Default.DirectionsBus + "Reading" -> AutoMirrored.Default.MenuBook + "Recipe Manager" -> Icons.Default.RestaurantMenu + "Religion" -> Icons.Default.Church + "Science & Education" -> Icons.Default.Science + "Security" -> Icons.Default.Security + "Shopping List" -> Icons.Default.ShoppingCart + "Social Network" -> Icons.Default.Groups + "Sports & Health" -> Icons.Default.HealthAndSafety + "System" -> Icons.Default.Settings + "Task" -> Icons.Default.TaskAlt + "Text Editor" -> Icons.Default.EditNote + "Theming" -> Icons.Default.Style + "Time" -> Icons.Default.AccessTime + "Translation & Dictionary" -> Icons.Default.Translate + "Voice & Video Chat" -> Icons.Default.VideoChat + "Unit Convertor" -> Icons.Default.CurrencyExchange + "VPN & Proxy" -> Icons.Default.VpnLock + "Wallet" -> Icons.Default.Wallet + "Wallpaper" -> Icons.Default.Wallpaper + "Weather" -> Icons.Default.WbSunny + "Workout" -> Icons.Default.FitnessCenter + "Writing" -> Icons.Default.EditNote + else -> Icons.Default.Category + } + + val group: CategoryGroup + get() = + when (id) { + "AI Chat" -> CategoryGroups.tools + "App Manager" -> CategoryGroups.device + "App Store & Updater" -> CategoryGroups.device + "Battery" -> CategoryGroups.device + "Bookmark" -> CategoryGroups.storage + "Browser" -> CategoryGroups.productivity + "Calculator" -> CategoryGroups.tools + "Calendar & Agenda" -> CategoryGroups.productivity + "Clock" -> CategoryGroups.productivity + "Cloud Storage & File Sync" -> CategoryGroups.storage + "Connectivity" -> CategoryGroups.network + "Development" -> CategoryGroups.interests + "DNS & Hosts" -> CategoryGroups.network + "Draw" -> CategoryGroups.interests + "Ebook Reader" -> CategoryGroups.media + "Email" -> CategoryGroups.communication + "File Encryption & Vault" -> CategoryGroups.storage + "File Transfer" -> CategoryGroups.storage + "Finance Manager" -> CategoryGroups.wallets + "Firewall" -> CategoryGroups.device + "Flashlight" -> CategoryGroups.tools + "Forum" -> CategoryGroups.communication + "Gallery" -> CategoryGroups.storage + "Games" -> CategoryGroups.media + "Graphics" -> CategoryGroups.interests + "Habit Tracker" -> CategoryGroups.productivity + "Icon Pack" -> CategoryGroups.device + "Internet" -> CategoryGroups.productivity + "Inventory" -> CategoryGroups.tools + "Keyboard & IME" -> CategoryGroups.device + "Launcher" -> CategoryGroups.device + "Local Media Player" -> CategoryGroups.media + "Location Tracker & Sharer" -> CategoryGroups.tools + "Messaging" -> CategoryGroups.communication + "Money" -> CategoryGroups.wallets + "Multimedia" -> CategoryGroups.media + "Music Practice Tool" -> CategoryGroups.interests + "Navigation" -> CategoryGroups.tools + "Network Analyzer" -> CategoryGroups.tools + "News" -> CategoryGroups.interests + "Note" -> CategoryGroups.storage + "Online Media Player" -> CategoryGroups.media + "Pass Wallet" -> CategoryGroups.wallets + "Password & 2FA" -> CategoryGroups.device + "Phone & SMS" -> CategoryGroups.communication + "Podcast" -> CategoryGroups.media + "Public Transport" -> CategoryGroups.tools + "Reading" -> CategoryGroups.media + "Recipe Manager" -> CategoryGroups.interests + "Religion" -> CategoryGroups.interests + "Science & Education" -> CategoryGroups.interests + "Security" -> CategoryGroups.device + "Shopping List" -> CategoryGroups.tools + "Social Network" -> CategoryGroups.communication + "Sports & Health" -> CategoryGroups.interests + "System" -> CategoryGroups.device + "Task" -> CategoryGroups.productivity + "Text Editor" -> CategoryGroups.productivity + "Theming" -> CategoryGroups.device + "Time" -> CategoryGroups.productivity + "Translation & Dictionary" -> CategoryGroups.tools + "Voice & Video Chat" -> CategoryGroups.communication + "Unit Convertor" -> CategoryGroups.tools + "VPN & Proxy" -> CategoryGroups.network + "Wallet" -> CategoryGroups.wallets + "Wallpaper" -> CategoryGroups.device + "Weather" -> CategoryGroups.tools + "Workout" -> CategoryGroups.interests + "Writing" -> CategoryGroups.productivity + else -> CategoryGroups.misc + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/categories/CategoryList.kt b/app/src/main/kotlin/org/fdroid/ui/categories/CategoryList.kt index 6904bd744..4eb791d9e 100644 --- a/app/src/main/kotlin/org/fdroid/ui/categories/CategoryList.kt +++ b/app/src/main/kotlin/org/fdroid/ui/categories/CategoryList.kt @@ -21,70 +21,67 @@ import org.fdroid.ui.navigation.NavigationKey @Composable fun CategoryList( - categoryMap: Map>?, - onNav: (NavKey) -> Unit, - modifier: Modifier = Modifier + categoryMap: Map>?, + onNav: (NavKey) -> Unit, + modifier: Modifier = Modifier, ) { - AnimatedVisibility(!categoryMap.isNullOrEmpty()) { - Column( - modifier = modifier.padding(top = 20.dp) - ) { - Text( - text = stringResource(R.string.main_menu__categories), - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(bottom = 8.dp, start = 16.dp, end = 16.dp), - ) - // we'll sort the groups here, because before we didn't have the context to get names - val res = LocalResources.current - val sortedMap = remember(categoryMap) { - val comparator = compareBy { res.getString(it.name) } - categoryMap?.toSortedMap(comparator) - } - sortedMap?.forEach { (group, categories) -> - Text( - text = stringResource(group.name), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier - .fillMaxWidth() - .padding(16.dp, 2.dp), - ) - ChipFlowRow( - modifier = Modifier - .padding(start = 16.dp, bottom = 12.dp) - ) { - categories.forEach { category -> - CategoryChip( - categoryItem = category, - onClick = { - val type = AppListType.Category(category.name, category.id) - val navKey = NavigationKey.AppList(type) - onNav(navKey) - }, - ) - } - } - } + AnimatedVisibility(!categoryMap.isNullOrEmpty()) { + Column(modifier = modifier.padding(top = 20.dp)) { + Text( + text = stringResource(R.string.main_menu__categories), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(bottom = 8.dp, start = 16.dp, end = 16.dp), + ) + // we'll sort the groups here, because before we didn't have the context to get names + val res = LocalResources.current + val sortedMap = + remember(categoryMap) { + val comparator = compareBy { res.getString(it.name) } + categoryMap?.toSortedMap(comparator) } + sortedMap?.forEach { (group, categories) -> + Text( + text = stringResource(group.name), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.fillMaxWidth().padding(16.dp, 2.dp), + ) + ChipFlowRow(modifier = Modifier.padding(start = 16.dp, bottom = 12.dp)) { + categories.forEach { category -> + CategoryChip( + categoryItem = category, + onClick = { + val type = AppListType.Category(category.name, category.id) + val navKey = NavigationKey.AppList(type) + onNav(navKey) + }, + ) + } + } + } } + } } @Preview @Composable fun CategoryListPreview() { - FDroidContent { - val categories = mapOf( - CategoryGroups.productivity to listOf( - CategoryItem("App Store & Updater", "App Store & Updater"), - CategoryItem("Browser", "Browser"), - CategoryItem("Calendar & Agenda", "Calendar & Agenda"), - ), - CategoryGroups.media to listOf( - CategoryItem("Cloud Storage & File Sync", "Cloud Storage & File Sync"), - CategoryItem("Connectivity", "Connectivity"), - CategoryItem("Development", "Development"), - CategoryItem("doesn't exist", "Foo bar"), - ) - ) - CategoryList(categories, {}) - } + FDroidContent { + val categories = + mapOf( + CategoryGroups.productivity to + listOf( + CategoryItem("App Store & Updater", "App Store & Updater"), + CategoryItem("Browser", "Browser"), + CategoryItem("Calendar & Agenda", "Calendar & Agenda"), + ), + CategoryGroups.media to + listOf( + CategoryItem("Cloud Storage & File Sync", "Cloud Storage & File Sync"), + CategoryItem("Connectivity", "Connectivity"), + CategoryItem("Development", "Development"), + CategoryItem("doesn't exist", "Foo bar"), + ), + ) + CategoryList(categories, {}) + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/categories/ChipFlowRow.kt b/app/src/main/kotlin/org/fdroid/ui/categories/ChipFlowRow.kt index 7ea9363c6..9c7d4f280 100644 --- a/app/src/main/kotlin/org/fdroid/ui/categories/ChipFlowRow.kt +++ b/app/src/main/kotlin/org/fdroid/ui/categories/ChipFlowRow.kt @@ -13,66 +13,53 @@ import org.fdroid.ui.FDroidContent val chipHeight = 36.dp /** - * When presenting a list of chips (e.g. categories, repositories, sort criteria), - * use this to ensure appropriate spacing between elements. Make sure to set the - * height of your chips to [chipHeight] so that all chips match. + * When presenting a list of chips (e.g. categories, repositories, sort criteria), use this to + * ensure appropriate spacing between elements. Make sure to set the height of your chips to + * [chipHeight] so that all chips match. */ @Composable -fun ChipFlowRow( - modifier: Modifier = Modifier, - content: @Composable FlowRowScope.() -> Unit, -) { - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier.padding(8.dp), - content = content, - ) +fun ChipFlowRow(modifier: Modifier = Modifier, content: @Composable FlowRowScope.() -> Unit) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier.padding(8.dp), + content = content, + ) } @Preview @Composable fun ChipFlowRowFewItemsPreview() { - val categories = listOf( - CategoryItem("News", "News"), - CategoryItem("Note", "Note"), - CategoryItem("doesn't exist", "Oops"), + val categories = + listOf( + CategoryItem("News", "News"), + CategoryItem("Note", "Note"), + CategoryItem("doesn't exist", "Oops"), ) - FDroidContent { - ChipFlowRow { - categories.map { category -> - CategoryChip(category, {}) - } - } - } + FDroidContent { ChipFlowRow { categories.map { category -> CategoryChip(category, {}) } } } } @Preview @Composable fun ChipFlowRowManyItemsPreview() { - val categories = listOf( - CategoryItem("Cloud Storage & File Sync", "Cloud Storage & File Sync"), - CategoryItem("Connectivity", "Connectivity"), - CategoryItem("Development", "Development"), - CategoryItem("doesn't exist", "Foo bar"), - CategoryItem("Online Media Player", "Online Media Player"), - CategoryItem("Pass Wallet", "Pass Wallet"), - CategoryItem("Password & 2FA", "Password & 2FA"), - CategoryItem("Phone & SMS", "Phone & SMS"), - CategoryItem("Podcast", "Podcast"), - CategoryItem("Public Transport", "Public Transport"), - CategoryItem("Reading", "Reading"), - CategoryItem("Recipe Manager", "Recipe Manager"), - CategoryItem("Religion", "Religion"), - CategoryItem("Science & Education", "Science & Education"), + val categories = + listOf( + CategoryItem("Cloud Storage & File Sync", "Cloud Storage & File Sync"), + CategoryItem("Connectivity", "Connectivity"), + CategoryItem("Development", "Development"), + CategoryItem("doesn't exist", "Foo bar"), + CategoryItem("Online Media Player", "Online Media Player"), + CategoryItem("Pass Wallet", "Pass Wallet"), + CategoryItem("Password & 2FA", "Password & 2FA"), + CategoryItem("Phone & SMS", "Phone & SMS"), + CategoryItem("Podcast", "Podcast"), + CategoryItem("Public Transport", "Public Transport"), + CategoryItem("Reading", "Reading"), + CategoryItem("Recipe Manager", "Recipe Manager"), + CategoryItem("Religion", "Religion"), + CategoryItem("Science & Education", "Science & Education"), ) - FDroidContent { - ChipFlowRow { - categories.map { category -> - CategoryChip(category, {}) - } - } - } + FDroidContent { ChipFlowRow { categories.map { category -> CategoryChip(category, {}) } } } } diff --git a/app/src/main/kotlin/org/fdroid/ui/crash/Crash.kt b/app/src/main/kotlin/org/fdroid/ui/crash/Crash.kt index e1f359b2b..7f5ecda82 100644 --- a/app/src/main/kotlin/org/fdroid/ui/crash/Crash.kt +++ b/app/src/main/kotlin/org/fdroid/ui/crash/Crash.kt @@ -29,49 +29,47 @@ import org.fdroid.utils.getLogName @Composable @OptIn(ExperimentalMaterial3Api::class) fun Crash( - isOldCrash: Boolean, - onCancel: () -> Unit, - onSend: (String, String) -> Unit, - onSave: (Uri, String) -> Boolean, - modifier: Modifier = Modifier + isOldCrash: Boolean, + onCancel: () -> Unit, + onSend: (String, String) -> Unit, + onSave: (Uri, String) -> Boolean, + modifier: Modifier = Modifier, ) { - val res = LocalResources.current - val coroutineScope = rememberCoroutineScope() - val textFieldState = rememberTextFieldState() - val snackbarHostState = remember { SnackbarHostState() } - val launcher = rememberLauncherForActivityResult(CreateDocument("application/json")) { - val success = it != null && onSave(it, textFieldState.text.toString()) - val msg = if (success) res.getString(R.string.crash_report_saved) + val res = LocalResources.current + val coroutineScope = rememberCoroutineScope() + val textFieldState = rememberTextFieldState() + val snackbarHostState = remember { SnackbarHostState() } + val launcher = + rememberLauncherForActivityResult(CreateDocument("application/json")) { + val success = it != null && onSave(it, textFieldState.text.toString()) + val msg = + if (success) res.getString(R.string.crash_report_saved) else res.getString(R.string.crash_report_error_saving) - coroutineScope.launch { - snackbarHostState.showSnackbar(msg) - } + coroutineScope.launch { snackbarHostState.showSnackbar(msg) } } - val context = LocalContext.current - Scaffold( - topBar = { - TopAppBar( - title = {}, - actions = { - TopAppBarButton( - imageVector = Icons.Default.Save, - contentDescription = stringResource(R.string.crash_report_save), - onClick = { launcher.launch("${getLogName(context)}.json") }, - ) - } - ) + val context = LocalContext.current + Scaffold( + topBar = { + TopAppBar( + title = {}, + actions = { + TopAppBarButton( + imageVector = Icons.Default.Save, + contentDescription = stringResource(R.string.crash_report_save), + onClick = { launcher.launch("${getLogName(context)}.json") }, + ) }, - snackbarHost = { SnackbarHost(snackbarHostState) }, - modifier = modifier, - ) { paddingValues -> - CrashContent(isOldCrash, onCancel, onSend, textFieldState, Modifier.padding(paddingValues)) - } + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + modifier = modifier, + ) { paddingValues -> + CrashContent(isOldCrash, onCancel, onSend, textFieldState, Modifier.padding(paddingValues)) + } } @Preview @Composable private fun Preview() { - FDroidContent { - Crash(false, {}, { _, _ -> }, { _, _ -> true }) - } + FDroidContent { Crash(false, {}, { _, _ -> }, { _, _ -> true }) } } diff --git a/app/src/main/kotlin/org/fdroid/ui/crash/CrashActivity.kt b/app/src/main/kotlin/org/fdroid/ui/crash/CrashActivity.kt index 1bedfd2a8..267b6d3af 100644 --- a/app/src/main/kotlin/org/fdroid/ui/crash/CrashActivity.kt +++ b/app/src/main/kotlin/org/fdroid/ui/crash/CrashActivity.kt @@ -6,70 +6,69 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import java.io.IOException +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.concurrent.TimeUnit import mu.KotlinLogging import org.acra.ACRAConstants.DATE_TIME_FORMAT_STRING import org.acra.ReportField import org.acra.ReportField.USER_CRASH_DATE import org.acra.dialog.CrashReportDialogHelper import org.fdroid.ui.FDroidContent -import java.io.IOException -import java.text.SimpleDateFormat -import java.util.Locale -import java.util.concurrent.TimeUnit class CrashActivity : ComponentActivity() { - private val log = KotlinLogging.logger {} + private val log = KotlinLogging.logger {} - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - val helper = CrashReportDialogHelper(this, intent) - setContent { - BackHandler { - log.info { "back nav, cancelling report..." } - helper.cancelReports() - finishAfterTransition() - } - FDroidContent { - Crash( - isOldCrash = isOldCrash(helper.reportData.getString(USER_CRASH_DATE)), - onCancel = { - helper.cancelReports() - finishAfterTransition() - }, - onSend = { comment, userEmail -> - helper.sendCrash(comment, userEmail) - finishAfterTransition() - }, - onSave = { uri, comment -> - onSave(helper, uri, comment) - }, - ) - } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + val helper = CrashReportDialogHelper(this, intent) + setContent { + BackHandler { + log.info { "back nav, cancelling report..." } + helper.cancelReports() + finishAfterTransition() + } + FDroidContent { + Crash( + isOldCrash = isOldCrash(helper.reportData.getString(USER_CRASH_DATE)), + onCancel = { + helper.cancelReports() + finishAfterTransition() + }, + onSend = { comment, userEmail -> + helper.sendCrash(comment, userEmail) + finishAfterTransition() + }, + onSave = { uri, comment -> onSave(helper, uri, comment) }, + ) + } + } + } + + private fun onSave(helper: CrashReportDialogHelper, uri: Uri, comment: String): Boolean { + return try { + val crashData = + helper.reportData.apply { + if (comment.isNotBlank()) { + put(ReportField.USER_COMMENT, comment) + } } + contentResolver.openOutputStream(uri, "wt")?.use { outputStream -> + outputStream.write(crashData.toJSON().encodeToByteArray()) + } ?: throw IOException("Could not open $uri") + true + } catch (e: Exception) { + log.error(e) { "Error saving log: " } + false } + } - private fun onSave(helper: CrashReportDialogHelper, uri: Uri, comment: String): Boolean { - return try { - val crashData = helper.reportData.apply { - if (comment.isNotBlank()) { - put(ReportField.USER_COMMENT, comment) - } - } - contentResolver.openOutputStream(uri, "wt")?.use { outputStream -> - outputStream.write(crashData.toJSON().encodeToByteArray()) - } ?: throw IOException("Could not open $uri") - true - } catch (e: Exception) { - log.error(e) { "Error saving log: " } - false - } - } - - private fun isOldCrash(dateStr: String?): Boolean { - if (dateStr == null) return false - val dateFormat = SimpleDateFormat(DATE_TIME_FORMAT_STRING, Locale.ENGLISH) - val timeMillis = dateFormat.parse(dateStr)?.time ?: return false - return System.currentTimeMillis() - timeMillis > TimeUnit.MINUTES.toMillis(1) - } + private fun isOldCrash(dateStr: String?): Boolean { + if (dateStr == null) return false + val dateFormat = SimpleDateFormat(DATE_TIME_FORMAT_STRING, Locale.ENGLISH) + val timeMillis = dateFormat.parse(dateStr)?.time ?: return false + return System.currentTimeMillis() - timeMillis > TimeUnit.MINUTES.toMillis(1) + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/crash/CrashContent.kt b/app/src/main/kotlin/org/fdroid/ui/crash/CrashContent.kt index f73ec7e01..44eadf714 100644 --- a/app/src/main/kotlin/org/fdroid/ui/crash/CrashContent.kt +++ b/app/src/main/kotlin/org/fdroid/ui/crash/CrashContent.kt @@ -33,83 +33,75 @@ import org.fdroid.ui.FDroidContent @Composable fun CrashContent( - isOldCrash: Boolean, - onCancel: () -> Unit, - onSend: (String, String) -> Unit, - textFieldState: TextFieldState, - modifier: Modifier = Modifier + isOldCrash: Boolean, + onCancel: () -> Unit, + onSend: (String, String) -> Unit, + textFieldState: TextFieldState, + modifier: Modifier = Modifier, ) { - val scrollState = rememberScrollState() - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.SpaceBetween, - modifier = modifier - .fillMaxSize() - .imePadding() - .padding(horizontal = 16.dp) - .verticalScroll(scrollState) - ) { - Image( - painter = painterResource(id = R.drawable.ic_crash), - colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary), - contentDescription = null, // decorative element - modifier = Modifier - .fillMaxWidth(0.5f) - .aspectRatio(1f) - .padding(vertical = 16.dp) - .semantics { hideFromAccessibility() }, + val scrollState = rememberScrollState() + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceBetween, + modifier = + modifier.fillMaxSize().imePadding().padding(horizontal = 16.dp).verticalScroll(scrollState), + ) { + Image( + painter = painterResource(id = R.drawable.ic_crash), + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary), + contentDescription = null, // decorative element + modifier = + Modifier.fillMaxWidth(0.5f).aspectRatio(1f).padding(vertical = 16.dp).semantics { + hideFromAccessibility() + }, + ) + Column(verticalArrangement = spacedBy(16.dp)) { + Text( + text = + if (isOldCrash) { + stringResource(R.string.crash_dialog_title_old) + } else { + stringResource(R.string.crash_dialog_title) + }, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + Text( + text = stringResource(R.string.crash_report_text), + style = MaterialTheme.typography.bodyLarge, + ) + if (!isOldCrash) + TextField( + state = textFieldState, + placeholder = { Text(stringResource(R.string.crash_report_comment_hint)) }, + modifier = Modifier.fillMaxWidth(), ) - Column(verticalArrangement = spacedBy(16.dp)) { - Text( - text = if (isOldCrash) { - stringResource(R.string.crash_dialog_title_old) - } else { - stringResource(R.string.crash_dialog_title) - }, - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.align(Alignment.CenterHorizontally) - ) - Text( - text = stringResource(R.string.crash_report_text), - style = MaterialTheme.typography.bodyLarge, - ) - if (!isOldCrash) TextField( - state = textFieldState, - placeholder = { Text(stringResource(R.string.crash_report_comment_hint)) }, - modifier = Modifier.fillMaxWidth() - ) - } - Row( - horizontalArrangement = Arrangement.SpaceEvenly, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp) - ) { - OutlinedButton(onClick = onCancel) { - Text(stringResource(R.string.cancel)) - } - Button(onClick = { - val text = if (isOldCrash) "old crash" else textFieldState.text.toString() - onSend(text, "") - }) { - Text(stringResource(R.string.crash_report_button_send)) - } - } } + Row( + horizontalArrangement = Arrangement.SpaceEvenly, + modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp), + ) { + OutlinedButton(onClick = onCancel) { Text(stringResource(R.string.cancel)) } + Button( + onClick = { + val text = if (isOldCrash) "old crash" else textFieldState.text.toString() + onSend(text, "") + } + ) { + Text(stringResource(R.string.crash_report_button_send)) + } + } + } } @Preview @Composable private fun Preview() { - FDroidContent { - Crash(false, {}, { _, _ -> }, { _, _ -> true }) - } + FDroidContent { Crash(false, {}, { _, _ -> }, { _, _ -> true }) } } @Preview @Composable private fun PreviewOld() { - FDroidContent { - Crash(true, {}, { _, _ -> }, { _, _ -> true }) - } + FDroidContent { Crash(true, {}, { _, _ -> }, { _, _ -> true }) } } diff --git a/app/src/main/kotlin/org/fdroid/ui/crash/NoRetryPolicy.kt b/app/src/main/kotlin/org/fdroid/ui/crash/NoRetryPolicy.kt index a79dc1e6c..451a88428 100644 --- a/app/src/main/kotlin/org/fdroid/ui/crash/NoRetryPolicy.kt +++ b/app/src/main/kotlin/org/fdroid/ui/crash/NoRetryPolicy.kt @@ -3,11 +3,11 @@ package org.fdroid.ui.crash import org.acra.config.RetryPolicy import org.acra.sender.ReportSender -class NoRetryPolicy() : RetryPolicy { - override fun shouldRetrySend( - senders: List, - failedSenders: List - ): Boolean { - return false - } +class NoRetryPolicy : RetryPolicy { + override fun shouldRetrySend( + senders: List, + failedSenders: List, + ): Boolean { + return false + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/details/AntiFeatures.kt b/app/src/main/kotlin/org/fdroid/ui/details/AntiFeatures.kt index 26807650e..d58fe2f46 100644 --- a/app/src/main/kotlin/org/fdroid/ui/details/AntiFeatures.kt +++ b/app/src/main/kotlin/org/fdroid/ui/details/AntiFeatures.kt @@ -30,63 +30,56 @@ import org.fdroid.ui.utils.testApp @Composable @OptIn(ExperimentalMaterial3ExpressiveApi::class) -fun AntiFeatures( - antiFeatures: List, -) { - ElevatedCard( - colors = CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.inverseSurface, - ), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), +fun AntiFeatures(antiFeatures: List) { + ElevatedCard( + colors = + CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.inverseSurface), + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + ) { + ExpandableSection( + icon = rememberVectorPainter(Icons.Default.WarningAmber), + title = stringResource(R.string.anti_features_title), + modifier = Modifier.padding(horizontal = 16.dp), ) { - ExpandableSection( - icon = rememberVectorPainter(Icons.Default.WarningAmber), - title = stringResource(R.string.anti_features_title), - modifier = Modifier.padding(horizontal = 16.dp), - ) { - Column { - antiFeatures.forEach { antiFeature -> - ListItem( - leadingContent = { - AsyncShimmerImage( - model = antiFeature.icon, - contentDescription = "", - colorFilter = tint(MaterialTheme.colorScheme.inverseOnSurface), - error = rememberVectorPainter(Icons.Default.CrisisAlert), - modifier = Modifier.size(32.dp), - ) - }, - headlineContent = { - Text( - text = antiFeature.name, - color = MaterialTheme.colorScheme.inverseOnSurface, - style = MaterialTheme.typography.bodyMediumEmphasized, - ) - }, - supportingContent = { - antiFeature.reason?.let { - Text( - text = antiFeature.reason, - color = MaterialTheme.colorScheme.inverseOnSurface, - style = MaterialTheme.typography.labelMedium, - ) - } - }, - colors = ListItemDefaults.colors(containerColor = Color.Transparent), - modifier = Modifier.padding(bottom = 8.dp), - ) - } - } + Column { + antiFeatures.forEach { antiFeature -> + ListItem( + leadingContent = { + AsyncShimmerImage( + model = antiFeature.icon, + contentDescription = "", + colorFilter = tint(MaterialTheme.colorScheme.inverseOnSurface), + error = rememberVectorPainter(Icons.Default.CrisisAlert), + modifier = Modifier.size(32.dp), + ) + }, + headlineContent = { + Text( + text = antiFeature.name, + color = MaterialTheme.colorScheme.inverseOnSurface, + style = MaterialTheme.typography.bodyMediumEmphasized, + ) + }, + supportingContent = { + antiFeature.reason?.let { + Text( + text = antiFeature.reason, + color = MaterialTheme.colorScheme.inverseOnSurface, + style = MaterialTheme.typography.labelMedium, + ) + } + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + modifier = Modifier.padding(bottom = 8.dp), + ) } + } } + } } @Preview @Composable fun AntiFeaturesPreview() { - FDroidContent { - AntiFeatures(testApp.antiFeatures!!) - } + FDroidContent { AntiFeatures(testApp.antiFeatures!!) } } diff --git a/app/src/main/kotlin/org/fdroid/ui/details/AppDetails.kt b/app/src/main/kotlin/org/fdroid/ui/details/AppDetails.kt index 9d1eaa7f9..cb9e6aa0e 100644 --- a/app/src/main/kotlin/org/fdroid/ui/details/AppDetails.kt +++ b/app/src/main/kotlin/org/fdroid/ui/details/AppDetails.kt @@ -78,384 +78,359 @@ import org.fdroid.ui.utils.ExpandableSection import org.fdroid.ui.utils.testApp @Composable -@OptIn( - ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class -) +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) fun AppDetails( - item: AppDetailsItem?, - onNav: (NavigationKey) -> Unit, - onBackNav: (() -> Unit)?, - modifier: Modifier = Modifier, + item: AppDetailsItem?, + onNav: (NavigationKey) -> Unit, + onBackNav: (() -> Unit)?, + modifier: Modifier = Modifier, ) { - val topAppBarState = rememberTopAppBarState() - var showInstallError by remember { mutableStateOf(false) } - val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(topAppBarState) - if (item == null) BigLoadingIndicator() - else Scaffold( - topBar = { - AppDetailsTopAppBar(item, topAppBarState, scrollBehavior, onBackNav) - }, - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + val topAppBarState = rememberTopAppBarState() + var showInstallError by remember { mutableStateOf(false) } + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(topAppBarState) + if (item == null) BigLoadingIndicator() + else + Scaffold( + topBar = { AppDetailsTopAppBar(item, topAppBarState, scrollBehavior, onBackNav) }, + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), ) { innerPadding -> - // react to install state changes - LaunchedEffect(item.installState) { - val state = item.installState - if (state is InstallState.UserConfirmationNeeded) { - Log.i("AppDetails", "Requesting user confirmation... $state") - item.actions.requestUserConfirmation(state) - } else if (state is InstallState.Error) { - showInstallError = true - } + // react to install state changes + LaunchedEffect(item.installState) { + val state = item.installState + if (state is InstallState.UserConfirmationNeeded) { + Log.i("AppDetails", "Requesting user confirmation... $state") + item.actions.requestUserConfirmation(state) + } else if (state is InstallState.Error) { + showInstallError = true } - val scrollState = rememberScrollState() - var size by remember { mutableStateOf(IntSize.Zero) } - Column( - modifier = modifier - .verticalScroll(scrollState) - .fillMaxWidth() - .padding(bottom = innerPadding.calculateBottomPadding()) - .onGloballyPositioned { coordinates -> - size = coordinates.size - } + } + val scrollState = rememberScrollState() + var size by remember { mutableStateOf(IntSize.Zero) } + Column( + modifier = + modifier + .verticalScroll(scrollState) + .fillMaxWidth() + .padding(bottom = innerPadding.calculateBottomPadding()) + .onGloballyPositioned { coordinates -> size = coordinates.size } + ) { + // Header is taking care of top innerPadding + AppDetailsHeader(item, innerPadding) + AnimatedVisibility(item.showWarnings) { + AppDetailsWarnings(item, Modifier.padding(horizontal = 16.dp)) + } + // What's New + if ( + item.installedVersionCode != null && (item.whatsNew != null || item.app.changelog != null) ) { - // Header is taking care of top innerPadding - AppDetailsHeader(item, innerPadding) - AnimatedVisibility(item.showWarnings) { - AppDetailsWarnings(item, Modifier.padding(horizontal = 16.dp)) - } - // What's New - if (item.installedVersionCode != null && - (item.whatsNew != null || item.app.changelog != null) - ) { - ElevatedCard( - colors = CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, - ), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - ) { - Text( - text = stringResource(R.string.whats_new_title), - style = MaterialTheme.typography.titleMediumEmphasized, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - ) - if (item.whatsNew != null) SelectionContainer { - Text( - text = item.whatsNew, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - ) - } else if (item.app.changelog != null) { - Text( - text = buildAnnotatedString { - withLink(LinkAnnotation.Url(item.app.changelog!!)) { - append(item.app.changelog) - } - }, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - ) - } - } - } - // Description - item.description?.let { description -> - val maxLines = 3 - val textMeasurer = rememberTextMeasurer() - val allowExpand = remember(size.width, description) { - textMeasurer.measure( - text = description, - constraints = Constraints.fixedWidth(size.width), - ).lineCount > maxLines - } - var descriptionExpanded by remember(allowExpand) { - // not expanded (false) by default, - // but expanded (true) when expanding not allowed - mutableStateOf(!allowExpand) - } - val htmlDescription = AnnotatedString.fromHtml(description) - AnimatedVisibility( - visible = descriptionExpanded, - modifier = Modifier - .semantics { liveRegion = LiveRegionMode.Polite }, - ) { - SelectionContainer { - Text( - text = htmlDescription, - modifier = Modifier - .padding(horizontal = 16.dp) - .padding(top = 8.dp), - ) - } - } - if (allowExpand) { - AnimatedVisibility(!descriptionExpanded) { - Text( - text = htmlDescription, - maxLines = maxLines, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(top = 8.dp), - ) - } - TextButton(onClick = { descriptionExpanded = !descriptionExpanded }) { - Text( - text = if (descriptionExpanded) { - stringResource(R.string.less) - } else { - stringResource(R.string.more) - }, - textAlign = Center, - maxLines = if (descriptionExpanded) Int.MAX_VALUE else 3, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = 8.dp), - ) - } - } - } - // Anti-features - if (!item.antiFeatures.isNullOrEmpty()) { - AntiFeatures(item.antiFeatures) - } - // Screenshots - if (item.phoneScreenshots.isNotEmpty()) { - Screenshots(item.networkState.isMetered, item.phoneScreenshots) - } - // Donate card - if (item.showDonate) ElevatedCard( - colors = CardDefaults.elevatedCardColors(), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - ) { + ElevatedCard( + colors = + CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest + ), + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Text( + text = stringResource(R.string.whats_new_title), + style = MaterialTheme.typography.titleMediumEmphasized, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + if (item.whatsNew != null) + SelectionContainer { Text( - text = stringResource(R.string.donate_title), - style = MaterialTheme.typography.titleMediumEmphasized, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + text = item.whatsNew, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), ) - item.app.donate?.forEach { donation -> - AppDetailsLink( - icon = Icons.Default.Link, - title = donation, - url = donation, - modifier = modifier - .padding(horizontal = 16.dp) - .padding(bottom = 8.dp), - ) - } - item.liberapayUri?.let { liberapayUri -> - AppDetailsLink( - icon = Icons.Default.ChangeHistory, - title = "LiberaPay", - url = liberapayUri, - modifier = modifier - .padding(horizontal = 16.dp) - .padding(bottom = 8.dp), - ) - } - item.openCollectiveUri?.let { openCollectiveUri -> - AppDetailsLink( - icon = Icons.Default.Groups, - title = "OpenCollective", - url = openCollectiveUri, - modifier = modifier - .padding(horizontal = 16.dp) - .padding(bottom = 8.dp), - ) - } - item.bitcoinUri?.let { bitcoinUri -> - AppDetailsLink( - icon = Icons.Default.CurrencyBitcoin, - title = stringResource(R.string.menu_bitcoin), - url = bitcoinUri, - modifier = modifier - .padding(horizontal = 16.dp) - .padding(bottom = 8.dp), - ) - } - item.litecoinUri?.let { litecoinUri -> - AppDetailsLink( - icon = Litecoin, - title = stringResource(R.string.menu_litecoin), - url = litecoinUri, - modifier = modifier - .padding(horizontal = 16.dp) - .padding(bottom = 8.dp), - ) - } - } - // Links - ExpandableSection( - icon = rememberVectorPainter(Icons.Default.Link), - title = stringResource(R.string.links), + } + else if (item.app.changelog != null) { + Text( + text = + buildAnnotatedString { + withLink(LinkAnnotation.Url(item.app.changelog!!)) { + append(item.app.changelog) + } + }, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - ) { - Column(modifier = Modifier.padding(start = 16.dp)) { - item.app.webSite?.let { webSite -> - AppDetailsLink( - icon = Icons.Default.Home, - title = stringResource(R.string.menu_website), - url = webSite, - ) - } - item.app.issueTracker?.let { issueTracker -> - AppDetailsLink( - icon = Icons.Default.EditNote, - title = stringResource(R.string.menu_issues), - url = issueTracker, - ) - } - item.app.changelog?.let { changelog -> - AppDetailsLink( - icon = Icons.Default.ChangeHistory, - title = stringResource(R.string.menu_changelog), - url = changelog, - ) - } - item.app.license?.let { license -> - AppDetailsLink( - icon = License, - title = stringResource(R.string.menu_license, license), - url = "https://spdx.org/licenses/$license", - ) - } - item.app.translation?.let { translation -> - AppDetailsLink( - icon = Icons.Default.Translate, - title = stringResource(R.string.menu_translation), - url = translation, - ) - } - item.app.sourceCode?.let { sourceCode -> - AppDetailsLink( - icon = Icons.Default.Code, - title = stringResource(R.string.menu_source), - url = sourceCode, - ) - } - item.app.video?.getBestLocale(LocaleListCompat.getDefault())?.let { video -> - AppDetailsLink( - icon = Icons.Default.OndemandVideo, - title = stringResource(R.string.menu_video), - url = video, - ) - } - } - } - // Versions - if (!item.versions.isNullOrEmpty()) { - Versions(item) { scrollState.scrollTo(0) } - } - // Developer contact - if (item.showAuthorContact) ExpandableSection( - icon = rememberVectorPainter(Icons.Default.Person), - title = stringResource(R.string.developer_contact), - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - ) { - Column(modifier = Modifier.padding(start = 16.dp)) { - item.app.authorWebSite?.let { authorWebSite -> - AppDetailsLink( - icon = Icons.Default.Home, - title = stringResource(R.string.menu_website), - url = authorWebSite, - ) - } - item.app.authorEmail?.let { authorEmail -> - AppDetailsLink( - icon = Icons.Default.Mail, - title = stringResource(R.string.menu_email), - url = authorEmail, - ) - } - } - } - if (!item.categories.isNullOrEmpty()) ExpandableSection( - icon = rememberVectorPainter(Icons.Default.Category), - title = stringResource(R.string.main_menu__categories), - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - initiallyExpanded = true, - ) { - ChipFlowRow(modifier = Modifier.padding(start = 8.dp)) { - item.categories.forEach { item -> - CategoryChip(item, onClick = { - val categoryNav = AppListType.Category(item.name, item.id) - onNav(NavigationKey.AppList(categoryNav)) - }) - } - } - } - ExpandableSection( - icon = rememberVectorPainter(Icons.Default.AppSettingsAlt), - title = stringResource(R.string.technical_info), - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - ) { - TechnicalInfo(item) - } - // More apps by dev - if (item.authorHasMoreThanOneApp) { - val authorName = item.app.authorName!! - val title = stringResource(R.string.app_list_author, authorName) - Button( - onClick = { - onNav(NavigationKey.AppList(AppListType.Author(title, authorName))) - }, - modifier = Modifier - .align(CenterHorizontally) - .padding(bottom = 16.dp), - ) { - val s = stringResource(R.string.app_details_more_apps_by_author, authorName) - Text(s) - } + ) } + } } + // Description + item.description?.let { description -> + val maxLines = 3 + val textMeasurer = rememberTextMeasurer() + val allowExpand = + remember(size.width, description) { + textMeasurer + .measure(text = description, constraints = Constraints.fixedWidth(size.width)) + .lineCount > maxLines + } + var descriptionExpanded by + remember(allowExpand) { + // not expanded (false) by default, + // but expanded (true) when expanding not allowed + mutableStateOf(!allowExpand) + } + val htmlDescription = AnnotatedString.fromHtml(description) + AnimatedVisibility( + visible = descriptionExpanded, + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, + ) { + SelectionContainer { + Text( + text = htmlDescription, + modifier = Modifier.padding(horizontal = 16.dp).padding(top = 8.dp), + ) + } + } + if (allowExpand) { + AnimatedVisibility(!descriptionExpanded) { + Text( + text = htmlDescription, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).padding(top = 8.dp), + ) + } + TextButton(onClick = { descriptionExpanded = !descriptionExpanded }) { + Text( + text = + if (descriptionExpanded) { + stringResource(R.string.less) + } else { + stringResource(R.string.more) + }, + textAlign = Center, + maxLines = if (descriptionExpanded) Int.MAX_VALUE else 3, + modifier = + Modifier.fillMaxWidth().padding(horizontal = 16.dp).padding(bottom = 8.dp), + ) + } + } + } + // Anti-features + if (!item.antiFeatures.isNullOrEmpty()) { + AntiFeatures(item.antiFeatures) + } + // Screenshots + if (item.phoneScreenshots.isNotEmpty()) { + Screenshots(item.networkState.isMetered, item.phoneScreenshots) + } + // Donate card + if (item.showDonate) + ElevatedCard( + colors = CardDefaults.elevatedCardColors(), + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Text( + text = stringResource(R.string.donate_title), + style = MaterialTheme.typography.titleMediumEmphasized, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + item.app.donate?.forEach { donation -> + AppDetailsLink( + icon = Icons.Default.Link, + title = donation, + url = donation, + modifier = modifier.padding(horizontal = 16.dp).padding(bottom = 8.dp), + ) + } + item.liberapayUri?.let { liberapayUri -> + AppDetailsLink( + icon = Icons.Default.ChangeHistory, + title = "LiberaPay", + url = liberapayUri, + modifier = modifier.padding(horizontal = 16.dp).padding(bottom = 8.dp), + ) + } + item.openCollectiveUri?.let { openCollectiveUri -> + AppDetailsLink( + icon = Icons.Default.Groups, + title = "OpenCollective", + url = openCollectiveUri, + modifier = modifier.padding(horizontal = 16.dp).padding(bottom = 8.dp), + ) + } + item.bitcoinUri?.let { bitcoinUri -> + AppDetailsLink( + icon = Icons.Default.CurrencyBitcoin, + title = stringResource(R.string.menu_bitcoin), + url = bitcoinUri, + modifier = modifier.padding(horizontal = 16.dp).padding(bottom = 8.dp), + ) + } + item.litecoinUri?.let { litecoinUri -> + AppDetailsLink( + icon = Litecoin, + title = stringResource(R.string.menu_litecoin), + url = litecoinUri, + modifier = modifier.padding(horizontal = 16.dp).padding(bottom = 8.dp), + ) + } + } + // Links + ExpandableSection( + icon = rememberVectorPainter(Icons.Default.Link), + title = stringResource(R.string.links), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Column(modifier = Modifier.padding(start = 16.dp)) { + item.app.webSite?.let { webSite -> + AppDetailsLink( + icon = Icons.Default.Home, + title = stringResource(R.string.menu_website), + url = webSite, + ) + } + item.app.issueTracker?.let { issueTracker -> + AppDetailsLink( + icon = Icons.Default.EditNote, + title = stringResource(R.string.menu_issues), + url = issueTracker, + ) + } + item.app.changelog?.let { changelog -> + AppDetailsLink( + icon = Icons.Default.ChangeHistory, + title = stringResource(R.string.menu_changelog), + url = changelog, + ) + } + item.app.license?.let { license -> + AppDetailsLink( + icon = License, + title = stringResource(R.string.menu_license, license), + url = "https://spdx.org/licenses/$license", + ) + } + item.app.translation?.let { translation -> + AppDetailsLink( + icon = Icons.Default.Translate, + title = stringResource(R.string.menu_translation), + url = translation, + ) + } + item.app.sourceCode?.let { sourceCode -> + AppDetailsLink( + icon = Icons.Default.Code, + title = stringResource(R.string.menu_source), + url = sourceCode, + ) + } + item.app.video?.getBestLocale(LocaleListCompat.getDefault())?.let { video -> + AppDetailsLink( + icon = Icons.Default.OndemandVideo, + title = stringResource(R.string.menu_video), + url = video, + ) + } + } + } + // Versions + if (!item.versions.isNullOrEmpty()) { + Versions(item) { scrollState.scrollTo(0) } + } + // Developer contact + if (item.showAuthorContact) + ExpandableSection( + icon = rememberVectorPainter(Icons.Default.Person), + title = stringResource(R.string.developer_contact), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Column(modifier = Modifier.padding(start = 16.dp)) { + item.app.authorWebSite?.let { authorWebSite -> + AppDetailsLink( + icon = Icons.Default.Home, + title = stringResource(R.string.menu_website), + url = authorWebSite, + ) + } + item.app.authorEmail?.let { authorEmail -> + AppDetailsLink( + icon = Icons.Default.Mail, + title = stringResource(R.string.menu_email), + url = authorEmail, + ) + } + } + } + if (!item.categories.isNullOrEmpty()) + ExpandableSection( + icon = rememberVectorPainter(Icons.Default.Category), + title = stringResource(R.string.main_menu__categories), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + initiallyExpanded = true, + ) { + ChipFlowRow(modifier = Modifier.padding(start = 8.dp)) { + item.categories.forEach { item -> + CategoryChip( + item, + onClick = { + val categoryNav = AppListType.Category(item.name, item.id) + onNav(NavigationKey.AppList(categoryNav)) + }, + ) + } + } + } + ExpandableSection( + icon = rememberVectorPainter(Icons.Default.AppSettingsAlt), + title = stringResource(R.string.technical_info), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) { + TechnicalInfo(item) + } + // More apps by dev + if (item.authorHasMoreThanOneApp) { + val authorName = item.app.authorName!! + val title = stringResource(R.string.app_list_author, authorName) + Button( + onClick = { onNav(NavigationKey.AppList(AppListType.Author(title, authorName))) }, + modifier = Modifier.align(CenterHorizontally).padding(bottom = 16.dp), + ) { + val s = stringResource(R.string.app_details_more_apps_by_author, authorName) + Text(s) + } + } + } } - if (showInstallError && item != null && item.installState is InstallState.Error) AlertDialog( - onDismissRequest = { showInstallError = false }, - containerColor = MaterialTheme.colorScheme.errorContainer, - title = { - Text(stringResource(R.string.install_error_notify_title, item.name)) - }, - text = { - if (item.installState.msg == null) { - Text(stringResource(R.string.app_details_install_error_text)) - } else { - ExpandableSection( - icon = null, - title = stringResource(R.string.app_details_install_error_text) - ) { - SelectionContainer { - Text( - text = item.installState.msg, - fontFamily = FontFamily.Monospace, - modifier = Modifier.padding(top = 8.dp), - ) - } - } + if (showInstallError && item != null && item.installState is InstallState.Error) + AlertDialog( + onDismissRequest = { showInstallError = false }, + containerColor = MaterialTheme.colorScheme.errorContainer, + title = { Text(stringResource(R.string.install_error_notify_title, item.name)) }, + text = { + if (item.installState.msg == null) { + Text(stringResource(R.string.app_details_install_error_text)) + } else { + ExpandableSection( + icon = null, + title = stringResource(R.string.app_details_install_error_text), + ) { + SelectionContainer { + Text( + text = item.installState.msg, + fontFamily = FontFamily.Monospace, + modifier = Modifier.padding(top = 8.dp), + ) } - }, - confirmButton = { - TextButton(onClick = { showInstallError = false }) { - Text(stringResource(R.string.ok)) - } - }, + } + } + }, + confirmButton = { + TextButton(onClick = { showInstallError = false }) { Text(stringResource(R.string.ok)) } + }, ) } @Preview @Composable fun AppDetailsLoadingPreview() { - FDroidContent { - AppDetails(null, { }, {}) - } + FDroidContent { AppDetails(null, {}, {}) } } @Preview @Composable fun AppDetailsPreview() { - FDroidContent { - AppDetails(testApp, { }, {}) - } + FDroidContent { AppDetails(testApp, {}, {}) } } diff --git a/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsEntry.kt b/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsEntry.kt index c6fcc93e4..68cbe4d02 100644 --- a/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsEntry.kt +++ b/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsEntry.kt @@ -10,24 +10,20 @@ import org.fdroid.ui.navigation.NavigationKey import org.fdroid.ui.navigation.Navigator @OptIn(ExperimentalMaterial3AdaptiveApi::class) -fun EntryProviderScope.appDetailsEntry( - navigator: Navigator, - isBigScreen: Boolean, -) { - entry( - metadata = ListDetailSceneStrategy.detailPane("appdetails") - ) { - val viewModel = hiltViewModel( - creationCallback = { factory -> - factory.create(it.packageName) - } - ) - AppDetails( - item = viewModel.appDetails.collectAsStateWithLifecycle().value, - onNav = { navKey -> navigator.navigate(navKey) }, - onBackNav = if (isBigScreen) null else { - { navigator.goBack() } - }, - ) - } +fun EntryProviderScope.appDetailsEntry(navigator: Navigator, isBigScreen: Boolean) { + entry(metadata = ListDetailSceneStrategy.detailPane("appdetails")) { + val viewModel = + hiltViewModel( + creationCallback = { factory -> factory.create(it.packageName) } + ) + AppDetails( + item = viewModel.appDetails.collectAsStateWithLifecycle().value, + onNav = { navKey -> navigator.navigate(navKey) }, + onBackNav = + if (isBigScreen) null + else { + { navigator.goBack() } + }, + ) + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsHeader.kt b/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsHeader.kt index d5fc5b48e..ac5e4e2c7 100644 --- a/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsHeader.kt +++ b/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsHeader.kt @@ -71,265 +71,234 @@ import org.fdroid.ui.utils.testApp @Composable @OptIn(ExperimentalMaterial3ExpressiveApi::class) -fun AppDetailsHeader( - item: AppDetailsItem, - innerPadding: PaddingValues, -) { - Box { - Spacer(modifier = Modifier.padding(top = innerPadding.calculateTopPadding())) - item.featureGraphic?.let { featureGraphic -> - AsyncImage( - model = featureGraphic, - contentDescription = null, - contentScale = ContentScale.FillWidth, - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 196.dp) - .graphicsLayer { alpha = 0.5f } - .drawWithContent { - val colors = listOf( - Color.Black, - Color.Transparent - ) - drawContent() - drawRect( - brush = Brush.verticalGradient(colors), - blendMode = BlendMode.DstIn - ) - } - .semantics { hideFromAccessibility() }, - ) - } +fun AppDetailsHeader(item: AppDetailsItem, innerPadding: PaddingValues) { + Box { + Spacer(modifier = Modifier.padding(top = innerPadding.calculateTopPadding())) + item.featureGraphic?.let { featureGraphic -> + AsyncImage( + model = featureGraphic, + contentDescription = null, + contentScale = ContentScale.FillWidth, + modifier = + Modifier.fillMaxWidth() + .heightIn(max = 196.dp) + .graphicsLayer { alpha = 0.5f } + .drawWithContent { + val colors = listOf(Color.Black, Color.Transparent) + drawContent() + drawRect(brush = Brush.verticalGradient(colors), blendMode = BlendMode.DstIn) + } + .semantics { hideFromAccessibility() }, + ) } - var showMeteredDialog by remember { mutableStateOf(false) } - // Offline bar, if no internet - if (!item.networkState.isOnline) { - OfflineBar(modifier = Modifier.absoluteOffset(y = (-8).dp)) + } + var showMeteredDialog by remember { mutableStateOf(false) } + // Offline bar, if no internet + if (!item.networkState.isOnline) { + OfflineBar(modifier = Modifier.absoluteOffset(y = (-8).dp)) + } + // Header + val version = item.suggestedVersion ?: item.versions?.first()?.version + Row( + modifier = Modifier.padding(horizontal = 16.dp), + horizontalArrangement = spacedBy(16.dp), + verticalAlignment = CenterVertically, + ) { + BadgedBox(badge = { if (item.installedVersionCode != null) InstalledBadge() }) { + AsyncShimmerImage( + model = item.icon, + contentDescription = "", + contentScale = ContentScale.Crop, + error = painterResource(R.drawable.ic_repo_app_default), + modifier = + Modifier.size(64.dp).clip(MaterialTheme.shapes.large).semantics { + hideFromAccessibility() + }, + ) } - // Header - val version = item.suggestedVersion ?: item.versions?.first()?.version - Row( - modifier = Modifier - .padding(horizontal = 16.dp), - horizontalArrangement = spacedBy(16.dp), - verticalAlignment = CenterVertically, - ) { - BadgedBox(badge = { if (item.installedVersionCode != null) InstalledBadge() }) { - AsyncShimmerImage( - model = item.icon, - contentDescription = "", - contentScale = ContentScale.Crop, - error = painterResource(R.drawable.ic_repo_app_default), - modifier = Modifier - .size(64.dp) - .clip(MaterialTheme.shapes.large) - .semantics { hideFromAccessibility() }, - ) - } - Column { - SelectionContainer { - Text( - item.name, - style = MaterialTheme.typography.headlineMediumEmphasized - ) - } - item.app.authorName?.let { authorName -> - SelectionContainer { - Text( - text = stringResource(R.string.author_by, authorName), - style = MaterialTheme.typography.bodyMedium, - ) - } - } - val lastUpdated = item.app.lastUpdated.asRelativeTimeString() - val size = version?.size?.let { size -> - Formatter.formatFileSize(LocalContext.current, size) - } - SelectionContainer { - Text( - text = if (size == null) { - stringResource(R.string.last_updated, lastUpdated) - } else { - stringResource(R.string.last_updated_with_size, lastUpdated, size) - }, - style = MaterialTheme.typography.bodyMedium, - ) - } - } - } - // Summary - item.summary?.let { summary -> + Column { + SelectionContainer { + Text(item.name, style = MaterialTheme.typography.headlineMediumEmphasized) + } + item.app.authorName?.let { authorName -> SelectionContainer { - Text( - text = summary, - style = MaterialTheme.typography.bodyLargeEmphasized, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - ) + Text( + text = stringResource(R.string.author_by, authorName), + style = MaterialTheme.typography.bodyMedium, + ) } + } + val lastUpdated = item.app.lastUpdated.asRelativeTimeString() + val size = version?.size?.let { size -> Formatter.formatFileSize(LocalContext.current, size) } + SelectionContainer { + Text( + text = + if (size == null) { + stringResource(R.string.last_updated, lastUpdated) + } else { + stringResource(R.string.last_updated_with_size, lastUpdated, size) + }, + style = MaterialTheme.typography.bodyMedium, + ) + } } - // Repo Chooser - RepoChooser( - repos = item.repositories, - currentRepoId = item.app.repoId, - preferredRepoId = item.preferredRepoId, - proxy = item.proxy, - onRepoChanged = item.actions.onRepoChanged, - onPreferredRepoChanged = item.actions.onPreferredRepoChanged, + } + // Summary + item.summary?.let { summary -> + SelectionContainer { + Text( + text = summary, + style = MaterialTheme.typography.bodyLargeEmphasized, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - ) - // check user confirmation ON_RESUME to work around Android bug - val lifecycleOwner = LocalLifecycleOwner.current - val currentInstallState by rememberUpdatedState(item.installState) - var numChecks by remember { mutableStateOf(0) } - DisposableEffect(lifecycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - val state = currentInstallState - if (state is InstallState.UserConfirmationNeeded && numChecks < 3) { - Log.i( - "AppDetailsHeader", - "Resumed ($numChecks). Checking user confirmation... $state" - ) - // there's annoying installer bugs where it doesn't tell us about errors - // and we would run into infinite UI loops here, so there's a counter. - @Suppress("AssignedValueIsNeverRead") - numChecks += 1 - item.actions.checkUserConfirmation(state) - } else if (state is InstallState.UserConfirmationNeeded) { - // we tried three times, so cancel install now - Log.i("AppDetailsHeader", "Cancel installation") - item.actions.cancelInstall() - } - } - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { - lifecycleOwner.lifecycle.removeObserver(observer) - } + ) } - // Main Buttons - val buttonLineModifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp) - if (item.mainButtonState == MainButtonState.PROGRESS) { - Row( - modifier = buttonLineModifier, - verticalAlignment = CenterVertically, + } + // Repo Chooser + RepoChooser( + repos = item.repositories, + currentRepoId = item.app.repoId, + preferredRepoId = item.preferredRepoId, + proxy = item.proxy, + onRepoChanged = item.actions.onRepoChanged, + onPreferredRepoChanged = item.actions.onPreferredRepoChanged, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + // check user confirmation ON_RESUME to work around Android bug + val lifecycleOwner = LocalLifecycleOwner.current + val currentInstallState by rememberUpdatedState(item.installState) + var numChecks by remember { mutableStateOf(0) } + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + val state = currentInstallState + if (state is InstallState.UserConfirmationNeeded && numChecks < 3) { + Log.i("AppDetailsHeader", "Resumed ($numChecks). Checking user confirmation... $state") + // there's annoying installer bugs where it doesn't tell us about errors, + // and we would run into infinite UI loops here, so there's a counter. + numChecks += 1 + item.actions.checkUserConfirmation(state) + } else if (state is InstallState.UserConfirmationNeeded) { + // we tried three times, so cancel install now + Log.i("AppDetailsHeader", "Cancel installation") + item.actions.cancelInstall() + } + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + // Main Buttons + val buttonLineModifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp) + if (item.mainButtonState == MainButtonState.PROGRESS) { + Row(modifier = buttonLineModifier, verticalAlignment = CenterVertically) { + Column { + val strRes = + when (item.installState) { + is InstallState.Waiting -> R.string.status_install_preparing + is InstallState.Starting -> R.string.status_install_preparing + is InstallState.PreApproved -> R.string.status_install_preparing + is InstallState.Downloading -> R.string.downloading + is InstallState.Installing -> R.string.installing + is InstallState.UserConfirmationNeeded -> R.string.installing + else -> -1 + } + if (strRes >= 0) + Text(text = stringResource(strRes), style = MaterialTheme.typography.bodyMedium) + Row(verticalAlignment = CenterVertically) { + if (item.installState is InstallState.Downloading) { + val animatedProgress by + animateFloatAsState( + targetValue = item.installState.progress, + animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, + ) + LinearWavyProgressIndicator( + stopSize = 0.dp, + progress = { animatedProgress }, + modifier = Modifier.weight(1f), + ) + } else { + LinearWavyProgressIndicator(modifier = Modifier.weight(1f)) + } + var cancelled by remember { mutableStateOf(false) } + IconButton( + onClick = { + if (!cancelled) item.actions.cancelInstall() + cancelled = true + } + ) { + AnimatedVisibility(cancelled) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + AnimatedVisibility(!cancelled) { + Icon( + imageVector = Icons.Default.Cancel, + contentDescription = stringResource(R.string.cancel), + ) + } + } + } + } + } + } else if (item.showOpenButton || item.mainButtonState != MainButtonState.NONE) + Row(horizontalArrangement = spacedBy(8.dp, CenterHorizontally), modifier = buttonLineModifier) { + if (item.showOpenButton) { + val context = LocalContext.current + OutlinedButton( + onClick = { context.startActivitySafe(item.actions.launchIntent) }, + modifier = Modifier.weight(1f), ) { - Column { - val strRes = when (item.installState) { - is InstallState.Waiting -> R.string.status_install_preparing - is InstallState.Starting -> R.string.status_install_preparing - is InstallState.PreApproved -> R.string.status_install_preparing - is InstallState.Downloading -> R.string.downloading - is InstallState.Installing -> R.string.installing - is InstallState.UserConfirmationNeeded -> R.string.installing - else -> -1 - } - if (strRes >= 0) Text( - text = stringResource(strRes), - style = MaterialTheme.typography.bodyMedium, - ) - Row(verticalAlignment = CenterVertically) { - if (item.installState is InstallState.Downloading) { - val animatedProgress by animateFloatAsState( - targetValue = item.installState.progress, - animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, - ) - LinearWavyProgressIndicator( - stopSize = 0.dp, - progress = { animatedProgress }, - modifier = Modifier.weight(1f), - ) - } else { - LinearWavyProgressIndicator(modifier = Modifier.weight(1f)) - } - var cancelled by remember { mutableStateOf(false) } - IconButton(onClick = { - if (!cancelled) item.actions.cancelInstall() - cancelled = true - }) { - AnimatedVisibility(cancelled) { - CircularProgressIndicator(modifier = Modifier.size(24.dp)) - } - AnimatedVisibility(!cancelled) { - Icon( - imageVector = Icons.Default.Cancel, - contentDescription = stringResource(R.string.cancel), - ) - } - } - } - } + Text(stringResource(R.string.menu_open)) } - } else if (item.showOpenButton || item.mainButtonState != MainButtonState.NONE) Row( - horizontalArrangement = spacedBy(8.dp, CenterHorizontally), - modifier = buttonLineModifier, - ) { - if (item.showOpenButton) { - val context = LocalContext.current - OutlinedButton( - onClick = { - context.startActivitySafe(item.actions.launchIntent) - }, - modifier = Modifier.weight(1f) - ) { - Text(stringResource(R.string.menu_open)) - } - } - if (item.mainButtonState != MainButtonState.NONE) { - // button is for either installing or updating - Button( - onClick = { - if (item.networkState.isMetered) { - showMeteredDialog = true - } else { - require(item.suggestedVersion != null) { - "suggestedVersion was null" - } - item.actions.installAction(item.app, item.suggestedVersion, item.icon) - } - }, - modifier = Modifier.weight(1f) - ) { - if (item.mainButtonState == MainButtonState.INSTALL) { - Text(stringResource(R.string.menu_install)) - } else if (item.mainButtonState == MainButtonState.UPDATE) { - Text(stringResource(R.string.app__install_downloaded_update)) - } + } + if (item.mainButtonState != MainButtonState.NONE) { + // button is for either installing or updating + Button( + onClick = { + if (item.networkState.isMetered) { + showMeteredDialog = true + } else { + require(item.suggestedVersion != null) { "suggestedVersion was null" } + item.actions.installAction(item.app, item.suggestedVersion, item.icon) } + }, + modifier = Modifier.weight(1f), + ) { + if (item.mainButtonState == MainButtonState.INSTALL) { + Text(stringResource(R.string.menu_install)) + } else if (item.mainButtonState == MainButtonState.UPDATE) { + Text(stringResource(R.string.app__install_downloaded_update)) + } } + } } - if (showMeteredDialog) MeteredConnectionDialog( - numBytes = version?.size, - onConfirm = { - require(item.suggestedVersion != null) { "suggestedVersion was null" } - item.actions.installAction(item.app, item.suggestedVersion, item.icon) - }, - onDismiss = { showMeteredDialog = false }, + if (showMeteredDialog) + MeteredConnectionDialog( + numBytes = version?.size, + onConfirm = { + require(item.suggestedVersion != null) { "suggestedVersion was null" } + item.actions.installAction(item.app, item.suggestedVersion, item.icon) + }, + onDismiss = { showMeteredDialog = false }, ) } @Preview @Composable fun AppDetailsHeaderPreview() { - FDroidContent { - Column { - AppDetailsHeader(testApp, PaddingValues(top = 16.dp)) - } - } + FDroidContent { Column { AppDetailsHeader(testApp, PaddingValues(top = 16.dp)) } } } @Preview @Composable private fun PreviewProgress() { - FDroidContent(dynamicColors = true) { - Column { - val app = testApp.copy( - installState = InstallState.Starting("", "", "", 23), - networkState = NetworkState(true, isMetered = true), - ) - AppDetailsHeader(app, PaddingValues(top = 16.dp)) - } + FDroidContent(dynamicColors = true) { + Column { + val app = + testApp.copy( + installState = InstallState.Starting("", "", "", 23), + networkState = NetworkState(true, isMetered = true), + ) + AppDetailsHeader(app, PaddingValues(top = 16.dp)) } + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsItem.kt b/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsItem.kt index 98f11d877..dc17389d4 100644 --- a/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsItem.kt +++ b/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsItem.kt @@ -23,270 +23,266 @@ import org.fdroid.install.SessionInstallManager import org.fdroid.ui.categories.CategoryItem data class AppDetailsItem( - val app: AppMetadata, - val actions: AppDetailsActions, - val installState: InstallState, - val networkState: NetworkState, - /** - * The ID of the repo that is currently set as preferred. - * Note that the repository ID of this [app] may be different. - */ - val preferredRepoId: Long = app.repoId, - /** - * A list of [Repository]s the app is in. If this is empty, we don't want to show the list. - */ - val repositories: List = emptyList(), - val name: String, - val summary: String? = null, - val description: String? = null, - val icon: Any? = null, - val featureGraphic: Any? = null, - val phoneScreenshots: List = emptyList(), - val categories: List? = null, - val versions: List? = null, - val installedVersion: PackageVersion? = null, - /** - * Needed, because the [installedVersion] may not be available, e.g. too old. - */ - val installedVersionCode: Long? = null, - val installedVersionName: String? = null, - /** - * Needed, because the [installedVersion] may not be available, e.g. not version from any repo. - */ - val installedSigner: String? = null, - /** - * The currently suggested version for installation. - */ - val suggestedVersion: AppVersion? = null, - /** - * Similar to [suggestedVersion], but doesn't obey [appPrefs] for ignoring versions. - * This is useful for (un-)ignoring this version. - */ - val possibleUpdate: PackageVersion? = null, - val appPrefs: AppPrefs? = null, - val whatsNew: String? = null, - val antiFeatures: List? = null, - val issue: AppIssue? = null, - val authorHasMoreThanOneApp: Boolean = false, - val proxy: ProxyConfig?, + val app: AppMetadata, + val actions: AppDetailsActions, + val installState: InstallState, + val networkState: NetworkState, + /** + * The ID of the repo that is currently set as preferred. Note that the repository ID of this + * [app] may be different. + */ + val preferredRepoId: Long = app.repoId, + /** A list of [Repository]s the app is in. If this is empty, we don't want to show the list. */ + val repositories: List = emptyList(), + val name: String, + val summary: String? = null, + val description: String? = null, + val icon: Any? = null, + val featureGraphic: Any? = null, + val phoneScreenshots: List = emptyList(), + val categories: List? = null, + val versions: List? = null, + val installedVersion: PackageVersion? = null, + /** Needed, because the [installedVersion] may not be available, e.g. too old. */ + val installedVersionCode: Long? = null, + val installedVersionName: String? = null, + /** + * Needed, because the [installedVersion] may not be available, e.g. not version from any repo. + */ + val installedSigner: String? = null, + /** The currently suggested version for installation. */ + val suggestedVersion: AppVersion? = null, + /** + * Similar to [suggestedVersion], but doesn't obey [appPrefs] for ignoring versions. This is + * useful for (un-)ignoring this version. + */ + val possibleUpdate: PackageVersion? = null, + val appPrefs: AppPrefs? = null, + val whatsNew: String? = null, + val antiFeatures: List? = null, + val issue: AppIssue? = null, + val authorHasMoreThanOneApp: Boolean = false, + val proxy: ProxyConfig?, ) { - constructor( - repository: Repository, - preferredRepoId: Long, - repositories: List, - dbApp: App, - actions: AppDetailsActions, - installState: InstallState, - networkState: NetworkState, - versions: List?, - installedVersion: AppVersion?, - installedVersionCode: Long?, - installedVersionName: String?, - installedSigner: String?, - suggestedVersion: AppVersion?, - possibleUpdate: AppVersion?, - appPrefs: AppPrefs?, - issue: AppIssue?, - authorHasMoreThanOneApp: Boolean, - localeList: LocaleListCompat, - proxy: ProxyConfig?, - ) : this( - app = dbApp.metadata, - actions = actions, - installState = installState, - networkState = networkState, - preferredRepoId = preferredRepoId, - repositories = repositories, - name = dbApp.name ?: "Unknown App", - summary = dbApp.summary, - description = getHtmlDescription(dbApp.getDescription(localeList)), - icon = if (installedVersionCode == null) { - dbApp.getIcon(localeList)?.getImageModel(repository, proxy) - } else { - val request = - dbApp.getIcon(localeList)?.getImageModel(repository, proxy) as? DownloadRequest - PackageName(dbApp.packageName, request) - }, - featureGraphic = dbApp.getFeatureGraphic(localeList)?.getImageModel(repository, proxy), - phoneScreenshots = dbApp.getPhoneScreenshots(localeList).mapNotNull { - it.getImageModel(repository, proxy) - }, - categories = dbApp.metadata.categories?.mapNotNull { categoryId -> - val category = repository.getCategories()[categoryId] ?: return@mapNotNull null - CategoryItem( - id = category.id, - name = category.getName(localeList) ?: "Unknown Category", - ) - }, - versions = versions, - installedVersion = installedVersion, - installedVersionCode = installedVersionCode, - installedVersionName = installedVersionName, - installedSigner = installedSigner, - suggestedVersion = suggestedVersion, - possibleUpdate = possibleUpdate, - appPrefs = appPrefs, - whatsNew = suggestedVersion?.getWhatsNew(localeList)?.trim() - ?: installedVersion?.getWhatsNew(localeList)?.trim(), - antiFeatures = installedVersion?.getAntiFeatures(repository, localeList, proxy) - ?: suggestedVersion?.getAntiFeatures(repository, localeList, proxy) - ?: (versions?.first()?.version as? AppVersion).getAntiFeatures( - repository = repository, - localeList = localeList, - proxy = proxy, - ), - issue = issue, - authorHasMoreThanOneApp = authorHasMoreThanOneApp, - proxy = proxy, - ) + constructor( + repository: Repository, + preferredRepoId: Long, + repositories: List, + dbApp: App, + actions: AppDetailsActions, + installState: InstallState, + networkState: NetworkState, + versions: List?, + installedVersion: AppVersion?, + installedVersionCode: Long?, + installedVersionName: String?, + installedSigner: String?, + suggestedVersion: AppVersion?, + possibleUpdate: AppVersion?, + appPrefs: AppPrefs?, + issue: AppIssue?, + authorHasMoreThanOneApp: Boolean, + localeList: LocaleListCompat, + proxy: ProxyConfig?, + ) : this( + app = dbApp.metadata, + actions = actions, + installState = installState, + networkState = networkState, + preferredRepoId = preferredRepoId, + repositories = repositories, + name = dbApp.name ?: "Unknown App", + summary = dbApp.summary, + description = getHtmlDescription(dbApp.getDescription(localeList)), + icon = + if (installedVersionCode == null) { + dbApp.getIcon(localeList)?.getImageModel(repository, proxy) + } else { + val request = + dbApp.getIcon(localeList)?.getImageModel(repository, proxy) as? DownloadRequest + PackageName(dbApp.packageName, request) + }, + featureGraphic = dbApp.getFeatureGraphic(localeList)?.getImageModel(repository, proxy), + phoneScreenshots = + dbApp.getPhoneScreenshots(localeList).mapNotNull { it.getImageModel(repository, proxy) }, + categories = + dbApp.metadata.categories?.mapNotNull { categoryId -> + val category = repository.getCategories()[categoryId] ?: return@mapNotNull null + CategoryItem(id = category.id, name = category.getName(localeList) ?: "Unknown Category") + }, + versions = versions, + installedVersion = installedVersion, + installedVersionCode = installedVersionCode, + installedVersionName = installedVersionName, + installedSigner = installedSigner, + suggestedVersion = suggestedVersion, + possibleUpdate = possibleUpdate, + appPrefs = appPrefs, + whatsNew = + suggestedVersion?.getWhatsNew(localeList)?.trim() + ?: installedVersion?.getWhatsNew(localeList)?.trim(), + antiFeatures = + installedVersion?.getAntiFeatures(repository, localeList, proxy) + ?: suggestedVersion?.getAntiFeatures(repository, localeList, proxy) + ?: (versions?.first()?.version as? AppVersion).getAntiFeatures( + repository = repository, + localeList = localeList, + proxy = proxy, + ), + issue = issue, + authorHasMoreThanOneApp = authorHasMoreThanOneApp, + proxy = proxy, + ) - /** - * True if the app is installed (and has a launch intent) - * and thus the 'Open' button should be shown. - */ - val showOpenButton: Boolean get() = actions.launchIntent != null - val allowsBetaVersions: Boolean - get() = appPrefs?.releaseChannels?.contains(RELEASE_CHANNEL_BETA) == true + /** + * True if the app is installed (and has a launch intent) and thus the 'Open' button should be + * shown. + */ + val showOpenButton: Boolean + get() = actions.launchIntent != null - val ignoresAllUpdates: Boolean get() = appPrefs?.ignoreAllUpdates == true + val allowsBetaVersions: Boolean + get() = appPrefs?.releaseChannels?.contains(RELEASE_CHANNEL_BETA) == true - /** - * True if the update from [possibleUpdate] is being ignored - * and not already ignoring all updates anyway. - */ - val ignoresCurrentUpdate: Boolean - get() { - if (ignoresAllUpdates) return false - val prefs = appPrefs ?: return false - val updateVersionCode = possibleUpdate?.versionCode ?: return false - return actions.ignoreThisUpdate != null && prefs.shouldIgnoreUpdate(updateVersionCode) - } + val ignoresAllUpdates: Boolean + get() = appPrefs?.ignoreAllUpdates == true - /** - * Specifies what main button should be shown. - */ - val mainButtonState: MainButtonState - get() { - return if (installState.showProgress) { - MainButtonState.PROGRESS - } else if (installedVersionCode == null) { // app is not installed - if (suggestedVersion == null) MainButtonState.NONE - else MainButtonState.INSTALL - } else { // app is installed - if (suggestedVersion == null || - suggestedVersion.versionCode <= installedVersionCode - ) MainButtonState.NONE - else MainButtonState.UPDATE - } - } + /** + * True if the update from [possibleUpdate] is being ignored and not already ignoring all updates + * anyway. + */ + val ignoresCurrentUpdate: Boolean + get() { + if (ignoresAllUpdates) return false + val prefs = appPrefs ?: return false + val updateVersionCode = possibleUpdate?.versionCode ?: return false + return actions.ignoreThisUpdate != null && prefs.shouldIgnoreUpdate(updateVersionCode) + } - /** - * True if all available versions for this app are incompatible with this device. - */ - val isIncompatible: Boolean = versions?.all { !it.isCompatible } ?: false + /** Specifies what main button should be shown. */ + val mainButtonState: MainButtonState + get() { + return if (installState.showProgress) { + MainButtonState.PROGRESS + } else if (installedVersionCode == null) { // app is not installed + if (suggestedVersion == null) MainButtonState.NONE else MainButtonState.INSTALL + } else { // app is installed + if (suggestedVersion == null || suggestedVersion.versionCode <= installedVersionCode) + MainButtonState.NONE + else MainButtonState.UPDATE + } + } - /** - * True if this app has warnings, we need to show to the user. - */ - val showWarnings: Boolean - get() = isIncompatible || oldTargetSdk || issue != null + /** True if all available versions for this app are incompatible with this device. */ + val isIncompatible: Boolean = versions?.all { !it.isCompatible } ?: false - /** - * True if the targetSdk of the suggested version is so old - * that auto updates for this app are not available (due to system restrictions). - */ - val oldTargetSdk: Boolean - get() { - val targetSdk = suggestedVersion?.packageManifest?.targetSdkVersion - // auto-updates are only available on SDK 31 and up - return if (targetSdk != null && SDK_INT >= 31) { - !SessionInstallManager.isAutoUpdateSupported(targetSdk) - } else { - false - } - } - val showAuthorContact: Boolean get() = app.authorEmail != null || app.authorWebSite != null - val showDonate: Boolean - get() = !app.donate.isNullOrEmpty() || - app.liberapay != null || - app.openCollective != null || - app.litecoin != null || - app.bitcoin != null - val liberapayUri = app.liberapay?.let { "https://liberapay.com/$it/donate" } - val openCollectiveUri = app.openCollective?.let { "https://opencollective.com/$it/donate" } - val litecoinUri = app.litecoin?.let { "litecoin:$it" } - val bitcoinUri = app.bitcoin?.let { "bitcoin:$it" } + /** True if this app has warnings, we need to show to the user. */ + val showWarnings: Boolean + get() = isIncompatible || oldTargetSdk || issue != null + + /** + * True if the targetSdk of the suggested version is so old that auto updates for this app are not + * available (due to system restrictions). + */ + val oldTargetSdk: Boolean + get() { + val targetSdk = suggestedVersion?.packageManifest?.targetSdkVersion + // auto-updates are only available on SDK 31 and up + return if (targetSdk != null && SDK_INT >= 31) { + !SessionInstallManager.isAutoUpdateSupported(targetSdk) + } else { + false + } + } + + val showAuthorContact: Boolean + get() = app.authorEmail != null || app.authorWebSite != null + + val showDonate: Boolean + get() = + !app.donate.isNullOrEmpty() || + app.liberapay != null || + app.openCollective != null || + app.litecoin != null || + app.bitcoin != null + + val liberapayUri = app.liberapay?.let { "https://liberapay.com/$it/donate" } + val openCollectiveUri = app.openCollective?.let { "https://opencollective.com/$it/donate" } + val litecoinUri = app.litecoin?.let { "litecoin:$it" } + val bitcoinUri = app.bitcoin?.let { "bitcoin:$it" } } class AppDetailsActions( - val installAction: (AppMetadata, AppVersion, Any?) -> Unit, - val requestUserConfirmation: (InstallState.UserConfirmationNeeded) -> Unit, - /** - * A workaround for Android 10, 11, 12 and 13 where tapping outside the confirmation dialog - * dismisses it without any feedback for us. - * So when our activity resumes while we are in state [InstallState.UserConfirmationNeeded] - * we need to call this method, so we can manually check if our session progressed or not. - */ - val checkUserConfirmation: (InstallState.UserConfirmationNeeded) -> Unit, - val cancelInstall: () -> Unit, - val onUninstallResult: (ActivityResult) -> Unit, - val onRepoChanged: (Long) -> Unit, - val onPreferredRepoChanged: (Long) -> Unit, - val allowBetaVersions: () -> Unit, - val ignoreAllUpdates: (() -> Unit)? = null, - val ignoreThisUpdate: (() -> Unit)? = null, - val shareApk: Intent? = null, - val uninstallIntent: Intent? = null, - val launchIntent: Intent? = null, - val shareIntent: Intent? = null, + val installAction: (AppMetadata, AppVersion, Any?) -> Unit, + val requestUserConfirmation: (InstallState.UserConfirmationNeeded) -> Unit, + /** + * A workaround for Android 10, 11, 12 and 13 where tapping outside the confirmation dialog + * dismisses it without any feedback for us. So when our activity resumes while we are in state + * [InstallState.UserConfirmationNeeded] we need to call this method, so we can manually check if + * our session progressed or not. + */ + val checkUserConfirmation: (InstallState.UserConfirmationNeeded) -> Unit, + val cancelInstall: () -> Unit, + val onUninstallResult: (ActivityResult) -> Unit, + val onRepoChanged: (Long) -> Unit, + val onPreferredRepoChanged: (Long) -> Unit, + val allowBetaVersions: () -> Unit, + val ignoreAllUpdates: (() -> Unit)? = null, + val ignoreThisUpdate: (() -> Unit)? = null, + val shareApk: Intent? = null, + val uninstallIntent: Intent? = null, + val launchIntent: Intent? = null, + val shareIntent: Intent? = null, ) data class VersionItem( - val version: PackageVersion, - val isInstalled: Boolean, - val isSuggested: Boolean, - val isCompatible: Boolean, - val isSignerCompatible: Boolean, - val showInstallButton: Boolean, + val version: PackageVersion, + val isInstalled: Boolean, + val isSuggested: Boolean, + val isCompatible: Boolean, + val isSignerCompatible: Boolean, + val showInstallButton: Boolean, ) enum class MainButtonState { - NONE, - INSTALL, - UPDATE, - PROGRESS, + NONE, + INSTALL, + UPDATE, + PROGRESS, } data class AntiFeature( - val id: String, - val icon: Any? = null, - val name: String = id, - val reason: String? = null, + val id: String, + val icon: Any? = null, + val name: String = id, + val reason: String? = null, ) private fun AppVersion?.getAntiFeatures( - repository: Repository, - localeList: LocaleListCompat, - proxy: ProxyConfig?, + repository: Repository, + localeList: LocaleListCompat, + proxy: ProxyConfig?, ): List? { - return this?.antiFeatureKeys?.mapNotNull { key -> - val antiFeature = repository.getAntiFeatures()[key] ?: return@mapNotNull null - AntiFeature( - id = key, - icon = antiFeature.getIcon(localeList)?.getImageModel(repository, proxy), - name = antiFeature.getName(localeList) ?: key, - reason = getAntiFeatureReason(key, localeList), - ) - } + return this?.antiFeatureKeys?.mapNotNull { key -> + val antiFeature = repository.getAntiFeatures()[key] ?: return@mapNotNull null + AntiFeature( + id = key, + icon = antiFeature.getIcon(localeList)?.getImageModel(repository, proxy), + name = antiFeature.getName(localeList) ?: key, + reason = getAntiFeatureReason(key, localeList), + ) + } } @VisibleForTesting internal fun getHtmlDescription(description: String?): String? { - return description?.replace("".toRegex(), "") - ?.replace("(\\s\\(?)(https://\\S+[^\\s).])([\\s\\n).]|$)".toRegex()) { - val prefix = it.groups[1]?.value ?: it.value - val url = it.groups[2]?.value ?: it.value - val suffix = it.groups[3]?.value ?: it.value - "$prefix$url$suffix" - }?.replace("(?|ul>|)\n".toRegex(), "
\n") + return description + ?.replace("".toRegex(), "") + ?.replace("(\\s\\(?)(https://\\S+[^\\s).])([\\s\\n).]|$)".toRegex()) { + val prefix = it.groups[1]?.value ?: it.value + val url = it.groups[2]?.value ?: it.value + val suffix = it.groups[3]?.value ?: it.value + "$prefix$url$suffix" + } + ?.replace("(?|ul>|)\n".toRegex(), "
\n") } diff --git a/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsLink.kt b/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsLink.kt index f8417a712..ec62d4fa9 100644 --- a/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsLink.kt +++ b/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsLink.kt @@ -26,29 +26,28 @@ import org.fdroid.ui.utils.openUriSafe @Composable fun AppDetailsLink(icon: ImageVector, title: String, url: String, modifier: Modifier = Modifier) { - val uriHandler = LocalUriHandler.current - val haptics = LocalHapticFeedback.current - val clipboardManager = LocalClipboard.current - val coroutineScope = rememberCoroutineScope() - Row( - horizontalArrangement = spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = modifier - .heightIn(min = 48.dp) - .fillMaxWidth() - .combinedClickable( - onClick = { uriHandler.openUriSafe(url) }, - onLongClick = { - haptics.performHapticFeedback(HapticFeedbackType.LongPress) - val entry = ClipEntry(ClipData.newPlainText("", url)) - coroutineScope.launch { - clipboardManager.setClipEntry(entry) - } - }, - onLongClickLabel = stringResource(R.string.copy_link), - ), - ) { - Icon(icon, null) - Text(title) - } + val uriHandler = LocalUriHandler.current + val haptics = LocalHapticFeedback.current + val clipboardManager = LocalClipboard.current + val coroutineScope = rememberCoroutineScope() + Row( + horizontalArrangement = spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = + modifier + .heightIn(min = 48.dp) + .fillMaxWidth() + .combinedClickable( + onClick = { uriHandler.openUriSafe(url) }, + onLongClick = { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + val entry = ClipEntry(ClipData.newPlainText("", url)) + coroutineScope.launch { clipboardManager.setClipEntry(entry) } + }, + onLongClickLabel = stringResource(R.string.copy_link), + ), + ) { + Icon(icon, null) + Text(title) + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsMenu.kt b/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsMenu.kt index 7801c8594..4882b22a4 100644 --- a/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsMenu.kt +++ b/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsMenu.kt @@ -25,105 +25,88 @@ import org.fdroid.ui.utils.startActivitySafe import org.fdroid.ui.utils.testApp @Composable -fun AppDetailsMenu( - item: AppDetailsItem, - onDismiss: () -> Unit, -) { - val res = LocalResources.current - val context = LocalContext.current - val uninstallLauncher = rememberLauncherForActivityResult(StartActivityForResult()) { - item.actions.onUninstallResult(it) +fun AppDetailsMenu(item: AppDetailsItem, onDismiss: () -> Unit) { + val res = LocalResources.current + val context = LocalContext.current + val uninstallLauncher = + rememberLauncherForActivityResult(StartActivityForResult()) { + item.actions.onUninstallResult(it) } - if (item.appPrefs != null) DropdownMenuItem( - leadingIcon = { - Icon(Icons.Default.Preview, null) - }, - text = { Text(stringResource(R.string.menu_release_channel_beta)) }, - trailingIcon = { - Checkbox( - checked = item.allowsBetaVersions, - onCheckedChange = null, - enabled = !item.ignoresAllUpdates, - ) - }, - enabled = !item.ignoresAllUpdates, - onClick = { - item.actions.allowBetaVersions() - onDismiss() - }, + if (item.appPrefs != null) + DropdownMenuItem( + leadingIcon = { Icon(Icons.Default.Preview, null) }, + text = { Text(stringResource(R.string.menu_release_channel_beta)) }, + trailingIcon = { + Checkbox( + checked = item.allowsBetaVersions, + onCheckedChange = null, + enabled = !item.ignoresAllUpdates, + ) + }, + enabled = !item.ignoresAllUpdates, + onClick = { + item.actions.allowBetaVersions() + onDismiss() + }, ) - if (item.actions.ignoreAllUpdates != null) DropdownMenuItem( - leadingIcon = { - Icon(Icons.Default.UpdateDisabled, null) - }, - text = { Text(stringResource(R.string.menu_ignore_all)) }, - trailingIcon = { - Checkbox(item.ignoresAllUpdates, null) - }, - onClick = { - item.actions.ignoreAllUpdates() - onDismiss() - }, + if (item.actions.ignoreAllUpdates != null) + DropdownMenuItem( + leadingIcon = { Icon(Icons.Default.UpdateDisabled, null) }, + text = { Text(stringResource(R.string.menu_ignore_all)) }, + trailingIcon = { Checkbox(item.ignoresAllUpdates, null) }, + onClick = { + item.actions.ignoreAllUpdates() + onDismiss() + }, ) - if (item.actions.ignoreThisUpdate != null) DropdownMenuItem( - leadingIcon = { - Icon(Icons.Default.UpdateDisabled, null) - }, - text = { Text(stringResource(R.string.menu_ignore_this)) }, - trailingIcon = { - Checkbox( - checked = item.ignoresCurrentUpdate, - onCheckedChange = null, - enabled = !item.ignoresAllUpdates, - ) - }, - enabled = !item.ignoresAllUpdates, - onClick = { - item.actions.ignoreThisUpdate() - onDismiss() - }, + if (item.actions.ignoreThisUpdate != null) + DropdownMenuItem( + leadingIcon = { Icon(Icons.Default.UpdateDisabled, null) }, + text = { Text(stringResource(R.string.menu_ignore_this)) }, + trailingIcon = { + Checkbox( + checked = item.ignoresCurrentUpdate, + onCheckedChange = null, + enabled = !item.ignoresAllUpdates, + ) + }, + enabled = !item.ignoresAllUpdates, + onClick = { + item.actions.ignoreThisUpdate() + onDismiss() + }, ) - if (item.actions.shareApk != null) DropdownMenuItem( - leadingIcon = { - Icon(Icons.Default.Share, null) - }, - text = { Text(stringResource(R.string.menu_share_apk)) }, - onClick = { - val s = res.getString(R.string.menu_share_apk) - val i = Intent.createChooser(item.actions.shareApk, s) - context.startActivitySafe(i) - onDismiss() - }, + if (item.actions.shareApk != null) + DropdownMenuItem( + leadingIcon = { Icon(Icons.Default.Share, null) }, + text = { Text(stringResource(R.string.menu_share_apk)) }, + onClick = { + val s = res.getString(R.string.menu_share_apk) + val i = Intent.createChooser(item.actions.shareApk, s) + context.startActivitySafe(i) + onDismiss() + }, ) - if (item.actions.uninstallIntent != null) DropdownMenuItem( - leadingIcon = { - Icon(Icons.Default.Delete, null) - }, - text = { Text(stringResource(R.string.menu_uninstall)) }, - onClick = { - uninstallLauncher.launch(item.actions.uninstallIntent) - onDismiss() - }, + if (item.actions.uninstallIntent != null) + DropdownMenuItem( + leadingIcon = { Icon(Icons.Default.Delete, null) }, + text = { Text(stringResource(R.string.menu_uninstall)) }, + onClick = { + uninstallLauncher.launch(item.actions.uninstallIntent) + onDismiss() + }, ) } @Preview @Composable fun AppDetailsMenuPreview() { - FDroidContent { - Column { - AppDetailsMenu(testApp) {} - } - } + FDroidContent { Column { AppDetailsMenu(testApp) {} } } } @Preview(uiMode = UI_MODE_NIGHT_YES) @Composable fun AppDetailsMenuAllIgnoredPreview() { - val appPrefs = testApp.appPrefs!!.toggleIgnoreAllUpdates() - FDroidContent { - Column { - AppDetailsMenu(testApp.copy(appPrefs = appPrefs)) {} - } - } + val appPrefs = testApp.appPrefs!!.toggleIgnoreAllUpdates() + FDroidContent { Column { AppDetailsMenu(testApp.copy(appPrefs = appPrefs)) {} } } } diff --git a/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsTopAppBar.kt b/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsTopAppBar.kt index 8878b5491..38db29de8 100644 --- a/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsTopAppBar.kt +++ b/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsTopAppBar.kt @@ -22,36 +22,30 @@ import org.fdroid.ui.utils.startActivitySafe @OptIn(ExperimentalMaterial3Api::class) @Composable fun AppDetailsTopAppBar( - item: AppDetailsItem, - topAppBarState: TopAppBarState, - scrollBehavior: TopAppBarScrollBehavior, - onBackNav: (() -> Unit)?, + item: AppDetailsItem, + topAppBarState: TopAppBarState, + scrollBehavior: TopAppBarScrollBehavior, + onBackNav: (() -> Unit)?, ) { - TopAppBar( - colors = TopAppBarDefaults.topAppBarColors( - containerColor = Color.Transparent, - ), - title = { - if (topAppBarState.overlappedFraction == 1f) { - Text(item.name, maxLines = 1, overflow = TextOverflow.Ellipsis) - } - }, - navigationIcon = { - if (onBackNav != null) BackButton(onClick = onBackNav) - }, - actions = { - val context = LocalContext.current - item.actions.shareIntent?.let { shareIntent -> - TopAppBarButton( - imageVector = Icons.Filled.Share, - contentDescription = stringResource(R.string.menu_share), - onClick = { context.startActivitySafe(shareIntent) }, - ) - } - TopAppBarOverflowButton { onDismissRequest -> - AppDetailsMenu(item, onDismissRequest) - } - }, - scrollBehavior = scrollBehavior, - ) + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent), + title = { + if (topAppBarState.overlappedFraction == 1f) { + Text(item.name, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + }, + navigationIcon = { if (onBackNav != null) BackButton(onClick = onBackNav) }, + actions = { + val context = LocalContext.current + item.actions.shareIntent?.let { shareIntent -> + TopAppBarButton( + imageVector = Icons.Filled.Share, + contentDescription = stringResource(R.string.menu_share), + onClick = { context.startActivitySafe(shareIntent) }, + ) + } + TopAppBarOverflowButton { onDismissRequest -> AppDetailsMenu(item, onDismissRequest) } + }, + scrollBehavior = scrollBehavior, + ) } diff --git a/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsViewModel.kt b/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsViewModel.kt index 8b006fe4c..13857c265 100644 --- a/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsViewModel.kt +++ b/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsViewModel.kt @@ -44,185 +44,193 @@ import org.fdroid.updates.UpdatesManager import org.fdroid.utils.IoDispatcher @HiltViewModel(assistedFactory = AppDetailsViewModel.Factory::class) -class AppDetailsViewModel @AssistedInject constructor( - private val app: Application, - @Assisted private val packageName: String, - @param:IoDispatcher private val scope: CoroutineScope, - private val db: FDroidDatabase, - private val repoManager: RepoManager, - private val repoPreLoader: RepoPreLoader, - private val updateChecker: UpdateChecker, - private val updatesManager: UpdatesManager, - private val networkMonitor: NetworkMonitor, - private val settingsManager: SettingsManager, - private val appInstallManager: AppInstallManager, +class AppDetailsViewModel +@AssistedInject +constructor( + private val app: Application, + @Assisted private val packageName: String, + @param:IoDispatcher private val scope: CoroutineScope, + private val db: FDroidDatabase, + private val repoManager: RepoManager, + private val repoPreLoader: RepoPreLoader, + private val updateChecker: UpdateChecker, + private val updatesManager: UpdatesManager, + private val networkMonitor: NetworkMonitor, + private val settingsManager: SettingsManager, + private val appInstallManager: AppInstallManager, ) : AndroidViewModel(app) { - private val log = KotlinLogging.logger { } - private val packageInfoFlow = MutableStateFlow(null) - private val currentRepoIdFlow = MutableStateFlow(null) - private val moleculeScope = - CoroutineScope(viewModelScope.coroutineContext + AndroidUiDispatcher.Main) + private val log = KotlinLogging.logger {} + private val packageInfoFlow = MutableStateFlow(null) + private val currentRepoIdFlow = MutableStateFlow(null) + private val moleculeScope = + CoroutineScope(viewModelScope.coroutineContext + AndroidUiDispatcher.Main) - val appDetails: StateFlow by lazy(LazyThreadSafetyMode.NONE) { - moleculeScope.launchMolecule(mode = ContextClock) { - DetailsPresenter( - db = db, - scope = scope, - repoManager = repoManager, - repoPreLoader = repoPreLoader, - updateChecker = updateChecker, - settingsManager = settingsManager, - appInstallManager = appInstallManager, - viewModel = this, - packageInfoFlow = packageInfoFlow, - currentRepoIdFlow = currentRepoIdFlow, - appsWithIssuesFlow = updatesManager.appsWithIssues, - networkStateFlow = networkMonitor.networkState, - ) + val appDetails: StateFlow by + lazy(LazyThreadSafetyMode.NONE) { + moleculeScope.launchMolecule(mode = ContextClock) { + DetailsPresenter( + db = db, + scope = scope, + repoManager = repoManager, + repoPreLoader = repoPreLoader, + updateChecker = updateChecker, + settingsManager = settingsManager, + appInstallManager = appInstallManager, + viewModel = this, + packageInfoFlow = packageInfoFlow, + currentRepoIdFlow = currentRepoIdFlow, + appsWithIssuesFlow = updatesManager.appsWithIssues, + networkStateFlow = networkMonitor.networkState, + ) + } + } + + init { + loadPackageInfoFlow() + } + + private fun loadPackageInfoFlow() { + val packageManager = app.packageManager + scope.launch { + val packageInfo = + try { + @Suppress("DEPRECATION") packageManager.getPackageInfo(packageName, GET_SIGNATURES) + } catch (_: PackageManager.NameNotFoundException) { + null } - } - - init { - loadPackageInfoFlow() - } - - private fun loadPackageInfoFlow() { - val packageManager = app.packageManager - scope.launch { - val packageInfo = try { - @Suppress("DEPRECATION") - packageManager.getPackageInfo(packageName, GET_SIGNATURES) - } catch (_: PackageManager.NameNotFoundException) { - null - } - packageInfoFlow.value = if (packageInfo == null) { - AppInfo(packageName) + packageInfoFlow.value = + if (packageInfo == null) { + AppInfo(packageName) + } else { + val intent = + if (packageName == app.packageName) { + null // we shouldn't launch ourselves, so no launch intent here } else { - val intent = if (packageName == app.packageName) { - null // we shouldn't launch ourselves, so no launch intent here - } else { - packageManager.getLaunchIntentForPackage(packageName) - } - AppInfo(packageName, packageInfo, intent) + packageManager.getLaunchIntentForPackage(packageName) } + AppInfo(packageName, packageInfo, intent) } } + } - @UiThread - fun install(appMetadata: AppMetadata, version: AppVersion, iconModel: Any?) { - scope.launch(Dispatchers.Main) { - val result = appInstallManager.install( - appMetadata = appMetadata, - version = version, - currentVersionName = packageInfoFlow.value?.packageInfo?.versionName, - repo = repoManager.getRepository(version.repoId) ?: return@launch, // TODO - iconModel = iconModel, - canAskPreApprovalNow = true, - ) - if (result is InstallState.Installed) { - // to reload packageInfoFlow with fresh packageInfo - loadPackageInfoFlow() - } + @UiThread + fun install(appMetadata: AppMetadata, version: AppVersion, iconModel: Any?) { + scope.launch(Dispatchers.Main) { + val result = + appInstallManager.install( + appMetadata = appMetadata, + version = version, + currentVersionName = packageInfoFlow.value?.packageInfo?.versionName, + repo = repoManager.getRepository(version.repoId) ?: return@launch, // TODO + iconModel = iconModel, + canAskPreApprovalNow = true, + ) + if (result is InstallState.Installed) { + // to reload packageInfoFlow with fresh packageInfo + loadPackageInfoFlow() + } + } + } + + @UiThread + fun requestUserConfirmation(installState: InstallState.UserConfirmationNeeded) { + scope.launch(Dispatchers.Main) { + val result = appInstallManager.requestUserConfirmation(packageName, installState) + if (result is InstallState.Installed) + withContext(Dispatchers.Main) { + // to reload packageInfoFlow with fresh packageInfo + loadPackageInfoFlow() } } + } - @UiThread - fun requestUserConfirmation(installState: InstallState.UserConfirmationNeeded) { - scope.launch(Dispatchers.Main) { - val result = appInstallManager.requestUserConfirmation(packageName, installState) - if (result is InstallState.Installed) withContext(Dispatchers.Main) { - // to reload packageInfoFlow with fresh packageInfo - loadPackageInfoFlow() - } + @UiThread + fun checkUserConfirmation(installState: InstallState.UserConfirmationNeeded) { + scope.launch(Dispatchers.Main) { + delay(500) // wait a moment to increase chance that state got updated + appInstallManager.checkUserConfirmation(packageName, installState) + } + } + + @UiThread + fun cancelInstall() { + appInstallManager.cancel(packageName) + } + + @UiThread + fun onUninstallResult(activityResult: ActivityResult) { + val name = appDetails.value?.name + val result = appInstallManager.onUninstallResult(packageName, name, activityResult) + if (result is InstallState.Uninstalled) { + // to reload packageInfoFlow with fresh packageInfo + loadPackageInfoFlow() + } + } + + @UiThread + fun onRepoChanged(repoId: Long) { + currentRepoIdFlow.update { repoId } + } + + @UiThread + fun onPreferredRepoChanged(repoId: Long) { + scope.launch { + repoManager.setPreferredRepoId(packageName, repoId).join() + updatesManager.loadUpdates() + } + } + + override fun onCleared() { + log.info { "App details screen left: $packageName" } + appInstallManager.cleanUp(packageName) + // remove screenshots from disk cache to not fill it up quickly with large images + val diskCache = SingletonImageLoader.get(application).diskCache + if (diskCache != null) + scope.launch { + appDetails.value?.phoneScreenshots?.forEach { screenshot -> + if (screenshot is DownloadRequest) { + diskCache.remove(screenshot.getCacheKey()) + } } - } + } + } - @UiThread - fun checkUserConfirmation(installState: InstallState.UserConfirmationNeeded) { - scope.launch(Dispatchers.Main) { - delay(500) // wait a moment to increase chance that state got updated - appInstallManager.checkUserConfirmation(packageName, installState) - } + @UiThread + fun allowBetaUpdates() { + val appPrefs = appDetails.value?.appPrefs ?: return + scope.launch { + db.getAppPrefsDao().update(appPrefs.toggleReleaseChannel(RELEASE_CHANNEL_BETA)) + updatesManager.loadUpdates() } + } - @UiThread - fun cancelInstall() { - appInstallManager.cancel(packageName) + @UiThread + fun ignoreAllUpdates() { + val appPrefs = appDetails.value?.appPrefs ?: return + scope.launch { + db.getAppPrefsDao().update(appPrefs.toggleIgnoreAllUpdates()) + updatesManager.loadUpdates() } + } - @UiThread - fun onUninstallResult(activityResult: ActivityResult) { - val name = appDetails.value?.name - val result = appInstallManager.onUninstallResult(packageName, name, activityResult) - if (result is InstallState.Uninstalled) { - // to reload packageInfoFlow with fresh packageInfo - loadPackageInfoFlow() - } + @UiThread + fun ignoreThisUpdate() { + val appPrefs = appDetails.value?.appPrefs ?: return + val versionCode = appDetails.value?.possibleUpdate?.versionCode ?: return + scope.launch { + db.getAppPrefsDao().update(appPrefs.toggleIgnoreVersionCodeUpdate(versionCode)) + updatesManager.loadUpdates() } + } - @UiThread - fun onRepoChanged(repoId: Long) { - currentRepoIdFlow.update { repoId } - } - - @UiThread - fun onPreferredRepoChanged(repoId: Long) { - scope.launch { - repoManager.setPreferredRepoId(packageName, repoId).join() - updatesManager.loadUpdates() - } - } - - override fun onCleared() { - log.info { "App details screen left: $packageName" } - appInstallManager.cleanUp(packageName) - // remove screenshots from disk cache to not fill it up quickly with large images - val diskCache = SingletonImageLoader.get(application).diskCache - if (diskCache != null) scope.launch { - appDetails.value?.phoneScreenshots?.forEach { screenshot -> - if (screenshot is DownloadRequest) { - diskCache.remove(screenshot.getCacheKey()) - } - } - } - } - - @UiThread - fun allowBetaUpdates() { - val appPrefs = appDetails.value?.appPrefs ?: return - scope.launch { - db.getAppPrefsDao().update(appPrefs.toggleReleaseChannel(RELEASE_CHANNEL_BETA)) - updatesManager.loadUpdates() - } - } - - @UiThread - fun ignoreAllUpdates() { - val appPrefs = appDetails.value?.appPrefs ?: return - scope.launch { - db.getAppPrefsDao().update(appPrefs.toggleIgnoreAllUpdates()) - updatesManager.loadUpdates() - } - } - - @UiThread - fun ignoreThisUpdate() { - val appPrefs = appDetails.value?.appPrefs ?: return - val versionCode = appDetails.value?.possibleUpdate?.versionCode ?: return - scope.launch { - db.getAppPrefsDao().update(appPrefs.toggleIgnoreVersionCodeUpdate(versionCode)) - updatesManager.loadUpdates() - } - } - - @AssistedFactory - interface Factory { - fun create(packageName: String): AppDetailsViewModel - } + @AssistedFactory + interface Factory { + fun create(packageName: String): AppDetailsViewModel + } } class AppInfo( - val packageName: String, - val packageInfo: PackageInfo? = null, - val launchIntent: Intent? = null, + val packageName: String, + val packageInfo: PackageInfo? = null, + val launchIntent: Intent? = null, ) diff --git a/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsWarnings.kt b/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsWarnings.kt index 4ae495622..561ca2b85 100644 --- a/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsWarnings.kt +++ b/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsWarnings.kt @@ -30,116 +30,103 @@ import org.fdroid.ui.FDroidContent import org.fdroid.ui.utils.testApp @Composable -fun AppDetailsWarnings( - item: AppDetailsItem, - modifier: Modifier = Modifier, -) { - val (color, string) = when { - // app issues take priority - item.issue != null -> when (item.issue) { - // apps has a known security vulnerability - is KnownVulnerability -> { - val details = item.versions?.firstNotNullOfOrNull { versionItem -> - (versionItem.version as? AppVersion)?.getAntiFeatureReason( - antiFeatureKey = ANTI_FEATURE_KNOWN_VULNERABILITY, - localeList = LocaleListCompat.getDefault(), - ) - } - Pair( - MaterialTheme.colorScheme.errorContainer, - if (details.isNullOrBlank()) { - stringResource(R.string.antiknownvulnlist) - } else { - stringResource(R.string.antiknownvulnlist) + ":\n\n" + details - }, +fun AppDetailsWarnings(item: AppDetailsItem, modifier: Modifier = Modifier) { + val (color, string) = + when { + // app issues take priority + item.issue != null -> + when (item.issue) { + // apps has a known security vulnerability + is KnownVulnerability -> { + val details = + item.versions?.firstNotNullOfOrNull { versionItem -> + (versionItem.version as? AppVersion)?.getAntiFeatureReason( + antiFeatureKey = ANTI_FEATURE_KNOWN_VULNERABILITY, + localeList = LocaleListCompat.getDefault(), ) - } - is NoCompatibleSigner -> Pair( - MaterialTheme.colorScheme.errorContainer, - if (item.issue.repoIdWithCompatibleSigner == null) { - stringResource(R.string.app_no_compatible_signer) - } else { - stringResource(R.string.app_no_compatible_signer_in_this_repo) - }, + } + Pair( + MaterialTheme.colorScheme.errorContainer, + if (details.isNullOrBlank()) { + stringResource(R.string.antiknownvulnlist) + } else { + stringResource(R.string.antiknownvulnlist) + ":\n\n" + details + }, ) - is UpdateInOtherRepo -> Pair( - MaterialTheme.colorScheme.inverseSurface, - stringResource(R.string.app_issue_update_other_repo), + } + is NoCompatibleSigner -> + Pair( + MaterialTheme.colorScheme.errorContainer, + if (item.issue.repoIdWithCompatibleSigner == null) { + stringResource(R.string.app_no_compatible_signer) + } else { + stringResource(R.string.app_no_compatible_signer_in_this_repo) + }, ) - NotAvailable -> Pair( - MaterialTheme.colorScheme.errorContainer, - stringResource(R.string.error), + is UpdateInOtherRepo -> + Pair( + MaterialTheme.colorScheme.inverseSurface, + stringResource(R.string.app_issue_update_other_repo), ) + NotAvailable -> + Pair(MaterialTheme.colorScheme.errorContainer, stringResource(R.string.error)) } - // app is outright incompatible - item.isIncompatible -> Pair( - MaterialTheme.colorScheme.errorContainer, - stringResource(R.string.app_no_compatible_versions), - ) - // app targets old targetSdk, not a deal breaker, but worth flagging, no auto-update - item.oldTargetSdk -> Pair( - MaterialTheme.colorScheme.inverseSurface, - stringResource(R.string.app_no_auto_update), - ) - else -> return - } - ElevatedCard( - colors = CardDefaults.elevatedCardColors(containerColor = color), - modifier = modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - ) { - WarningRow( - text = string, + // app is outright incompatible + item.isIncompatible -> + Pair( + MaterialTheme.colorScheme.errorContainer, + stringResource(R.string.app_no_compatible_versions), ) + // app targets old targetSdk, not a deal breaker, but worth flagging, no auto-update + item.oldTargetSdk -> + Pair(MaterialTheme.colorScheme.inverseSurface, stringResource(R.string.app_no_auto_update)) + else -> return } + ElevatedCard( + colors = CardDefaults.elevatedCardColors(containerColor = color), + modifier = modifier.fillMaxWidth().padding(vertical = 8.dp), + ) { + WarningRow(text = string) + } } @Composable private fun WarningRow(text: String) { - Row( - horizontalArrangement = spacedBy(16.dp), - verticalAlignment = CenterVertically, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - ) { - Icon(Icons.Default.WarningAmber, null) - Text(text, style = MaterialTheme.typography.bodyLarge) - } + Row( + horizontalArrangement = spacedBy(16.dp), + verticalAlignment = CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Icon(Icons.Default.WarningAmber, null) + Text(text, style = MaterialTheme.typography.bodyLarge) + } } @Preview @Composable fun AppDetailsWarningsPreview() { - FDroidContent { - Column { - AppDetailsWarnings(testApp) - } - } + FDroidContent { Column { AppDetailsWarnings(testApp) } } } @Preview @Composable private fun KnownVulnPreview() { - FDroidContent { - Column { - AppDetailsWarnings(testApp.copy(issue = KnownVulnerability(true))) - AppDetailsWarnings(testApp.copy(issue = KnownVulnerability(false))) - } + FDroidContent { + Column { + AppDetailsWarnings(testApp.copy(issue = KnownVulnerability(true))) + AppDetailsWarnings(testApp.copy(issue = KnownVulnerability(false))) } + } } @Preview @Composable private fun IncompatiblePreview() { - FDroidContent { - Column { - AppDetailsWarnings( - testApp.copy( - versions = listOf( - testApp.versions!!.first().copy(isCompatible = false), - ), - ) - ) - } + FDroidContent { + Column { + AppDetailsWarnings( + testApp.copy(versions = listOf(testApp.versions!!.first().copy(isCompatible = false))) + ) } + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/details/DetailsPresenter.kt b/app/src/main/kotlin/org/fdroid/ui/details/DetailsPresenter.kt index c0ed7eb92..d8be87b88 100644 --- a/app/src/main/kotlin/org/fdroid/ui/details/DetailsPresenter.kt +++ b/app/src/main/kotlin/org/fdroid/ui/details/DetailsPresenter.kt @@ -36,232 +36,250 @@ private const val TAG = "DetailsPresenter" // see: https://github.com/cashapp/molecule?tab=readme-ov-file#testing @Composable fun DetailsPresenter( - db: FDroidDatabase, - scope: CoroutineScope, - repoManager: RepoManager, - repoPreLoader: RepoPreLoader, - updateChecker: UpdateChecker, - settingsManager: SettingsManager, - appInstallManager: AppInstallManager, - viewModel: AppDetailsViewModel, - packageInfoFlow: StateFlow, - currentRepoIdFlow: StateFlow, - appsWithIssuesFlow: StateFlow?>, - networkStateFlow: StateFlow, + db: FDroidDatabase, + scope: CoroutineScope, + repoManager: RepoManager, + repoPreLoader: RepoPreLoader, + updateChecker: UpdateChecker, + settingsManager: SettingsManager, + appInstallManager: AppInstallManager, + viewModel: AppDetailsViewModel, + packageInfoFlow: StateFlow, + currentRepoIdFlow: StateFlow, + appsWithIssuesFlow: StateFlow?>, + networkStateFlow: StateFlow, ): AppDetailsItem? { - val packagePair = packageInfoFlow.collectAsState().value ?: return null - val packageName = packagePair.packageName - val packageInfo = packagePair.packageInfo - val currentRepoId = currentRepoIdFlow.collectAsState().value - val appsWithIssues = appsWithIssuesFlow.collectAsState().value - val appDao = db.getAppDao() - val app = produceState(null, currentRepoId) { + val packagePair = packageInfoFlow.collectAsState().value ?: return null + val packageName = packagePair.packageName + val packageInfo = packagePair.packageInfo + val currentRepoId = currentRepoIdFlow.collectAsState().value + val appsWithIssues = appsWithIssuesFlow.collectAsState().value + val appDao = db.getAppDao() + val app = + produceState(null, currentRepoId) { withContext(scope.coroutineContext) { - if (currentRepoId == null) { - val flow = appDao.getApp(packageName).asFlow() - flow.collect { value = it } - } else { - value = appDao.getApp(currentRepoId, packageName) + if (currentRepoId == null) { + val flow = appDao.getApp(packageName).asFlow() + flow.collect { value = it } + } else { + value = appDao.getApp(currentRepoId, packageName) + } + } + } + .value ?: return null + val repo = + produceState(null) { + withContext(scope.coroutineContext) { value = repoManager.getRepository(app.repoId) } + } + .value ?: return null + val repositories = + produceState(emptyList(), packageName) { + withContext(scope.coroutineContext) { + val repos = + appDao.getRepositoryIdsForApp(packageName).mapNotNull { repoId -> + repoManager.getRepository(repoId) } + // show repo chooser only if + // * app is in more than one repo, or + // * app is from a non-default repo + value = + if (repos.size > 1) repos + else if (repo.address in repoPreLoader.defaultRepoAddresses) emptyList() else repos } - }.value ?: return null - val repo = produceState(null) { - withContext(scope.coroutineContext) { - value = repoManager.getRepository(app.repoId) - } - }.value ?: return null - val repositories = produceState(emptyList(), packageName) { - withContext(scope.coroutineContext) { - val repos = appDao.getRepositoryIdsForApp(packageName).mapNotNull { repoId -> - repoManager.getRepository(repoId) - } - // show repo chooser only if - // * app is in more than one repo, or - // * app is from a non-default repo - value = if (repos.size > 1) repos - else if (repo.address in repoPreLoader.defaultRepoAddresses) emptyList() - else repos - } - }.value - val installState = - appInstallManager.getAppFlow(packageName).collectAsState(InstallState.Unknown).value + } + .value + val installState = + appInstallManager.getAppFlow(packageName).collectAsState(InstallState.Unknown).value - val versions = produceState?>(null, currentRepoId) { + val versions = + produceState?>(null, currentRepoId) { withContext(scope.coroutineContext) { - if (currentRepoId == null) { - db.getVersionDao().getAppVersions(app.repoId, packageName).asFlow().collect { - value = it - } - } else { - db.getVersionDao().getAppVersions(currentRepoId, packageName).asFlow().collect { - value = it - } + if (currentRepoId == null) { + db.getVersionDao().getAppVersions(app.repoId, packageName).asFlow().collect { + value = it } + } else { + db.getVersionDao().getAppVersions(currentRepoId, packageName).asFlow().collect { + value = it + } + } } - }.value - val appPrefs = produceState(null, packageName) { + } + .value + val appPrefs = + produceState(null, packageName) { withContext(scope.coroutineContext) { - db.getAppPrefsDao().getAppPrefs(packageName).asFlow().collect { value = it } + db.getAppPrefsDao().getAppPrefs(packageName).asFlow().collect { value = it } } - }.value - val preferredRepoId = remember(packageName, appPrefs) { - appPrefs?.preferredRepoId ?: app.repoId // DB loads preferred repo first, so we remember it + } + .value + val preferredRepoId = + remember(packageName, appPrefs) { + appPrefs?.preferredRepoId ?: app.repoId // DB loads preferred repo first, so we remember it } - val installedSigner = remember(packageInfo?.packageName) { - @Suppress("DEPRECATION") // so far we had issues with the new way of getting sigs - packageInfo?.signatures?.get(0)?.let { - sha256(it.toByteArray()) - } + val installedSigner = + remember(packageInfo?.packageName) { + @Suppress("DEPRECATION") // so far we had issues with the new way of getting sigs + packageInfo?.signatures?.get(0)?.let { sha256(it.toByteArray()) } } - val suggestedVersion = remember(versions, appPrefs, installedSigner) { - if (versions == null || appPrefs == null) { - null - } else { - updateChecker.getSuggestedVersion( - versions = versions, - preferredSigner = installedSigner ?: app.metadata.preferredSigner, - releaseChannels = appPrefs.releaseChannels, - preferencesGetter = { appPrefs }, - ) - } + val suggestedVersion = + remember(versions, appPrefs, installedSigner) { + if (versions == null || appPrefs == null) { + null + } else { + updateChecker.getSuggestedVersion( + versions = versions, + preferredSigner = installedSigner ?: app.metadata.preferredSigner, + releaseChannels = appPrefs.releaseChannels, + preferencesGetter = { appPrefs }, + ) + } } - val possibleUpdate = remember(versions, appPrefs, packageInfo) { - if (versions == null || appPrefs == null || packageInfo == null) { - null - } else { - updateChecker.getUpdate( - versions = versions, - packageInfo = packageInfo, - releaseChannels = appPrefs.releaseChannels, - // ignoring existing preferences to include ignored versions - preferencesGetter = null, - ) - } + val possibleUpdate = + remember(versions, appPrefs, packageInfo) { + if (versions == null || appPrefs == null || packageInfo == null) { + null + } else { + updateChecker.getUpdate( + versions = versions, + packageInfo = packageInfo, + releaseChannels = appPrefs.releaseChannels, + // ignoring existing preferences to include ignored versions + preferencesGetter = null, + ) + } } - val installedVersionCode = packageInfo?.let { - getLongVersionCode(packageInfo) - } - val installedVersion = packageInfo?.let { - val installedVersions = versions?.filter { it.versionCode == installedVersionCode } - when (installedVersions?.size) { - null -> null - 0 -> null - 1 -> installedVersions.first() - // more than version with the same version code, find a matching signer - else -> installedVersions.find { - val versionSigners = it.signer?.sha256?.toSet() - // F-Droid allows versions without a signer entry, allow those - if (versionSigners != null && installedSigner != null) { - versionSigners.intersect(setOf(installedSigner)).isNotEmpty() - } else { - true - } + val installedVersionCode = packageInfo?.let { getLongVersionCode(packageInfo) } + val installedVersion = + packageInfo?.let { + val installedVersions = versions?.filter { it.versionCode == installedVersionCode } + when (installedVersions?.size) { + null -> null + 0 -> null + 1 -> installedVersions.first() + // more than version with the same version code, find a matching signer + else -> + installedVersions.find { + val versionSigners = it.signer?.sha256?.toSet() + // F-Droid allows versions without a signer entry, allow those + if (versionSigners != null && installedSigner != null) { + versionSigners.intersect(setOf(installedSigner)).isNotEmpty() + } else { + true } + } + } + } + val authorName = app.authorName + val authorHasMoreThanOneApp = + if (authorName == null) false + else { + produceState(false) { + withContext(scope.coroutineContext) { + db.getAppDao().hasAuthorMoreThanOneApp(authorName).asFlow().collect { value = it } + } } + .value } - val authorName = app.authorName - val authorHasMoreThanOneApp = if (authorName == null) false else { - produceState(false) { - withContext(scope.coroutineContext) { - db.getAppDao().hasAuthorMoreThanOneApp(authorName).asFlow().collect { value = it } + val issue = + remember(appsWithIssues) { appsWithIssues?.find { it.packageName == packageName }?.issue } + val locales = LocaleListCompat.getDefault() + Log.d(TAG, "Presenting app details:") + Log.d(TAG, " app '${app.name}' ($packageName) in ${repo.address}") + Log.d(TAG, " versions: ${versions?.size}") + Log.d(TAG, " appPrefs: $appPrefs") + Log.d(TAG, " installState: $installState") + return AppDetailsItem( + repository = repo, + preferredRepoId = preferredRepoId, + repositories = repositories, + dbApp = app, + actions = + AppDetailsActions( + installAction = viewModel::install, + requestUserConfirmation = viewModel::requestUserConfirmation, + checkUserConfirmation = viewModel::checkUserConfirmation, + cancelInstall = viewModel::cancelInstall, + onUninstallResult = viewModel::onUninstallResult, + onRepoChanged = viewModel::onRepoChanged, + onPreferredRepoChanged = viewModel::onPreferredRepoChanged, + allowBetaVersions = viewModel::allowBetaUpdates, + ignoreAllUpdates = + if (installedVersionCode == null) { + null + } else { + viewModel::ignoreAllUpdates + }, + ignoreThisUpdate = + if ( + installedVersionCode == null || + possibleUpdate == null || + possibleUpdate.versionCode <= installedVersionCode + ) { + null + } else { + viewModel::ignoreThisUpdate + }, + shareApk = + if (installedVersionCode == null) { + null + } else { + ApkFileProvider.getIntent(packageName) + }, + uninstallIntent = + packageInfo?.let { + Intent(Intent.ACTION_DELETE).apply { + setData(Uri.fromParts("package", it.packageName, null)) + putExtra(Intent.EXTRA_RETURN_RESULT, true) } - }.value - } - val issue = remember(appsWithIssues) { - appsWithIssues?.find { it.packageName == packageName }?.issue - } - val locales = LocaleListCompat.getDefault() - Log.d(TAG, "Presenting app details:") - Log.d(TAG, " app '${app.name}' ($packageName) in ${repo.address}") - Log.d(TAG, " versions: ${versions?.size}") - Log.d(TAG, " appPrefs: $appPrefs") - Log.d(TAG, " installState: $installState") - return AppDetailsItem( - repository = repo, - preferredRepoId = preferredRepoId, - repositories = repositories, - dbApp = app, - actions = AppDetailsActions( - installAction = viewModel::install, - requestUserConfirmation = viewModel::requestUserConfirmation, - checkUserConfirmation = viewModel::checkUserConfirmation, - cancelInstall = viewModel::cancelInstall, - onUninstallResult = viewModel::onUninstallResult, - onRepoChanged = viewModel::onRepoChanged, - onPreferredRepoChanged = viewModel::onPreferredRepoChanged, - allowBetaVersions = viewModel::allowBetaUpdates, - ignoreAllUpdates = if (installedVersionCode == null) { - null + }, + launchIntent = packagePair.launchIntent, + shareIntent = getShareIntent(repo, packageName, app.name ?: ""), + ), + installState = installState, + networkState = networkStateFlow.collectAsState().value, + versions = + versions?.map { version -> + val signerCompatible = + installedSigner == null || version.signer?.sha256?.first() == installedSigner + VersionItem( + version = version, + isInstalled = installedVersion == version, + isSuggested = suggestedVersion == version, + isCompatible = version.isCompatible, + isSignerCompatible = signerCompatible, + showInstallButton = + if (!signerCompatible || installState.showProgress) { + false } else { - viewModel::ignoreAllUpdates + (installedVersion?.versionCode ?: 0) < version.versionCode }, - ignoreThisUpdate = if (installedVersionCode == null || - possibleUpdate == null || - possibleUpdate.versionCode <= installedVersionCode - ) { - null - } else { - viewModel::ignoreThisUpdate - }, - shareApk = if (installedVersionCode == null) { - null - } else { - ApkFileProvider.getIntent(packageName) - }, - uninstallIntent = packageInfo?.let { - Intent(Intent.ACTION_DELETE).apply { - setData(Uri.fromParts("package", it.packageName, null)) - putExtra(Intent.EXTRA_RETURN_RESULT, true) - } - }, - launchIntent = packagePair.launchIntent, - shareIntent = getShareIntent(repo, packageName, app.name ?: ""), - ), - installState = installState, - networkState = networkStateFlow.collectAsState().value, - versions = versions?.map { version -> - val signerCompatible = installedSigner == null || - version.signer?.sha256?.first() == installedSigner - VersionItem( - version = version, - isInstalled = installedVersion == version, - isSuggested = suggestedVersion == version, - isCompatible = version.isCompatible, - isSignerCompatible = signerCompatible, - showInstallButton = if (!signerCompatible || installState.showProgress) { - false - } else { - (installedVersion?.versionCode ?: 0) < version.versionCode - }, - ) - }, - installedVersion = installedVersion, - installedVersionCode = installedVersionCode, - installedVersionName = packageInfo?.versionName, - installedSigner = installedSigner, - suggestedVersion = suggestedVersion, - possibleUpdate = possibleUpdate, - appPrefs = appPrefs, - issue = issue, - authorHasMoreThanOneApp = authorHasMoreThanOneApp, - localeList = locales, - proxy = settingsManager.proxyConfig, - ) + ) + }, + installedVersion = installedVersion, + installedVersionCode = installedVersionCode, + installedVersionName = packageInfo?.versionName, + installedSigner = installedSigner, + suggestedVersion = suggestedVersion, + possibleUpdate = possibleUpdate, + appPrefs = appPrefs, + issue = issue, + authorHasMoreThanOneApp = authorHasMoreThanOneApp, + localeList = locales, + proxy = settingsManager.proxyConfig, + ) } -private fun getShareIntent( - repo: Repository, - packageName: String, - appName: String, -): Intent? { - val webBaseUrl = repo.webBaseUrl ?: return null - val shareUri = webBaseUrl.toUri().buildUpon().appendPath(packageName).build() - val uriIntent = Intent(Intent.ACTION_SEND).apply { - setType("text/plain") - putExtra(Intent.EXTRA_SUBJECT, appName) - putExtra(Intent.EXTRA_TITLE, appName) - putExtra(Intent.EXTRA_TEXT, shareUri.toString()) +private fun getShareIntent(repo: Repository, packageName: String, appName: String): Intent? { + val webBaseUrl = repo.webBaseUrl ?: return null + val shareUri = webBaseUrl.toUri().buildUpon().appendPath(packageName).build() + val uriIntent = + Intent(Intent.ACTION_SEND).apply { + setType("text/plain") + putExtra(Intent.EXTRA_SUBJECT, appName) + putExtra(Intent.EXTRA_TITLE, appName) + putExtra(Intent.EXTRA_TEXT, shareUri.toString()) } - return Intent.createChooser(uriIntent, appName) + return Intent.createChooser(uriIntent, appName) } diff --git a/app/src/main/kotlin/org/fdroid/ui/details/NoAppSelected.kt b/app/src/main/kotlin/org/fdroid/ui/details/NoAppSelected.kt index 38aeb7498..b1d9df95a 100644 --- a/app/src/main/kotlin/org/fdroid/ui/details/NoAppSelected.kt +++ b/app/src/main/kotlin/org/fdroid/ui/details/NoAppSelected.kt @@ -17,25 +17,18 @@ import org.fdroid.ui.FDroidContent @Composable fun NoAppSelected() { - Box( - contentAlignment = Center, - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - ) { - AboutContent( - modifier = Modifier - .fillMaxHeight() - .fillMaxWidth(fraction = 0.7f) - .padding(top = 32.dp) - ) - } + Box( + contentAlignment = Center, + modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background), + ) { + AboutContent( + modifier = Modifier.fillMaxHeight().fillMaxWidth(fraction = 0.7f).padding(top = 32.dp) + ) + } } @Preview @Composable private fun Preview() { - FDroidContent { - NoAppSelected() - } + FDroidContent { NoAppSelected() } } diff --git a/app/src/main/kotlin/org/fdroid/ui/details/RepoChooser.kt b/app/src/main/kotlin/org/fdroid/ui/details/RepoChooser.kt index 812310408..58e435ed4 100644 --- a/app/src/main/kotlin/org/fdroid/ui/details/RepoChooser.kt +++ b/app/src/main/kotlin/org/fdroid/ui/details/RepoChooser.kt @@ -41,163 +41,150 @@ import org.fdroid.ui.utils.FDroidOutlineButton @Composable fun RepoChooser( - repos: List, - currentRepoId: Long, - preferredRepoId: Long, - proxy: ProxyConfig?, - onRepoChanged: (Long) -> Unit, - onPreferredRepoChanged: (Long) -> Unit, - modifier: Modifier = Modifier, + repos: List, + currentRepoId: Long, + preferredRepoId: Long, + proxy: ProxyConfig?, + onRepoChanged: (Long) -> Unit, + onPreferredRepoChanged: (Long) -> Unit, + modifier: Modifier = Modifier, ) { - if (repos.isEmpty()) return - var expanded by remember { mutableStateOf(false) } - val currentRepo = remember(currentRepoId) { - repos.find { it.repoId == currentRepoId } ?: error("Current repoId not in list") + if (repos.isEmpty()) return + var expanded by remember { mutableStateOf(false) } + val currentRepo = + remember(currentRepoId) { + repos.find { it.repoId == currentRepoId } ?: error("Current repoId not in list") } - val isPreferred = currentRepo.repoId == preferredRepoId - Column( - modifier = modifier.fillMaxWidth(), - ) { - Box { - val borderColor = if (isPreferred) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.outline - } - OutlinedTextField( - value = TextFieldValue( - annotatedString = getRepoString( - repo = currentRepo, - isPreferred = repos.size > 1 && isPreferred, - ), - ), - textStyle = MaterialTheme.typography.bodyMedium, - onValueChange = {}, - label = { - if (repos.size == 1) { - Text(stringResource(R.string.app_details_repository)) - } else { - Text(stringResource(R.string.app_details_repositories)) - } - }, - leadingIcon = { - RepoIcon(repo = currentRepo, proxy = proxy, modifier = Modifier.size(24.dp)) - }, - trailingIcon = { - if (repos.size > 1) Icon( - imageVector = Icons.Default.ArrowDropDown, - contentDescription = stringResource(R.string.app_details_repository_expand), - tint = if (isPreferred) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurface - }, - ) - }, - singleLine = false, - enabled = false, - colors = OutlinedTextFieldDefaults.colors( - // hack to enable clickable and look like enabled - disabledTextColor = MaterialTheme.colorScheme.onSurface, - disabledBorderColor = borderColor, - disabledLeadingIconColor = MaterialTheme.colorScheme.onSurface, - disabledLabelColor = borderColor, - ), - modifier = Modifier - .fillMaxWidth() - .let { - if (repos.size > 1) it.clickable(onClick = { expanded = true }) else it - }, - ) - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - ) { - repos.iterator().forEach { repo -> - RepoMenuItem( - repo = repo, - isPreferred = repo.repoId == preferredRepoId, - proxy = proxy, - onClick = { - onRepoChanged(repo.repoId) - expanded = false - }, - modifier = modifier, - ) - } - } + val isPreferred = currentRepo.repoId == preferredRepoId + Column(modifier = modifier.fillMaxWidth()) { + Box { + val borderColor = + if (isPreferred) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outline } - if (!isPreferred) { - FDroidOutlineButton( - text = stringResource(R.string.app_details_repository_button_prefer), - onClick = { onPreferredRepoChanged(currentRepo.repoId) }, - modifier = Modifier - .align(End) - .padding(top = 8.dp), + OutlinedTextField( + value = + TextFieldValue( + annotatedString = + getRepoString(repo = currentRepo, isPreferred = repos.size > 1 && isPreferred) + ), + textStyle = MaterialTheme.typography.bodyMedium, + onValueChange = {}, + label = { + if (repos.size == 1) { + Text(stringResource(R.string.app_details_repository)) + } else { + Text(stringResource(R.string.app_details_repositories)) + } + }, + leadingIcon = { + RepoIcon(repo = currentRepo, proxy = proxy, modifier = Modifier.size(24.dp)) + }, + trailingIcon = { + if (repos.size > 1) + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = stringResource(R.string.app_details_repository_expand), + tint = + if (isPreferred) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + }, ) + }, + singleLine = false, + enabled = false, + colors = + OutlinedTextFieldDefaults.colors( + // hack to enable clickable and look like enabled + disabledTextColor = MaterialTheme.colorScheme.onSurface, + disabledBorderColor = borderColor, + disabledLeadingIconColor = MaterialTheme.colorScheme.onSurface, + disabledLabelColor = borderColor, + ), + modifier = + Modifier.fillMaxWidth().let { + if (repos.size > 1) it.clickable(onClick = { expanded = true }) else it + }, + ) + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + repos.iterator().forEach { repo -> + RepoMenuItem( + repo = repo, + isPreferred = repo.repoId == preferredRepoId, + proxy = proxy, + onClick = { + onRepoChanged(repo.repoId) + expanded = false + }, + modifier = modifier, + ) } + } } + if (!isPreferred) { + FDroidOutlineButton( + text = stringResource(R.string.app_details_repository_button_prefer), + onClick = { onPreferredRepoChanged(currentRepo.repoId) }, + modifier = Modifier.align(End).padding(top = 8.dp), + ) + } + } } @Composable private fun RepoMenuItem( - repo: Repository, - isPreferred: Boolean, - proxy: ProxyConfig?, - modifier: Modifier = Modifier, - onClick: () -> Unit + repo: Repository, + isPreferred: Boolean, + proxy: ProxyConfig?, + modifier: Modifier = Modifier, + onClick: () -> Unit, ) { - DropdownMenuItem( - text = { - Text( - text = getRepoString(repo, isPreferred), - style = MaterialTheme.typography.bodyMedium, - ) - }, - modifier = modifier, - onClick = onClick, - leadingIcon = { RepoIcon(repo, proxy, Modifier.size(24.dp)) } - ) + DropdownMenuItem( + text = { + Text(text = getRepoString(repo, isPreferred), style = MaterialTheme.typography.bodyMedium) + }, + modifier = modifier, + onClick = onClick, + leadingIcon = { RepoIcon(repo, proxy, Modifier.size(24.dp)) }, + ) } @Composable private fun getRepoString(repo: Repository, isPreferred: Boolean) = buildAnnotatedString { - append(repo.getName(LocaleListCompat.getDefault()) ?: "Unknown Repository") - if (isPreferred) { - append(" ") - pushStyle(SpanStyle(fontWeight = Bold)) - append(" ") - append(stringResource(R.string.app_details_repository_preferred)) - } + append(repo.getName(LocaleListCompat.getDefault()) ?: "Unknown Repository") + if (isPreferred) { + append(" ") + pushStyle(SpanStyle(fontWeight = Bold)) + append(" ") + append(stringResource(R.string.app_details_repository_preferred)) + } } @Composable @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) fun RepoChooserSingleRepoPreview() { - val repo1 = Repository(1L, "1", 1L, TWO, "null", 1L, 1, 1L) - FDroidContent { - RepoChooser(listOf(repo1), 1L, 1L, null, {}, {}) - } + val repo1 = Repository(1L, "1", 1L, TWO, "null", 1L, 1, 1L) + FDroidContent { RepoChooser(listOf(repo1), 1L, 1L, null, {}, {}) } } @Composable @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) fun RepoChooserPreview() { - val repo1 = Repository(1L, "1", 1L, TWO, "null", 1L, 1, 1L) - val repo2 = Repository(2L, "2", 2L, TWO, "null", 2L, 2, 2L) - val repo3 = Repository(3L, "2", 3L, TWO, "null", 3L, 3, 3L) - FDroidContent { - RepoChooser(listOf(repo1, repo2, repo3), 1L, 1L, null, {}, {}) - } + val repo1 = Repository(1L, "1", 1L, TWO, "null", 1L, 1, 1L) + val repo2 = Repository(2L, "2", 2L, TWO, "null", 2L, 2, 2L) + val repo3 = Repository(3L, "2", 3L, TWO, "null", 3L, 3, 3L) + FDroidContent { RepoChooser(listOf(repo1, repo2, repo3), 1L, 1L, null, {}, {}) } } @Composable @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) fun RepoChooserNightPreview() { - val repo1 = Repository(1L, "1", 1L, TWO, "null", 1L, 1, 1L) - val repo2 = Repository(2L, "2", 2L, TWO, "null", 2L, 2, 2L) - val repo3 = Repository(3L, "2", 3L, TWO, "null", 3L, 3, 3L) - FDroidContent { - RepoChooser(listOf(repo1, repo2, repo3), 1L, 2L, null, {}, {}) - } + val repo1 = Repository(1L, "1", 1L, TWO, "null", 1L, 1, 1L) + val repo2 = Repository(2L, "2", 2L, TWO, "null", 2L, 2, 2L) + val repo3 = Repository(3L, "2", 3L, TWO, "null", 3L, 3, 3L) + FDroidContent { RepoChooser(listOf(repo1, repo2, repo3), 1L, 2L, null, {}, {}) } } diff --git a/app/src/main/kotlin/org/fdroid/ui/details/Screenshots.kt b/app/src/main/kotlin/org/fdroid/ui/details/Screenshots.kt index 174367c1a..59f18da7a 100644 --- a/app/src/main/kotlin/org/fdroid/ui/details/Screenshots.kt +++ b/app/src/main/kotlin/org/fdroid/ui/details/Screenshots.kt @@ -48,106 +48,92 @@ import org.fdroid.ui.utils.testApp @Composable fun Screenshots(isMetered: Boolean, phoneScreenshots: List) { - var showEvenWhenMetered by remember { mutableStateOf(false) } - if (isMetered && !showEvenWhenMetered) Box( - contentAlignment = Center, - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp) - .padding(horizontal = 16.dp) - .clickable { showEvenWhenMetered = true } + var showEvenWhenMetered by remember { mutableStateOf(false) } + if (isMetered && !showEvenWhenMetered) + Box( + contentAlignment = Center, + modifier = + Modifier.fillMaxWidth().padding(top = 8.dp).padding(horizontal = 16.dp).clickable { + showEvenWhenMetered = true + }, ) { - Image( - painterResource(R.drawable.screenshots_placeholder), - contentDescription = null, - contentScale = ContentScale.FillWidth, - modifier = Modifier - .fillMaxWidth() - .clip(MaterialTheme.shapes.large) - .semantics { hideFromAccessibility() } - ) - ElevatedButton( - onClick = { showEvenWhenMetered = true }, - modifier = Modifier.padding(horizontal = 16.dp) - ) { - Text( - text = stringResource(R.string.screenshots_metered), - textAlign = TextAlign.Center, - ) - } - } else { - Screenshots(phoneScreenshots) + Image( + painterResource(R.drawable.screenshots_placeholder), + contentDescription = null, + contentScale = ContentScale.FillWidth, + modifier = + Modifier.fillMaxWidth().clip(MaterialTheme.shapes.large).semantics { + hideFromAccessibility() + }, + ) + ElevatedButton( + onClick = { showEvenWhenMetered = true }, + modifier = Modifier.padding(horizontal = 16.dp), + ) { + Text(text = stringResource(R.string.screenshots_metered), textAlign = TextAlign.Center) + } } + else { + Screenshots(phoneScreenshots) + } } @Composable @OptIn(ExperimentalMaterial3Api::class) private fun Screenshots(phoneScreenshots: List) { - val carouselState = rememberCarouselState { phoneScreenshots.size } - var showScreenshot by remember { mutableStateOf(null) } - val screenshotIndex = showScreenshot - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - if (screenshotIndex != null) ModalBottomSheet( - onDismissRequest = { showScreenshot = null }, - sheetState = sheetState, - properties = ModalBottomSheetProperties(), + val carouselState = rememberCarouselState { phoneScreenshots.size } + var showScreenshot by remember { mutableStateOf(null) } + val screenshotIndex = showScreenshot + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + if (screenshotIndex != null) + ModalBottomSheet( + onDismissRequest = { showScreenshot = null }, + sheetState = sheetState, + properties = ModalBottomSheetProperties(), ) { - val pagerState = rememberPagerState( - initialPage = screenshotIndex, - pageCount = { phoneScreenshots.size }, - ) - Surface { - // The overscrollEffect was bouncing screenshots with each swipe. - // Maybe this was a bug and overscroll effect can be enabled again once fixed. - HorizontalPager(state = pagerState, overscrollEffect = null) { page -> - AsyncShimmerImage( - model = phoneScreenshots[page], - contentDescription = "", - contentScale = ContentScale.Fit, - placeholder = rememberVectorPainter(Icons.Default.Image), - error = rememberVectorPainter(Icons.Default.Error), - modifier = Modifier.fillMaxSize() - ) - } - } - } - HorizontalUncontainedCarousel( - state = carouselState, - itemWidth = 120.dp, - itemSpacing = 2.dp, - contentPadding = PaddingValues(horizontal = 16.dp), - modifier = Modifier - .fillMaxWidth() - .height(240.dp) - .padding(vertical = 8.dp) - ) { index -> - AsyncShimmerImage( - model = phoneScreenshots[index], + val pagerState = + rememberPagerState(initialPage = screenshotIndex, pageCount = { phoneScreenshots.size }) + Surface { + // The overscrollEffect was bouncing screenshots with each swipe. + // Maybe this was a bug and overscroll effect can be enabled again once fixed. + HorizontalPager(state = pagerState, overscrollEffect = null) { page -> + AsyncShimmerImage( + model = phoneScreenshots[page], contentDescription = "", contentScale = ContentScale.Fit, placeholder = rememberVectorPainter(Icons.Default.Image), error = rememberVectorPainter(Icons.Default.Error), - modifier = Modifier - .size(120.dp, 240.dp) - .clickable { - showScreenshot = index - } - ) + modifier = Modifier.fillMaxSize(), + ) + } + } } + HorizontalUncontainedCarousel( + state = carouselState, + itemWidth = 120.dp, + itemSpacing = 2.dp, + contentPadding = PaddingValues(horizontal = 16.dp), + modifier = Modifier.fillMaxWidth().height(240.dp).padding(vertical = 8.dp), + ) { index -> + AsyncShimmerImage( + model = phoneScreenshots[index], + contentDescription = "", + contentScale = ContentScale.Fit, + placeholder = rememberVectorPainter(Icons.Default.Image), + error = rememberVectorPainter(Icons.Default.Error), + modifier = Modifier.size(120.dp, 240.dp).clickable { showScreenshot = index }, + ) + } } @Preview @Composable private fun Preview() { - FDroidContent { - Screenshots(false, testApp.phoneScreenshots) - } + FDroidContent { Screenshots(false, testApp.phoneScreenshots) } } @Preview(widthDp = 300) @Composable private fun PreviewMetered() { - FDroidContent { - Screenshots(true, testApp.phoneScreenshots) - } + FDroidContent { Screenshots(true, testApp.phoneScreenshots) } } diff --git a/app/src/main/kotlin/org/fdroid/ui/details/TechnicalInfo.kt b/app/src/main/kotlin/org/fdroid/ui/details/TechnicalInfo.kt index 71fdadec0..a6a470be9 100644 --- a/app/src/main/kotlin/org/fdroid/ui/details/TechnicalInfo.kt +++ b/app/src/main/kotlin/org/fdroid/ui/details/TechnicalInfo.kt @@ -18,51 +18,36 @@ import org.fdroid.ui.utils.testApp @Composable fun TechnicalInfo(item: AppDetailsItem) { - val items = mutableMapOf( - stringResource(R.string.package_name) to item.app.packageName - ) - if (item.installedVersionCode != null) { - items[stringResource(R.string.installed_version)] = - "${item.installedVersionName} (${item.installedVersionCode})" + val items = mutableMapOf(stringResource(R.string.package_name) to item.app.packageName) + if (item.installedVersionCode != null) { + items[stringResource(R.string.installed_version)] = + "${item.installedVersionName} (${item.installedVersionCode})" + } + Column( + verticalArrangement = spacedBy(4.dp), + modifier = Modifier.padding(start = 16.dp, bottom = 16.dp), + ) { + items.forEach { (name, content) -> + Row(horizontalArrangement = spacedBy(2.dp)) { + Text(text = name, style = MaterialTheme.typography.bodyMedium) + SelectionContainer { Text(text = content, style = MaterialTheme.typography.bodyMedium) } + } } - Column( - verticalArrangement = spacedBy(4.dp), - modifier = Modifier - .padding(start = 16.dp, bottom = 16.dp), - ) { - items.forEach { (name, content) -> - Row(horizontalArrangement = spacedBy(2.dp)) { - Text( - text = name, - style = MaterialTheme.typography.bodyMedium - ) - SelectionContainer { - Text( - text = content, - style = MaterialTheme.typography.bodyMedium, - ) - } - } + val signer = item.installedSigner + if (signer != null) + Row { + SelectionContainer { + Text( + text = stringResource(R.string.signer_colon, signer.substring(0..15)), + style = MaterialTheme.typography.bodyMedium, + ) } - val signer = item.installedSigner - if (signer != null) Row { - SelectionContainer { - Text( - text = stringResource( - R.string.signer_colon, - signer.substring(0..15) - ), - style = MaterialTheme.typography.bodyMedium, - ) - } - } - } + } + } } @Preview @Composable private fun Preview() { - FDroidContent { - TechnicalInfo(testApp) - } + FDroidContent { TechnicalInfo(testApp) } } diff --git a/app/src/main/kotlin/org/fdroid/ui/details/Versions.kt b/app/src/main/kotlin/org/fdroid/ui/details/Versions.kt index cd6f0d63d..91b6c7665 100644 --- a/app/src/main/kotlin/org/fdroid/ui/details/Versions.kt +++ b/app/src/main/kotlin/org/fdroid/ui/details/Versions.kt @@ -46,214 +46,189 @@ import org.fdroid.ui.utils.testApp @Composable @OptIn(ExperimentalMaterial3ExpressiveApi::class) -fun Versions( - item: AppDetailsItem, - scrollUp: suspend () -> Unit, -) { - ExpandableSection( - icon = rememberVectorPainter(Icons.Default.AccessTime), - title = stringResource(R.string.versions), - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - ) { - Column(modifier = Modifier) { - item.versions?.forEach { versionItem -> - Version( - item = versionItem, - isMetered = item.networkState.isMetered, - installAction = { version: AppVersion -> - item.actions.installAction(item.app, version, item.icon) - }, - scrollUp = scrollUp, - ) - } - } +fun Versions(item: AppDetailsItem, scrollUp: suspend () -> Unit) { + ExpandableSection( + icon = rememberVectorPainter(Icons.Default.AccessTime), + title = stringResource(R.string.versions), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Column(modifier = Modifier) { + item.versions?.forEach { versionItem -> + Version( + item = versionItem, + isMetered = item.networkState.isMetered, + installAction = { version: AppVersion -> + item.actions.installAction(item.app, version, item.icon) + }, + scrollUp = scrollUp, + ) + } } + } } @Composable fun Version( - item: VersionItem, - isMetered: Boolean, - installAction: (AppVersion) -> Unit, - scrollUp: suspend () -> Unit, + item: VersionItem, + isMetered: Boolean, + installAction: (AppVersion) -> Unit, + scrollUp: suspend () -> Unit, ) { - val isPreview = LocalInspectionMode.current - var expanded by rememberSaveable { mutableStateOf(isPreview) } - Column(modifier = Modifier.padding(bottom = 16.dp)) { - Row( - horizontalArrangement = spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .padding(bottom = 8.dp) - .clickable { - expanded = !expanded - } - ) { - ExpandIconChevron(expanded) - Row { - Column(modifier = Modifier.weight(1f)) { - Text( - text = item.version.versionName, - style = MaterialTheme.typography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = stringResource( - R.string.added_x_ago, - item.version.added.asRelativeTimeString(), - ), - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - if (item.isInstalled) Badge( - containerColor = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 8.dp) - ) { - Text( - text = stringResource(R.string.app_installed), - modifier = Modifier.padding(2.dp) - ) - } - if (item.isSuggested) Badge( - containerColor = MaterialTheme.colorScheme.secondary, - modifier = Modifier.padding(start = 8.dp) - ) { - Text( - text = stringResource(R.string.app_suggested), - modifier = Modifier.padding(2.dp) - ) - } - } - } - AnimatedVisibility( - visible = expanded, - modifier = Modifier - .semantics { liveRegion = LiveRegionMode.Polite } - ) { - val coroutineScope = rememberCoroutineScope() - var showMeteredDialog by remember { mutableStateOf(false) } - Row( - horizontalArrangement = spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - if (!item.isCompatible || !item.isSignerCompatible) Text( - text = if (!item.isCompatible) { - stringResource(R.string.app_details_incompatible_version) - } else { - stringResource(R.string.app_details_incompatible_signer) - }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - item.version.size?.let { size -> - Text( - text = stringResource( - R.string.size_colon, - Formatter.formatFileSize(LocalContext.current, size) - ), - style = MaterialTheme.typography.bodySmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - SelectionContainer { - Text( - text = stringResource( - R.string.version_code_colon, - item.version.versionCode.toString() - ), - style = MaterialTheme.typography.bodySmall, - maxLines = 1, - overflow = TextOverflow.MiddleEllipsis, - ) - } - val sdkString = buildString { - item.version.packageManifest.minSdkVersion?.let { sdk -> - append(stringResource(R.string.sdk_min_version, sdk)) - } - item.version.packageManifest.targetSdkVersion?.let { sdk -> - if (isNotEmpty()) append(" ") - append(stringResource(R.string.sdk_target_version, sdk)) - } - item.version.packageManifest.maxSdkVersion?.let { sdk -> - if (isNotEmpty()) append(" ") - append(stringResource(R.string.sdk_max_version, sdk)) - } - } - if (sdkString.isNotEmpty()) SelectionContainer { - Text( - text = stringResource(R.string.sdk_versions_colon, sdkString), - style = MaterialTheme.typography.bodySmall, - maxLines = 1, - overflow = TextOverflow.StartEllipsis, - ) - } - item.version.packageManifest.nativecode?.let { nativeCode -> - if (nativeCode.isNotEmpty()) SelectionContainer { - Text( - text = stringResource( - R.string.architectures_colon, - nativeCode.joinToString(", ") - ), - style = MaterialTheme.typography.bodySmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - item.version.signer?.let { signer -> - SelectionContainer { - Text( - text = stringResource( - R.string.signer_colon, - signer.sha256[0].substring(0..15) - ), - style = MaterialTheme.typography.bodySmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - } - if (item.showInstallButton) { - val coroutineScope = rememberCoroutineScope() - FDroidOutlineButton( - text = stringResource(R.string.menu_install), - onClick = { - if (isMetered) { - showMeteredDialog = true - } else { - installAction(item.version as AppVersion) - coroutineScope.launch { - scrollUp() - } - } - }, - ) - } - } - if (showMeteredDialog) MeteredConnectionDialog( - numBytes = item.version.size, - onConfirm = { - installAction(item.version as AppVersion) - coroutineScope.launch { - scrollUp() - } - }, - onDismiss = { showMeteredDialog = false }, - ) + val isPreview = LocalInspectionMode.current + var expanded by rememberSaveable { mutableStateOf(isPreview) } + Column(modifier = Modifier.padding(bottom = 16.dp)) { + Row( + horizontalArrangement = spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(bottom = 8.dp).clickable { expanded = !expanded }, + ) { + ExpandIconChevron(expanded) + Row { + Column(modifier = Modifier.weight(1f)) { + Text( + text = item.version.versionName, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = stringResource(R.string.added_x_ago, item.version.added.asRelativeTimeString()), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } + if (item.isInstalled) + Badge( + containerColor = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 8.dp), + ) { + Text(text = stringResource(R.string.app_installed), modifier = Modifier.padding(2.dp)) + } + if (item.isSuggested) + Badge( + containerColor = MaterialTheme.colorScheme.secondary, + modifier = Modifier.padding(start = 8.dp), + ) { + Text(text = stringResource(R.string.app_suggested), modifier = Modifier.padding(2.dp)) + } + } } + AnimatedVisibility( + visible = expanded, + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, + ) { + val coroutineScope = rememberCoroutineScope() + var showMeteredDialog by remember { mutableStateOf(false) } + Row(horizontalArrangement = spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + if (!item.isCompatible || !item.isSignerCompatible) + Text( + text = + if (!item.isCompatible) { + stringResource(R.string.app_details_incompatible_version) + } else { + stringResource(R.string.app_details_incompatible_signer) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + item.version.size?.let { size -> + Text( + text = + stringResource( + R.string.size_colon, + Formatter.formatFileSize(LocalContext.current, size), + ), + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + SelectionContainer { + Text( + text = + stringResource(R.string.version_code_colon, item.version.versionCode.toString()), + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + ) + } + val sdkString = buildString { + item.version.packageManifest.minSdkVersion?.let { sdk -> + append(stringResource(R.string.sdk_min_version, sdk)) + } + item.version.packageManifest.targetSdkVersion?.let { sdk -> + if (isNotEmpty()) append(" ") + append(stringResource(R.string.sdk_target_version, sdk)) + } + item.version.packageManifest.maxSdkVersion?.let { sdk -> + if (isNotEmpty()) append(" ") + append(stringResource(R.string.sdk_max_version, sdk)) + } + } + if (sdkString.isNotEmpty()) + SelectionContainer { + Text( + text = stringResource(R.string.sdk_versions_colon, sdkString), + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.StartEllipsis, + ) + } + item.version.packageManifest.nativecode?.let { nativeCode -> + if (nativeCode.isNotEmpty()) + SelectionContainer { + Text( + text = + stringResource(R.string.architectures_colon, nativeCode.joinToString(", ")), + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + item.version.signer?.let { signer -> + SelectionContainer { + Text( + text = stringResource(R.string.signer_colon, signer.sha256[0].substring(0..15)), + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + if (item.showInstallButton) { + val coroutineScope = rememberCoroutineScope() + FDroidOutlineButton( + text = stringResource(R.string.menu_install), + onClick = { + if (isMetered) { + showMeteredDialog = true + } else { + installAction(item.version as AppVersion) + coroutineScope.launch { scrollUp() } + } + }, + ) + } + } + if (showMeteredDialog) + MeteredConnectionDialog( + numBytes = item.version.size, + onConfirm = { + installAction(item.version as AppVersion) + coroutineScope.launch { scrollUp() } + }, + onDismiss = { showMeteredDialog = false }, + ) + } + } } @Preview @Composable fun VersionsPreview() { - FDroidContent { - Versions(testApp) {} - } + FDroidContent { Versions(testApp) {} } } diff --git a/app/src/main/kotlin/org/fdroid/ui/discover/AppCarousel.kt b/app/src/main/kotlin/org/fdroid/ui/discover/AppCarousel.kt index 4b289c907..89cf54f67 100644 --- a/app/src/main/kotlin/org/fdroid/ui/discover/AppCarousel.kt +++ b/app/src/main/kotlin/org/fdroid/ui/discover/AppCarousel.kt @@ -35,83 +35,67 @@ import org.fdroid.ui.utils.Names @OptIn(ExperimentalMaterial3Api::class) @Composable fun AppCarousel( - title: String, - apps: List, - modifier: Modifier = Modifier, - onTitleTap: () -> Unit, - onAppTap: (AppDiscoverItem) -> Unit, + title: String, + apps: List, + modifier: Modifier = Modifier, + onTitleTap: () -> Unit, + onAppTap: (AppDiscoverItem) -> Unit, ) { - Column( - verticalArrangement = spacedBy(8.dp), - modifier = modifier + Column(verticalArrangement = spacedBy(8.dp), modifier = modifier) { + Row( + verticalAlignment = CenterVertically, + modifier = + Modifier.fillMaxWidth() + .clickable(onClick = onTitleTap) + .padding(horizontal = 16.dp, vertical = 8.dp), ) { - Row( - verticalAlignment = CenterVertically, - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onTitleTap) - .padding(horizontal = 16.dp, vertical = 8.dp), - ) { - Text( - text = title, - style = MaterialTheme.typography.headlineSmall, - ) - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - modifier = Modifier.semantics { hideFromAccessibility() }, - contentDescription = null, - ) - } - LazyRow( - contentPadding = PaddingValues(horizontal = 16.dp), - horizontalArrangement = spacedBy(16.dp), - ) { - items(apps, key = { it.packageName }) { app -> - AppBox(app, onAppTap) - } - } + Text(text = title, style = MaterialTheme.typography.headlineSmall) + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + modifier = Modifier.semantics { hideFromAccessibility() }, + contentDescription = null, + ) } + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = spacedBy(16.dp), + ) { + items(apps, key = { it.packageName }) { app -> AppBox(app, onAppTap) } + } + } } @Composable fun AppBox(app: AppDiscoverItem, onAppTap: (AppDiscoverItem) -> Unit) { - Column( - verticalArrangement = spacedBy(8.dp), - modifier = Modifier - .width(80.dp) - .clickable { onAppTap(app) }, - ) { - BadgedBox(badge = { if (app.isInstalled) InstalledBadge() }) { - AsyncShimmerImage( - model = app.imageModel, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .size(76.dp) - .clip(MaterialTheme.shapes.large) - .semantics { hideFromAccessibility() }, - ) - } - Text( - text = app.name, - style = MaterialTheme.typography.bodySmall, - minLines = 2, - maxLines = 2, - ) + Column( + verticalArrangement = spacedBy(8.dp), + modifier = Modifier.width(80.dp).clickable { onAppTap(app) }, + ) { + BadgedBox(badge = { if (app.isInstalled) InstalledBadge() }) { + AsyncShimmerImage( + model = app.imageModel, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = + Modifier.size(76.dp).clip(MaterialTheme.shapes.large).semantics { + hideFromAccessibility() + }, + ) } + Text(text = app.name, style = MaterialTheme.typography.bodySmall, minLines = 2, maxLines = 2) + } } @Preview @Composable fun AppCarouselPreview() { - val apps = listOf( - AppDiscoverItem("1", Names.randomName, false), - AppDiscoverItem("2", Names.randomName, true), - AppDiscoverItem("3", Names.randomName, false), - AppDiscoverItem("4", Names.randomName, false), - AppDiscoverItem("5", Names.randomName, false), + val apps = + listOf( + AppDiscoverItem("1", Names.randomName, false), + AppDiscoverItem("2", Names.randomName, true), + AppDiscoverItem("3", Names.randomName, false), + AppDiscoverItem("4", Names.randomName, false), + AppDiscoverItem("5", Names.randomName, false), ) - FDroidContent { - AppCarousel("Preview Apps", apps, onTitleTap = {}) {} - } + FDroidContent { AppCarousel("Preview Apps", apps, onTitleTap = {}) {} } } diff --git a/app/src/main/kotlin/org/fdroid/ui/discover/AppDiscoverItem.kt b/app/src/main/kotlin/org/fdroid/ui/discover/AppDiscoverItem.kt index a0d8995c7..20dcf8cf1 100644 --- a/app/src/main/kotlin/org/fdroid/ui/discover/AppDiscoverItem.kt +++ b/app/src/main/kotlin/org/fdroid/ui/discover/AppDiscoverItem.kt @@ -1,9 +1,9 @@ package org.fdroid.ui.discover class AppDiscoverItem( - val packageName: String, - val name: String, - val isInstalled: Boolean, - val imageModel: Any? = null, - val lastUpdated: Long = -1, + val packageName: String, + val name: String, + val isInstalled: Boolean, + val imageModel: Any? = null, + val lastUpdated: Long = -1, ) diff --git a/app/src/main/kotlin/org/fdroid/ui/discover/Discover.kt b/app/src/main/kotlin/org/fdroid/ui/discover/Discover.kt index dea508145..c6544d0f5 100644 --- a/app/src/main/kotlin/org/fdroid/ui/discover/Discover.kt +++ b/app/src/main/kotlin/org/fdroid/ui/discover/Discover.kt @@ -38,125 +38,108 @@ import org.fdroid.ui.utils.TopAppBarOverflowButton @Composable @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) fun Discover( - discoverModel: DiscoverModel, - onListTap: (AppListType) -> Unit, - onAppTap: (AppDiscoverItem) -> Unit, - onNav: (NavKey) -> Unit, - modifier: Modifier = Modifier, + discoverModel: DiscoverModel, + onListTap: (AppListType) -> Unit, + onAppTap: (AppDiscoverItem) -> Unit, + onNav: (NavKey) -> Unit, + modifier: Modifier = Modifier, ) { - val scrollBehavior = enterAlwaysScrollBehavior(rememberTopAppBarState()) - Scaffold( - topBar = { - TopAppBar( - title = { - Text(stringResource(R.string.app_name)) - }, - actions = { - topBarMenuItems.forEach { dest -> - BadgedBox(badge = { - val hasRepoIssues = - (discoverModel as? LoadedDiscoverModel)?.hasRepoIssues == true - if (dest.id == NavigationKey.Repos && hasRepoIssues) Badge( - content = null, - modifier = Modifier.size(8.dp) - ) - }) { - TopAppBarButton( - imageVector = dest.icon, - contentDescription = stringResource(dest.label), - onClick = { onNav(dest.id) }, - ) - } - } - TopAppBarOverflowButton { onDismissRequest -> - DiscoverOverFlowMenu { - onDismissRequest() - onNav(it.id) - } - } - }, - scrollBehavior = scrollBehavior, - ) + val scrollBehavior = enterAlwaysScrollBehavior(rememberTopAppBarState()) + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.app_name)) }, + actions = { + topBarMenuItems.forEach { dest -> + BadgedBox( + badge = { + val hasRepoIssues = (discoverModel as? LoadedDiscoverModel)?.hasRepoIssues == true + if (dest.id == NavigationKey.Repos && hasRepoIssues) + Badge(content = null, modifier = Modifier.size(8.dp)) + } + ) { + TopAppBarButton( + imageVector = dest.icon, + contentDescription = stringResource(dest.label), + onClick = { onNav(dest.id) }, + ) + } + } + TopAppBarOverflowButton { onDismissRequest -> + DiscoverOverFlowMenu { + onDismissRequest() + onNav(it.id) + } + } }, - modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - ) { paddingValues -> - when (discoverModel) { - is FirstStartDiscoverModel -> FirstStart( - networkState = discoverModel.networkState, - repoUpdateState = discoverModel.repoUpdateState, - modifier = Modifier - .fillMaxSize() - .padding(paddingValues), - ) - is LoadingDiscoverModel -> BigLoadingIndicator(Modifier.padding(paddingValues)) - is LoadedDiscoverModel -> { - DiscoverContent( - discoverModel = discoverModel, - onListTap = onListTap, - onAppTap = onAppTap, - onNav = onNav, - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(paddingValues), - ) - } - NoEnabledReposDiscoverModel -> { - Box( - contentAlignment = Alignment.Center, - modifier = modifier - .fillMaxSize() - .padding(paddingValues) - ) { - Text( - text = stringResource(R.string.no_repos_enabled), - textAlign = TextAlign.Center, - modifier = Modifier.padding(16.dp) - ) - } - } + scrollBehavior = scrollBehavior, + ) + }, + modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + ) { paddingValues -> + when (discoverModel) { + is FirstStartDiscoverModel -> + FirstStart( + networkState = discoverModel.networkState, + repoUpdateState = discoverModel.repoUpdateState, + modifier = Modifier.fillMaxSize().padding(paddingValues), + ) + is LoadingDiscoverModel -> BigLoadingIndicator(Modifier.padding(paddingValues)) + is LoadedDiscoverModel -> { + DiscoverContent( + discoverModel = discoverModel, + onListTap = onListTap, + onAppTap = onAppTap, + onNav = onNav, + modifier = + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(paddingValues), + ) + } + NoEnabledReposDiscoverModel -> { + Box( + contentAlignment = Alignment.Center, + modifier = modifier.fillMaxSize().padding(paddingValues), + ) { + Text( + text = stringResource(R.string.no_repos_enabled), + textAlign = TextAlign.Center, + modifier = Modifier.padding(16.dp), + ) } + } } + } } @Preview @Composable fun FirstStartDiscoverPreview() { - FDroidContent { - Discover( - discoverModel = FirstStartDiscoverModel( - NetworkState(true, isMetered = false), - RepoUpdateProgress(1, true, 0.25f), - ), - onListTap = {}, - onAppTap = {}, - onNav = {}, - ) - } + FDroidContent { + Discover( + discoverModel = + FirstStartDiscoverModel( + NetworkState(true, isMetered = false), + RepoUpdateProgress(1, true, 0.25f), + ), + onListTap = {}, + onAppTap = {}, + onNav = {}, + ) + } } @Preview @Composable fun LoadingDiscoverPreview() { - FDroidContent { - Discover( - discoverModel = LoadingDiscoverModel, - onListTap = {}, - onAppTap = {}, - onNav = {}, - ) - } + FDroidContent { + Discover(discoverModel = LoadingDiscoverModel, onListTap = {}, onAppTap = {}, onNav = {}) + } } @Preview @Composable private fun NoEnabledReposPreview() { - FDroidContent { - Discover( - discoverModel = NoEnabledReposDiscoverModel, - onListTap = {}, - onAppTap = {}, - onNav = {}, - ) - } + FDroidContent { + Discover(discoverModel = NoEnabledReposDiscoverModel, onListTap = {}, onAppTap = {}, onNav = {}) + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/discover/DiscoverContent.kt b/app/src/main/kotlin/org/fdroid/ui/discover/DiscoverContent.kt index 281b4db07..47864e821 100644 --- a/app/src/main/kotlin/org/fdroid/ui/discover/DiscoverContent.kt +++ b/app/src/main/kotlin/org/fdroid/ui/discover/DiscoverContent.kt @@ -20,59 +20,58 @@ import org.fdroid.ui.search.AppsSearch @OptIn(ExperimentalMaterial3Api::class) @Composable fun DiscoverContent( - discoverModel: LoadedDiscoverModel, - onListTap: (AppListType) -> Unit, - onAppTap: (AppDiscoverItem) -> Unit, - onNav: (NavKey) -> Unit, - modifier: Modifier = Modifier, + discoverModel: LoadedDiscoverModel, + onListTap: (AppListType) -> Unit, + onAppTap: (AppDiscoverItem) -> Unit, + onNav: (NavKey) -> Unit, + modifier: Modifier = Modifier, ) { - Column(modifier = modifier) { - AppsSearch( - onNav = onNav, - textFieldState = discoverModel.searchTextFieldState, - modifier = Modifier - // focusable is a workaround for https://issuetracker.google.com/issues/445720462 - .focusable() - .padding(top = 16.dp, bottom = 4.dp) - .padding(horizontal = 16.dp) - .align(Alignment.CenterHorizontally), - ) - AnimatedVisibility(discoverModel.newApps.isNotEmpty()) { - val listNew = AppListType.New(stringResource(R.string.app_list_new)) - AppCarousel( - title = listNew.title, - apps = discoverModel.newApps, - onTitleTap = { onListTap(listNew) }, - onAppTap = onAppTap, - ) - } - AnimatedVisibility(!discoverModel.recentlyUpdatedApps.isEmpty()) { - val listRecentlyUpdated = AppListType.RecentlyUpdated( - stringResource(R.string.app_list_recently_updated), - ) - AppCarousel( - title = listRecentlyUpdated.title, - apps = discoverModel.recentlyUpdatedApps, - onTitleTap = { onListTap(listRecentlyUpdated) }, - onAppTap = onAppTap, - ) - } - AnimatedVisibility(!discoverModel.mostDownloadedApps.isNullOrEmpty()) { - val listMostDownloaded = AppListType.MostDownloaded( - stringResource(R.string.app_list_most_downloaded), - ) - if (discoverModel.mostDownloadedApps != null) AppCarousel( - title = listMostDownloaded.title, - apps = discoverModel.mostDownloadedApps, - onTitleTap = { onListTap(listMostDownloaded) }, - onAppTap = onAppTap, - ) - } - CategoryList( - categoryMap = discoverModel.categories, - onNav = onNav, - modifier = Modifier - .fillMaxWidth() + Column(modifier = modifier) { + AppsSearch( + onNav = onNav, + textFieldState = discoverModel.searchTextFieldState, + modifier = + Modifier + // focusable is a workaround for https://issuetracker.google.com/issues/445720462 + .focusable() + .padding(top = 16.dp, bottom = 4.dp) + .padding(horizontal = 16.dp) + .align(Alignment.CenterHorizontally), + ) + AnimatedVisibility(discoverModel.newApps.isNotEmpty()) { + val listNew = AppListType.New(stringResource(R.string.app_list_new)) + AppCarousel( + title = listNew.title, + apps = discoverModel.newApps, + onTitleTap = { onListTap(listNew) }, + onAppTap = onAppTap, + ) + } + AnimatedVisibility(!discoverModel.recentlyUpdatedApps.isEmpty()) { + val listRecentlyUpdated = + AppListType.RecentlyUpdated(stringResource(R.string.app_list_recently_updated)) + AppCarousel( + title = listRecentlyUpdated.title, + apps = discoverModel.recentlyUpdatedApps, + onTitleTap = { onListTap(listRecentlyUpdated) }, + onAppTap = onAppTap, + ) + } + AnimatedVisibility(!discoverModel.mostDownloadedApps.isNullOrEmpty()) { + val listMostDownloaded = + AppListType.MostDownloaded(stringResource(R.string.app_list_most_downloaded)) + if (discoverModel.mostDownloadedApps != null) + AppCarousel( + title = listMostDownloaded.title, + apps = discoverModel.mostDownloadedApps, + onTitleTap = { onListTap(listMostDownloaded) }, + onAppTap = onAppTap, ) } + CategoryList( + categoryMap = discoverModel.categories, + onNav = onNav, + modifier = Modifier.fillMaxWidth(), + ) + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/discover/DiscoverEntry.kt b/app/src/main/kotlin/org/fdroid/ui/discover/DiscoverEntry.kt index 8b27de5f7..e37cba401 100644 --- a/app/src/main/kotlin/org/fdroid/ui/discover/DiscoverEntry.kt +++ b/app/src/main/kotlin/org/fdroid/ui/discover/DiscoverEntry.kt @@ -11,29 +11,23 @@ import org.fdroid.ui.navigation.NavigationKey import org.fdroid.ui.navigation.Navigator @OptIn(ExperimentalMaterial3AdaptiveApi::class) -fun EntryProviderScope.discoverEntry( - navigator: Navigator, -) { - entry( - metadata = ListDetailSceneStrategy.listPane("appdetails") { - NoAppSelected() - }, - ) { - val viewModel = hiltViewModel() - Discover( - discoverModel = viewModel.discoverModel.collectAsStateWithLifecycle().value, - onListTap = { - navigator.navigate(NavigationKey.AppList(it)) - }, - onAppTap = { - val new = NavigationKey.AppDetails(it.packageName) - if (navigator.last is NavigationKey.AppDetails) { - navigator.replaceLast(new) - } else { - navigator.navigate(new) - } - }, - onNav = { navKey -> navigator.navigate(navKey) }, - ) - } +fun EntryProviderScope.discoverEntry(navigator: Navigator) { + entry( + metadata = ListDetailSceneStrategy.listPane("appdetails") { NoAppSelected() } + ) { + val viewModel = hiltViewModel() + Discover( + discoverModel = viewModel.discoverModel.collectAsStateWithLifecycle().value, + onListTap = { navigator.navigate(NavigationKey.AppList(it)) }, + onAppTap = { + val new = NavigationKey.AppDetails(it.packageName) + if (navigator.last is NavigationKey.AppDetails) { + navigator.replaceLast(new) + } else { + navigator.navigate(new) + } + }, + onNav = { navKey -> navigator.navigate(navKey) }, + ) + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/discover/DiscoverOverflowMenu.kt b/app/src/main/kotlin/org/fdroid/ui/discover/DiscoverOverflowMenu.kt index dcba78c7c..e069fd6ab 100644 --- a/app/src/main/kotlin/org/fdroid/ui/discover/DiscoverOverflowMenu.kt +++ b/app/src/main/kotlin/org/fdroid/ui/discover/DiscoverOverflowMenu.kt @@ -13,20 +13,18 @@ import org.fdroid.ui.navigation.NavDestinations import org.fdroid.ui.navigation.getMoreMenuItems @Composable -fun DiscoverOverFlowMenu( - onItemClicked: (NavDestinations) -> Unit, -) { - getMoreMenuItems(LocalContext.current).forEach { dest -> - DropdownMenuItem( - text = { Text(stringResource(dest.label)) }, - onClick = { onItemClicked(dest) }, - leadingIcon = { - Icon( - imageVector = dest.icon, - contentDescription = null, - modifier = Modifier.semantics { hideFromAccessibility() }, - ) - } +fun DiscoverOverFlowMenu(onItemClicked: (NavDestinations) -> Unit) { + getMoreMenuItems(LocalContext.current).forEach { dest -> + DropdownMenuItem( + text = { Text(stringResource(dest.label)) }, + onClick = { onItemClicked(dest) }, + leadingIcon = { + Icon( + imageVector = dest.icon, + contentDescription = null, + modifier = Modifier.semantics { hideFromAccessibility() }, ) - } + }, + ) + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/discover/DiscoverPresenter.kt b/app/src/main/kotlin/org/fdroid/ui/discover/DiscoverPresenter.kt index 4e7f17233..86ba33853 100644 --- a/app/src/main/kotlin/org/fdroid/ui/discover/DiscoverPresenter.kt +++ b/app/src/main/kotlin/org/fdroid/ui/discover/DiscoverPresenter.kt @@ -14,69 +14,73 @@ import org.fdroid.ui.categories.CategoryItem @Composable fun DiscoverPresenter( - newAppsFlow: Flow>, - recentlyUpdatedAppsFlow: Flow>, - mostDownloadedAppsFlow: MutableStateFlow?>, - categoriesFlow: Flow>, - repositoriesFlow: Flow>, - searchTextFieldState: TextFieldState, - isFirstStart: Boolean, - networkState: NetworkState, - repoUpdateStateFlow: StateFlow, - hasRepoIssuesFlow: Flow, + newAppsFlow: Flow>, + recentlyUpdatedAppsFlow: Flow>, + mostDownloadedAppsFlow: MutableStateFlow?>, + categoriesFlow: Flow>, + repositoriesFlow: Flow>, + searchTextFieldState: TextFieldState, + isFirstStart: Boolean, + networkState: NetworkState, + repoUpdateStateFlow: StateFlow, + hasRepoIssuesFlow: Flow, ): DiscoverModel { - val newApps = newAppsFlow.collectAsState(null).value - val recentlyUpdatedApps = recentlyUpdatedAppsFlow.collectAsState(null).value - val mostDownloadedApps = mostDownloadedAppsFlow.collectAsState().value - val categories = categoriesFlow.collectAsState(null).value + val newApps = newAppsFlow.collectAsState(null).value + val recentlyUpdatedApps = recentlyUpdatedAppsFlow.collectAsState(null).value + val mostDownloadedApps = mostDownloadedAppsFlow.collectAsState().value + val categories = categoriesFlow.collectAsState(null).value - return if (!mostDownloadedApps.isNullOrEmpty() || - !newApps.isNullOrEmpty() || - !categories.isNullOrEmpty() || - !recentlyUpdatedApps.isNullOrEmpty() - ) { - // As soon as we loaded a list, - // we start showing it on screen and update when other lists load. - // This is to speed up the time to first content on initial screen. - LoadedDiscoverModel( - newApps = newApps ?: emptyList(), - recentlyUpdatedApps = recentlyUpdatedApps ?: emptyList(), - mostDownloadedApps = mostDownloadedApps, - categories = categories?.groupBy { it.group }, - searchTextFieldState = searchTextFieldState, - hasRepoIssues = hasRepoIssuesFlow.collectAsState(false).value, - ) + return if ( + !mostDownloadedApps.isNullOrEmpty() || + !newApps.isNullOrEmpty() || + !categories.isNullOrEmpty() || + !recentlyUpdatedApps.isNullOrEmpty() + ) { + // As soon as we loaded a list, + // we start showing it on screen and update when other lists load. + // This is to speed up the time to first content on initial screen. + LoadedDiscoverModel( + newApps = newApps ?: emptyList(), + recentlyUpdatedApps = recentlyUpdatedApps ?: emptyList(), + mostDownloadedApps = mostDownloadedApps, + categories = categories?.groupBy { it.group }, + searchTextFieldState = searchTextFieldState, + hasRepoIssues = hasRepoIssuesFlow.collectAsState(false).value, + ) + } else { + // everything is still null or empty, so figure out why + val repositories = repositoriesFlow.collectAsState(null).value + if (repositories?.all { !it.enabled } == true) { + NoEnabledReposDiscoverModel + } else if (isFirstStart || recentlyUpdatedApps?.size == 0) { + // There should always be recently updated apps, + // because those don't have a freshness constraint. + // In case the DB got cleared (e.g. though panic action or failed migration), + // the isFirstStart condition would be false, + // but we still want to go down first start path to update repos again. + FirstStartDiscoverModel(networkState, repoUpdateStateFlow.collectAsState().value) } else { - // everything is still null or empty, so figure out why - val repositories = repositoriesFlow.collectAsState(null).value - if (repositories?.all { !it.enabled } == true) { - NoEnabledReposDiscoverModel - } else if (isFirstStart || recentlyUpdatedApps?.size == 0) { - // There should always be recently updated apps, - // because those don't have a freshness constraint. - // In case the DB got cleared (e.g. though panic action or failed migration), - // the isFirstStart condition would be false, - // but we still want to go down first start path to update repos again. - FirstStartDiscoverModel(networkState, repoUpdateStateFlow.collectAsState().value) - } else { - LoadingDiscoverModel - } + LoadingDiscoverModel } + } } sealed class DiscoverModel + data class FirstStartDiscoverModel( - val networkState: NetworkState, - val repoUpdateState: RepoUpdateState?, + val networkState: NetworkState, + val repoUpdateState: RepoUpdateState?, ) : DiscoverModel() data object LoadingDiscoverModel : DiscoverModel() + data object NoEnabledReposDiscoverModel : DiscoverModel() + data class LoadedDiscoverModel( - val newApps: List, - val recentlyUpdatedApps: List, - val mostDownloadedApps: List?, - val categories: Map>?, - val searchTextFieldState: TextFieldState, - val hasRepoIssues: Boolean, + val newApps: List, + val recentlyUpdatedApps: List, + val mostDownloadedApps: List?, + val categories: Map>?, + val searchTextFieldState: TextFieldState, + val hasRepoIssues: Boolean, ) : DiscoverModel() diff --git a/app/src/main/kotlin/org/fdroid/ui/discover/DiscoverViewModel.kt b/app/src/main/kotlin/org/fdroid/ui/discover/DiscoverViewModel.kt index 79f0f731f..a6a9e3728 100644 --- a/app/src/main/kotlin/org/fdroid/ui/discover/DiscoverViewModel.kt +++ b/app/src/main/kotlin/org/fdroid/ui/discover/DiscoverViewModel.kt @@ -12,6 +12,9 @@ import app.cash.molecule.RecompositionMode.ContextClock import app.cash.molecule.launchMolecule import dagger.hilt.android.lifecycle.HiltViewModel import io.ktor.client.engine.ProxyConfig +import java.text.Collator +import java.util.Locale +import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -35,119 +38,122 @@ import org.fdroid.settings.SettingsManager import org.fdroid.ui.categories.CategoryItem import org.fdroid.ui.search.SearchManager import org.fdroid.utils.IoDispatcher -import java.text.Collator -import java.util.Locale -import javax.inject.Inject @HiltViewModel -class DiscoverViewModel @Inject constructor( - private val app: Application, - savedStateHandle: SavedStateHandle, - private val db: FDroidDatabase, - networkMonitor: NetworkMonitor, - private val settingsManager: SettingsManager, - private val searchManager: SearchManager, - private val repoManager: RepoManager, - private val repoUpdateManager: RepoUpdateManager, - private val installedAppsCache: InstalledAppsCache, - @param:IoDispatcher private val ioScope: CoroutineScope, +class DiscoverViewModel +@Inject +constructor( + private val app: Application, + savedStateHandle: SavedStateHandle, + private val db: FDroidDatabase, + networkMonitor: NetworkMonitor, + private val settingsManager: SettingsManager, + private val searchManager: SearchManager, + private val repoManager: RepoManager, + private val repoUpdateManager: RepoUpdateManager, + private val installedAppsCache: InstalledAppsCache, + @param:IoDispatcher private val ioScope: CoroutineScope, ) : AndroidViewModel(app) { - private val log = KotlinLogging.logger { } - private val moleculeScope = - CoroutineScope(viewModelScope.coroutineContext + AndroidUiDispatcher.Main) - private val collator = Collator.getInstance(Locale.getDefault()) + private val log = KotlinLogging.logger {} + private val moleculeScope = + CoroutineScope(viewModelScope.coroutineContext + AndroidUiDispatcher.Main) + private val collator = Collator.getInstance(Locale.getDefault()) - private val newApps = db.getAppDao().getNewAppsFlow().map { list -> - val proxyConfig = settingsManager.proxyConfig - list.mapNotNull { - val repository = repoManager.getRepository(it.repoId) ?: return@mapNotNull null - it.toAppDiscoverItem(repository, proxyConfig) + private val newApps = + db.getAppDao().getNewAppsFlow().map { list -> + val proxyConfig = settingsManager.proxyConfig + list.mapNotNull { + val repository = repoManager.getRepository(it.repoId) ?: return@mapNotNull null + it.toAppDiscoverItem(repository, proxyConfig) + } + } + private val recentlyUpdatedApps = + db.getAppDao().getRecentlyUpdatedAppsFlow().map { list -> + val proxyConfig = settingsManager.proxyConfig + list.mapNotNull { + val repository = repoManager.getRepository(it.repoId) ?: return@mapNotNull null + it.toAppDiscoverItem(repository, proxyConfig) + } + } + private val mostDownloadedApps = MutableStateFlow?>(null) + private val categories = + db.getRepositoryDao().getLiveCategories().asFlow().map { categories -> + categories + .map { category -> + CategoryItem(id = category.id, name = category.getName(localeList) ?: "Unknown Category") } + .sortedWith { c1, c2 -> collator.compare(c1.name, c2.name) } } - private val recentlyUpdatedApps = db.getAppDao().getRecentlyUpdatedAppsFlow().map { list -> - val proxyConfig = settingsManager.proxyConfig - list.mapNotNull { - val repository = repoManager.getRepository(it.repoId) ?: return@mapNotNull null - it.toAppDiscoverItem(repository, proxyConfig) - } - } - private val mostDownloadedApps = MutableStateFlow?>(null) - private val categories = db.getRepositoryDao().getLiveCategories().asFlow().map { categories -> - categories.map { category -> - CategoryItem( - id = category.id, - name = category.getName(localeList) ?: "Unknown Category", - ) - }.sortedWith { c1, c2 -> collator.compare(c1.name, c2.name) } - } - private val hasRepoIssues = repoManager.repositoriesState.map { repos -> - repos.any { it.enabled && it.errorCount >= 5 } - } + private val hasRepoIssues = + repoManager.repositoriesState.map { repos -> repos.any { it.enabled && it.errorCount >= 5 } } - val localeList = LocaleListCompat.getDefault() - val discoverModel: StateFlow by lazy(LazyThreadSafetyMode.NONE) { - @SuppressLint("StateFlowValueCalledInComposition") // see comment below - moleculeScope.launchMolecule(mode = ContextClock) { - DiscoverPresenter( - newAppsFlow = newApps, - recentlyUpdatedAppsFlow = recentlyUpdatedApps, - mostDownloadedAppsFlow = mostDownloadedApps, - categoriesFlow = categories, - repositoriesFlow = repoManager.repositoriesState, - searchTextFieldState = searchManager.textFieldState, - isFirstStart = settingsManager.isFirstStart, - // not observing the flow, but just taking the current value, - // because we kick off repo updates from the UI depending on this state - networkState = networkMonitor.networkState.value, - repoUpdateStateFlow = repoUpdateManager.repoUpdateState, - hasRepoIssuesFlow = hasRepoIssues, - ) - } - } - - init { - loadMostDownloadedApps() - } - - private fun loadMostDownloadedApps() { - viewModelScope.launch(ioScope.coroutineContext) { - val packageNames = try { - app.assets.open("most_downloaded_apps.json").use { inputStream -> - @OptIn(ExperimentalSerializationApi::class) - Json.decodeFromStream>(inputStream) - } - } catch (e: Exception) { - log.error(e) { "Error loading most downloaded apps: " } - return@launch - } - db.getAppDao().getAppsFlow(packageNames).collect { apps -> - val proxyConfig = settingsManager.proxyConfig - mostDownloadedApps.value = apps.mapNotNull { - val repository = repoManager.getRepository(it.repoId) ?: return@mapNotNull null - it.toAppDiscoverItem(repository, proxyConfig) - } - } - } - } - - private fun AppOverviewItem.toAppDiscoverItem( - repository: Repository, - proxyConfig: ProxyConfig?, - ): AppDiscoverItem { - val isInstalled = installedAppsCache.isInstalled(packageName) - val imageModel = - getIcon(localeList)?.getImageModel(repository, proxyConfig) as? DownloadRequest - return AppDiscoverItem( - packageName = packageName, - name = getName(localeList) ?: "Unknown App", - lastUpdated = lastUpdated, - isInstalled = isInstalled, - imageModel = if (isInstalled) { - PackageName(packageName, imageModel) - } else { - imageModel - }, + val localeList = LocaleListCompat.getDefault() + val discoverModel: StateFlow by + lazy(LazyThreadSafetyMode.NONE) { + @SuppressLint("StateFlowValueCalledInComposition") // see comment below + moleculeScope.launchMolecule(mode = ContextClock) { + DiscoverPresenter( + newAppsFlow = newApps, + recentlyUpdatedAppsFlow = recentlyUpdatedApps, + mostDownloadedAppsFlow = mostDownloadedApps, + categoriesFlow = categories, + repositoriesFlow = repoManager.repositoriesState, + searchTextFieldState = searchManager.textFieldState, + isFirstStart = settingsManager.isFirstStart, + // not observing the flow, but just taking the current value, + // because we kick off repo updates from the UI depending on this state + networkState = networkMonitor.networkState.value, + repoUpdateStateFlow = repoUpdateManager.repoUpdateState, + hasRepoIssuesFlow = hasRepoIssues, ) + } } + + init { + loadMostDownloadedApps() + } + + private fun loadMostDownloadedApps() { + viewModelScope.launch(ioScope.coroutineContext) { + val packageNames = + try { + app.assets.open("most_downloaded_apps.json").use { inputStream -> + @OptIn(ExperimentalSerializationApi::class) + Json.decodeFromStream>(inputStream) + } + } catch (e: Exception) { + log.error(e) { "Error loading most downloaded apps: " } + return@launch + } + db.getAppDao().getAppsFlow(packageNames).collect { apps -> + val proxyConfig = settingsManager.proxyConfig + mostDownloadedApps.value = + apps.mapNotNull { + val repository = repoManager.getRepository(it.repoId) ?: return@mapNotNull null + it.toAppDiscoverItem(repository, proxyConfig) + } + } + } + } + + private fun AppOverviewItem.toAppDiscoverItem( + repository: Repository, + proxyConfig: ProxyConfig?, + ): AppDiscoverItem { + val isInstalled = installedAppsCache.isInstalled(packageName) + val imageModel = getIcon(localeList)?.getImageModel(repository, proxyConfig) as? DownloadRequest + return AppDiscoverItem( + packageName = packageName, + name = getName(localeList) ?: "Unknown App", + lastUpdated = lastUpdated, + isInstalled = isInstalled, + imageModel = + if (isInstalled) { + PackageName(packageName, imageModel) + } else { + imageModel + }, + ) + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/discover/FirstStart.kt b/app/src/main/kotlin/org/fdroid/ui/discover/FirstStart.kt index 81254564d..3ccbfac31 100644 --- a/app/src/main/kotlin/org/fdroid/ui/discover/FirstStart.kt +++ b/app/src/main/kotlin/org/fdroid/ui/discover/FirstStart.kt @@ -39,122 +39,97 @@ import org.fdroid.ui.FDroidContent @Composable @OptIn(ExperimentalMaterial3ExpressiveApi::class) fun FirstStart( - networkState: NetworkState, - repoUpdateState: RepoUpdateState?, - modifier: Modifier = Modifier, + networkState: NetworkState, + repoUpdateState: RepoUpdateState?, + modifier: Modifier = Modifier, ) { - val context = LocalContext.current - var override by rememberSaveable { mutableStateOf(false) } - // reset override on error, so user can press button again for re-try - LaunchedEffect(repoUpdateState) { - if (repoUpdateState is RepoUpdateFinished && - repoUpdateState.result is IndexUpdateResult.Error - ) override = false - // TODO it would be nice to surface normal update errors better and also let the user retry - } - Column(verticalArrangement = Center, modifier = modifier) { - if ((!networkState.isOnline || networkState.isMetered) && !override) { - // offline or metered, not overridden - val res = if (networkState.isMetered) { - stringResource(R.string.first_start_metered) - } else { - stringResource(R.string.first_start_offline) - } - Text( - text = stringResource(R.string.first_start_intro), - textAlign = TextAlign.Center, - modifier = Modifier - .padding(16.dp) - .fillMaxWidth() - ) - Text( - text = res, - textAlign = TextAlign.Center, - modifier = Modifier - .padding(16.dp) - .fillMaxWidth() - ) - Button( - onClick = { override = true }, - modifier = Modifier.align(CenterHorizontally), - ) { - Text(stringResource(R.string.first_start_button)) - } + val context = LocalContext.current + var override by rememberSaveable { mutableStateOf(false) } + // reset override on error, so user can press button again for re-try + LaunchedEffect(repoUpdateState) { + if (repoUpdateState is RepoUpdateFinished && repoUpdateState.result is IndexUpdateResult.Error) + override = false + // TODO it would be nice to surface normal update errors better and also let the user retry + } + Column(verticalArrangement = Center, modifier = modifier) { + if ((!networkState.isOnline || networkState.isMetered) && !override) { + // offline or metered, not overridden + val res = + if (networkState.isMetered) { + stringResource(R.string.first_start_metered) } else { - // happy path or user set override - LaunchedEffect(Unit) { - RepoUpdateWorker.updateNow(context) - } - Text( - text = stringResource(R.string.first_start_loading), - textAlign = TextAlign.Center, - modifier = Modifier - .padding(16.dp) - .fillMaxWidth() - ) - // use thicker stroke for larger circle - val stroke = Stroke( - width = with(LocalDensity.current) { - 6.dp.toPx() - }, - cap = StrokeCap.Round, - ) - val progressModifier = Modifier - .padding(16.dp) - .size(128.dp) - .align(CenterHorizontally) - // show indeterminate circle if we don't have any progress (may take a bit to start) - val progress = (repoUpdateState as? RepoUpdateProgress)?.progress ?: 0f - if (progress == 0f) CircularWavyProgressIndicator( - wavelength = 24.dp, - stroke = stroke, - trackStroke = stroke, - modifier = progressModifier, - ) else { - // animate real progress (download and DB insertion) - val animatedProgress by animateFloatAsState(targetValue = progress) - CircularWavyProgressIndicator( - progress = { animatedProgress }, - wavelength = 24.dp, - stroke = stroke, - trackStroke = stroke, - modifier = progressModifier, - ) - } + stringResource(R.string.first_start_offline) } + Text( + text = stringResource(R.string.first_start_intro), + textAlign = TextAlign.Center, + modifier = Modifier.padding(16.dp).fillMaxWidth(), + ) + Text( + text = res, + textAlign = TextAlign.Center, + modifier = Modifier.padding(16.dp).fillMaxWidth(), + ) + Button(onClick = { override = true }, modifier = Modifier.align(CenterHorizontally)) { + Text(stringResource(R.string.first_start_button)) + } + } else { + // happy path or user set override + LaunchedEffect(Unit) { RepoUpdateWorker.updateNow(context) } + Text( + text = stringResource(R.string.first_start_loading), + textAlign = TextAlign.Center, + modifier = Modifier.padding(16.dp).fillMaxWidth(), + ) + // use thicker stroke for larger circle + val stroke = Stroke(width = with(LocalDensity.current) { 6.dp.toPx() }, cap = StrokeCap.Round) + val progressModifier = Modifier.padding(16.dp).size(128.dp).align(CenterHorizontally) + // show indeterminate circle if we don't have any progress (may take a bit to start) + val progress = (repoUpdateState as? RepoUpdateProgress)?.progress ?: 0f + if (progress == 0f) + CircularWavyProgressIndicator( + wavelength = 24.dp, + stroke = stroke, + trackStroke = stroke, + modifier = progressModifier, + ) + else { + // animate real progress (download and DB insertion) + val animatedProgress by animateFloatAsState(targetValue = progress) + CircularWavyProgressIndicator( + progress = { animatedProgress }, + wavelength = 24.dp, + stroke = stroke, + trackStroke = stroke, + modifier = progressModifier, + ) + } } + } } @Preview @Composable private fun OfflinePreview() { - FDroidContent { - Column { - FirstStart(NetworkState(isOnline = false, isMetered = false), null) - } - } + FDroidContent { Column { FirstStart(NetworkState(isOnline = false, isMetered = false), null) } } } @Preview @Composable private fun MeteredPreview() { - FDroidContent { - Column { - FirstStart(NetworkState(isOnline = true, isMetered = true), null) - } - } + FDroidContent { Column { FirstStart(NetworkState(isOnline = true, isMetered = true), null) } } } @Preview @Composable private fun UpdatePreview() { - FDroidContent { - Column { - FirstStart( - networkState = NetworkState(isOnline = true, isMetered = false), - repoUpdateState = RepoUpdateProgress(1L, false, 0.5f), - modifier = Modifier.fillMaxSize(), - ) - } + FDroidContent { + Column { + FirstStart( + networkState = NetworkState(isOnline = true, isMetered = false), + repoUpdateState = RepoUpdateProgress(1L, false, 0.5f), + modifier = Modifier.fillMaxSize(), + ) } + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/history/History.kt b/app/src/main/kotlin/org/fdroid/ui/history/History.kt index 0d77a5cda..e9b0ffc91 100644 --- a/app/src/main/kotlin/org/fdroid/ui/history/History.kt +++ b/app/src/main/kotlin/org/fdroid/ui/history/History.kt @@ -30,86 +30,70 @@ import org.fdroid.ui.utils.TopAppBarButton @Composable @OptIn(ExperimentalMaterial3Api::class) fun History( - items: List?, - enabled: Boolean?, - onEnabled: (Boolean) -> Unit, - onDeleteAll: () -> Unit, - onBackClicked: (() -> Unit)?, + items: List?, + enabled: Boolean?, + onEnabled: (Boolean) -> Unit, + onDeleteAll: () -> Unit, + onBackClicked: (() -> Unit)?, ) { - var deleteAllDialogShown by remember { mutableStateOf(false) } - val scrollBehavior = enterAlwaysScrollBehavior(rememberTopAppBarState()) - Scaffold( - topBar = { - TopAppBar( - navigationIcon = { - if (onBackClicked != null) BackButton(onClick = onBackClicked) - }, - title = { - Text(stringResource(R.string.install_history)) - }, - actions = { - if (!items.isNullOrEmpty()) TopAppBarButton( - imageVector = Icons.Filled.Delete, - contentDescription = - stringResource(R.string.install_history_delete_ally), - onClick = { deleteAllDialogShown = true }, - ) - }, - scrollBehavior = scrollBehavior, + var deleteAllDialogShown by remember { mutableStateOf(false) } + val scrollBehavior = enterAlwaysScrollBehavior(rememberTopAppBarState()) + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { if (onBackClicked != null) BackButton(onClick = onBackClicked) }, + title = { Text(stringResource(R.string.install_history)) }, + actions = { + if (!items.isNullOrEmpty()) + TopAppBarButton( + imageVector = Icons.Filled.Delete, + contentDescription = stringResource(R.string.install_history_delete_ally), + onClick = { deleteAllDialogShown = true }, ) }, - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - ) { paddingValues -> - if (items == null) BigLoadingIndicator(modifier = Modifier.padding(paddingValues)) - else HistoryList(items, enabled, onEnabled, paddingValues) - val onDismiss = { deleteAllDialogShown = false } - if (deleteAllDialogShown) AlertDialog( - title = { - Text(text = stringResource(R.string.install_history_delete_text)) - }, - onDismissRequest = onDismiss, - confirmButton = { - TextButton( - onClick = { - onDeleteAll() - onDismiss() - }, - ) { - Text( - text = stringResource(R.string.delete), - color = MaterialTheme.colorScheme.error, - ) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(android.R.string.cancel)) - } + scrollBehavior = scrollBehavior, + ) + }, + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + ) { paddingValues -> + if (items == null) BigLoadingIndicator(modifier = Modifier.padding(paddingValues)) + else HistoryList(items, enabled, onEnabled, paddingValues) + val onDismiss = { deleteAllDialogShown = false } + if (deleteAllDialogShown) + AlertDialog( + title = { Text(text = stringResource(R.string.install_history_delete_text)) }, + onDismissRequest = onDismiss, + confirmButton = { + TextButton( + onClick = { + onDeleteAll() + onDismiss() } - ) - } + ) { + Text(text = stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } + }, + ) + } } @Preview @Composable private fun PreviewLoading() { - FDroidContent { - History(null, true, {}, {}) { } - } + FDroidContent { History(null, true, {}, {}) {} } } @Preview @Composable private fun PreviewEmpty() { - FDroidContent { - History(emptyList(), true, {}, {}) { } - } + FDroidContent { History(emptyList(), true, {}, {}) {} } } @Preview @Composable private fun PreviewEmptyDisabled() { - FDroidContent { - History(emptyList(), false, {}, {}) { } - } + FDroidContent { History(emptyList(), false, {}, {}) {} } } diff --git a/app/src/main/kotlin/org/fdroid/ui/history/HistoryItem.kt b/app/src/main/kotlin/org/fdroid/ui/history/HistoryItem.kt index 517d2f34c..f40830c3a 100644 --- a/app/src/main/kotlin/org/fdroid/ui/history/HistoryItem.kt +++ b/app/src/main/kotlin/org/fdroid/ui/history/HistoryItem.kt @@ -2,7 +2,4 @@ package org.fdroid.ui.history import org.fdroid.history.HistoryEvent -data class HistoryItem( - val event: HistoryEvent, - val iconModel: Any, -) +data class HistoryItem(val event: HistoryEvent, val iconModel: Any) diff --git a/app/src/main/kotlin/org/fdroid/ui/history/HistoryList.kt b/app/src/main/kotlin/org/fdroid/ui/history/HistoryList.kt index 0dee33af5..dcfb09038 100644 --- a/app/src/main/kotlin/org/fdroid/ui/history/HistoryList.kt +++ b/app/src/main/kotlin/org/fdroid/ui/history/HistoryList.kt @@ -41,105 +41,95 @@ import org.fdroid.ui.utils.asRelativeTimeString @Composable fun HistoryList( - items: List, - enabled: Boolean?, - onEnabled: (Boolean) -> Unit, - paddingValues: PaddingValues + items: List, + enabled: Boolean?, + onEnabled: (Boolean) -> Unit, + paddingValues: PaddingValues, ) { - LazyColumn( - contentPadding = paddingValues, - ) { - if (enabled != null) item { - Card( - shape = MaterialTheme.shapes.extraLarge, - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - ), - modifier = Modifier - .padding(16.dp) - .fillMaxWidth() - .clickable { onEnabled(!enabled) } + LazyColumn(contentPadding = paddingValues) { + if (enabled != null) + item { + Card( + shape = MaterialTheme.shapes.extraLarge, + colors = + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), + modifier = Modifier.padding(16.dp).fillMaxWidth().clickable { onEnabled(!enabled) }, + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(16.dp)) { + Text(text = "Use install history", fontSize = 19.sp, modifier = Modifier.weight(1f)) + Switch(enabled, onCheckedChange = onEnabled) + } + } + } + if (items.isEmpty()) + item { + val s = + if (enabled == true) stringResource(R.string.install_history_empty_state) + else stringResource(R.string.install_history_disabled_state) + Text( + text = s, + textAlign = TextAlign.Center, + modifier = Modifier.padding(16.dp).fillMaxWidth(), + ) + } + else + items( + items = items, + key = { "${it.event.time}-${it.event.packageName}" }, + contentType = { "item" }, + ) { item -> + ListItem( + leadingContent = { + BadgedBox( + badge = { + BadgeIcon( + icon = + if (item.event is InstallEvent) { + Icons.Filled.DownloadForOffline + } else { + Icons.Filled.RemoveCircleOutline + }, + color = + if (item.event is InstallEvent) { + MaterialTheme.colorScheme.secondary + } else { + MaterialTheme.colorScheme.error + }, + contentDescription = + if (item.event is InstallEvent) { + stringResource(R.string.menu_install) + } else { + stringResource(R.string.menu_uninstall) + }, + ) + } ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(16.dp), - ) { - Text( - text = "Use install history", - fontSize = 19.sp, - modifier = Modifier.weight(1f) - ) - Switch(enabled, onCheckedChange = onEnabled) - } + AsyncShimmerImage( + model = item.iconModel, + error = painterResource(R.drawable.ic_repo_app_default), + contentDescription = null, + modifier = Modifier.size(48.dp).semantics { hideFromAccessibility() }, + ) } - } - if (items.isEmpty()) item { - val s = if (enabled == true) stringResource(R.string.install_history_empty_state) - else stringResource(R.string.install_history_disabled_state) - Text( - text = s, - textAlign = TextAlign.Center, - modifier = Modifier - .padding(16.dp) - .fillMaxWidth() - ) - } else items( - items = items, - key = { "${it.event.time}-${it.event.packageName}" }, - contentType = { "item" }, - ) { item -> - ListItem( - leadingContent = { - BadgedBox(badge = { - BadgeIcon( - icon = if (item.event is InstallEvent) { - Icons.Filled.DownloadForOffline - } else { - Icons.Filled.RemoveCircleOutline - }, - color = if (item.event is InstallEvent) { - MaterialTheme.colorScheme.secondary - } else { - MaterialTheme.colorScheme.error - }, - contentDescription = if (item.event is InstallEvent) { - stringResource(R.string.menu_install) - } else { - stringResource(R.string.menu_uninstall) - }, - ) - }) { - AsyncShimmerImage( - model = item.iconModel, - error = painterResource(R.drawable.ic_repo_app_default), - contentDescription = null, - modifier = Modifier - .size(48.dp) - .semantics { hideFromAccessibility() }, - ) - } - }, - headlineContent = { - Text(item.event.name ?: item.event.packageName) - }, - supportingContent = { - val dateStr = item.event.time.asRelativeTimeString() - val text = if (item.event is InstallEvent) { - if (item.event.oldVersionName == null) { - "${item.event.versionName} • $dateStr" - } else if (LocalLayoutDirection.current == LayoutDirection.Ltr) { - "${item.event.oldVersionName} → ${item.event.versionName} • $dateStr" - } else { - "$dateStr • ${item.event.versionName} ← ${item.event.oldVersionName}" - } - } else dateStr - Text(text) - }, - colors = ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - modifier = if (enabled == false) Modifier.alpha(0.5f) else Modifier - ) - } - } + }, + headlineContent = { Text(item.event.name ?: item.event.packageName) }, + supportingContent = { + val dateStr = item.event.time.asRelativeTimeString() + val text = + if (item.event is InstallEvent) { + if (item.event.oldVersionName == null) { + "${item.event.versionName} • $dateStr" + } else if (LocalLayoutDirection.current == LayoutDirection.Ltr) { + "${item.event.oldVersionName} → ${item.event.versionName} • $dateStr" + } else { + "$dateStr • ${item.event.versionName} ← ${item.event.oldVersionName}" + } + } else dateStr + Text(text) + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + modifier = if (enabled == false) Modifier.alpha(0.5f) else Modifier, + ) + } + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/history/HistoryViewModel.kt b/app/src/main/kotlin/org/fdroid/ui/history/HistoryViewModel.kt index a0c472d1b..65edf7526 100644 --- a/app/src/main/kotlin/org/fdroid/ui/history/HistoryViewModel.kt +++ b/app/src/main/kotlin/org/fdroid/ui/history/HistoryViewModel.kt @@ -5,6 +5,7 @@ import androidx.annotation.WorkerThread import androidx.core.os.LocaleListCompat import androidx.lifecycle.AndroidViewModel import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -17,66 +18,69 @@ import org.fdroid.history.HistoryManager import org.fdroid.index.RepoManager import org.fdroid.settings.SettingsManager import org.fdroid.utils.IoDispatcher -import javax.inject.Inject @HiltViewModel -class HistoryViewModel @Inject constructor( - app: Application, - private val db: FDroidDatabase, - private val repoManager: RepoManager, - private val historyManager: HistoryManager, - private val settingsManager: SettingsManager, - @param:IoDispatcher private val scope: CoroutineScope, +class HistoryViewModel +@Inject +constructor( + app: Application, + private val db: FDroidDatabase, + private val repoManager: RepoManager, + private val historyManager: HistoryManager, + private val settingsManager: SettingsManager, + @param:IoDispatcher private val scope: CoroutineScope, ) : AndroidViewModel(app) { - private val _items = MutableStateFlow?>(null) - val items = _items.asStateFlow() - val useInstallHistory = settingsManager.useInstallHistoryFlow + private val _items = MutableStateFlow?>(null) + val items = _items.asStateFlow() + val useInstallHistory = settingsManager.useInstallHistoryFlow - init { - scope.launch { load() } - } + init { + scope.launch { load() } + } - @WorkerThread - private suspend fun load() { - val packageNames = mutableSetOf() - val items = historyManager.getEvents().map { event -> - packageNames.add(event.packageName) - HistoryItem( - event = event, - iconModel = PackageName(event.packageName, null) - ) - }.sortedByDescending { it.event.time } - _items.value = items - // second pass to also load icons - if (packageNames.isNotEmpty()) { - val proxyConfig = settingsManager.proxyConfig - val locales = LocaleListCompat.getDefault() - val apps = db.getAppDao().getApps(packageNames.toList()).associateBy { it.packageName } - val items = historyManager.getEvents().map { event -> - val iconRequest = run { - val app = apps[event.packageName] ?: return@run null - val repository = repoManager.getRepository(app.repoId) ?: return@run null - val icon = app.getIcon(locales) - icon?.getImageModel(repository, proxyConfig) as? DownloadRequest - } - HistoryItem( - event = event, - iconModel = PackageName(event.packageName, iconRequest) - ) - }.sortedByDescending { it.event.time } - _items.value = items + @WorkerThread + private suspend fun load() { + val packageNames = mutableSetOf() + val items = + historyManager + .getEvents() + .map { event -> + packageNames.add(event.packageName) + HistoryItem(event = event, iconModel = PackageName(event.packageName, null)) } + .sortedByDescending { it.event.time } + _items.value = items + // second pass to also load icons + if (packageNames.isNotEmpty()) { + val proxyConfig = settingsManager.proxyConfig + val locales = LocaleListCompat.getDefault() + val apps = db.getAppDao().getApps(packageNames.toList()).associateBy { it.packageName } + val items = + historyManager + .getEvents() + .map { event -> + val iconRequest = run { + val app = apps[event.packageName] ?: return@run null + val repository = repoManager.getRepository(app.repoId) ?: return@run null + val icon = app.getIcon(locales) + icon?.getImageModel(repository, proxyConfig) as? DownloadRequest + } + HistoryItem(event = event, iconModel = PackageName(event.packageName, iconRequest)) + } + .sortedByDescending { it.event.time } + _items.value = items } + } - fun useInstallHistory(use: Boolean) { - settingsManager.useInstallHistory = use - } + fun useInstallHistory(use: Boolean) { + settingsManager.useInstallHistory = use + } - fun deleteHistory() { - scope.launch { - historyManager.clearAll() - load() - } + fun deleteHistory() { + scope.launch { + historyManager.clearAll() + load() } + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/icons/License.kt b/app/src/main/kotlin/org/fdroid/ui/icons/License.kt index 58f44edb7..32d84cf99 100644 --- a/app/src/main/kotlin/org/fdroid/ui/icons/License.kt +++ b/app/src/main/kotlin/org/fdroid/ui/icons/License.kt @@ -17,87 +17,89 @@ import androidx.compose.ui.unit.dp import org.fdroid.ui.FDroidContent val License: ImageVector - get() { - if (_License != null) { - return _License!! - } - _License = ImageVector.Builder( - name = "License", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 960f, - viewportHeight = 960f - ).apply { - path( - fill = SolidColor(Color.Black), - fillAlpha = 1.0f, - stroke = null, - strokeAlpha = 1.0f, - strokeLineWidth = 1.0f, - strokeLineCap = StrokeCap.Butt, - strokeLineJoin = StrokeJoin.Miter, - strokeLineMiter = 1.0f, - pathFillType = PathFillType.NonZero - ) { - moveTo(480f, 520f) - quadToRelative(-50f, 0f, -85f, -35f) - reflectiveQuadToRelative(-35f, -85f) - reflectiveQuadToRelative(35f, -85f) - reflectiveQuadToRelative(85f, -35f) - reflectiveQuadToRelative(85f, 35f) - reflectiveQuadToRelative(35f, 85f) - reflectiveQuadToRelative(-35f, 85f) - reflectiveQuadToRelative(-85f, 35f) - moveToRelative(0f, 320f) - lineTo(293f, 902f) - quadToRelative(-20f, 7f, -36.5f, -5f) - reflectiveQuadTo(240f, 865f) - verticalLineToRelative(-254f) - quadToRelative(-38f, -42f, -59f, -96f) - reflectiveQuadToRelative(-21f, -115f) - quadToRelative(0f, -134f, 93f, -227f) - reflectiveQuadToRelative(227f, -93f) - reflectiveQuadToRelative(227f, 93f) - reflectiveQuadToRelative(93f, 227f) - quadToRelative(0f, 61f, -21f, 115f) - reflectiveQuadToRelative(-59f, 96f) - verticalLineToRelative(254f) - quadToRelative(0f, 20f, -16.5f, 32f) - reflectiveQuadTo(667f, 902f) - close() - moveToRelative(0f, -200f) - quadToRelative(100f, 0f, 170f, -70f) - reflectiveQuadToRelative(70f, -170f) - reflectiveQuadToRelative(-70f, -170f) - reflectiveQuadToRelative(-170f, -70f) - reflectiveQuadToRelative(-170f, 70f) - reflectiveQuadToRelative(-70f, 170f) - reflectiveQuadToRelative(70f, 170f) - reflectiveQuadToRelative(170f, 70f) - moveTo(320f, 801f) - lineToRelative(160f, -41f) - lineToRelative(160f, 41f) - verticalLineToRelative(-124f) - quadToRelative(-35f, 20f, -75.5f, 31.5f) - reflectiveQuadTo(480f, 720f) - reflectiveQuadToRelative(-84.5f, -11.5f) - reflectiveQuadTo(320f, 677f) - close() - moveToRelative(160f, -62f) - } - }.build() - return _License!! + get() { + if (_License != null) { + return _License!! } + _License = + ImageVector.Builder( + name = "License", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f, + ) + .apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1.0f, + stroke = null, + strokeAlpha = 1.0f, + strokeLineWidth = 1.0f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Miter, + strokeLineMiter = 1.0f, + pathFillType = PathFillType.NonZero, + ) { + moveTo(480f, 520f) + quadToRelative(-50f, 0f, -85f, -35f) + reflectiveQuadToRelative(-35f, -85f) + reflectiveQuadToRelative(35f, -85f) + reflectiveQuadToRelative(85f, -35f) + reflectiveQuadToRelative(85f, 35f) + reflectiveQuadToRelative(35f, 85f) + reflectiveQuadToRelative(-35f, 85f) + reflectiveQuadToRelative(-85f, 35f) + moveToRelative(0f, 320f) + lineTo(293f, 902f) + quadToRelative(-20f, 7f, -36.5f, -5f) + reflectiveQuadTo(240f, 865f) + verticalLineToRelative(-254f) + quadToRelative(-38f, -42f, -59f, -96f) + reflectiveQuadToRelative(-21f, -115f) + quadToRelative(0f, -134f, 93f, -227f) + reflectiveQuadToRelative(227f, -93f) + reflectiveQuadToRelative(227f, 93f) + reflectiveQuadToRelative(93f, 227f) + quadToRelative(0f, 61f, -21f, 115f) + reflectiveQuadToRelative(-59f, 96f) + verticalLineToRelative(254f) + quadToRelative(0f, 20f, -16.5f, 32f) + reflectiveQuadTo(667f, 902f) + close() + moveToRelative(0f, -200f) + quadToRelative(100f, 0f, 170f, -70f) + reflectiveQuadToRelative(70f, -170f) + reflectiveQuadToRelative(-70f, -170f) + reflectiveQuadToRelative(-170f, -70f) + reflectiveQuadToRelative(-170f, 70f) + reflectiveQuadToRelative(-70f, 170f) + reflectiveQuadToRelative(70f, 170f) + reflectiveQuadToRelative(170f, 70f) + moveTo(320f, 801f) + lineToRelative(160f, -41f) + lineToRelative(160f, 41f) + verticalLineToRelative(-124f) + quadToRelative(-35f, 20f, -75.5f, 31.5f) + reflectiveQuadTo(480f, 720f) + reflectiveQuadToRelative(-84.5f, -11.5f) + reflectiveQuadTo(320f, 677f) + close() + moveToRelative(160f, -62f) + } + } + .build() + return _License!! + } -@Suppress("ktlint:standard:backing-property-naming") private var _License: ImageVector? = null @Preview @Composable private fun Preview() { - FDroidContent { - Box(modifier = Modifier.padding(12.dp)) { - Image(imageVector = License, contentDescription = "") - } + FDroidContent { + Box(modifier = Modifier.padding(12.dp)) { + Image(imageVector = License, contentDescription = "") } + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/icons/Litecoin.kt b/app/src/main/kotlin/org/fdroid/ui/icons/Litecoin.kt index ba6945a3a..29a5abe96 100644 --- a/app/src/main/kotlin/org/fdroid/ui/icons/Litecoin.kt +++ b/app/src/main/kotlin/org/fdroid/ui/icons/Litecoin.kt @@ -7,59 +7,50 @@ import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val Litecoin: ImageVector - get() { - if (_Litecoin != null) return _Litecoin!! + get() { + if (_Litecoin != null) return _Litecoin!! - _Litecoin = ImageVector.Builder( - name = "litecoin", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 24f, - viewportHeight = 24f - ).apply { - path( - fill = SolidColor(Color(0xFF000000)) - ) { - } - path( - fill = SolidColor(Color(0xFF000000)) - ) { - } - path( - fill = SolidColor(Color(0xFF000000)) - ) { - moveTo(12f, 1f) - arcTo(11f, 11f, 0f, true, false, 23f, 12f) - arcTo(11f, 11f, 0f, false, false, 12f, 1f) - close() - moveToRelative(0.178277f, 11.361877f) - lineToRelative(-1.14417f, 3.860909f) - horizontalLineToRelative(6.119981f) - arcToRelative(0.31398162f, 0.31398162f, 0f, false, true, 0.300677f, 0.401791f) - lineToRelative(-0.532172f, 1.833333f) - arcToRelative(0.42041606f, 0.42041606f, 0f, false, true, -0.404451f, 0.303339f) - horizontalLineTo(7.170537f) - lineTo(8.7510885f, 13.423561f) - lineTo(7.0029028f, 13.955733f) - lineTo(7.3887276f, 12.707789f) - lineTo(9.1395743f, 12.175616f) - lineTo(11.358733f, 4.6773101f) - arcToRelative(0.4177552f, 0.4177552f, 0f, false, true, 0.401789f, -0.305999f) - horizontalLineToRelative(2.368167f) - arcToRelative(0.31398162f, 0.31398162f, 0f, false, true, 0.303338f, 0.3991292f) - lineTo(12.569424f, 11.111273f) - lineTo(14.31761f, 10.5791f) - lineTo(13.942429f, 11.848331f) - close() - } - path( - fill = SolidColor(Color(0xFF000000)) - ) { - } - }.build() + _Litecoin = + ImageVector.Builder( + name = "litecoin", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ) + .apply { + path(fill = SolidColor(Color(0xFF000000))) {} + path(fill = SolidColor(Color(0xFF000000))) {} + path(fill = SolidColor(Color(0xFF000000))) { + moveTo(12f, 1f) + arcTo(11f, 11f, 0f, true, false, 23f, 12f) + arcTo(11f, 11f, 0f, false, false, 12f, 1f) + close() + moveToRelative(0.178277f, 11.361877f) + lineToRelative(-1.14417f, 3.860909f) + horizontalLineToRelative(6.119981f) + arcToRelative(0.31398162f, 0.31398162f, 0f, false, true, 0.300677f, 0.401791f) + lineToRelative(-0.532172f, 1.833333f) + arcToRelative(0.42041606f, 0.42041606f, 0f, false, true, -0.404451f, 0.303339f) + horizontalLineTo(7.170537f) + lineTo(8.7510885f, 13.423561f) + lineTo(7.0029028f, 13.955733f) + lineTo(7.3887276f, 12.707789f) + lineTo(9.1395743f, 12.175616f) + lineTo(11.358733f, 4.6773101f) + arcToRelative(0.4177552f, 0.4177552f, 0f, false, true, 0.401789f, -0.305999f) + horizontalLineToRelative(2.368167f) + arcToRelative(0.31398162f, 0.31398162f, 0f, false, true, 0.303338f, 0.3991292f) + lineTo(12.569424f, 11.111273f) + lineTo(14.31761f, 10.5791f) + lineTo(13.942429f, 11.848331f) + close() + } + path(fill = SolidColor(Color(0xFF000000))) {} + } + .build() - return _Litecoin!! - } + return _Litecoin!! + } -@Suppress("ktlint:standard:backing-property-naming") private var _Litecoin: ImageVector? = null diff --git a/app/src/main/kotlin/org/fdroid/ui/icons/PackageVariant.kt b/app/src/main/kotlin/org/fdroid/ui/icons/PackageVariant.kt index 58e31b823..4bcedb507 100644 --- a/app/src/main/kotlin/org/fdroid/ui/icons/PackageVariant.kt +++ b/app/src/main/kotlin/org/fdroid/ui/icons/PackageVariant.kt @@ -18,81 +18,86 @@ import androidx.compose.ui.unit.dp import org.fdroid.ui.FDroidContent val PackageVariant: ImageVector - get() { - if (_PackageVariant != null) { - return _PackageVariant!! - } - _PackageVariant = Builder( - name = "packageVariant", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 24f, - viewportHeight = 24f, - ).apply { - path( - fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f, - strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, - pathFillType = NonZero - ) { - moveTo(2.0f, 10.96f) - curveTo(1.5f, 10.68f, 1.35f, 10.07f, 1.63f, 9.59f) - lineTo(3.13f, 7.0f) - curveTo(3.24f, 6.8f, 3.41f, 6.66f, 3.6f, 6.58f) - lineTo(11.43f, 2.18f) - curveTo(11.59f, 2.06f, 11.79f, 2.0f, 12.0f, 2.0f) - curveTo(12.21f, 2.0f, 12.41f, 2.06f, 12.57f, 2.18f) - lineTo(20.47f, 6.62f) - curveTo(20.66f, 6.72f, 20.82f, 6.88f, 20.91f, 7.08f) - lineTo(22.36f, 9.6f) - curveTo(22.64f, 10.08f, 22.47f, 10.69f, 22.0f, 10.96f) - lineTo(21.0f, 11.54f) - verticalLineTo(16.5f) - curveTo(21.0f, 16.88f, 20.79f, 17.21f, 20.47f, 17.38f) - lineTo(12.57f, 21.82f) - curveTo(12.41f, 21.94f, 12.21f, 22.0f, 12.0f, 22.0f) - curveTo(11.79f, 22.0f, 11.59f, 21.94f, 11.43f, 21.82f) - lineTo(3.53f, 17.38f) - curveTo(3.21f, 17.21f, 3.0f, 16.88f, 3.0f, 16.5f) - verticalLineTo(10.96f) - curveTo(2.7f, 11.13f, 2.32f, 11.14f, 2.0f, 10.96f) - moveTo(12.0f, 4.15f) - verticalLineTo(4.15f) - lineTo(12.0f, 10.85f) - verticalLineTo(10.85f) - lineTo(17.96f, 7.5f) - lineTo(12.0f, 4.15f) - moveTo(5.0f, 15.91f) - lineTo(11.0f, 19.29f) - verticalLineTo(12.58f) - lineTo(5.0f, 9.21f) - verticalLineTo(15.91f) - moveTo(19.0f, 15.91f) - verticalLineTo(12.69f) - lineTo(14.0f, 15.59f) - curveTo(13.67f, 15.77f, 13.3f, 15.76f, 13.0f, 15.6f) - verticalLineTo(19.29f) - lineTo(19.0f, 15.91f) - moveTo(13.85f, 13.36f) - lineTo(20.13f, 9.73f) - lineTo(19.55f, 8.72f) - lineTo(13.27f, 12.35f) - lineTo(13.85f, 13.36f) - close() - } - } - .build() - return _PackageVariant!! + get() { + if (_PackageVariant != null) { + return _PackageVariant!! } + _PackageVariant = + Builder( + name = "packageVariant", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ) + .apply { + path( + fill = SolidColor(Color(0xFF000000)), + stroke = null, + strokeLineWidth = 0.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + moveTo(2.0f, 10.96f) + curveTo(1.5f, 10.68f, 1.35f, 10.07f, 1.63f, 9.59f) + lineTo(3.13f, 7.0f) + curveTo(3.24f, 6.8f, 3.41f, 6.66f, 3.6f, 6.58f) + lineTo(11.43f, 2.18f) + curveTo(11.59f, 2.06f, 11.79f, 2.0f, 12.0f, 2.0f) + curveTo(12.21f, 2.0f, 12.41f, 2.06f, 12.57f, 2.18f) + lineTo(20.47f, 6.62f) + curveTo(20.66f, 6.72f, 20.82f, 6.88f, 20.91f, 7.08f) + lineTo(22.36f, 9.6f) + curveTo(22.64f, 10.08f, 22.47f, 10.69f, 22.0f, 10.96f) + lineTo(21.0f, 11.54f) + verticalLineTo(16.5f) + curveTo(21.0f, 16.88f, 20.79f, 17.21f, 20.47f, 17.38f) + lineTo(12.57f, 21.82f) + curveTo(12.41f, 21.94f, 12.21f, 22.0f, 12.0f, 22.0f) + curveTo(11.79f, 22.0f, 11.59f, 21.94f, 11.43f, 21.82f) + lineTo(3.53f, 17.38f) + curveTo(3.21f, 17.21f, 3.0f, 16.88f, 3.0f, 16.5f) + verticalLineTo(10.96f) + curveTo(2.7f, 11.13f, 2.32f, 11.14f, 2.0f, 10.96f) + moveTo(12.0f, 4.15f) + verticalLineTo(4.15f) + lineTo(12.0f, 10.85f) + verticalLineTo(10.85f) + lineTo(17.96f, 7.5f) + lineTo(12.0f, 4.15f) + moveTo(5.0f, 15.91f) + lineTo(11.0f, 19.29f) + verticalLineTo(12.58f) + lineTo(5.0f, 9.21f) + verticalLineTo(15.91f) + moveTo(19.0f, 15.91f) + verticalLineTo(12.69f) + lineTo(14.0f, 15.59f) + curveTo(13.67f, 15.77f, 13.3f, 15.76f, 13.0f, 15.6f) + verticalLineTo(19.29f) + lineTo(19.0f, 15.91f) + moveTo(13.85f, 13.36f) + lineTo(20.13f, 9.73f) + lineTo(19.55f, 8.72f) + lineTo(13.27f, 12.35f) + lineTo(13.85f, 13.36f) + close() + } + } + .build() + return _PackageVariant!! + } -@Suppress("ktlint:standard:backing-property-naming") private var _PackageVariant: ImageVector? = null @Preview @Composable private fun Preview() { - FDroidContent { - Box(modifier = Modifier.padding(12.dp)) { - Image(imageVector = PackageVariant, contentDescription = "") - } + FDroidContent { + Box(modifier = Modifier.padding(12.dp)) { + Image(imageVector = PackageVariant, contentDescription = "") } + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/lists/AppList.kt b/app/src/main/kotlin/org/fdroid/ui/lists/AppList.kt index 5dd6a0e4a..72a3a094b 100644 --- a/app/src/main/kotlin/org/fdroid/ui/lists/AppList.kt +++ b/app/src/main/kotlin/org/fdroid/ui/lists/AppList.kt @@ -68,246 +68,227 @@ import org.fdroid.ui.utils.getHintOverlayColor @Composable @OptIn(ExperimentalMaterial3Api::class) fun AppList( - appListInfo: AppListInfo, - currentPackageName: String?, - modifier: Modifier = Modifier, - onBackClicked: () -> Unit, - onItemClick: (String) -> Unit, + appListInfo: AppListInfo, + currentPackageName: String?, + modifier: Modifier = Modifier, + onBackClicked: () -> Unit, + onItemClick: (String) -> Unit, ) { - var searchActive by rememberSaveable { mutableStateOf(false) } - val scrollBehavior = enterAlwaysScrollBehavior(rememberTopAppBarState()) + var searchActive by rememberSaveable { mutableStateOf(false) } + val scrollBehavior = enterAlwaysScrollBehavior(rememberTopAppBarState()) - val hintController = rememberHintController( - overlay = getHintOverlayColor(), + val hintController = rememberHintController(overlay = getHintOverlayColor()) + val hint = rememberHint { + OnboardingCard( + title = stringResource(R.string.onboarding_app_list_filter_title), + message = stringResource(R.string.onboarding_app_list_filter_message), + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + onGotIt = { + appListInfo.actions.onOnboardingSeen() + hintController.dismiss() + }, ) - val hint = rememberHint { - OnboardingCard( - title = stringResource(R.string.onboarding_app_list_filter_title), - message = stringResource(R.string.onboarding_app_list_filter_message), - modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), - onGotIt = { - appListInfo.actions.onOnboardingSeen() - hintController.dismiss() - }, - ) - } - val hintAnchor = rememberHintAnchorState(hint) - LaunchedEffect(appListInfo.showOnboarding) { - if (appListInfo.showOnboarding) { - hintController.show(hintAnchor) - appListInfo.actions.onOnboardingSeen() - } + } + val hintAnchor = rememberHintAnchorState(hint) + LaunchedEffect(appListInfo.showOnboarding) { + if (appListInfo.showOnboarding) { + hintController.show(hintAnchor) + appListInfo.actions.onOnboardingSeen() } + } - Scaffold( - topBar = { - if (searchActive) { - val onSearchCleared = { appListInfo.actions.onSearch("") } - TopSearchBar( - onSearch = appListInfo.actions::onSearch, - onSearchCleared = onSearchCleared, - onHideSearch = { - searchActive = false - onSearchCleared() - }, - actions = { - FilterButton( - showFilterBadge = appListInfo.model.showFilterBadge, - toggleFilterVisibility = appListInfo.actions::toggleFilterVisibility, - ) - } - ) - } else TopAppBar( - title = { - Text( - text = appListInfo.list.title, - maxLines = 1, - overflow = TextOverflow.MiddleEllipsis, - ) - }, - navigationIcon = { - BackButton(onClick = { - if (searchActive) searchActive = false else onBackClicked() - }) - }, - actions = { - TopAppBarButton( - imageVector = Icons.Filled.Search, - contentDescription = stringResource(R.string.menu_search), - onClick = { searchActive = true }, - ) - FilterButton( - showFilterBadge = appListInfo.model.showFilterBadge, - toggleFilterVisibility = appListInfo.actions::toggleFilterVisibility, - modifier = Modifier.hintAnchor( - state = hintAnchor, - shape = RoundedCornerShape(16.dp), - ) - ) - }, - scrollBehavior = scrollBehavior, + Scaffold( + topBar = { + if (searchActive) { + val onSearchCleared = { appListInfo.actions.onSearch("") } + TopSearchBar( + onSearch = appListInfo.actions::onSearch, + onSearchCleared = onSearchCleared, + onHideSearch = { + searchActive = false + onSearchCleared() + }, + actions = { + FilterButton( + showFilterBadge = appListInfo.model.showFilterBadge, + toggleFilterVisibility = appListInfo.actions::toggleFilterVisibility, ) - }, - modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - ) { paddingValues -> - val listState = rememberSaveable(saver = LazyListState.Saver) { - LazyListState() - } - Column( - modifier = Modifier - .fillMaxSize() - .imePadding() + }, + ) + } else + TopAppBar( + title = { + Text( + text = appListInfo.list.title, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + ) + }, + navigationIcon = { + BackButton(onClick = { if (searchActive) searchActive = false else onBackClicked() }) + }, + actions = { + TopAppBarButton( + imageVector = Icons.Filled.Search, + contentDescription = stringResource(R.string.menu_search), + onClick = { searchActive = true }, + ) + FilterButton( + showFilterBadge = appListInfo.model.showFilterBadge, + toggleFilterVisibility = appListInfo.actions::toggleFilterVisibility, + modifier = Modifier.hintAnchor(state = hintAnchor, shape = RoundedCornerShape(16.dp)), + ) + }, + scrollBehavior = scrollBehavior, + ) + }, + modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + ) { paddingValues -> + val listState = rememberSaveable(saver = LazyListState.Saver) { LazyListState() } + Column(modifier = Modifier.fillMaxSize().imePadding()) { + val apps = appListInfo.model.apps + if (apps == null) BigLoadingIndicator() + else if (apps.isEmpty()) { + Text( + text = stringResource(R.string.search_filter_no_results), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxSize().padding(paddingValues).padding(16.dp), + ) + } else + LazyColumn( + state = listState, + contentPadding = paddingValues + PaddingValues(top = 8.dp), + verticalArrangement = spacedBy(8.dp), + modifier = + Modifier.then(if (currentPackageName == null) Modifier else Modifier.selectableGroup()), ) { - val apps = appListInfo.model.apps - if (apps == null) BigLoadingIndicator() - else if (apps.isEmpty()) { - Text( - text = stringResource(R.string.search_filter_no_results), - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .padding(16.dp), + items(apps, key = { it.packageName }, contentType = { "A" }) { navItem -> + val isSelected = currentPackageName == navItem.packageName + val interactionModifier = + if (currentPackageName == null) { + Modifier.clickable(onClick = { onItemClick(navItem.packageName) }) + } else { + Modifier.selectable( + selected = isSelected, + onClick = { onItemClick(navItem.packageName) }, ) - } else LazyColumn( - state = listState, - contentPadding = paddingValues + PaddingValues(top = 8.dp), - verticalArrangement = spacedBy(8.dp), - modifier = Modifier.then( - if (currentPackageName == null) Modifier - else Modifier.selectableGroup() - ), - ) { - items(apps, key = { it.packageName }, contentType = { "A" }) { navItem -> - val isSelected = currentPackageName == navItem.packageName - val interactionModifier = if (currentPackageName == null) { - Modifier.clickable( - onClick = { onItemClick(navItem.packageName) } - ) - } else { - Modifier.selectable( - selected = isSelected, - onClick = { onItemClick(navItem.packageName) } - ) - } - AppListRow( - item = navItem, - isSelected = isSelected, - modifier = Modifier - .fillMaxWidth() - .animateItem() - .padding(horizontal = 8.dp) - .then(interactionModifier) - ) - } - } - // Bottom Sheet - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) - if (appListInfo.showFilters) { - ModalBottomSheet( - modifier = Modifier.fillMaxHeight(), - sheetState = sheetState, - onDismissRequest = { appListInfo.actions.toggleFilterVisibility() }, - ) { - AppsFilter(info = appListInfo) - } - } + } + AppListRow( + item = navItem, + isSelected = isSelected, + modifier = + Modifier.fillMaxWidth() + .animateItem() + .padding(horizontal = 8.dp) + .then(interactionModifier), + ) + } } + // Bottom Sheet + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) + if (appListInfo.showFilters) { + ModalBottomSheet( + modifier = Modifier.fillMaxHeight(), + sheetState = sheetState, + onDismissRequest = { appListInfo.actions.toggleFilterVisibility() }, + ) { + AppsFilter(info = appListInfo) + } + } } + } } @Composable private fun FilterButton( - showFilterBadge: Boolean, - toggleFilterVisibility: () -> Unit, - modifier: Modifier = Modifier, + showFilterBadge: Boolean, + toggleFilterVisibility: () -> Unit, + modifier: Modifier = Modifier, ) { - TooltipBox( - positionProvider = - TooltipDefaults.rememberTooltipPositionProvider(Below), - tooltip = { PlainTooltip { Text(stringResource(R.string.filter)) } }, - state = rememberTooltipState(), - ) { - IconButton( - onClick = toggleFilterVisibility, - modifier = modifier, - ) { - BadgedBox(badge = { - if (showFilterBadge) Badge( - containerColor = MaterialTheme.colorScheme.secondary, - ) - }) { - Icon( - imageVector = Icons.Filled.FilterList, - contentDescription = stringResource(R.string.filter), - ) - } - } + TooltipBox( + positionProvider = TooltipDefaults.rememberTooltipPositionProvider(Below), + tooltip = { PlainTooltip { Text(stringResource(R.string.filter)) } }, + state = rememberTooltipState(), + ) { + IconButton(onClick = toggleFilterVisibility, modifier = modifier) { + BadgedBox( + badge = { if (showFilterBadge) Badge(containerColor = MaterialTheme.colorScheme.secondary) } + ) { + Icon( + imageVector = Icons.Filled.FilterList, + contentDescription = stringResource(R.string.filter), + ) + } } + } } @Preview @Composable private fun Preview() { - FDroidContent { - val model = AppListModel( - apps = listOf( - AppListItem(1, "1", "This is app 1", "It has summary 2", 0, false, true), - AppListItem(2, "2", "This is app 2", "It has summary 2", 0, true, true), - ), - showFilterBadge = true, - sortBy = AppListSortOrder.NAME, - filterIncompatible = true, - categories = null, - filteredCategoryIds = emptySet(), - antiFeatures = null, - filteredAntiFeatureIds = emptySet(), - repositories = emptyList(), - filteredRepositoryIds = emptySet(), - ) - val info = getAppListInfo(model) - AppList(appListInfo = info, currentPackageName = null, onBackClicked = {}, onItemClick = {}) - } + FDroidContent { + val model = + AppListModel( + apps = + listOf( + AppListItem(1, "1", "This is app 1", "It has summary 2", 0, false, true), + AppListItem(2, "2", "This is app 2", "It has summary 2", 0, true, true), + ), + showFilterBadge = true, + sortBy = AppListSortOrder.NAME, + filterIncompatible = true, + categories = null, + filteredCategoryIds = emptySet(), + antiFeatures = null, + filteredAntiFeatureIds = emptySet(), + repositories = emptyList(), + filteredRepositoryIds = emptySet(), + ) + val info = getAppListInfo(model) + AppList(appListInfo = info, currentPackageName = null, onBackClicked = {}, onItemClick = {}) + } } @Preview @Composable private fun PreviewLoading() { - FDroidContent { - val model = AppListModel( - apps = null, - showFilterBadge = false, - sortBy = AppListSortOrder.NAME, - filterIncompatible = false, - categories = null, - filteredCategoryIds = emptySet(), - antiFeatures = null, - filteredAntiFeatureIds = emptySet(), - repositories = emptyList(), - filteredRepositoryIds = emptySet(), - ) - val info = getAppListInfo(model) - AppList(appListInfo = info, currentPackageName = null, onBackClicked = {}, onItemClick = {}) - } + FDroidContent { + val model = + AppListModel( + apps = null, + showFilterBadge = false, + sortBy = AppListSortOrder.NAME, + filterIncompatible = false, + categories = null, + filteredCategoryIds = emptySet(), + antiFeatures = null, + filteredAntiFeatureIds = emptySet(), + repositories = emptyList(), + filteredRepositoryIds = emptySet(), + ) + val info = getAppListInfo(model) + AppList(appListInfo = info, currentPackageName = null, onBackClicked = {}, onItemClick = {}) + } } @Preview @Composable private fun PreviewEmpty() { - FDroidContent { - val model = AppListModel( - apps = emptyList(), - showFilterBadge = false, - sortBy = AppListSortOrder.NAME, - filterIncompatible = false, - categories = null, - filteredCategoryIds = emptySet(), - antiFeatures = null, - filteredAntiFeatureIds = emptySet(), - repositories = emptyList(), - filteredRepositoryIds = emptySet(), - ) - val info = getAppListInfo(model) - AppList(appListInfo = info, currentPackageName = null, onBackClicked = {}, onItemClick = {}) - } + FDroidContent { + val model = + AppListModel( + apps = emptyList(), + showFilterBadge = false, + sortBy = AppListSortOrder.NAME, + filterIncompatible = false, + categories = null, + filteredCategoryIds = emptySet(), + antiFeatures = null, + filteredAntiFeatureIds = emptySet(), + repositories = emptyList(), + filteredRepositoryIds = emptySet(), + ) + val info = getAppListInfo(model) + AppList(appListInfo = info, currentPackageName = null, onBackClicked = {}, onItemClick = {}) + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/lists/AppListEntry.kt b/app/src/main/kotlin/org/fdroid/ui/lists/AppListEntry.kt index e85d7937a..81e24f22c 100644 --- a/app/src/main/kotlin/org/fdroid/ui/lists/AppListEntry.kt +++ b/app/src/main/kotlin/org/fdroid/ui/lists/AppListEntry.kt @@ -10,40 +10,36 @@ import org.fdroid.ui.navigation.NavigationKey import org.fdroid.ui.navigation.Navigator @OptIn(ExperimentalMaterial3AdaptiveApi::class) -fun EntryProviderScope.appListEntry( - navigator: Navigator, - isBigScreen: Boolean, -) { - entry( - metadata = ListDetailSceneStrategy.listPane("appdetails"), - ) { - val viewModel = hiltViewModel( - creationCallback = { factory -> - factory.create(it.type) - } - ) - val appListInfo = object : AppListInfo { - override val model = viewModel.appListModel.collectAsStateWithLifecycle().value - override val list: AppListType = it.type - override val actions: AppListActions = viewModel - override val showFilters: Boolean = - viewModel.showFilters.collectAsStateWithLifecycle().value - override val showOnboarding: Boolean = - viewModel.showOnboarding.collectAsStateWithLifecycle().value - } - AppList( - appListInfo = appListInfo, - currentPackageName = if (isBigScreen) { - (navigator.last as? NavigationKey.AppDetails)?.packageName - } else null, - onBackClicked = { navigator.goBack() }, - ) { packageName -> - val new = NavigationKey.AppDetails(packageName) - if (navigator.last is NavigationKey.AppDetails) { - navigator.replaceLast(new) - } else { - navigator.navigate(new) - } - } +fun EntryProviderScope.appListEntry(navigator: Navigator, isBigScreen: Boolean) { + entry(metadata = ListDetailSceneStrategy.listPane("appdetails")) { + val viewModel = + hiltViewModel( + creationCallback = { factory -> factory.create(it.type) } + ) + val appListInfo = + object : AppListInfo { + override val model = viewModel.appListModel.collectAsStateWithLifecycle().value + override val list: AppListType = it.type + override val actions: AppListActions = viewModel + override val showFilters: Boolean = + viewModel.showFilters.collectAsStateWithLifecycle().value + override val showOnboarding: Boolean = + viewModel.showOnboarding.collectAsStateWithLifecycle().value + } + AppList( + appListInfo = appListInfo, + currentPackageName = + if (isBigScreen) { + (navigator.last as? NavigationKey.AppDetails)?.packageName + } else null, + onBackClicked = { navigator.goBack() }, + ) { packageName -> + val new = NavigationKey.AppDetails(packageName) + if (navigator.last is NavigationKey.AppDetails) { + navigator.replaceLast(new) + } else { + navigator.navigate(new) + } } + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/lists/AppListInfo.kt b/app/src/main/kotlin/org/fdroid/ui/lists/AppListInfo.kt index 93fcdf9eb..032e29392 100644 --- a/app/src/main/kotlin/org/fdroid/ui/lists/AppListInfo.kt +++ b/app/src/main/kotlin/org/fdroid/ui/lists/AppListInfo.kt @@ -5,38 +5,50 @@ import org.fdroid.ui.categories.CategoryItem import org.fdroid.ui.repositories.RepositoryItem interface AppListInfo { - val model: AppListModel - val actions: AppListActions - val list: AppListType - val showFilters: Boolean - val showOnboarding: Boolean + val model: AppListModel + val actions: AppListActions + val list: AppListType + val showFilters: Boolean + val showOnboarding: Boolean } data class AppListModel( - val apps: List?, - val showFilterBadge: Boolean, - val sortBy: AppListSortOrder, - val filterIncompatible: Boolean, - val categories: List?, - val filteredCategoryIds: Set, - val antiFeatures: List?, - val filteredAntiFeatureIds: Set, - val repositories: List, - val filteredRepositoryIds: Set, + val apps: List?, + val showFilterBadge: Boolean, + val sortBy: AppListSortOrder, + val filterIncompatible: Boolean, + val categories: List?, + val filteredCategoryIds: Set, + val antiFeatures: List?, + val filteredAntiFeatureIds: Set, + val repositories: List, + val filteredRepositoryIds: Set, ) interface AppListActions { - fun toggleFilterVisibility() - fun sortBy(sort: AppListSortOrder) - fun toggleFilterIncompatible() - fun addCategory(categoryId: String) - fun removeCategory(categoryId: String) - fun addAntiFeature(antiFeatureId: String) - fun removeAntiFeature(antiFeatureId: String) - fun addRepository(repoId: Long) - fun removeRepository(repoId: Long) - fun saveFilters() - fun clearFilters() - fun onSearch(query: String) - fun onOnboardingSeen() + fun toggleFilterVisibility() + + fun sortBy(sort: AppListSortOrder) + + fun toggleFilterIncompatible() + + fun addCategory(categoryId: String) + + fun removeCategory(categoryId: String) + + fun addAntiFeature(antiFeatureId: String) + + fun removeAntiFeature(antiFeatureId: String) + + fun addRepository(repoId: Long) + + fun removeRepository(repoId: Long) + + fun saveFilters() + + fun clearFilters() + + fun onSearch(query: String) + + fun onOnboardingSeen() } diff --git a/app/src/main/kotlin/org/fdroid/ui/lists/AppListItem.kt b/app/src/main/kotlin/org/fdroid/ui/lists/AppListItem.kt index 0223d9ff2..1ab19c4ad 100644 --- a/app/src/main/kotlin/org/fdroid/ui/lists/AppListItem.kt +++ b/app/src/main/kotlin/org/fdroid/ui/lists/AppListItem.kt @@ -1,14 +1,14 @@ package org.fdroid.ui.lists data class AppListItem( - val repoId: Long, - val packageName: String, - val name: String, - val summary: String, - val lastUpdated: Long, - val isInstalled: Boolean, - val isCompatible: Boolean, - val iconModel: Any? = null, - val categoryIds: Set? = null, - val antiFeatureIds: Set = emptySet(), + val repoId: Long, + val packageName: String, + val name: String, + val summary: String, + val lastUpdated: Long, + val isInstalled: Boolean, + val isCompatible: Boolean, + val iconModel: Any? = null, + val categoryIds: Set? = null, + val antiFeatureIds: Set = emptySet(), ) diff --git a/app/src/main/kotlin/org/fdroid/ui/lists/AppListPresenter.kt b/app/src/main/kotlin/org/fdroid/ui/lists/AppListPresenter.kt index c180fa0b9..c58c82207 100644 --- a/app/src/main/kotlin/org/fdroid/ui/lists/AppListPresenter.kt +++ b/app/src/main/kotlin/org/fdroid/ui/lists/AppListPresenter.kt @@ -1,111 +1,114 @@ -@file:Suppress("ktlint:standard:filename") - package org.fdroid.ui.lists import android.annotation.SuppressLint import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember +import java.util.Locale import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import org.fdroid.database.AppListSortOrder import org.fdroid.ui.categories.CategoryItem import org.fdroid.ui.repositories.RepositoryItem import org.fdroid.ui.utils.normalize -import java.util.Locale @Composable fun AppListPresenter( - type: AppListType, - appsFlow: StateFlow?>, - sortByFlow: StateFlow, - filterIncompatibleFlow: StateFlow, - categoriesFlow: Flow>, - antiFeaturesFlow: Flow>, - filteredCategoryIdsFlow: StateFlow>, - notSelectedAntiFeatureIdsFlow: StateFlow>, - repositoriesFlow: Flow>, - filteredRepositoryIdsFlow: StateFlow>, - searchQueryFlow: StateFlow, + type: AppListType, + appsFlow: StateFlow?>, + sortByFlow: StateFlow, + filterIncompatibleFlow: StateFlow, + categoriesFlow: Flow>, + antiFeaturesFlow: Flow>, + filteredCategoryIdsFlow: StateFlow>, + notSelectedAntiFeatureIdsFlow: StateFlow>, + repositoriesFlow: Flow>, + filteredRepositoryIdsFlow: StateFlow>, + searchQueryFlow: StateFlow, ): AppListModel { - val apps = appsFlow.collectAsState(null).value - val sortBy = sortByFlow.collectAsState().value - val filterIncompatible = filterIncompatibleFlow.collectAsState().value - val categories = categoriesFlow.collectAsState(null).value - val antiFeatures = antiFeaturesFlow.collectAsState(null).value - val filteredCategoryIds = filteredCategoryIdsFlow.collectAsState().value - val filteredAntiFeatureIds = notSelectedAntiFeatureIdsFlow.collectAsState().value - val repositories = repositoriesFlow.collectAsState(emptyList()).value - val filteredRepositoryIds = filteredRepositoryIdsFlow.collectAsState().value - val searchQuery = searchQueryFlow.collectAsState().value.normalize() + val apps = appsFlow.collectAsState(null).value + val sortBy = sortByFlow.collectAsState().value + val filterIncompatible = filterIncompatibleFlow.collectAsState().value + val categories = categoriesFlow.collectAsState(null).value + val antiFeatures = antiFeaturesFlow.collectAsState(null).value + val filteredCategoryIds = filteredCategoryIdsFlow.collectAsState().value + val filteredAntiFeatureIds = notSelectedAntiFeatureIdsFlow.collectAsState().value + val repositories = repositoriesFlow.collectAsState(emptyList()).value + val filteredRepositoryIds = filteredRepositoryIdsFlow.collectAsState().value + val searchQuery = searchQueryFlow.collectAsState().value.normalize() - val availableCategoryIds = remember(apps) { - // if there's only one category, we'll not show the filters for it - apps?.flatMap { it.categoryIds ?: emptySet() }?.toSet()?.takeIf { it.size > 1 } - ?: emptySet() + val availableCategoryIds = + remember(apps) { + // if there's only one category, we'll not show the filters for it + apps?.flatMap { it.categoryIds ?: emptySet() }?.toSet()?.takeIf { it.size > 1 } ?: emptySet() } - val availableCategories = remember(categories, apps) { - categories?.filter { - if (type is AppListType.Category) { - // don't show category for list we are currently seeing, because all apps are in it - it.id in availableCategoryIds && it.id != type.categoryId - } else { - it.id in availableCategoryIds - } - } - } - val availableAntiFeatureIds = remember(apps) { - apps?.flatMap { it.antiFeatureIds }?.toSet()?.takeIf { it.size > 1 } - ?: emptySet() - } - val availableAntiFeatures = remember(antiFeatures, apps) { - antiFeatures?.filter { - it.id in availableAntiFeatureIds - } - } - val availableRepositories = remember(apps) { - val repoIds = mutableSetOf() - apps?.forEach { repoIds.add(it.repoId) } - val repos = repositories.filter { it.repoId in repoIds } - // if there's only one repository, we'll not show the filters for it - if (repos.size > 1) repos else emptyList() - } - val filteredApps = apps?.filter { - val matchesCategories = filteredCategoryIds.isEmpty() || - (it.categoryIds ?: emptySet()).intersect(filteredCategoryIds).isNotEmpty() - val matchesAntiFeatures = filteredAntiFeatureIds.isEmpty() || - it.antiFeatureIds.intersect(filteredAntiFeatureIds).isEmpty() - val matchesRepos = filteredRepositoryIds.isEmpty() || it.repoId in filteredRepositoryIds - val matchesQuery = searchQuery.isEmpty() || - it.name.normalize().contains(searchQuery, ignoreCase = true) || - it.summary.normalize().contains(searchQuery, ignoreCase = true) || - it.packageName.contains(searchQuery, ignoreCase = true) - val matchesCompatibility = !filterIncompatible || it.isCompatible - matchesCategories && - matchesAntiFeatures && - matchesRepos && - matchesQuery && - matchesCompatibility - } - - @SuppressLint("NonObservableLocale") // the alternative isn't available here - val locale = Locale.getDefault() - return AppListModel( - apps = if (sortBy == AppListSortOrder.NAME) { - filteredApps?.sortedBy { it.name.lowercase(locale) } + val availableCategories = + remember(categories, apps) { + categories?.filter { + if (type is AppListType.Category) { + // don't show category for list we are currently seeing, because all apps are in it + it.id in availableCategoryIds && it.id != type.categoryId } else { - filteredApps?.sortedByDescending { it.lastUpdated } - }, - showFilterBadge = filteredCategoryIds.isNotEmpty() || - filteredAntiFeatureIds.isNotEmpty() || - filteredRepositoryIds.isNotEmpty(), - sortBy = sortBy, - filterIncompatible = filterIncompatible, - categories = availableCategories, - filteredCategoryIds = filteredCategoryIds, - antiFeatures = availableAntiFeatures, - filteredAntiFeatureIds = filteredAntiFeatureIds, - repositories = availableRepositories, - filteredRepositoryIds = filteredRepositoryIds, - ) + it.id in availableCategoryIds + } + } + } + val availableAntiFeatureIds = + remember(apps) { + apps?.flatMap { it.antiFeatureIds }?.toSet()?.takeIf { it.size > 1 } ?: emptySet() + } + val availableAntiFeatures = + remember(antiFeatures, apps) { antiFeatures?.filter { it.id in availableAntiFeatureIds } } + val availableRepositories = + remember(apps) { + val repoIds = mutableSetOf() + apps?.forEach { repoIds.add(it.repoId) } + val repos = repositories.filter { it.repoId in repoIds } + // if there's only one repository, we'll not show the filters for it + if (repos.size > 1) repos else emptyList() + } + val filteredApps = + apps?.filter { + val matchesCategories = + filteredCategoryIds.isEmpty() || + (it.categoryIds ?: emptySet()).intersect(filteredCategoryIds).isNotEmpty() + val matchesAntiFeatures = + filteredAntiFeatureIds.isEmpty() || + it.antiFeatureIds.intersect(filteredAntiFeatureIds).isEmpty() + val matchesRepos = filteredRepositoryIds.isEmpty() || it.repoId in filteredRepositoryIds + val matchesQuery = + searchQuery.isEmpty() || + it.name.normalize().contains(searchQuery, ignoreCase = true) || + it.summary.normalize().contains(searchQuery, ignoreCase = true) || + it.packageName.contains(searchQuery, ignoreCase = true) + val matchesCompatibility = !filterIncompatible || it.isCompatible + matchesCategories && + matchesAntiFeatures && + matchesRepos && + matchesQuery && + matchesCompatibility + } + + @SuppressLint("NonObservableLocale") // the alternative isn't available here + val locale = Locale.getDefault() + return AppListModel( + apps = + if (sortBy == AppListSortOrder.NAME) { + filteredApps?.sortedBy { it.name.lowercase(locale) } + } else { + filteredApps?.sortedByDescending { it.lastUpdated } + }, + showFilterBadge = + filteredCategoryIds.isNotEmpty() || + filteredAntiFeatureIds.isNotEmpty() || + filteredRepositoryIds.isNotEmpty(), + sortBy = sortBy, + filterIncompatible = filterIncompatible, + categories = availableCategories, + filteredCategoryIds = filteredCategoryIds, + antiFeatures = availableAntiFeatures, + filteredAntiFeatureIds = filteredAntiFeatureIds, + repositories = availableRepositories, + filteredRepositoryIds = filteredRepositoryIds, + ) } diff --git a/app/src/main/kotlin/org/fdroid/ui/lists/AppListRow.kt b/app/src/main/kotlin/org/fdroid/ui/lists/AppListRow.kt index df2a6b2b0..863e3c6b0 100644 --- a/app/src/main/kotlin/org/fdroid/ui/lists/AppListRow.kt +++ b/app/src/main/kotlin/org/fdroid/ui/lists/AppListRow.kt @@ -22,59 +22,55 @@ import org.fdroid.ui.utils.AsyncShimmerImage import org.fdroid.ui.utils.InstalledBadge @Composable -fun AppListRow( - item: AppListItem, - isSelected: Boolean, - modifier: Modifier = Modifier, -) { - ListItem( - headlineContent = { Text(item.name) }, - supportingContent = { Text(item.summary) }, - leadingContent = { - BadgedBox(badge = { if (item.isInstalled) InstalledBadge() }) { - AsyncShimmerImage( - model = item.iconModel, - error = painterResource(R.drawable.ic_repo_app_default), - contentDescription = null, - modifier = Modifier - .size(48.dp) - .semantics { hideFromAccessibility() }, - ) - } - }, - colors = ListItemDefaults.colors( - containerColor = if (isSelected) { - MaterialTheme.colorScheme.surfaceVariant - } else { - Color.Transparent - } - ), - modifier = modifier, - ) +fun AppListRow(item: AppListItem, isSelected: Boolean, modifier: Modifier = Modifier) { + ListItem( + headlineContent = { Text(item.name) }, + supportingContent = { Text(item.summary) }, + leadingContent = { + BadgedBox(badge = { if (item.isInstalled) InstalledBadge() }) { + AsyncShimmerImage( + model = item.iconModel, + error = painterResource(R.drawable.ic_repo_app_default), + contentDescription = null, + modifier = Modifier.size(48.dp).semantics { hideFromAccessibility() }, + ) + } + }, + colors = + ListItemDefaults.colors( + containerColor = + if (isSelected) { + MaterialTheme.colorScheme.surfaceVariant + } else { + Color.Transparent + } + ), + modifier = modifier, + ) } @Preview @Composable fun AppListRowPreview() { - FDroidContent { - val item1 = AppListItem(1, "1", "This is app 1", "It has summary 2", 0, false, true) - val item2 = AppListItem(2, "2", "This is app 2", "It has summary 2", 0, true, true) - Column { - AppListRow(item1, false) - AppListRow(item2, true) - } + FDroidContent { + val item1 = AppListItem(1, "1", "This is app 1", "It has summary 2", 0, false, true) + val item2 = AppListItem(2, "2", "This is app 2", "It has summary 2", 0, true, true) + Column { + AppListRow(item1, false) + AppListRow(item2, true) } + } } @Preview(uiMode = UI_MODE_NIGHT_YES) @Composable fun AppListRowPreviewNight() { - FDroidContent { - val item1 = AppListItem(1, "1", "This is app 1", "It has summary 2", 0, true, true) - val item2 = AppListItem(2, "2", "This is app 2", "It has summary 2", 0, false, true) - Column { - AppListRow(item1, false) - AppListRow(item2, true) - } + FDroidContent { + val item1 = AppListItem(1, "1", "This is app 1", "It has summary 2", 0, true, true) + val item2 = AppListItem(2, "2", "This is app 2", "It has summary 2", 0, false, true) + Column { + AppListRow(item1, false) + AppListRow(item2, true) } + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/lists/AppListType.kt b/app/src/main/kotlin/org/fdroid/ui/lists/AppListType.kt index d273430c3..660fa5554 100644 --- a/app/src/main/kotlin/org/fdroid/ui/lists/AppListType.kt +++ b/app/src/main/kotlin/org/fdroid/ui/lists/AppListType.kt @@ -4,27 +4,21 @@ import kotlinx.serialization.Serializable @Serializable sealed class AppListType { - abstract val title: String + abstract val title: String - @Serializable - data class New(override val title: String) : AppListType() + @Serializable data class New(override val title: String) : AppListType() - @Serializable - data class RecentlyUpdated(override val title: String) : AppListType() + @Serializable data class RecentlyUpdated(override val title: String) : AppListType() - @Serializable - data class MostDownloaded(override val title: String) : AppListType() + @Serializable data class MostDownloaded(override val title: String) : AppListType() - @Serializable - data class All(override val title: String) : AppListType() + @Serializable data class All(override val title: String) : AppListType() - @Serializable - data class Category(override val title: String, val categoryId: String) : AppListType() + @Serializable + data class Category(override val title: String, val categoryId: String) : AppListType() - @Serializable - data class Repository(override val title: String, val repoId: Long) : AppListType() - - @Serializable - data class Author(override val title: String, val authorName: String) : AppListType() + @Serializable data class Repository(override val title: String, val repoId: Long) : AppListType() + @Serializable + data class Author(override val title: String, val authorName: String) : AppListType() } diff --git a/app/src/main/kotlin/org/fdroid/ui/lists/AppListViewModel.kt b/app/src/main/kotlin/org/fdroid/ui/lists/AppListViewModel.kt index fe28b27e2..83c169083 100644 --- a/app/src/main/kotlin/org/fdroid/ui/lists/AppListViewModel.kt +++ b/app/src/main/kotlin/org/fdroid/ui/lists/AppListViewModel.kt @@ -14,6 +14,8 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel +import java.text.Collator +import java.util.Locale import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -36,212 +38,191 @@ import org.fdroid.settings.OnboardingManager import org.fdroid.settings.SettingsManager import org.fdroid.ui.categories.CategoryItem import org.fdroid.ui.repositories.RepositoryItem -import java.text.Collator -import java.util.Locale @HiltViewModel(assistedFactory = AppListViewModel.Factory::class) -class AppListViewModel @AssistedInject constructor( - private val app: Application, - @Assisted val type: AppListType, - savedStateHandle: SavedStateHandle, - private val db: FDroidDatabase, - private val repoManager: RepoManager, - private val settingsManager: SettingsManager, - private val onboardingManager: OnboardingManager, - private val installedAppsCache: InstalledAppsCache, +class AppListViewModel +@AssistedInject +constructor( + private val app: Application, + @Assisted val type: AppListType, + savedStateHandle: SavedStateHandle, + private val db: FDroidDatabase, + private val repoManager: RepoManager, + private val settingsManager: SettingsManager, + private val onboardingManager: OnboardingManager, + private val installedAppsCache: InstalledAppsCache, ) : AndroidViewModel(app), AppListActions { - private val moleculeScope = - CoroutineScope(viewModelScope.coroutineContext + AndroidUiDispatcher.Main) + private val moleculeScope = + CoroutineScope(viewModelScope.coroutineContext + AndroidUiDispatcher.Main) - private val localeList = LocaleListCompat.getDefault() - private val apps = MutableStateFlow?>(null) - private val categories = db.getRepositoryDao().getLiveCategories().asFlow().map { categories -> - val collator = Collator.getInstance(Locale.getDefault()) - categories.map { category -> - CategoryItem( - id = category.id, - name = category.getName(localeList) ?: "Unknown Category", - ) - }.sortedWith { c1, c2 -> collator.compare(c1.name, c2.name) } - } - private val antiFeatures = db.getRepositoryDao().getAntiFeaturesFlow().map { antiFeatures -> - val collator = Collator.getInstance(Locale.getDefault()) - val proxy = settingsManager.proxyConfig - antiFeatures.map { antiFeature -> - val repo = repoManager.getRepository(antiFeature.repoId) - AntiFeatureItem( - id = antiFeature.id, - name = antiFeature.getName(localeList) ?: "Unknown Anti-Feature", - iconModel = antiFeature.getIcon(localeList)?.getImageModel(repo, proxy) - ) - }.sortedWith { a1, a2 -> collator.compare(a1.name, a2.name) } - } - private val repositories = repoManager.repositoriesState.map { repositories -> - val proxyConfig = settingsManager.proxyConfig - repositories.mapNotNull { - if (it.enabled) RepositoryItem(it, localeList, proxyConfig) - else null - }.sortedBy { it.weight } - } - private val query = MutableStateFlow("") - - private val _showFilters = savedStateHandle.getMutableStateFlow("showFilters", false) - val showFilters = _showFilters.asStateFlow() - - private val sortBy = MutableStateFlow(settingsManager.appListSortOrder) - private val filterIncompatible = MutableStateFlow(settingsManager.filterIncompatible) - private val filteredCategoryIds = MutableStateFlow>(emptySet()) - private val filteredAntiFeatureIds = MutableStateFlow>(emptySet()) - private val filteredRepositoryIds = MutableStateFlow>(emptySet()) - val showOnboarding = onboardingManager.showFilterOnboarding - - val appListModel: StateFlow by lazy(LazyThreadSafetyMode.NONE) { - moleculeScope.launchMolecule(mode = ContextClock) { - AppListPresenter( - type = type, - appsFlow = apps, - sortByFlow = sortBy, - filterIncompatibleFlow = filterIncompatible, - categoriesFlow = categories, - filteredCategoryIdsFlow = filteredCategoryIds, - antiFeaturesFlow = antiFeatures, - notSelectedAntiFeatureIdsFlow = filteredAntiFeatureIds, - repositoriesFlow = repositories, - filteredRepositoryIdsFlow = filteredRepositoryIds, - searchQueryFlow = query, - ) + private val localeList = LocaleListCompat.getDefault() + private val apps = MutableStateFlow?>(null) + private val categories = + db.getRepositoryDao().getLiveCategories().asFlow().map { categories -> + val collator = Collator.getInstance(Locale.getDefault()) + categories + .map { category -> + CategoryItem(id = category.id, name = category.getName(localeList) ?: "Unknown Category") } + .sortedWith { c1, c2 -> collator.compare(c1.name, c2.name) } } - - init { - viewModelScope.launch(Dispatchers.IO) { - apps.value = loadApps(type) + private val antiFeatures = + db.getRepositoryDao().getAntiFeaturesFlow().map { antiFeatures -> + val collator = Collator.getInstance(Locale.getDefault()) + val proxy = settingsManager.proxyConfig + antiFeatures + .map { antiFeature -> + val repo = repoManager.getRepository(antiFeature.repoId) + AntiFeatureItem( + id = antiFeature.id, + name = antiFeature.getName(localeList) ?: "Unknown Anti-Feature", + iconModel = antiFeature.getIcon(localeList)?.getImageModel(repo, proxy), + ) } + .sortedWith { a1, a2 -> collator.compare(a1.name, a2.name) } + } + private val repositories = + repoManager.repositoriesState.map { repositories -> + val proxyConfig = settingsManager.proxyConfig + repositories + .mapNotNull { if (it.enabled) RepositoryItem(it, localeList, proxyConfig) else null } + .sortedBy { it.weight } + } + private val query = MutableStateFlow("") + + private val _showFilters = savedStateHandle.getMutableStateFlow("showFilters", false) + val showFilters = _showFilters.asStateFlow() + + private val sortBy = MutableStateFlow(settingsManager.appListSortOrder) + private val filterIncompatible = MutableStateFlow(settingsManager.filterIncompatible) + private val filteredCategoryIds = MutableStateFlow>(emptySet()) + private val filteredAntiFeatureIds = MutableStateFlow>(emptySet()) + private val filteredRepositoryIds = MutableStateFlow>(emptySet()) + val showOnboarding = onboardingManager.showFilterOnboarding + + val appListModel: StateFlow by + lazy(LazyThreadSafetyMode.NONE) { + moleculeScope.launchMolecule(mode = ContextClock) { + AppListPresenter( + type = type, + appsFlow = apps, + sortByFlow = sortBy, + filterIncompatibleFlow = filterIncompatible, + categoriesFlow = categories, + filteredCategoryIdsFlow = filteredCategoryIds, + antiFeaturesFlow = antiFeatures, + notSelectedAntiFeatureIdsFlow = filteredAntiFeatureIds, + repositoriesFlow = repositories, + filteredRepositoryIdsFlow = filteredRepositoryIds, + searchQueryFlow = query, + ) + } } - @WorkerThread - private suspend fun loadApps(type: AppListType): List { - val appDao = db.getAppDao() - val proxyConfig = settingsManager.proxyConfig - return when (type) { - is AppListType.Author -> appDao.getAppsByAuthor(type.authorName) - is AppListType.Category -> appDao.getAppsByCategory(type.categoryId) - is AppListType.New -> appDao.getNewApps() - is AppListType.RecentlyUpdated -> appDao.getRecentlyUpdatedApps() - is AppListType.MostDownloaded -> { - val packageNames = app.assets.open("most_downloaded_apps.json").use { inputStream -> - @OptIn(ExperimentalSerializationApi::class) - Json.decodeFromStream>(inputStream) - } - appDao.getApps(packageNames) - } - is AppListType.All -> appDao.getAllApps() - is AppListType.Repository -> appDao.getAppsByRepository(type.repoId) - }.mapNotNull { - val repository = repoManager.getRepository(it.repoId) - ?: return@mapNotNull null - val iconModel = it.getIcon(localeList)?.getImageModel(repository, proxyConfig) - as? DownloadRequest - val isInstalled = installedAppsCache.isInstalled(it.packageName) - AppListItem( - repoId = it.repoId, - packageName = it.packageName, - name = it.getName(localeList) ?: "Unknown App", - summary = it.getSummary(localeList) ?: "Unknown", - lastUpdated = it.lastUpdated, - isInstalled = isInstalled, - isCompatible = it.isCompatible, - iconModel = if (isInstalled) { - PackageName(it.packageName, iconModel) - } else { - iconModel - }, - categoryIds = it.categories?.toSet(), - antiFeatureIds = it.antiFeatureKeys.toSet(), - ) - } - } + init { + viewModelScope.launch(Dispatchers.IO) { apps.value = loadApps(type) } + } - override fun toggleFilterVisibility() { - _showFilters.update { !it } + @WorkerThread + private suspend fun loadApps(type: AppListType): List { + val appDao = db.getAppDao() + val proxyConfig = settingsManager.proxyConfig + return when (type) { + is AppListType.Author -> appDao.getAppsByAuthor(type.authorName) + is AppListType.Category -> appDao.getAppsByCategory(type.categoryId) + is AppListType.New -> appDao.getNewApps() + is AppListType.RecentlyUpdated -> appDao.getRecentlyUpdatedApps() + is AppListType.MostDownloaded -> { + val packageNames = + app.assets.open("most_downloaded_apps.json").use { inputStream -> + @OptIn(ExperimentalSerializationApi::class) + Json.decodeFromStream>(inputStream) + } + appDao.getApps(packageNames) + } + is AppListType.All -> appDao.getAllApps() + is AppListType.Repository -> appDao.getAppsByRepository(type.repoId) + }.mapNotNull { + val repository = repoManager.getRepository(it.repoId) ?: return@mapNotNull null + val iconModel = + it.getIcon(localeList)?.getImageModel(repository, proxyConfig) as? DownloadRequest + val isInstalled = installedAppsCache.isInstalled(it.packageName) + AppListItem( + repoId = it.repoId, + packageName = it.packageName, + name = it.getName(localeList) ?: "Unknown App", + summary = it.getSummary(localeList) ?: "Unknown", + lastUpdated = it.lastUpdated, + isInstalled = isInstalled, + isCompatible = it.isCompatible, + iconModel = + if (isInstalled) { + PackageName(it.packageName, iconModel) + } else { + iconModel + }, + categoryIds = it.categories?.toSet(), + antiFeatureIds = it.antiFeatureKeys.toSet(), + ) } + } - override fun sortBy(sort: AppListSortOrder) { - sortBy.update { sort } - } + override fun toggleFilterVisibility() { + _showFilters.update { !it } + } - override fun toggleFilterIncompatible() { - filterIncompatible.update { !it } - } + override fun sortBy(sort: AppListSortOrder) { + sortBy.update { sort } + } - override fun addCategory(categoryId: String) { - filteredCategoryIds.update { - it.toMutableSet().apply { - add(categoryId) - } - } - } + override fun toggleFilterIncompatible() { + filterIncompatible.update { !it } + } - override fun removeCategory(categoryId: String) { - filteredCategoryIds.update { - it.toMutableSet().apply { - remove(categoryId) - } - } - } + override fun addCategory(categoryId: String) { + filteredCategoryIds.update { it.toMutableSet().apply { add(categoryId) } } + } - override fun addAntiFeature(antiFeatureId: String) { - filteredAntiFeatureIds.update { - it.toMutableSet().apply { - add(antiFeatureId) - } - } - } + override fun removeCategory(categoryId: String) { + filteredCategoryIds.update { it.toMutableSet().apply { remove(categoryId) } } + } - override fun removeAntiFeature(antiFeatureId: String) { - filteredAntiFeatureIds.update { - it.toMutableSet().apply { - remove(antiFeatureId) - } - } - } + override fun addAntiFeature(antiFeatureId: String) { + filteredAntiFeatureIds.update { it.toMutableSet().apply { add(antiFeatureId) } } + } - override fun addRepository(repoId: Long) { - filteredRepositoryIds.update { - it.toMutableSet().apply { - add(repoId) - } - } - } + override fun removeAntiFeature(antiFeatureId: String) { + filteredAntiFeatureIds.update { it.toMutableSet().apply { remove(antiFeatureId) } } + } - override fun removeRepository(repoId: Long) { - filteredRepositoryIds.update { - it.toMutableSet().apply { - remove(repoId) - } - } - } + override fun addRepository(repoId: Long) { + filteredRepositoryIds.update { it.toMutableSet().apply { add(repoId) } } + } - override fun saveFilters() { - settingsManager.saveAppListFilter(sortBy.value, filterIncompatible.value) - } + override fun removeRepository(repoId: Long) { + filteredRepositoryIds.update { it.toMutableSet().apply { remove(repoId) } } + } - override fun clearFilters() { - filterIncompatible.value = false - filteredCategoryIds.value = emptySet() - filteredAntiFeatureIds.value = emptySet() - filteredRepositoryIds.value = emptySet() - } + override fun saveFilters() { + settingsManager.saveAppListFilter(sortBy.value, filterIncompatible.value) + } - override fun onSearch(query: String) { - this.query.value = query - } + override fun clearFilters() { + filterIncompatible.value = false + filteredCategoryIds.value = emptySet() + filteredAntiFeatureIds.value = emptySet() + filteredRepositoryIds.value = emptySet() + } - override fun onOnboardingSeen() = onboardingManager.onFilterOnboardingSeen() + override fun onSearch(query: String) { + this.query.value = query + } - @AssistedFactory - interface Factory { - fun create(type: AppListType): AppListViewModel - } + override fun onOnboardingSeen() = onboardingManager.onFilterOnboardingSeen() + + @AssistedFactory + interface Factory { + fun create(type: AppListType): AppListViewModel + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/lists/AppsFilter.kt b/app/src/main/kotlin/org/fdroid/ui/lists/AppsFilter.kt index 3c09ff905..46318b0fd 100644 --- a/app/src/main/kotlin/org/fdroid/ui/lists/AppsFilter.kt +++ b/app/src/main/kotlin/org/fdroid/ui/lists/AppsFilter.kt @@ -52,258 +52,232 @@ import org.fdroid.ui.utils.getAppListInfo import org.fdroid.ui.utils.repoItems @Composable -fun AppsFilter( - info: AppListInfo, - modifier: Modifier = Modifier, -) { - val scrollState = rememberScrollState() - Column(modifier = modifier.verticalScroll(scrollState)) { - FilterHeader( - icon = Icons.AutoMirrored.Default.Sort, - text = stringResource(R.string.sort_title), - ) - ChipFlowRow( - modifier = Modifier.padding(start = 16.dp) - ) { - val byNameSelected = info.model.sortBy == AppListSortOrder.NAME - FilterChip( - selected = byNameSelected, - modifier = Modifier.height(chipHeight), - leadingIcon = { - if (byNameSelected) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = stringResource(R.string.filter_selected), - ) - } else { - Icon(Icons.Default.SortByAlpha, null) - } - }, - label = { - Text(stringResource(R.string.sort_by_name)) - }, - onClick = { - if (!byNameSelected) info.actions.sortBy(AppListSortOrder.NAME) - }, +fun AppsFilter(info: AppListInfo, modifier: Modifier = Modifier) { + val scrollState = rememberScrollState() + Column(modifier = modifier.verticalScroll(scrollState)) { + FilterHeader(icon = Icons.AutoMirrored.Default.Sort, text = stringResource(R.string.sort_title)) + ChipFlowRow(modifier = Modifier.padding(start = 16.dp)) { + val byNameSelected = info.model.sortBy == AppListSortOrder.NAME + FilterChip( + selected = byNameSelected, + modifier = Modifier.height(chipHeight), + leadingIcon = { + if (byNameSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = stringResource(R.string.filter_selected), ) - val byLatestSelected = info.model.sortBy == AppListSortOrder.LAST_UPDATED - FilterChip( - selected = byLatestSelected, - modifier = Modifier.height(chipHeight), - leadingIcon = { - if (byLatestSelected) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = stringResource(R.string.filter_selected), - ) - } else { - Icon(Icons.Default.AccessTime, null) - } - }, - label = { - Text(stringResource(R.string.sort_by_latest)) - }, - onClick = { - if (!byLatestSelected) info.actions.sortBy(AppListSortOrder.LAST_UPDATED) - }, + } else { + Icon(Icons.Default.SortByAlpha, null) + } + }, + label = { Text(stringResource(R.string.sort_by_name)) }, + onClick = { if (!byNameSelected) info.actions.sortBy(AppListSortOrder.NAME) }, + ) + val byLatestSelected = info.model.sortBy == AppListSortOrder.LAST_UPDATED + FilterChip( + selected = byLatestSelected, + modifier = Modifier.height(chipHeight), + leadingIcon = { + if (byLatestSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = stringResource(R.string.filter_selected), ) - FilterChip( - selected = info.model.filterIncompatible, - modifier = Modifier.height(chipHeight), - leadingIcon = { - if (info.model.filterIncompatible) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = stringResource(R.string.filter_selected), - ) - } else { - Icon(Icons.Default.PhonelinkErase, null) - } - }, - label = { - Text(stringResource(R.string.filter_only_compatible)) - }, - onClick = info.actions::toggleFilterIncompatible, + } else { + Icon(Icons.Default.AccessTime, null) + } + }, + label = { Text(stringResource(R.string.sort_by_latest)) }, + onClick = { if (!byLatestSelected) info.actions.sortBy(AppListSortOrder.LAST_UPDATED) }, + ) + FilterChip( + selected = info.model.filterIncompatible, + modifier = Modifier.height(chipHeight), + leadingIcon = { + if (info.model.filterIncompatible) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = stringResource(R.string.filter_selected), ) - } - TextButton( - onClick = info.actions::saveFilters, - modifier = modifier - .align(Alignment.End) - .padding(horizontal = 16.dp), - ) { - Icon(Icons.Default.Save, null) - Spacer(Modifier.width(ButtonDefaults.IconSpacing)) - Text( - text = stringResource(R.string.filter_button_save) - ) - } - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - Text( - text = stringResource(R.string.filter_intro), - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - ) - // Categories - val categories = info.model.categories - if (!categories.isNullOrEmpty()) FilterSection( - icon = Icons.Default.Category, - title = stringResource(R.string.main_menu__categories), - initiallyExpanded = info.model.filteredCategoryIds.isNotEmpty(), - onCollapsed = { - info.model.filteredCategoryIds.forEach { - info.actions.removeCategory(it) - } - }, - ) { - categories.forEach { item -> - val isSelected = item.id in info.model.filteredCategoryIds - CategoryChip(categoryItem = item, selected = isSelected, onSelected = { - if (isSelected) { - info.actions.removeCategory(item.id) - } else { - info.actions.addCategory(item.id) - } - }) - } - } - // Repositories - if (info.model.repositories.isNotEmpty()) FilterSection( - icon = PackageVariant, - title = stringResource(R.string.app_details_repositories), - initiallyExpanded = info.model.filteredRepositoryIds.isNotEmpty(), - onCollapsed = { - info.model.filteredRepositoryIds.forEach { - info.actions.removeRepository(it) - } - }, - ) { - info.model.repositories.forEach { repo -> - val selected = repo.repoId in info.model.filteredRepositoryIds - FilterChip( - selected = selected, - modifier = Modifier.height(chipHeight), - leadingIcon = { - if (selected) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = stringResource(R.string.filter_selected), - ) - } else AsyncShimmerImage( - model = repo.icon, - contentDescription = null, - modifier = Modifier - .size(24.dp) - .semantics { hideFromAccessibility() }, - ) - }, - label = { - Text(repo.name) - }, - onClick = { - if (selected) { - info.actions.removeRepository(repo.repoId) - } else { - info.actions.addRepository(repo.repoId) - } - }, - ) - } - } - // Anti-Features - val antiFeatures = info.model.antiFeatures - if (!antiFeatures.isNullOrEmpty()) FilterSection( - icon = Icons.Default.WarningAmber, - title = stringResource(R.string.filter_antifeatures), - initiallyExpanded = info.model.filteredAntiFeatureIds.isNotEmpty(), - onCollapsed = { - info.model.filteredAntiFeatureIds.forEach { - info.actions.removeAntiFeature(it) - } - }, - ) { - antiFeatures.forEach { item -> - val isSelected = item.id in info.model.filteredAntiFeatureIds - FilterChip( - selected = isSelected, - modifier = Modifier.height(chipHeight), - leadingIcon = { - if (isSelected) { - Icon( - imageVector = Icons.Default.Remove, - contentDescription = stringResource(R.string.filter_selected), - ) - } else AsyncShimmerImage( - model = item.iconModel, - colorFilter = tint(MaterialTheme.colorScheme.onSurfaceVariant), - error = rememberVectorPainter(Icons.Default.CrisisAlert), - contentDescription = null, - modifier = Modifier - .size(24.dp) - .semantics { hideFromAccessibility() }, - ) - }, - label = { - Text(item.name) - }, - colors = FilterChipDefaults.filterChipColors() - .copy(selectedContainerColor = MaterialTheme.colorScheme.errorContainer), - onClick = { - if (isSelected) { - info.actions.removeAntiFeature(item.id) - } else { - info.actions.addAntiFeature(item.id) - } - }, - ) - } - } - // clear all filters - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - TextButton( - onClick = info.actions::clearFilters, - modifier = modifier - .align(Alignment.End) - .padding(horizontal = 16.dp), - ) { - Icon(Icons.Default.Clear, null) - Spacer(Modifier.width(ButtonDefaults.IconSpacing)) - Text(stringResource(R.string.filter_button_clear_all)) - } + } else { + Icon(Icons.Default.PhonelinkErase, null) + } + }, + label = { Text(stringResource(R.string.filter_only_compatible)) }, + onClick = info.actions::toggleFilterIncompatible, + ) } + TextButton( + onClick = info.actions::saveFilters, + modifier = modifier.align(Alignment.End).padding(horizontal = 16.dp), + ) { + Icon(Icons.Default.Save, null) + Spacer(Modifier.width(ButtonDefaults.IconSpacing)) + Text(text = stringResource(R.string.filter_button_save)) + } + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + Text( + text = stringResource(R.string.filter_intro), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + // Categories + val categories = info.model.categories + if (!categories.isNullOrEmpty()) + FilterSection( + icon = Icons.Default.Category, + title = stringResource(R.string.main_menu__categories), + initiallyExpanded = info.model.filteredCategoryIds.isNotEmpty(), + onCollapsed = { info.model.filteredCategoryIds.forEach { info.actions.removeCategory(it) } }, + ) { + categories.forEach { item -> + val isSelected = item.id in info.model.filteredCategoryIds + CategoryChip( + categoryItem = item, + selected = isSelected, + onSelected = { + if (isSelected) { + info.actions.removeCategory(item.id) + } else { + info.actions.addCategory(item.id) + } + }, + ) + } + } + // Repositories + if (info.model.repositories.isNotEmpty()) + FilterSection( + icon = PackageVariant, + title = stringResource(R.string.app_details_repositories), + initiallyExpanded = info.model.filteredRepositoryIds.isNotEmpty(), + onCollapsed = { + info.model.filteredRepositoryIds.forEach { info.actions.removeRepository(it) } + }, + ) { + info.model.repositories.forEach { repo -> + val selected = repo.repoId in info.model.filteredRepositoryIds + FilterChip( + selected = selected, + modifier = Modifier.height(chipHeight), + leadingIcon = { + if (selected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = stringResource(R.string.filter_selected), + ) + } else + AsyncShimmerImage( + model = repo.icon, + contentDescription = null, + modifier = Modifier.size(24.dp).semantics { hideFromAccessibility() }, + ) + }, + label = { Text(repo.name) }, + onClick = { + if (selected) { + info.actions.removeRepository(repo.repoId) + } else { + info.actions.addRepository(repo.repoId) + } + }, + ) + } + } + // Anti-Features + val antiFeatures = info.model.antiFeatures + if (!antiFeatures.isNullOrEmpty()) + FilterSection( + icon = Icons.Default.WarningAmber, + title = stringResource(R.string.filter_antifeatures), + initiallyExpanded = info.model.filteredAntiFeatureIds.isNotEmpty(), + onCollapsed = { + info.model.filteredAntiFeatureIds.forEach { info.actions.removeAntiFeature(it) } + }, + ) { + antiFeatures.forEach { item -> + val isSelected = item.id in info.model.filteredAntiFeatureIds + FilterChip( + selected = isSelected, + modifier = Modifier.height(chipHeight), + leadingIcon = { + if (isSelected) { + Icon( + imageVector = Icons.Default.Remove, + contentDescription = stringResource(R.string.filter_selected), + ) + } else + AsyncShimmerImage( + model = item.iconModel, + colorFilter = tint(MaterialTheme.colorScheme.onSurfaceVariant), + error = rememberVectorPainter(Icons.Default.CrisisAlert), + contentDescription = null, + modifier = Modifier.size(24.dp).semantics { hideFromAccessibility() }, + ) + }, + label = { Text(item.name) }, + colors = + FilterChipDefaults.filterChipColors() + .copy(selectedContainerColor = MaterialTheme.colorScheme.errorContainer), + onClick = { + if (isSelected) { + info.actions.removeAntiFeature(item.id) + } else { + info.actions.addAntiFeature(item.id) + } + }, + ) + } + } + // clear all filters + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + TextButton( + onClick = info.actions::clearFilters, + modifier = modifier.align(Alignment.End).padding(horizontal = 16.dp), + ) { + Icon(Icons.Default.Clear, null) + Spacer(Modifier.width(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.filter_button_clear_all)) + } + } } @Composable @Preview private fun Preview() { - FDroidContent { - val model = AppListModel( - apps = listOf( - AppListItem(1, "1", "This is app 1", "It has summary 2", 0, false, true), - AppListItem(2, "2", "This is app 2", "It has summary 2", 0, true, true), - ), - showFilterBadge = true, - sortBy = AppListSortOrder.NAME, - filterIncompatible = Random.nextBoolean(), - categories = listOf( - CategoryItem("App Store & Updater", "App Store & Updater"), - CategoryItem("Browser", "Browser"), - CategoryItem("Calendar & Agenda", "Calendar & Agenda"), - CategoryItem("Cloud Storage & File Sync", "Cloud Storage & File Sync"), - CategoryItem("Connectivity", "Connectivity"), - CategoryItem("Development", "Development"), - CategoryItem("doesn't exist", "Foo bar"), - ), - filteredCategoryIds = setOf("Browser"), - antiFeatures = listOf( - AntiFeatureItem("foo1", "bar1", null), - AntiFeatureItem("foo2", "bar2", null), - AntiFeatureItem("foo3", "bar3", null), - ), - filteredAntiFeatureIds = setOf("foo2"), - repositories = repoItems, - filteredRepositoryIds = setOf(2), - ) - val info = getAppListInfo(model) - AppsFilter(info) - } + FDroidContent { + val model = + AppListModel( + apps = + listOf( + AppListItem(1, "1", "This is app 1", "It has summary 2", 0, false, true), + AppListItem(2, "2", "This is app 2", "It has summary 2", 0, true, true), + ), + showFilterBadge = true, + sortBy = AppListSortOrder.NAME, + filterIncompatible = Random.nextBoolean(), + categories = + listOf( + CategoryItem("App Store & Updater", "App Store & Updater"), + CategoryItem("Browser", "Browser"), + CategoryItem("Calendar & Agenda", "Calendar & Agenda"), + CategoryItem("Cloud Storage & File Sync", "Cloud Storage & File Sync"), + CategoryItem("Connectivity", "Connectivity"), + CategoryItem("Development", "Development"), + CategoryItem("doesn't exist", "Foo bar"), + ), + filteredCategoryIds = setOf("Browser"), + antiFeatures = + listOf( + AntiFeatureItem("foo1", "bar1", null), + AntiFeatureItem("foo2", "bar2", null), + AntiFeatureItem("foo3", "bar3", null), + ), + filteredAntiFeatureIds = setOf("foo2"), + repositories = repoItems, + filteredRepositoryIds = setOf(2), + ) + val info = getAppListInfo(model) + AppsFilter(info) + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/lists/FilterSection.kt b/app/src/main/kotlin/org/fdroid/ui/lists/FilterSection.kt index 49e00bafa..29340dce5 100644 --- a/app/src/main/kotlin/org/fdroid/ui/lists/FilterSection.kt +++ b/app/src/main/kotlin/org/fdroid/ui/lists/FilterSection.kt @@ -31,68 +31,60 @@ import org.fdroid.ui.categories.ChipFlowRow @Composable fun FilterSection( - icon: ImageVector, - title: String, - initiallyExpanded: Boolean, - onCollapsed: () -> Unit, - modifier: Modifier = Modifier, - content: @Composable (FlowRowScope.() -> Unit), + icon: ImageVector, + title: String, + initiallyExpanded: Boolean, + onCollapsed: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable (FlowRowScope.() -> Unit), ) { - var expanded by rememberSaveable { mutableStateOf(initiallyExpanded) } - val onExpand = { - expanded = !expanded - if (!expanded) onCollapsed() - } - FilterHeader(icon = icon, text = title, modifier = Modifier.clickable { onExpand() }) { - IconButton(onClick = onExpand) { - Icon( - imageVector = if (expanded) { - Icons.Default.Remove - } else { - Icons.Default.Add - }, - contentDescription = if (expanded) { - stringResource(R.string.collapse) - } else { - stringResource(R.string.expand) - }, - ) - } - } - AnimatedVisibility(expanded) { - ChipFlowRow( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - ) { - content() - } + var expanded by rememberSaveable { mutableStateOf(initiallyExpanded) } + val onExpand = { + expanded = !expanded + if (!expanded) onCollapsed() + } + FilterHeader(icon = icon, text = title, modifier = Modifier.clickable { onExpand() }) { + IconButton(onClick = onExpand) { + Icon( + imageVector = + if (expanded) { + Icons.Default.Remove + } else { + Icons.Default.Add + }, + contentDescription = + if (expanded) { + stringResource(R.string.collapse) + } else { + stringResource(R.string.expand) + }, + ) } + } + AnimatedVisibility(expanded) { + ChipFlowRow(modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp)) { content() } + } } @Composable fun FilterHeader( - icon: ImageVector, - text: String, - modifier: Modifier = Modifier, - trailingContent: @Composable () -> Unit = {} + icon: ImageVector, + text: String, + modifier: Modifier = Modifier, + trailingContent: @Composable () -> Unit = {}, ) { - Row( - horizontalArrangement = spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp) - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.semantics { hideFromAccessibility() }, - ) - Text( - text = text, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.weight(1f) - ) - trailingContent() - } + Row( + horizontalArrangement = spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.semantics { hideFromAccessibility() }, + ) + Text(text = text, style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f)) + trailingContent() + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/navigation/BottomBar.kt b/app/src/main/kotlin/org/fdroid/ui/navigation/BottomBar.kt index 0a6b6aa15..e2f1eec62 100644 --- a/app/src/main/kotlin/org/fdroid/ui/navigation/BottomBar.kt +++ b/app/src/main/kotlin/org/fdroid/ui/navigation/BottomBar.kt @@ -28,143 +28,133 @@ import org.fdroid.ui.FDroidContent @Composable fun BottomBar( - numUpdates: Int, - hasIssues: Boolean, - currentNavKey: NavKey, - onNav: (MainNavKey) -> Unit, + numUpdates: Int, + hasIssues: Boolean, + currentNavKey: NavKey, + onNav: (MainNavKey) -> Unit, ) { - val res = LocalResources.current - NavigationBar { - topLevelRoutes.forEach { dest -> - NavigationBarItem( - icon = { NavIcon(dest, numUpdates, hasIssues) }, - label = { Text(stringResource(dest.label)) }, - selected = dest == currentNavKey, - colors = NavigationBarItemDefaults.colors( - indicatorColor = MaterialTheme.colorScheme.primary, - selectedTextColor = MaterialTheme.colorScheme.primary, - selectedIconColor = contentColorFor(MaterialTheme.colorScheme.primary), - ), - onClick = { - if (dest != currentNavKey) onNav(dest) - }, - modifier = Modifier.semantics { - if (dest == NavigationKey.MyApps) { - if (numUpdates > 0) { - stateDescription = - res.getString(R.string.notification_channel_updates_available_title) - } else if (hasIssues) { - stateDescription = - res.getString(R.string.my_apps_header_apps_with_issue) - } - } - } - ) - } + val res = LocalResources.current + NavigationBar { + topLevelRoutes.forEach { dest -> + NavigationBarItem( + icon = { NavIcon(dest, numUpdates, hasIssues) }, + label = { Text(stringResource(dest.label)) }, + selected = dest == currentNavKey, + colors = + NavigationBarItemDefaults.colors( + indicatorColor = MaterialTheme.colorScheme.primary, + selectedTextColor = MaterialTheme.colorScheme.primary, + selectedIconColor = contentColorFor(MaterialTheme.colorScheme.primary), + ), + onClick = { if (dest != currentNavKey) onNav(dest) }, + modifier = + Modifier.semantics { + if (dest == NavigationKey.MyApps) { + if (numUpdates > 0) { + stateDescription = + res.getString(R.string.notification_channel_updates_available_title) + } else if (hasIssues) { + stateDescription = res.getString(R.string.my_apps_header_apps_with_issue) + } + } + }, + ) } + } } @Composable fun NavigationRail( - numUpdates: Int, - hasIssues: Boolean, - currentNavKey: NavKey, - onNav: (MainNavKey) -> Unit, - modifier: Modifier = Modifier, + numUpdates: Int, + hasIssues: Boolean, + currentNavKey: NavKey, + onNav: (MainNavKey) -> Unit, + modifier: Modifier = Modifier, ) { - val res = LocalResources.current - NavigationRail(modifier) { - topLevelRoutes.forEach { dest -> - NavigationRailItem( - icon = { NavIcon(dest, numUpdates, hasIssues) }, - label = { Text(stringResource(dest.label)) }, - selected = dest == currentNavKey, - colors = NavigationRailItemDefaults.colors( - indicatorColor = MaterialTheme.colorScheme.primary, - selectedTextColor = MaterialTheme.colorScheme.primary, - selectedIconColor = contentColorFor(MaterialTheme.colorScheme.primary), - ), - onClick = { - if (dest != currentNavKey) onNav(dest) - }, - modifier = Modifier.semantics { - if (dest == NavigationKey.MyApps) { - if (numUpdates > 0) { - stateDescription = - res.getString(R.string.notification_channel_updates_available_title) - } else if (hasIssues) { - stateDescription = - res.getString(R.string.my_apps_header_apps_with_issue) - } - } - } - ) - } + val res = LocalResources.current + NavigationRail(modifier) { + topLevelRoutes.forEach { dest -> + NavigationRailItem( + icon = { NavIcon(dest, numUpdates, hasIssues) }, + label = { Text(stringResource(dest.label)) }, + selected = dest == currentNavKey, + colors = + NavigationRailItemDefaults.colors( + indicatorColor = MaterialTheme.colorScheme.primary, + selectedTextColor = MaterialTheme.colorScheme.primary, + selectedIconColor = contentColorFor(MaterialTheme.colorScheme.primary), + ), + onClick = { if (dest != currentNavKey) onNav(dest) }, + modifier = + Modifier.semantics { + if (dest == NavigationKey.MyApps) { + if (numUpdates > 0) { + stateDescription = + res.getString(R.string.notification_channel_updates_available_title) + } else if (hasIssues) { + stateDescription = res.getString(R.string.my_apps_header_apps_with_issue) + } + } + }, + ) } + } } @Composable private fun NavIcon(dest: MainNavKey, numUpdates: Int, hasIssues: Boolean) { - BadgedBox( - badge = { - if (dest == NavigationKey.MyApps && numUpdates > 0) { - Badge(containerColor = MaterialTheme.colorScheme.secondary) { - Text(text = numUpdates.toString()) - } - } else if (dest == NavigationKey.MyApps && hasIssues) { - Icon( - imageVector = Icons.Default.Error, - tint = MaterialTheme.colorScheme.error, - contentDescription = stringResource(R.string.my_apps_header_apps_with_issue) - ) - } + BadgedBox( + badge = { + if (dest == NavigationKey.MyApps && numUpdates > 0) { + Badge(containerColor = MaterialTheme.colorScheme.secondary) { + Text(text = numUpdates.toString()) } - ) { + } else if (dest == NavigationKey.MyApps && hasIssues) { Icon( - dest.icon, - contentDescription = stringResource(dest.label) + imageVector = Icons.Default.Error, + tint = MaterialTheme.colorScheme.error, + contentDescription = stringResource(R.string.my_apps_header_apps_with_issue), ) + } } + ) { + Icon(dest.icon, contentDescription = stringResource(dest.label)) + } } @Preview @Composable private fun Preview() { - FDroidContent { - Row { - NavigationRail( - numUpdates = 3, - hasIssues = false, - currentNavKey = NavigationKey.Discover, - onNav = {}, - ) - BottomBar( - numUpdates = 3, - hasIssues = false, - currentNavKey = NavigationKey.Discover, - onNav = {}, - ) - } + FDroidContent { + Row { + NavigationRail( + numUpdates = 3, + hasIssues = false, + currentNavKey = NavigationKey.Discover, + onNav = {}, + ) + BottomBar( + numUpdates = 3, + hasIssues = false, + currentNavKey = NavigationKey.Discover, + onNav = {}, + ) } + } } @Preview @Composable private fun PreviewIssues() { - FDroidContent { - Row { - NavigationRail( - numUpdates = 0, - hasIssues = true, - currentNavKey = NavigationKey.MyApps, - onNav = {}, - ) - BottomBar( - numUpdates = 0, - hasIssues = true, - currentNavKey = NavigationKey.MyApps, - onNav = {}, - ) - } + FDroidContent { + Row { + NavigationRail( + numUpdates = 0, + hasIssues = true, + currentNavKey = NavigationKey.MyApps, + onNav = {}, + ) + BottomBar(numUpdates = 0, hasIssues = true, currentNavKey = NavigationKey.MyApps, onNav = {}) } + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/navigation/IntentRouter.kt b/app/src/main/kotlin/org/fdroid/ui/navigation/IntentRouter.kt index 31f600e6f..29724bbf1 100644 --- a/app/src/main/kotlin/org/fdroid/ui/navigation/IntentRouter.kt +++ b/app/src/main/kotlin/org/fdroid/ui/navigation/IntentRouter.kt @@ -8,51 +8,52 @@ import androidx.core.util.Consumer import mu.KotlinLogging class IntentRouter(private val navigator: Navigator) : Consumer { - private val log = KotlinLogging.logger { } - private val packageNameRegex = "[A-Za-z\\d_.]+".toRegex() + private val log = KotlinLogging.logger {} + private val packageNameRegex = "[A-Za-z\\d_.]+".toRegex() - companion object { - const val ACTION_MY_APPS = "org.fdroid.action.MY_APPS" - } + companion object { + const val ACTION_MY_APPS = "org.fdroid.action.MY_APPS" + } - override fun accept(value: Intent) { - val intent = value - log.info { "Incoming intent: $intent" } - val uri = intent.data - if (ACTION_MAIN == intent.action) { - // launcher intent, do nothing - } else if (ACTION_SHOW_APP_INFO == intent.action) { // App Details - val packageName = intent.getStringExtra(EXTRA_PACKAGE_NAME) ?: return - if (packageName.matches(packageNameRegex)) { - navigator.navigate(NavigationKey.AppDetails(packageName)) - } else { - log.warn { "Malformed package name: $packageName" } - } - } else if (uri != null) { - val packagesUrlRegex = "^/([a-z][a-z][a-zA-Z_-]*/)?packages/[A-Za-z\\d_.]+/?$".toRegex() - if (uri.scheme == "market" && uri.host == "details") { - val packageName = uri.getQueryParameter("id") ?: return - if (packageName.matches(packageNameRegex)) { - navigator.navigate(NavigationKey.AppDetails(packageName)) - } else { - log.warn { "Malformed package name: $packageName" } - } - } else if (uri.path?.matches(packagesUrlRegex) == true) { - val packageName = uri.lastPathSegment ?: return - navigator.navigate(NavigationKey.AppDetails(packageName)) - } else if (uri.scheme == "fdroidrepos" || - uri.scheme == "FDROIDREPOS" || - (uri.scheme == "https" && uri.host == "fdroid.link") - ) { - navigator.navigate(NavigationKey.AddRepo(uri.toString())) - } - } else if (ACTION_MY_APPS == intent.action) { - val lastOnBackStack = navigator.last - if (lastOnBackStack !is NavigationKey.MyApps) { - navigator.navigate(NavigationKey.MyApps) - } + override fun accept(value: Intent) { + val intent = value + log.info { "Incoming intent: $intent" } + val uri = intent.data + if (ACTION_MAIN == intent.action) { + // launcher intent, do nothing + } else if (ACTION_SHOW_APP_INFO == intent.action) { // App Details + val packageName = intent.getStringExtra(EXTRA_PACKAGE_NAME) ?: return + if (packageName.matches(packageNameRegex)) { + navigator.navigate(NavigationKey.AppDetails(packageName)) + } else { + log.warn { "Malformed package name: $packageName" } + } + } else if (uri != null) { + val packagesUrlRegex = "^/([a-z][a-z][a-zA-Z_-]*/)?packages/[A-Za-z\\d_.]+/?$".toRegex() + if (uri.scheme == "market" && uri.host == "details") { + val packageName = uri.getQueryParameter("id") ?: return + if (packageName.matches(packageNameRegex)) { + navigator.navigate(NavigationKey.AppDetails(packageName)) } else { - log.warn { "Unknown intent: $intent - uri: $uri" } + log.warn { "Malformed package name: $packageName" } } + } else if (uri.path?.matches(packagesUrlRegex) == true) { + val packageName = uri.lastPathSegment ?: return + navigator.navigate(NavigationKey.AppDetails(packageName)) + } else if ( + uri.scheme == "fdroidrepos" || + uri.scheme == "FDROIDREPOS" || + (uri.scheme == "https" && uri.host == "fdroid.link") + ) { + navigator.navigate(NavigationKey.AddRepo(uri.toString())) + } + } else if (ACTION_MY_APPS == intent.action) { + val lastOnBackStack = navigator.last + if (lastOnBackStack !is NavigationKey.MyApps) { + navigator.navigate(NavigationKey.MyApps) + } + } else { + log.warn { "Unknown intent: $intent - uri: $uri" } } + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/navigation/NavigationKey.kt b/app/src/main/kotlin/org/fdroid/ui/navigation/NavigationKey.kt index bdba136d7..9a209e404 100644 --- a/app/src/main/kotlin/org/fdroid/ui/navigation/NavigationKey.kt +++ b/app/src/main/kotlin/org/fdroid/ui/navigation/NavigationKey.kt @@ -16,84 +16,66 @@ import org.fdroid.ui.lists.AppListType sealed interface NavigationKey : NavKey { - @Serializable - data object Discover : NavigationKey, MainNavKey { - override val label: Int = R.string.menu_discover - override val icon: ImageVector = Icons.Filled.Explore - } + @Serializable + data object Discover : NavigationKey, MainNavKey { + override val label: Int = R.string.menu_discover + override val icon: ImageVector = Icons.Filled.Explore + } - @Serializable - data object MyApps : NavigationKey, MainNavKey { - override val label: Int = R.string.menu_apps_my - override val icon: ImageVector = Icons.Filled.Apps - } + @Serializable + data object MyApps : NavigationKey, MainNavKey { + override val label: Int = R.string.menu_apps_my + override val icon: ImageVector = Icons.Filled.Apps + } - @Serializable - data object Search : NavigationKey + @Serializable data object Search : NavigationKey - @Serializable - data class AppDetails(val packageName: String) : NavigationKey + @Serializable data class AppDetails(val packageName: String) : NavigationKey - @Serializable - data class AppList(val type: AppListType) : NavigationKey + @Serializable data class AppList(val type: AppListType) : NavigationKey - @Serializable - data object Repos : NavigationKey + @Serializable data object Repos : NavigationKey - @Serializable - data class RepoDetails(val repoId: Long) : NavigationKey + @Serializable data class RepoDetails(val repoId: Long) : NavigationKey - @Serializable - data class AddRepo(val uri: String? = null) : NavigationKey + @Serializable data class AddRepo(val uri: String? = null) : NavigationKey - @Serializable - data object Settings : NavigationKey + @Serializable data object Settings : NavigationKey - @Serializable - data object About : NavigationKey - - @Serializable - data object InstallationHistory : NavigationKey + @Serializable data object About : NavigationKey + @Serializable data object InstallationHistory : NavigationKey } sealed interface MainNavKey : NavKey { - @get:StringRes - val label: Int - val icon: ImageVector + @get:StringRes val label: Int + val icon: ImageVector } -val topLevelRoutes = listOf( - NavigationKey.Discover, - NavigationKey.MyApps, -) +val topLevelRoutes = listOf(NavigationKey.Discover, NavigationKey.MyApps) sealed class NavDestinations( - val id: NavigationKey, - @param:StringRes val label: Int, - val icon: ImageVector, + val id: NavigationKey, + @param:StringRes val label: Int, + val icon: ImageVector, ) { - object Repos : - NavDestinations(NavigationKey.Repos, R.string.app_details_repositories, PackageVariant) + object Repos : + NavDestinations(NavigationKey.Repos, R.string.app_details_repositories, PackageVariant) - object Settings : - NavDestinations(NavigationKey.Settings, R.string.menu_settings, Icons.Filled.Settings) + object Settings : + NavDestinations(NavigationKey.Settings, R.string.menu_settings, Icons.Filled.Settings) - class AllApps(title: String) : NavDestinations( - id = NavigationKey.AppList(AppListType.All(title)), - label = R.string.app_list_all, - icon = Icons.Filled.Apps, + class AllApps(title: String) : + NavDestinations( + id = NavigationKey.AppList(AppListType.All(title)), + label = R.string.app_list_all, + icon = Icons.Filled.Apps, ) - object About : NavDestinations(NavigationKey.About, R.string.menu_about, Icons.Filled.Info) + object About : NavDestinations(NavigationKey.About, R.string.menu_about, Icons.Filled.Info) } -val topBarMenuItems = listOf( - NavDestinations.Repos, - NavDestinations.Settings, -) +val topBarMenuItems = listOf(NavDestinations.Repos, NavDestinations.Settings) -fun getMoreMenuItems(context: Context) = listOf( - NavDestinations.AllApps(context.getString(R.string.app_list_all)), - NavDestinations.About, -) +fun getMoreMenuItems(context: Context) = + listOf(NavDestinations.AllApps(context.getString(R.string.app_list_all)), NavDestinations.About) diff --git a/app/src/main/kotlin/org/fdroid/ui/navigation/NavigationState.kt b/app/src/main/kotlin/org/fdroid/ui/navigation/NavigationState.kt index 72aaae03f..f8bb92956 100644 --- a/app/src/main/kotlin/org/fdroid/ui/navigation/NavigationState.kt +++ b/app/src/main/kotlin/org/fdroid/ui/navigation/NavigationState.kt @@ -37,30 +37,23 @@ import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.runtime.serialization.NavKeySerializer import androidx.savedstate.compose.serialization.serializers.MutableStateSerializer -/** - * Create a navigation state that persists config changes and process death. - */ +/** Create a navigation state that persists config changes and process death. */ @Composable -fun rememberNavigationState( - startRoute: NavKey, - topLevelRoutes: List -): NavigationState { - val topLevelRoute = rememberSerializable( - startRoute, topLevelRoutes, - serializer = MutableStateSerializer(NavKeySerializer()) +fun rememberNavigationState(startRoute: NavKey, topLevelRoutes: List): NavigationState { + val topLevelRoute = + rememberSerializable( + startRoute, + topLevelRoutes, + serializer = MutableStateSerializer(NavKeySerializer()), ) { - mutableStateOf(startRoute) + mutableStateOf(startRoute) } - val backStacks = topLevelRoutes.associateWith { key -> rememberNavBackStack(key) } + val backStacks = topLevelRoutes.associateWith { key -> rememberNavBackStack(key) } - return remember(startRoute, topLevelRoutes) { - NavigationState( - startRoute = startRoute, - topLevelRoute = topLevelRoute, - backStacks = backStacks - ) - } + return remember(startRoute, topLevelRoutes) { + NavigationState(startRoute = startRoute, topLevelRoute = topLevelRoute, backStacks = backStacks) + } } /** @@ -71,40 +64,38 @@ fun rememberNavigationState( * @param backStacks - the back stacks for each top level route */ class NavigationState( - val startRoute: NavKey, - topLevelRoute: MutableState, - val backStacks: Map> + val startRoute: NavKey, + topLevelRoute: MutableState, + val backStacks: Map>, ) { - var topLevelRoute: NavKey by topLevelRoute - val stacksInUse: List - get() = if (topLevelRoute == startRoute) { - listOf(startRoute) - } else { - listOf(startRoute, topLevelRoute) - } - + var topLevelRoute: NavKey by topLevelRoute + val stacksInUse: List + get() = + if (topLevelRoute == startRoute) { + listOf(startRoute) + } else { + listOf(startRoute, topLevelRoute) + } } -/** - * Convert NavigationState into NavEntries. - */ +/** Convert NavigationState into NavEntries. */ @Composable fun NavigationState.toEntries( - entryProvider: (NavKey) -> NavEntry + entryProvider: (NavKey) -> NavEntry ): SnapshotStateList> { - val decoratedEntries = backStacks.mapValues { (_, stack) -> - val decorators = listOf( - rememberSaveableStateHolderNavEntryDecorator(), - rememberViewModelStoreNavEntryDecorator(), - ) - rememberDecoratedNavEntries( - backStack = stack, - entryDecorators = decorators, - entryProvider = entryProvider + val decoratedEntries = + backStacks.mapValues { (_, stack) -> + val decorators = + listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), ) + rememberDecoratedNavEntries( + backStack = stack, + entryDecorators = decorators, + entryProvider = entryProvider, + ) } - return stacksInUse - .flatMap { decoratedEntries[it] ?: emptyList() } - .toMutableStateList() + return stacksInUse.flatMap { decoratedEntries[it] ?: emptyList() }.toMutableStateList() } diff --git a/app/src/main/kotlin/org/fdroid/ui/navigation/Navigator.kt b/app/src/main/kotlin/org/fdroid/ui/navigation/Navigator.kt index 4b97e6bd4..947537fec 100644 --- a/app/src/main/kotlin/org/fdroid/ui/navigation/Navigator.kt +++ b/app/src/main/kotlin/org/fdroid/ui/navigation/Navigator.kt @@ -20,41 +20,39 @@ package org.fdroid.ui.navigation import androidx.navigation3.runtime.NavKey -/** - * Handles navigation events (forward and back) by updating the navigation state. - */ +/** Handles navigation events (forward and back) by updating the navigation state. */ class Navigator(val state: NavigationState) { - val last: NavKey? - get() { - val currentStack = state.backStacks[state.topLevelRoute] - ?: error("Stack for ${state.topLevelRoute} not found") - return currentStack.lastOrNull() - } - - fun navigate(route: NavKey) { - if (route in state.backStacks.keys) { - // This is a top level route, just switch to it - state.topLevelRoute = route - } else { - state.backStacks[state.topLevelRoute]?.add(route) - } + val last: NavKey? + get() { + val currentStack = + state.backStacks[state.topLevelRoute] ?: error("Stack for ${state.topLevelRoute} not found") + return currentStack.lastOrNull() } - fun replaceLast(route: NavKey) { - val stack = state.backStacks[state.topLevelRoute] ?: return - stack[stack.lastIndex] = route + fun navigate(route: NavKey) { + if (route in state.backStacks.keys) { + // This is a top level route, just switch to it + state.topLevelRoute = route + } else { + state.backStacks[state.topLevelRoute]?.add(route) } + } - fun goBack() { - val currentStack = state.backStacks[state.topLevelRoute] - ?: error("Stack for ${state.topLevelRoute} not found") - val currentRoute = currentStack.last() + fun replaceLast(route: NavKey) { + val stack = state.backStacks[state.topLevelRoute] ?: return + stack[stack.lastIndex] = route + } - // If we're at the base of the current route, go back to the start route stack. - if (currentRoute == state.topLevelRoute) { - state.topLevelRoute = state.startRoute - } else { - currentStack.removeLastOrNull() - } + fun goBack() { + val currentStack = + state.backStacks[state.topLevelRoute] ?: error("Stack for ${state.topLevelRoute} not found") + val currentRoute = currentStack.last() + + // If we're at the base of the current route, go back to the start route stack. + if (currentRoute == state.topLevelRoute) { + state.topLevelRoute = state.startRoute + } else { + currentStack.removeLastOrNull() } + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/NoRepoSelected.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/NoRepoSelected.kt index d5727a899..28e0d8e11 100644 --- a/app/src/main/kotlin/org/fdroid/ui/repositories/NoRepoSelected.kt +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/NoRepoSelected.kt @@ -26,36 +26,34 @@ import org.fdroid.ui.icons.PackageVariant @Composable fun NoRepoSelected() { - Box( - contentAlignment = Center, - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .background(MaterialTheme.colorScheme.background) - .padding(16.dp) + Box( + contentAlignment = Center, + modifier = + Modifier.fillMaxSize() + .verticalScroll(rememberScrollState()) + .background(MaterialTheme.colorScheme.background) + .padding(16.dp), + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = spacedBy(32.dp), ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = spacedBy(32.dp) - ) { - Icon( - imageVector = PackageVariant, - contentDescription = null, - tint = MaterialTheme.colorScheme.surfaceContainerHigh, - modifier = Modifier.size(64.dp) - ) - Text( - text = stringResource(R.string.repo_list_info_text), - modifier = Modifier.fillMaxWidth(fraction = 0.7f) - ) - } + Icon( + imageVector = PackageVariant, + contentDescription = null, + tint = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier.size(64.dp), + ) + Text( + text = stringResource(R.string.repo_list_info_text), + modifier = Modifier.fillMaxWidth(fraction = 0.7f), + ) } + } } @Preview(widthDp = 200, heightDp = 400) @Composable private fun Preview() { - FDroidContent { - NoRepoSelected() - } + FDroidContent { NoRepoSelected() } } diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/RepoEntry.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/RepoEntry.kt index fac239863..4767175ba 100644 --- a/app/src/main/kotlin/org/fdroid/ui/repositories/RepoEntry.kt +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/RepoEntry.kt @@ -17,102 +17,95 @@ import org.fdroid.ui.repositories.details.RepoDetailsInfo import org.fdroid.ui.repositories.details.RepoDetailsViewModel @OptIn(ExperimentalMaterial3AdaptiveApi::class) -fun EntryProviderScope.repoEntry( - navigator: Navigator, - isBigScreen: Boolean, -) { - entry( - metadata = ListDetailSceneStrategy.listPane("repos") { - NoRepoSelected() +fun EntryProviderScope.repoEntry(navigator: Navigator, isBigScreen: Boolean) { + entry( + metadata = ListDetailSceneStrategy.listPane("repos") { NoRepoSelected() } + ) { + val viewModel = hiltViewModel() + val info = + object : RepositoryInfo { + override val model: RepositoryModel = viewModel.model.collectAsStateWithLifecycle().value + + override val currentRepositoryId: Long? = + if (isBigScreen) { + (navigator.last as? NavigationKey.RepoDetails)?.repoId + } else null + + override fun onOnboardingSeen() = viewModel.onOnboardingSeen() + + override fun onRepositorySelected(repositoryItem: RepositoryItem) { + val last = navigator.last + val new = NavigationKey.RepoDetails(repositoryItem.repoId) + if (last is NavigationKey.RepoDetails) { + navigator.replaceLast(new) + } else { + navigator.navigate(new) + } + } + + override fun onRepositoryEnabled(repoId: Long, enabled: Boolean) = + viewModel.onRepositoryEnabled(repoId, enabled) + + override fun onAddRepo() { + navigator.navigate(NavigationKey.AddRepo()) + } + + override fun onRepositoryMoved(fromRepoId: Long, toRepoId: Long) = + viewModel.onRepositoriesMoved(fromRepoId, toRepoId) + + override fun onRepositoriesFinishedMoving(fromRepoId: Long, toRepoId: Long) = + viewModel.onRepositoriesFinishedMoving(fromRepoId, toRepoId) + } + Repositories(info, isBigScreen) { navigator.goBack() } + } + entry(metadata = ListDetailSceneStrategy.detailPane("repos")) { navKey + -> + val viewModel = + hiltViewModel( + creationCallback = { factory -> factory.create(navKey.repoId) } + ) + RepoDetails( + info = + object : RepoDetailsInfo { + override val model = viewModel.model.collectAsStateWithLifecycle().value + override val actions = viewModel }, - ) { - val viewModel = hiltViewModel() - val info = object : RepositoryInfo { - override val model: RepositoryModel = - viewModel.model.collectAsStateWithLifecycle().value - - override val currentRepositoryId: Long? = if (isBigScreen) { - (navigator.last as? NavigationKey.RepoDetails)?.repoId - } else null - - override fun onOnboardingSeen() = viewModel.onOnboardingSeen() - - override fun onRepositorySelected(repositoryItem: RepositoryItem) { - val last = navigator.last - val new = NavigationKey.RepoDetails(repositoryItem.repoId) - if (last is NavigationKey.RepoDetails) { - navigator.replaceLast(new) - } else { - navigator.navigate(new) - } - } - - override fun onRepositoryEnabled(repoId: Long, enabled: Boolean) = - viewModel.onRepositoryEnabled(repoId, enabled) - - override fun onAddRepo() { - navigator.navigate(NavigationKey.AddRepo()) - } - - override fun onRepositoryMoved(fromRepoId: Long, toRepoId: Long) = - viewModel.onRepositoriesMoved(fromRepoId, toRepoId) - - override fun onRepositoriesFinishedMoving( - fromRepoId: Long, - toRepoId: Long, - ) = viewModel.onRepositoriesFinishedMoving(fromRepoId, toRepoId) - } - Repositories(info, isBigScreen) { - navigator.goBack() - } - } - entry( - metadata = ListDetailSceneStrategy.detailPane("repos") - ) { navKey -> - val viewModel = hiltViewModel( - creationCallback = { factory -> - factory.create(navKey.repoId) - } - ) - RepoDetails( - info = object : RepoDetailsInfo { - override val model = viewModel.model.collectAsStateWithLifecycle().value - override val actions = viewModel - }, - onShowAppsClicked = { title, repoId -> - val type = AppListType.Repository(title, repoId) - navigator.navigate(NavigationKey.AppList(type)) - }, - onBackNav = if (isBigScreen) null else { - { navigator.goBack() } - }, - ) - } - entry { navKey -> - val viewModel = hiltViewModel() - // this is for intents we receive via IntentRouter, usually the user provides URI later - LaunchedEffect(navKey) { - if (navKey.uri != null) { - viewModel.onFetchRepo(navKey.uri) - } - } - AddRepo( - state = viewModel.state.collectAsStateWithLifecycle().value, - networkStateFlow = viewModel.networkState, - proxyConfig = viewModel.proxyConfig, - onFetchRepo = viewModel::onFetchRepo, - onAddRepo = viewModel::addFetchedRepository, - onExistingRepo = { repoId -> - navigator.goBack() - navigator.navigate(NavigationKey.RepoDetails(repoId)) - }, - onRepoAdded = { title, repoId -> - navigator.goBack() - navigator.navigate(NavigationKey.RepoDetails(repoId)) - val type = AppListType.Repository(title, repoId) - navigator.navigate(NavigationKey.AppList(type)) - }, - onBackClicked = { navigator.goBack() }, - ) + onShowAppsClicked = { title, repoId -> + val type = AppListType.Repository(title, repoId) + navigator.navigate(NavigationKey.AppList(type)) + }, + onBackNav = + if (isBigScreen) null + else { + { navigator.goBack() } + }, + ) + } + entry { navKey -> + val viewModel = hiltViewModel() + // this is for intents we receive via IntentRouter, usually the user provides URI later + LaunchedEffect(navKey) { + if (navKey.uri != null) { + viewModel.onFetchRepo(navKey.uri) + } } + AddRepo( + state = viewModel.state.collectAsStateWithLifecycle().value, + networkStateFlow = viewModel.networkState, + proxyConfig = viewModel.proxyConfig, + onFetchRepo = viewModel::onFetchRepo, + onAddRepo = viewModel::addFetchedRepository, + onExistingRepo = { repoId -> + navigator.goBack() + navigator.navigate(NavigationKey.RepoDetails(repoId)) + }, + onRepoAdded = { title, repoId -> + navigator.goBack() + navigator.navigate(NavigationKey.RepoDetails(repoId)) + val type = AppListType.Repository(title, repoId) + navigator.navigate(NavigationKey.AppList(type)) + }, + onBackClicked = { navigator.goBack() }, + ) + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/RepoIcon.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/RepoIcon.kt index e9d893433..dba890eb4 100644 --- a/app/src/main/kotlin/org/fdroid/ui/repositories/RepoIcon.kt +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/RepoIcon.kt @@ -12,10 +12,10 @@ import org.fdroid.ui.utils.AsyncShimmerImage @Composable fun RepoIcon(repo: Repository, proxy: ProxyConfig?, modifier: Modifier = Modifier) { - AsyncShimmerImage( - model = repo.getIcon(LocaleListCompat.getDefault())?.getImageModel(repo, proxy), - contentDescription = null, - error = painterResource(R.drawable.ic_repo_app_default), - modifier = modifier, - ) + AsyncShimmerImage( + model = repo.getIcon(LocaleListCompat.getDefault())?.getImageModel(repo, proxy), + contentDescription = null, + error = painterResource(R.drawable.ic_repo_app_default), + modifier = modifier, + ) } diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/Repositories.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/Repositories.kt index 67930de59..8bc10fb85 100644 --- a/app/src/main/kotlin/org/fdroid/ui/repositories/Repositories.kt +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/Repositories.kt @@ -47,135 +47,118 @@ import org.fdroid.ui.utils.repoItems @Composable @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) -fun Repositories( - info: RepositoryInfo, - isBigScreen: Boolean, - onBackClicked: () -> Unit, -) { - val hintController = rememberHintController( - overlay = getHintOverlayColor(), - ) - val hint = rememberHint { - Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { - OnboardingCard( - title = stringResource(R.string.repo_list_info_title), - message = stringResource(R.string.repo_list_info_text), - modifier = Modifier - .padding(horizontal = 32.dp, vertical = 8.dp), - onGotIt = { - info.onOnboardingSeen() - hintController.dismiss() - }, - ) - } - } - val hintAnchor = rememberHintAnchorState(hint) - LaunchedEffect(info.model.showOnboarding) { - if (!isBigScreen && info.model.showOnboarding) { - hintController.show(hintAnchor) - info.onOnboardingSeen() - } - } - val scrollBehavior = enterAlwaysScrollBehavior(rememberTopAppBarState()) - val listState = rememberLazyListState() - val showFab by remember { - derivedStateOf { - val firstVisibleItemIndex = listState.firstVisibleItemIndex - val firstVisibleItemScrollOffset = listState.firstVisibleItemScrollOffset - if (firstVisibleItemIndex == 0 && firstVisibleItemScrollOffset == 0) { - true - } else if (listState.isScrollInProgress) { - false - } else { - listState.canScrollForward - } - } - } - Scaffold( - topBar = { - TopAppBar( - navigationIcon = { - IconButton(onClick = onBackClicked) { - Icon( - imageVector = Icons.AutoMirrored.Default.ArrowBack, - contentDescription = stringResource(R.string.back), - ) - } - }, - title = { - Text(stringResource(R.string.app_details_repositories)) - }, - subtitle = { - val lastUpdated = info.model.lastCheckForUpdate - Text(stringResource(R.string.repo_last_update_check, lastUpdated)) - }, - scrollBehavior = scrollBehavior, - ) +fun Repositories(info: RepositoryInfo, isBigScreen: Boolean, onBackClicked: () -> Unit) { + val hintController = rememberHintController(overlay = getHintOverlayColor()) + val hint = rememberHint { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { + OnboardingCard( + title = stringResource(R.string.repo_list_info_title), + message = stringResource(R.string.repo_list_info_text), + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + onGotIt = { + info.onOnboardingSeen() + hintController.dismiss() }, - floatingActionButton = { - AnimatedVisibility(showFab) { - FloatingActionButton( - onClick = info::onAddRepo, - modifier = Modifier - .padding(16.dp) - ) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = stringResource(R.string.menu_add_repo), - ) - } - } - }, - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - ) { paddingValues -> - if (info.model.repositories == null) BigLoadingIndicator() - else RepositoriesList( - info = info, - listState = listState, - // we split up top and bottom padding to not cause bugs with list drag and drop - paddingValues = PaddingValues(bottom = paddingValues.calculateBottomPadding()), - modifier = Modifier - .padding(top = paddingValues.calculateTopPadding()), - ) + ) } + } + val hintAnchor = rememberHintAnchorState(hint) + LaunchedEffect(info.model.showOnboarding) { + if (!isBigScreen && info.model.showOnboarding) { + hintController.show(hintAnchor) + info.onOnboardingSeen() + } + } + val scrollBehavior = enterAlwaysScrollBehavior(rememberTopAppBarState()) + val listState = rememberLazyListState() + val showFab by remember { + derivedStateOf { + val firstVisibleItemIndex = listState.firstVisibleItemIndex + val firstVisibleItemScrollOffset = listState.firstVisibleItemScrollOffset + if (firstVisibleItemIndex == 0 && firstVisibleItemScrollOffset == 0) { + true + } else if (listState.isScrollInProgress) { + false + } else { + listState.canScrollForward + } + } + } + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = onBackClicked) { + Icon( + imageVector = Icons.AutoMirrored.Default.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } + }, + title = { Text(stringResource(R.string.app_details_repositories)) }, + subtitle = { + val lastUpdated = info.model.lastCheckForUpdate + Text(stringResource(R.string.repo_last_update_check, lastUpdated)) + }, + scrollBehavior = scrollBehavior, + ) + }, + floatingActionButton = { + AnimatedVisibility(showFab) { + FloatingActionButton(onClick = info::onAddRepo, modifier = Modifier.padding(16.dp)) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.menu_add_repo), + ) + } + } + }, + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + ) { paddingValues -> + if (info.model.repositories == null) BigLoadingIndicator() + else + RepositoriesList( + info = info, + listState = listState, + // we split up top and bottom padding to not cause bugs with list drag and drop + paddingValues = PaddingValues(bottom = paddingValues.calculateBottomPadding()), + modifier = Modifier.padding(top = paddingValues.calculateTopPadding()), + ) + } } -@Preview( - showBackground = true, - uiMode = UI_MODE_NIGHT_YES or UI_MODE_TYPE_NORMAL, -) +@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES or UI_MODE_TYPE_NORMAL) @Composable fun RepositoriesScaffoldLoadingPreview() { - HintHost { - FDroidContent { - val model = RepositoryModel( - repositories = null, - showOnboarding = false, - lastCheckForUpdate = "never", - networkState = NetworkState(isOnline = true, isMetered = false), - ) - val info = getRepositoriesInfo(model) - Repositories(info, true) {} - } + HintHost { + FDroidContent { + val model = + RepositoryModel( + repositories = null, + showOnboarding = false, + lastCheckForUpdate = "never", + networkState = NetworkState(isOnline = true, isMetered = false), + ) + val info = getRepositoriesInfo(model) + Repositories(info, true) {} } + } } -@Preview( - showBackground = true, - uiMode = UI_MODE_NIGHT_YES or UI_MODE_TYPE_NORMAL, -) +@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES or UI_MODE_TYPE_NORMAL) @Composable private fun RepositoriesScaffoldPreview() { - HintHost { - FDroidContent { - val model = RepositoryModel( - repositories = repoItems, - showOnboarding = false, - lastCheckForUpdate = "42min. ago", - networkState = NetworkState(isOnline = true, isMetered = false), - ) - val info = getRepositoriesInfo(model, repoItems[0].repoId) - Repositories(info, true) { } - } + HintHost { + FDroidContent { + val model = + RepositoryModel( + repositories = repoItems, + showOnboarding = false, + lastCheckForUpdate = "42min. ago", + networkState = NetworkState(isOnline = true, isMetered = false), + ) + val info = getRepositoriesInfo(model, repoItems[0].repoId) + Repositories(info, true) {} } + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/RepositoriesList.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/RepositoriesList.kt index 73f29f0e2..253b8b0cb 100644 --- a/app/src/main/kotlin/org/fdroid/ui/repositories/RepositoriesList.kt +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/RepositoriesList.kt @@ -35,117 +35,107 @@ import org.fdroid.ui.utils.rememberDragDropState @Composable fun RepositoriesList( - info: RepositoryInfo, - listState: LazyListState, - paddingValues: PaddingValues, - modifier: Modifier = Modifier, + info: RepositoryInfo, + listState: LazyListState, + paddingValues: PaddingValues, + modifier: Modifier = Modifier, ) { - val repositories = info.model.repositories ?: return - var showDisableRepoDialog by remember { mutableStateOf(null) } - var showMeteredDialog by remember { mutableStateOf<(() -> Unit)?>(null) } - val currentRepositoryId = info.currentRepositoryId - val dragDropState = rememberDragDropState( - lazyListState = listState, - onMove = { from, to -> - from as? Long ?: error("from $from was not a repoId") - to as? Long ?: error("to $to was not a repoId") - info.onRepositoryMoved(from, to) - }, - onEnd = { from, to -> - from as? Long ?: error("from $from was not a repoId") - to as? Long ?: error("to $to was not a repoId") - info.onRepositoriesFinishedMoving(from, to) - }, + val repositories = info.model.repositories ?: return + var showDisableRepoDialog by remember { mutableStateOf(null) } + var showMeteredDialog by remember { mutableStateOf<(() -> Unit)?>(null) } + val currentRepositoryId = info.currentRepositoryId + val dragDropState = + rememberDragDropState( + lazyListState = listState, + onMove = { from, to -> + from as? Long ?: error("from $from was not a repoId") + to as? Long ?: error("to $to was not a repoId") + info.onRepositoryMoved(from, to) + }, + onEnd = { from, to -> + from as? Long ?: error("from $from was not a repoId") + to as? Long ?: error("to $to was not a repoId") + info.onRepositoriesFinishedMoving(from, to) + }, ) - LazyColumn( - state = listState, - contentPadding = paddingValues + PaddingValues(top = 8.dp), - verticalArrangement = spacedBy(8.dp), - modifier = modifier - .then( - if (repositories.size > 1) Modifier.dragContainer(dragDropState) - else Modifier - ) - .then( - if (currentRepositoryId == null) Modifier - else Modifier.selectableGroup() - ), - ) { - itemsIndexed( - items = repositories, - key = { _, item -> item.repoId }, - ) { index, repoItem -> - val isSelected = currentRepositoryId == repoItem.repoId - val interactionModifier = if (currentRepositoryId == null) { - Modifier.clickable( - onClick = { info.onRepositorySelected(repoItem) } - ) - } else { - Modifier.selectable( - selected = isSelected, - onClick = { info.onRepositorySelected(repoItem) } - ) - } - DraggableItem(dragDropState, index) { isDragging -> - val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp) - RepositoryRow( - repoItem = repoItem, - isSelected = isSelected, - onRepoEnabled = { enabled -> - if (enabled) { - if (info.model.networkState.isMetered) showMeteredDialog = { - info.onRepositoryEnabled(repoItem.repoId, true) - } else info.onRepositoryEnabled(repoItem.repoId, true) - } else { - showDisableRepoDialog = repoItem.repoId - } - }, - modifier = Modifier - .fillMaxWidth() - .then(interactionModifier) - .let { - if (isDragging) it.dropShadow( - shape = RoundedCornerShape(4.dp), - shadow = Shadow( - radius = 8.dp, - offset = DpOffset(x = elevation, elevation) - ) - ) else it - } - ) - } + LazyColumn( + state = listState, + contentPadding = paddingValues + PaddingValues(top = 8.dp), + verticalArrangement = spacedBy(8.dp), + modifier = + modifier + .then(if (repositories.size > 1) Modifier.dragContainer(dragDropState) else Modifier) + .then(if (currentRepositoryId == null) Modifier else Modifier.selectableGroup()), + ) { + itemsIndexed(items = repositories, key = { _, item -> item.repoId }) { index, repoItem -> + val isSelected = currentRepositoryId == repoItem.repoId + val interactionModifier = + if (currentRepositoryId == null) { + Modifier.clickable(onClick = { info.onRepositorySelected(repoItem) }) + } else { + Modifier.selectable( + selected = isSelected, + onClick = { info.onRepositorySelected(repoItem) }, + ) } - } - val repoId = showDisableRepoDialog - if (repoId != null) { - AlertDialog( - text = { - Text(text = stringResource(R.string.repo_disable_warning)) - }, - onDismissRequest = { showDisableRepoDialog = null }, - confirmButton = { - TextButton(onClick = { - info.onRepositoryEnabled(repoId, false) - showDisableRepoDialog = null - }) { - Text( - text = stringResource(R.string.repo_disable_warning_button), - color = MaterialTheme.colorScheme.error, - ) - } - }, - dismissButton = { - TextButton(onClick = { showDisableRepoDialog = null }) { - Text(stringResource(android.R.string.cancel)) - } + DraggableItem(dragDropState, index) { isDragging -> + val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp) + RepositoryRow( + repoItem = repoItem, + isSelected = isSelected, + onRepoEnabled = { enabled -> + if (enabled) { + if (info.model.networkState.isMetered) + showMeteredDialog = { info.onRepositoryEnabled(repoItem.repoId, true) } + else info.onRepositoryEnabled(repoItem.repoId, true) + } else { + showDisableRepoDialog = repoItem.repoId } + }, + modifier = + Modifier.fillMaxWidth().then(interactionModifier).let { + if (isDragging) + it.dropShadow( + shape = RoundedCornerShape(4.dp), + shadow = Shadow(radius = 8.dp, offset = DpOffset(x = elevation, elevation)), + ) + else it + }, ) + } } - // Metered warning dialog - val meteredLambda = showMeteredDialog - if (meteredLambda != null) MeteredConnectionDialog( - numBytes = null, - onConfirm = { meteredLambda() }, - onDismiss = { showMeteredDialog = null }, + } + val repoId = showDisableRepoDialog + if (repoId != null) { + AlertDialog( + text = { Text(text = stringResource(R.string.repo_disable_warning)) }, + onDismissRequest = { showDisableRepoDialog = null }, + confirmButton = { + TextButton( + onClick = { + info.onRepositoryEnabled(repoId, false) + showDisableRepoDialog = null + } + ) { + Text( + text = stringResource(R.string.repo_disable_warning_button), + color = MaterialTheme.colorScheme.error, + ) + } + }, + dismissButton = { + TextButton(onClick = { showDisableRepoDialog = null }) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) + } + // Metered warning dialog + val meteredLambda = showMeteredDialog + if (meteredLambda != null) + MeteredConnectionDialog( + numBytes = null, + onConfirm = { meteredLambda() }, + onDismiss = { showMeteredDialog = null }, ) } diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/RepositoriesPresenter.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/RepositoriesPresenter.kt index f71c4c91c..aa6d6b3dc 100644 --- a/app/src/main/kotlin/org/fdroid/ui/repositories/RepositoriesPresenter.kt +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/RepositoriesPresenter.kt @@ -10,26 +10,26 @@ import org.fdroid.ui.utils.asRelativeTimeString @Composable fun RepositoriesPresenter( - context: Context, - repositoriesFlow: StateFlow?>, - repoSortingMapFlow: StateFlow>, - showOnboardingFlow: StateFlow, - lastUpdateFlow: StateFlow, - networkStateFlow: StateFlow, + context: Context, + repositoriesFlow: StateFlow?>, + repoSortingMapFlow: StateFlow>, + showOnboardingFlow: StateFlow, + lastUpdateFlow: StateFlow, + networkStateFlow: StateFlow, ): RepositoryModel { - val repositories = repositoriesFlow.collectAsState().value - val repoSortingMap = repoSortingMapFlow.collectAsState().value - val lastUpdated = lastUpdateFlow.collectAsState().value - return RepositoryModel( - repositories = repositories?.sortedBy { repo -> - repoSortingMap[repo.repoId] ?: repoSortingMap.size - }, - showOnboarding = showOnboardingFlow.collectAsState().value, - lastCheckForUpdate = if (lastUpdated <= 0) { - context.getString(R.string.repositories_last_update_never) - } else { - lastUpdated.asRelativeTimeString() - }, - networkState = networkStateFlow.collectAsState().value, - ) + val repositories = repositoriesFlow.collectAsState().value + val repoSortingMap = repoSortingMapFlow.collectAsState().value + val lastUpdated = lastUpdateFlow.collectAsState().value + return RepositoryModel( + repositories = + repositories?.sortedBy { repo -> repoSortingMap[repo.repoId] ?: repoSortingMap.size }, + showOnboarding = showOnboardingFlow.collectAsState().value, + lastCheckForUpdate = + if (lastUpdated <= 0) { + context.getString(R.string.repositories_last_update_never) + } else { + lastUpdated.asRelativeTimeString() + }, + networkState = networkStateFlow.collectAsState().value, + ) } diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/RepositoriesViewModel.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/RepositoriesViewModel.kt index 6e1012dbe..bf225a8e1 100644 --- a/app/src/main/kotlin/org/fdroid/ui/repositories/RepositoriesViewModel.kt +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/RepositoriesViewModel.kt @@ -9,6 +9,7 @@ import app.cash.molecule.AndroidUiDispatcher import app.cash.molecule.RecompositionMode.ContextClock import app.cash.molecule.launchMolecule import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -25,96 +26,87 @@ import org.fdroid.settings.OnboardingManager import org.fdroid.settings.SettingsManager import org.fdroid.updates.UpdatesManager import org.fdroid.utils.IoDispatcher -import javax.inject.Inject @HiltViewModel -class RepositoriesViewModel @Inject constructor( - app: Application, - networkMonitor: NetworkMonitor, - private val repoManager: RepoManager, - private val updateManager: UpdatesManager, - private val settingsManager: SettingsManager, - private val onboardingManager: OnboardingManager, - @param:IoDispatcher private val ioScope: CoroutineScope, +class RepositoriesViewModel +@Inject +constructor( + app: Application, + networkMonitor: NetworkMonitor, + private val repoManager: RepoManager, + private val updateManager: UpdatesManager, + private val settingsManager: SettingsManager, + private val onboardingManager: OnboardingManager, + @param:IoDispatcher private val ioScope: CoroutineScope, ) : AndroidViewModel(app) { - private val log = KotlinLogging.logger { } - private val localeList = LocaleListCompat.getDefault() - private val moleculeScope = - CoroutineScope(viewModelScope.coroutineContext + AndroidUiDispatcher.Main) - private val repos = MutableStateFlow?>(null) - private val repoSortingMap = MutableStateFlow>(emptyMap()) - private val showOnboarding = onboardingManager.showRepositoriesOnboarding + private val log = KotlinLogging.logger {} + private val localeList = LocaleListCompat.getDefault() + private val moleculeScope = + CoroutineScope(viewModelScope.coroutineContext + AndroidUiDispatcher.Main) + private val repos = MutableStateFlow?>(null) + private val repoSortingMap = MutableStateFlow>(emptyMap()) + private val showOnboarding = onboardingManager.showRepositoriesOnboarding - init { - viewModelScope.launch { - repoManager.repositoriesState.collect { - onRepositoriesChanged(it) - } - } + init { + viewModelScope.launch { repoManager.repositoriesState.collect { onRepositoriesChanged(it) } } + } + + // define below init, because this only defines repoSortingMap + val model: StateFlow by + lazy(LazyThreadSafetyMode.NONE) { + moleculeScope.launchMolecule(mode = ContextClock) { + RepositoriesPresenter( + context = application, + repositoriesFlow = repos, + repoSortingMapFlow = repoSortingMap, + showOnboardingFlow = showOnboarding, + lastUpdateFlow = settingsManager.lastRepoUpdateFlow, + networkStateFlow = networkMonitor.networkState, + ) + } } - // define below init, because this only defines repoSortingMap - val model: StateFlow by lazy(LazyThreadSafetyMode.NONE) { - moleculeScope.launchMolecule(mode = ContextClock) { - RepositoriesPresenter( - context = application, - repositoriesFlow = repos, - repoSortingMapFlow = repoSortingMap, - showOnboardingFlow = showOnboarding, - lastUpdateFlow = settingsManager.lastRepoUpdateFlow, - networkStateFlow = networkMonitor.networkState, - ) - } + private fun onRepositoriesChanged(repositories: List) { + log.info("onRepositoriesChanged(${repositories.size})") + repos.update { + repositories.mapNotNull { + if (it.isArchiveRepo) null else RepositoryItem(it, localeList, settingsManager.proxyConfig) + } } - - private fun onRepositoriesChanged(repositories: List) { - log.info("onRepositoriesChanged(${repositories.size})") - repos.update { - repositories.mapNotNull { - if (it.isArchiveRepo) null - else RepositoryItem(it, localeList, settingsManager.proxyConfig) - } - } - repoSortingMap.update { - // just add repos to sortingMap, because they are already pre-sorted by weight - mutableMapOf().apply { - repositories.forEachIndexed { index, repository -> - this[repository.repoId] = index - } - } - } + repoSortingMap.update { + // just add repos to sortingMap, because they are already pre-sorted by weight + mutableMapOf().apply { + repositories.forEachIndexed { index, repository -> this[repository.repoId] = index } + } } + } - fun onRepositoryEnabled(repoId: Long, enabled: Boolean) { - ioScope.launch { - repoManager.setRepositoryEnabled(repoId, enabled) - updateManager.loadUpdates() - if (enabled) withContext(Dispatchers.Main) { - RepoUpdateWorker.updateNow(application, repoId) - } - } + fun onRepositoryEnabled(repoId: Long, enabled: Boolean) { + ioScope.launch { + repoManager.setRepositoryEnabled(repoId, enabled) + updateManager.loadUpdates() + if (enabled) withContext(Dispatchers.Main) { RepoUpdateWorker.updateNow(application, repoId) } } + } - fun onRepositoriesMoved(fromRepoId: Long, toRepoId: Long) { - log.info { "onRepositoriesMoved($fromRepoId, $toRepoId)" } - repoSortingMap.update { - repoSortingMap.value.toMutableMap().apply { - val toIndex = get(toRepoId) ?: error("No position for toRepoId $toRepoId") - replace(toRepoId, replace(fromRepoId, toIndex)!!) - } - } + fun onRepositoriesMoved(fromRepoId: Long, toRepoId: Long) { + log.info { "onRepositoriesMoved($fromRepoId, $toRepoId)" } + repoSortingMap.update { + repoSortingMap.value.toMutableMap().apply { + val toIndex = get(toRepoId) ?: error("No position for toRepoId $toRepoId") + replace(toRepoId, replace(fromRepoId, toIndex)!!) + } } + } - fun onRepositoriesFinishedMoving(fromRepoId: Long, toRepoId: Long) { - log.info { "onRepositoriesFinishedMoving($fromRepoId, $toRepoId)" } - val fromRepo = repoManager.getRepository(fromRepoId) - ?: error("No repo for repoId $fromRepoId") - val toRepo = repoManager.getRepository(toRepoId) - ?: error("No repo for repoId $toRepoId") - log.info { " ${fromRepo.address} => ${toRepo.address}" } - repoManager.reorderRepositories(fromRepo, toRepo) - } + fun onRepositoriesFinishedMoving(fromRepoId: Long, toRepoId: Long) { + log.info { "onRepositoriesFinishedMoving($fromRepoId, $toRepoId)" } + val fromRepo = repoManager.getRepository(fromRepoId) ?: error("No repo for repoId $fromRepoId") + val toRepo = repoManager.getRepository(toRepoId) ?: error("No repo for repoId $toRepoId") + log.info { " ${fromRepo.address} => ${toRepo.address}" } + repoManager.reorderRepositories(fromRepo, toRepo) + } - fun onOnboardingSeen() = onboardingManager.onRepositoriesOnboardingSeen() + fun onOnboardingSeen() = onboardingManager.onRepositoriesOnboardingSeen() } diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/RepositoryInfo.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/RepositoryInfo.kt index 1b827d903..b07c23bb0 100644 --- a/app/src/main/kotlin/org/fdroid/ui/repositories/RepositoryInfo.kt +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/RepositoryInfo.kt @@ -3,19 +3,25 @@ package org.fdroid.ui.repositories import org.fdroid.download.NetworkState interface RepositoryInfo { - val model: RepositoryModel - val currentRepositoryId: Long? - fun onOnboardingSeen() - fun onRepositorySelected(repositoryItem: RepositoryItem) - fun onRepositoryEnabled(repoId: Long, enabled: Boolean) - fun onAddRepo() - fun onRepositoryMoved(fromRepoId: Long, toRepoId: Long) - fun onRepositoriesFinishedMoving(fromRepoId: Long, toRepoId: Long) + val model: RepositoryModel + val currentRepositoryId: Long? + + fun onOnboardingSeen() + + fun onRepositorySelected(repositoryItem: RepositoryItem) + + fun onRepositoryEnabled(repoId: Long, enabled: Boolean) + + fun onAddRepo() + + fun onRepositoryMoved(fromRepoId: Long, toRepoId: Long) + + fun onRepositoriesFinishedMoving(fromRepoId: Long, toRepoId: Long) } data class RepositoryModel( - val repositories: List?, - val showOnboarding: Boolean, - val lastCheckForUpdate: String, - val networkState: NetworkState, + val repositories: List?, + val showOnboarding: Boolean, + val lastCheckForUpdate: String, + val networkState: NetworkState, ) diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/RepositoryItem.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/RepositoryItem.kt index 8ef97db07..5a84ebcc2 100644 --- a/app/src/main/kotlin/org/fdroid/ui/repositories/RepositoryItem.kt +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/RepositoryItem.kt @@ -6,27 +6,31 @@ import org.fdroid.database.Repository import org.fdroid.download.getImageModel data class RepositoryItem( - val repoId: Long, - val address: String, - val name: String, - val icon: Any? = null, - val timestamp: Long, - val lastUpdated: Long?, - val weight: Int, - val enabled: Boolean, - private val errorCount: Int, + val repoId: Long, + val address: String, + val name: String, + val icon: Any? = null, + val timestamp: Long, + val lastUpdated: Long?, + val weight: Int, + val enabled: Boolean, + private val errorCount: Int, ) { - constructor(repo: Repository, localeList: LocaleListCompat, proxy: ProxyConfig?) : this( - repoId = repo.repoId, - address = repo.address, - name = repo.getName(localeList) ?: "Unknown Repo", - icon = repo.getIcon(localeList)?.getImageModel(repo, proxy), - timestamp = repo.timestamp, - lastUpdated = repo.lastUpdated, - weight = repo.weight, - enabled = repo.enabled, - errorCount = repo.errorCount, - ) + constructor( + repo: Repository, + localeList: LocaleListCompat, + proxy: ProxyConfig?, + ) : this( + repoId = repo.repoId, + address = repo.address, + name = repo.getName(localeList) ?: "Unknown Repo", + icon = repo.getIcon(localeList)?.getImageModel(repo, proxy), + timestamp = repo.timestamp, + lastUpdated = repo.lastUpdated, + weight = repo.weight, + enabled = repo.enabled, + errorCount = repo.errorCount, + ) - val hasIssue: Boolean = enabled && errorCount >= 3 + val hasIssue: Boolean = enabled && errorCount >= 3 } diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/RepositoryRow.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/RepositoryRow.kt index 49d5f4c77..20bc9d3db 100644 --- a/app/src/main/kotlin/org/fdroid/ui/repositories/RepositoryRow.kt +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/RepositoryRow.kt @@ -24,62 +24,62 @@ import org.fdroid.ui.utils.repoItems @Composable fun RepositoryRow( - repoItem: RepositoryItem, - isSelected: Boolean, - onRepoEnabled: (Boolean) -> Unit, - modifier: Modifier = Modifier, + repoItem: RepositoryItem, + isSelected: Boolean, + onRepoEnabled: (Boolean) -> Unit, + modifier: Modifier = Modifier, ) { - ListItem( - leadingContent = { - BadgedBox( - badge = { - if (repoItem.hasIssue) BadgeIcon( - icon = Icons.Filled.Error, - color = MaterialTheme.colorScheme.error, - contentDescription = stringResource(R.string.repo_has_update_error), - ) - }, - ) { - AsyncShimmerImage( - model = repoItem.icon, - contentDescription = null, - modifier = Modifier.size(48.dp) - ) - } - }, - headlineContent = { - Text(repoItem.name) - }, - supportingContent = { - val lastUpdated = if (repoItem.timestamp <= 0) { - stringResource(R.string.repositories_last_update_never) - } else { - repoItem.timestamp.asRelativeTimeString() - } - Text(stringResource(R.string.repo_last_update_upstream, lastUpdated)) - }, - trailingContent = { - Switch(repoItem.enabled, onCheckedChange = onRepoEnabled) - }, - colors = ListItemDefaults.colors( - containerColor = if (isSelected) { - MaterialTheme.colorScheme.surfaceVariant - } else { - MaterialTheme.colorScheme.background - } - ), - modifier = modifier, - ) + ListItem( + leadingContent = { + BadgedBox( + badge = { + if (repoItem.hasIssue) + BadgeIcon( + icon = Icons.Filled.Error, + color = MaterialTheme.colorScheme.error, + contentDescription = stringResource(R.string.repo_has_update_error), + ) + } + ) { + AsyncShimmerImage( + model = repoItem.icon, + contentDescription = null, + modifier = Modifier.size(48.dp), + ) + } + }, + headlineContent = { Text(repoItem.name) }, + supportingContent = { + val lastUpdated = + if (repoItem.timestamp <= 0) { + stringResource(R.string.repositories_last_update_never) + } else { + repoItem.timestamp.asRelativeTimeString() + } + Text(stringResource(R.string.repo_last_update_upstream, lastUpdated)) + }, + trailingContent = { Switch(repoItem.enabled, onCheckedChange = onRepoEnabled) }, + colors = + ListItemDefaults.colors( + containerColor = + if (isSelected) { + MaterialTheme.colorScheme.surfaceVariant + } else { + MaterialTheme.colorScheme.background + } + ), + modifier = modifier, + ) } @Preview @Composable private fun Preview() { - FDroidContent { - Column { - RepositoryRow(repoItems[0], false, {}) - RepositoryRow(repoItems[1], true, {}) - RepositoryRow(repoItems[2].copy(timestamp = 0), false, {}) - } + FDroidContent { + Column { + RepositoryRow(repoItems[0], false, {}) + RepositoryRow(repoItems[1], true, {}) + RepositoryRow(repoItems[2].copy(timestamp = 0), false, {}) } + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/add/AddRepo.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/add/AddRepo.kt index 91fa69480..22b7d2e5c 100644 --- a/app/src/main/kotlin/org/fdroid/ui/repositories/add/AddRepo.kt +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/add/AddRepo.kt @@ -33,79 +33,81 @@ import org.fdroid.repo.RepoUpdateWorker @OptIn(ExperimentalMaterial3Api::class) @Composable fun AddRepo( - state: AddRepoState, - networkStateFlow: StateFlow, - proxyConfig: ProxyConfig?, - onFetchRepo: (String) -> Unit, - onAddRepo: () -> Unit, - onExistingRepo: (Long) -> Unit, - onRepoAdded: (String, Long) -> Unit, - onBackClicked: () -> Unit, + state: AddRepoState, + networkStateFlow: StateFlow, + proxyConfig: ProxyConfig?, + onFetchRepo: (String) -> Unit, + onAddRepo: () -> Unit, + onExistingRepo: (Long) -> Unit, + onRepoAdded: (String, Long) -> Unit, + onBackClicked: () -> Unit, ) { - Scaffold( - topBar = { - TopAppBar( - navigationIcon = { - IconButton(onClick = onBackClicked) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back)) - } - }, - title = { - Text( - text = if (state is Fetching) { - when (state.fetchResult) { - is FetchResult.IsNewMirror, - is FetchResult.IsExistingMirror -> { - stringResource(R.string.repo_add_mirror) - } - else -> stringResource(R.string.repo_add_new_title) - } - } else { - stringResource(R.string.repo_add_new_title) - }, - ) - }, - ) + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = onBackClicked) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back)) + } }, - ) { paddingValues -> - when (state) { - None -> { - val networkState = networkStateFlow.collectAsStateWithLifecycle().value - AddRepoIntroContent(networkState, onFetchRepo, Modifier.padding(paddingValues)) - } - is Fetching -> { - if (state.receivedRepo == null) { - AddRepoProgressScreen( - text = stringResource(R.string.repo_state_fetching), - modifier = Modifier.padding(paddingValues) - ) - } else { - AddRepoPreviewScreen( - state = state, - proxyConfig = proxyConfig, - onAddRepo = onAddRepo, - onExistingRepo = onExistingRepo, - modifier = Modifier.padding(paddingValues), - ) + title = { + Text( + text = + if (state is Fetching) { + when (state.fetchResult) { + is FetchResult.IsNewMirror, + is FetchResult.IsExistingMirror -> { + stringResource(R.string.repo_add_mirror) + } + else -> stringResource(R.string.repo_add_new_title) } - } - Adding -> AddRepoProgressScreen( - text = stringResource(R.string.repo_state_adding), - modifier = Modifier.padding(paddingValues) - ) - is Added -> { - val context = LocalContext.current - LaunchedEffect(state) { - if (state.updateResult is IndexUpdateResult.Error) { - // try updating newly added repo again - RepoUpdateWorker.updateNow(context, state.repo.repoId) - } - // tell UI that repo got added, so it can show info on it - val name = state.repo.getName(LocaleListCompat.getDefault()) ?: "Unknown Repo" - onRepoAdded(name, state.repo.repoId) - } - } - is AddRepoError -> AddRepoErrorScreen(state, Modifier.padding(paddingValues)) - } + } else { + stringResource(R.string.repo_add_new_title) + } + ) + }, + ) } + ) { paddingValues -> + when (state) { + None -> { + val networkState = networkStateFlow.collectAsStateWithLifecycle().value + AddRepoIntroContent(networkState, onFetchRepo, Modifier.padding(paddingValues)) + } + is Fetching -> { + if (state.receivedRepo == null) { + AddRepoProgressScreen( + text = stringResource(R.string.repo_state_fetching), + modifier = Modifier.padding(paddingValues), + ) + } else { + AddRepoPreviewScreen( + state = state, + proxyConfig = proxyConfig, + onAddRepo = onAddRepo, + onExistingRepo = onExistingRepo, + modifier = Modifier.padding(paddingValues), + ) + } + } + Adding -> + AddRepoProgressScreen( + text = stringResource(R.string.repo_state_adding), + modifier = Modifier.padding(paddingValues), + ) + is Added -> { + val context = LocalContext.current + LaunchedEffect(state) { + if (state.updateResult is IndexUpdateResult.Error) { + // try updating newly added repo again + RepoUpdateWorker.updateNow(context, state.repo.repoId) + } + // tell UI that repo got added, so it can show info on it + val name = state.repo.getName(LocaleListCompat.getDefault()) ?: "Unknown Repo" + onRepoAdded(name, state.repo.repoId) + } + } + is AddRepoError -> AddRepoErrorScreen(state, Modifier.padding(paddingValues)) + } + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/add/AddRepoErrorScreen.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/add/AddRepoErrorScreen.kt index 628dac0e3..0bec50487 100644 --- a/app/src/main/kotlin/org/fdroid/ui/repositories/add/AddRepoErrorScreen.kt +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/add/AddRepoErrorScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import java.io.IOException import org.fdroid.R import org.fdroid.repo.AddRepoError import org.fdroid.repo.AddRepoError.ErrorType.INVALID_FINGERPRINT @@ -33,96 +34,81 @@ import org.fdroid.repo.AddRepoError.ErrorType.IO_ERROR import org.fdroid.repo.AddRepoError.ErrorType.IS_ARCHIVE_REPO import org.fdroid.repo.AddRepoError.ErrorType.UNKNOWN_SOURCES_DISALLOWED import org.fdroid.ui.FDroidContent -import java.io.IOException @Composable fun AddRepoErrorScreen(state: AddRepoError, modifier: Modifier = Modifier) { - Column( - verticalArrangement = Arrangement.spacedBy(16.dp, CenterVertically), - horizontalAlignment = CenterHorizontally, - modifier = modifier - .padding(16.dp) - .fillMaxSize(), - ) { - Image( - imageVector = Icons.Default.Error, - contentDescription = stringResource(R.string.error), - colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.error), - modifier = Modifier.size(48.dp), - ) - val title = when (state.errorType) { - INVALID_FINGERPRINT -> stringResource(R.string.bad_fingerprint) - UNKNOWN_SOURCES_DISALLOWED -> { - if (LocalInspectionMode.current) { - stringResource(R.string.has_disallow_install_unknown_sources) - } else { - getDisallowInstallUnknownSourcesErrorMessage(LocalContext.current) - } - } - INVALID_INDEX -> stringResource(R.string.repo_invalid) - IO_ERROR -> stringResource(R.string.repo_io_error) - IS_ARCHIVE_REPO -> stringResource(R.string.repo_error_adding_archive) + Column( + verticalArrangement = Arrangement.spacedBy(16.dp, CenterVertically), + horizontalAlignment = CenterHorizontally, + modifier = modifier.padding(16.dp).fillMaxSize(), + ) { + Image( + imageVector = Icons.Default.Error, + contentDescription = stringResource(R.string.error), + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.error), + modifier = Modifier.size(48.dp), + ) + val title = + when (state.errorType) { + INVALID_FINGERPRINT -> stringResource(R.string.bad_fingerprint) + UNKNOWN_SOURCES_DISALLOWED -> { + if (LocalInspectionMode.current) { + stringResource(R.string.has_disallow_install_unknown_sources) + } else { + getDisallowInstallUnknownSourcesErrorMessage(LocalContext.current) + } } - Text( - text = title, - style = MaterialTheme.typography.headlineSmall, - textAlign = TextAlign.Center, - ) - if (state.exception != null) Text( - text = state.exception.toString(), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + INVALID_INDEX -> stringResource(R.string.repo_invalid) + IO_ERROR -> stringResource(R.string.repo_io_error) + IS_ARCHIVE_REPO -> stringResource(R.string.repo_error_adding_archive) + } + Text(text = title, style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center) + if (state.exception != null) + Text( + text = state.exception.toString(), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } fun getDisallowInstallUnknownSourcesErrorMessage(context: Context): String { - val userManager = context.getSystemService(Context.USER_SERVICE) as UserManager - return if (SDK_INT >= 29 && - userManager.hasUserRestriction(DISALLOW_INSTALL_UNKNOWN_SOURCES_GLOBALLY) - ) { - context.getString(R.string.has_disallow_install_unknown_sources_globally) - } else { - context.getString(R.string.has_disallow_install_unknown_sources) - } + val userManager = context.getSystemService(Context.USER_SERVICE) as UserManager + return if ( + SDK_INT >= 29 && userManager.hasUserRestriction(DISALLOW_INSTALL_UNKNOWN_SOURCES_GLOBALLY) + ) { + context.getString(R.string.has_disallow_install_unknown_sources_globally) + } else { + context.getString(R.string.has_disallow_install_unknown_sources) + } } @Preview @Composable fun AddRepoErrorInvalidFingerprintPreview() { - FDroidContent { - AddRepoErrorScreen(AddRepoError(INVALID_FINGERPRINT)) - } + FDroidContent { AddRepoErrorScreen(AddRepoError(INVALID_FINGERPRINT)) } } @Preview @Composable fun AddRepoErrorIoErrorPreview() { - FDroidContent { - AddRepoErrorScreen(AddRepoError(IO_ERROR, IOException("foo bar"))) - } + FDroidContent { AddRepoErrorScreen(AddRepoError(IO_ERROR, IOException("foo bar"))) } } @Preview @Composable fun AddRepoErrorInvalidIndexPreview() { - FDroidContent { - AddRepoErrorScreen(AddRepoError(INVALID_INDEX, RuntimeException("foo bar"))) - } + FDroidContent { AddRepoErrorScreen(AddRepoError(INVALID_INDEX, RuntimeException("foo bar"))) } } @Preview @Composable fun AddRepoErrorUnknownSourcesPreview() { - FDroidContent { - AddRepoErrorScreen(AddRepoError(UNKNOWN_SOURCES_DISALLOWED)) - } + FDroidContent { AddRepoErrorScreen(AddRepoError(UNKNOWN_SOURCES_DISALLOWED)) } } @Preview @Composable fun AddRepoErrorArchivePreview() { - FDroidContent { - AddRepoErrorScreen(AddRepoError(IS_ARCHIVE_REPO)) - } + FDroidContent { AddRepoErrorScreen(AddRepoError(IS_ARCHIVE_REPO)) } } diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/add/AddRepoIntro.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/add/AddRepoIntro.kt index 3d6b1c4b8..4832905ea 100644 --- a/app/src/main/kotlin/org/fdroid/ui/repositories/add/AddRepoIntro.kt +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/add/AddRepoIntro.kt @@ -80,191 +80,166 @@ import org.fdroid.ui.utils.startActivitySafe @Composable fun AddRepoIntroContent( - networkState: NetworkState, - onFetchRepo: (String) -> Unit, - modifier: Modifier = Modifier + networkState: NetworkState, + onFetchRepo: (String) -> Unit, + modifier: Modifier = Modifier, ) { - val scrollState = rememberScrollState() - val context = LocalContext.current - val isPreview = LocalInspectionMode.current - var showPermissionWarning by remember { mutableStateOf(isPreview) } - val startForResult = rememberLauncherForActivityResult(ScanContract()) { result -> - if (result.contents != null) { - onFetchRepo(result.contents) + val scrollState = rememberScrollState() + val context = LocalContext.current + val isPreview = LocalInspectionMode.current + var showPermissionWarning by remember { mutableStateOf(isPreview) } + val startForResult = + rememberLauncherForActivityResult(ScanContract()) { result -> + if (result.contents != null) { + onFetchRepo(result.contents) + } + } + + fun startScanning() { + startForResult.launch( + ScanOptions().apply { + setPrompt("") + setBeepEnabled(true) + setOrientationLocked(false) + setDesiredBarcodeFormats(QR_CODE) + addExtra(SCAN_TYPE, MIXED_SCAN) + } + ) + } + + val permissionLauncher = + rememberLauncherForActivityResult(contract = RequestPermission()) { isGranted: Boolean -> + showPermissionWarning = !isGranted + if (isGranted) startScanning() + } + var showMeteredDialog by remember { mutableStateOf<(() -> Unit)?>(null) } + Column( + verticalArrangement = spacedBy(16.dp), + horizontalAlignment = CenterHorizontally, + modifier = modifier.imePadding().fillMaxSize().verticalScroll(scrollState).padding(16.dp), + ) { + if (!networkState.isOnline) OfflineBar() + Text(text = stringResource(R.string.repo_intro), style = MaterialTheme.typography.bodyLarge) + FDroidButton( + stringResource(R.string.repo_scan_qr_code), + imageVector = Icons.Filled.QrCode, + onClick = { + val scanLambda = { + if (checkSelfPermission(context, CAMERA) == PERMISSION_GRANTED) { + startScanning() + } else { + permissionLauncher.launch(CAMERA) + } } - } - - fun startScanning() { - startForResult.launch(ScanOptions().apply { - setPrompt("") - setBeepEnabled(true) - setOrientationLocked(false) - setDesiredBarcodeFormats(QR_CODE) - addExtra(SCAN_TYPE, MIXED_SCAN) - }) - } - - val permissionLauncher = rememberLauncherForActivityResult( - contract = RequestPermission() - ) { isGranted: Boolean -> - showPermissionWarning = !isGranted - if (isGranted) startScanning() - } - var showMeteredDialog by remember { mutableStateOf<(() -> Unit)?>(null) } - Column( - verticalArrangement = spacedBy(16.dp), - horizontalAlignment = CenterHorizontally, - modifier = modifier - .imePadding() - .fillMaxSize() - .verticalScroll(scrollState) - .padding(16.dp), + if (networkState.isMetered) showMeteredDialog = scanLambda else scanLambda() + }, + ) + AnimatedVisibility( + visible = showPermissionWarning, + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, ) { - if (!networkState.isOnline) OfflineBar() + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.inverseSurface), + modifier = + Modifier.fillMaxWidth().clickable { + val intent = + Intent(ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + } + context.startActivitySafe(intent) + }, + ) { Text( - text = stringResource(R.string.repo_intro), - style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(8.dp), + text = stringResource(R.string.permission_camera_denied), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge, ) - FDroidButton( - stringResource(R.string.repo_scan_qr_code), - imageVector = Icons.Filled.QrCode, - onClick = { - val scanLambda = { - if (checkSelfPermission(context, CAMERA) == PERMISSION_GRANTED) { - startScanning() - } else { - permissionLauncher.launch(CAMERA) - } - } - if (networkState.isMetered) showMeteredDialog = scanLambda - else scanLambda() + } + } + var manualExpanded by rememberSaveable { mutableStateOf(isPreview) } + Row( + horizontalArrangement = SpaceBetween, + verticalAlignment = CenterVertically, + modifier = + Modifier.fillMaxWidth().heightIn(min = ButtonDefaults.MinHeight).clickable { + manualExpanded = !manualExpanded + }, + ) { + Text( + text = stringResource(R.string.repo_enter_url), + style = MaterialTheme.typography.bodyMedium, + // avoid occupying the whole row + modifier = Modifier.weight(1f), + ) + ExpandIconArrow(manualExpanded) + } + val textState = remember { mutableStateOf(TextFieldValue()) } + val focusRequester = remember { FocusRequester() } + val coroutineScope = rememberCoroutineScope() + AnimatedVisibility( + visible = manualExpanded, + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, + ) { + Column(horizontalAlignment = Alignment.End, verticalArrangement = spacedBy(16.dp)) { + TextField( + value = textState.value, + minLines = 2, + onValueChange = { textState.value = it }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Go), + keyboardActions = KeyboardActions(onGo = { onFetchRepo(textState.value.text) }), + modifier = + Modifier.fillMaxWidth().focusRequester(focusRequester).onGloballyPositioned { + focusRequester.requestFocus() + coroutineScope.launch { scrollState.animateScrollTo(scrollState.maxValue) } }, ) - AnimatedVisibility( - visible = showPermissionWarning, - modifier = Modifier - .semantics { liveRegion = LiveRegionMode.Polite }, - ) { - Card( - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.inverseSurface, - ), - modifier = Modifier - .fillMaxWidth() - .clickable { - val intent = Intent(ACTION_APPLICATION_DETAILS_SETTINGS).apply { - data = Uri.fromParts("package", context.packageName, null) - } - context.startActivitySafe(intent) - } - ) { - Text( - modifier = Modifier.padding(8.dp), - text = stringResource(R.string.permission_camera_denied), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyLarge, - ) - } - } - var manualExpanded by rememberSaveable { mutableStateOf(isPreview) } - Row( - horizontalArrangement = SpaceBetween, - verticalAlignment = CenterVertically, - modifier = Modifier - .fillMaxWidth() - .heightIn(min = ButtonDefaults.MinHeight) - .clickable { manualExpanded = !manualExpanded }, - ) { - Text( - text = stringResource(R.string.repo_enter_url), - style = MaterialTheme.typography.bodyMedium, - // avoid occupying the whole row - modifier = Modifier.weight(1f), - ) - ExpandIconArrow(manualExpanded) - } - val textState = remember { mutableStateOf(TextFieldValue()) } - val focusRequester = remember { FocusRequester() } - val coroutineScope = rememberCoroutineScope() - AnimatedVisibility( - visible = manualExpanded, - modifier = Modifier - .semantics { liveRegion = LiveRegionMode.Polite }, - ) { - Column( - horizontalAlignment = Alignment.End, - verticalArrangement = spacedBy(16.dp), - ) { - TextField( - value = textState.value, - minLines = 2, - onValueChange = { textState.value = it }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Go), - keyboardActions = KeyboardActions( - onGo = { - onFetchRepo(textState.value.text) - }, - ), - modifier = Modifier - .fillMaxWidth() - .focusRequester(focusRequester) - .onGloballyPositioned { - focusRequester.requestFocus() - coroutineScope.launch { - scrollState.animateScrollTo(scrollState.maxValue) - } - }, - ) - Row( - horizontalArrangement = spacedBy(16.dp), - verticalAlignment = CenterVertically, - ) { - val clipboardManager = LocalClipboardManager.current - FDroidOutlineButton( - stringResource(id = R.string.paste), - imageVector = Icons.Default.ContentPaste, - onClick = { - if (clipboardManager.hasText()) { - textState.value = - TextFieldValue(clipboardManager.getText()?.text ?: "") - } - }, - ) - Spacer(modifier = Modifier.weight(1f)) - FDroidButton( - text = stringResource(R.string.repo_add_add), - onClick = { - if (networkState.isMetered) showMeteredDialog = { - onFetchRepo(textState.value.text) - } else onFetchRepo(textState.value.text) - }, - ) - } - } + Row(horizontalArrangement = spacedBy(16.dp), verticalAlignment = CenterVertically) { + val clipboardManager = LocalClipboardManager.current + FDroidOutlineButton( + stringResource(id = R.string.paste), + imageVector = Icons.Default.ContentPaste, + onClick = { + if (clipboardManager.hasText()) { + textState.value = TextFieldValue(clipboardManager.getText()?.text ?: "") + } + }, + ) + Spacer(modifier = Modifier.weight(1f)) + FDroidButton( + text = stringResource(R.string.repo_add_add), + onClick = { + if (networkState.isMetered) showMeteredDialog = { onFetchRepo(textState.value.text) } + else onFetchRepo(textState.value.text) + }, + ) } + } } - val meteredLambda = showMeteredDialog - if (meteredLambda != null) MeteredConnectionDialog( - numBytes = null, - onConfirm = { meteredLambda() }, - onDismiss = { showMeteredDialog = null }, + } + val meteredLambda = showMeteredDialog + if (meteredLambda != null) + MeteredConnectionDialog( + numBytes = null, + onConfirm = { meteredLambda() }, + onDismiss = { showMeteredDialog = null }, ) } @Composable @Preview private fun Preview() { - FDroidContent { - val networkStateFlow = MutableStateFlow(NetworkState(isOnline = true, isMetered = false)) - AddRepo(None, networkStateFlow, null, {}, {}, {}, { _, _ -> }) {} - } + FDroidContent { + val networkStateFlow = MutableStateFlow(NetworkState(isOnline = true, isMetered = false)) + AddRepo(None, networkStateFlow, null, {}, {}, {}, { _, _ -> }) {} + } } @Composable @Preview(uiMode = UI_MODE_NIGHT_YES, widthDp = 720, heightDp = 360) private fun PreviewNight() { - FDroidContent { - val networkStateFlow = MutableStateFlow(NetworkState(isOnline = false, isMetered = false)) - AddRepo(None, networkStateFlow, null, {}, {}, {}, { _, _ -> }) {} - } + FDroidContent { + val networkStateFlow = MutableStateFlow(NetworkState(isOnline = false, isMetered = false)) + AddRepo(None, networkStateFlow, null, {}, {}, {}, { _, _ -> }) {} + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/add/AddRepoPreviewScreen.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/add/AddRepoPreviewScreen.kt index 406e72b91..430c92629 100644 --- a/app/src/main/kotlin/org/fdroid/ui/repositories/add/AddRepoPreviewScreen.kt +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/add/AddRepoPreviewScreen.kt @@ -34,117 +34,112 @@ import org.fdroid.ui.utils.getRepository @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun AddRepoPreviewScreen( - state: Fetching, - proxyConfig: ProxyConfig?, - modifier: Modifier = Modifier, - onAddRepo: () -> Unit, - onExistingRepo: (Long) -> Unit, + state: Fetching, + proxyConfig: ProxyConfig?, + modifier: Modifier = Modifier, + onAddRepo: () -> Unit, + onExistingRepo: (Long) -> Unit, ) { - val localeList = LocaleListCompat.getDefault() - LazyColumn( - contentPadding = PaddingValues(horizontal = 8.dp), - verticalArrangement = spacedBy(8.dp), - modifier = modifier - .fillMaxWidth() - ) { - item { - RepoPreviewHeader( - state = state, - proxyConfig = proxyConfig, - onAddRepo = onAddRepo, - onExistingRepo = onExistingRepo, - modifier = Modifier - .padding(top = 16.dp) - .padding(horizontal = 8.dp), - localeList = localeList, - ) - } - if (state.fetchResult == null || - state.fetchResult is IsNewRepository || - state.fetchResult is IsNewRepoAndNewMirror - ) { - item { - Row( - verticalAlignment = CenterVertically, - horizontalArrangement = spacedBy(8.dp), - modifier = Modifier - .padding(top = 8.dp) - .padding(horizontal = 8.dp), - ) { - Text( - text = stringResource(R.string.repo_preview_included_apps), - style = MaterialTheme.typography.bodyLarge, - ) - Text( - text = state.apps.size.toString(), - style = MaterialTheme.typography.bodyLarge, - ) - if (!state.done) { - LinearWavyProgressIndicator(modifier = Modifier.weight(1f)) - } - } - } - items(items = state.apps, key = { it.packageName }) { app -> - val repo = state.receivedRepo ?: error("no repo") - // TODO this conversion ideally doesn't happen in the UI layer - val item = AppListItem( - repoId = repo.repoId, - packageName = app.packageName, - name = app.name ?: "Unknown app", - summary = app.summary ?: "", - iconModel = app.getIcon(localeList)?.getImageModel(repo, proxyConfig), - lastUpdated = 1L, - isInstalled = false, - isCompatible = true, - ) - AppListRow( - item = item, - isSelected = false, - modifier = Modifier - .animateItem() - .padding(horizontal = 8.dp) - .fillMaxWidth() - ) - } - } + val localeList = LocaleListCompat.getDefault() + LazyColumn( + contentPadding = PaddingValues(horizontal = 8.dp), + verticalArrangement = spacedBy(8.dp), + modifier = modifier.fillMaxWidth(), + ) { + item { + RepoPreviewHeader( + state = state, + proxyConfig = proxyConfig, + onAddRepo = onAddRepo, + onExistingRepo = onExistingRepo, + modifier = Modifier.padding(top = 16.dp).padding(horizontal = 8.dp), + localeList = localeList, + ) } + if ( + state.fetchResult == null || + state.fetchResult is IsNewRepository || + state.fetchResult is IsNewRepoAndNewMirror + ) { + item { + Row( + verticalAlignment = CenterVertically, + horizontalArrangement = spacedBy(8.dp), + modifier = Modifier.padding(top = 8.dp).padding(horizontal = 8.dp), + ) { + Text( + text = stringResource(R.string.repo_preview_included_apps), + style = MaterialTheme.typography.bodyLarge, + ) + Text(text = state.apps.size.toString(), style = MaterialTheme.typography.bodyLarge) + if (!state.done) { + LinearWavyProgressIndicator(modifier = Modifier.weight(1f)) + } + } + } + items(items = state.apps, key = { it.packageName }) { app -> + val repo = state.receivedRepo ?: error("no repo") + // TODO this conversion ideally doesn't happen in the UI layer + val item = + AppListItem( + repoId = repo.repoId, + packageName = app.packageName, + name = app.name ?: "Unknown app", + summary = app.summary ?: "", + iconModel = app.getIcon(localeList)?.getImageModel(repo, proxyConfig), + lastUpdated = 1L, + isInstalled = false, + isCompatible = true, + ) + AppListRow( + item = item, + isSelected = false, + modifier = Modifier.animateItem().padding(horizontal = 8.dp).fillMaxWidth(), + ) + } + } + } } @Preview @Composable private fun Preview() { - val address = "https://example.org" - val repo = getRepository(address) - val app1 = object : MinimalApp { - override val repoId = 0L - override val packageName = "org.example" - override val name: String = "App 1 with a long name" - override val summary: String = "Summary of App1 which can also be a bit longer" - override fun getIcon(localeList: LocaleListCompat): FileV2? = null - } - val app2 = object : MinimalApp { - override val repoId = 0L - override val packageName = "com.example" - override val name: String = "App 2 with a name that is even longer than the first app" - override val summary: String = - "Summary of App2 which can also be a bit longer, even longer than other apps." + val address = "https://example.org" + val repo = getRepository(address) + val app1 = + object : MinimalApp { + override val repoId = 0L + override val packageName = "org.example" + override val name: String = "App 1 with a long name" + override val summary: String = "Summary of App1 which can also be a bit longer" - override fun getIcon(localeList: LocaleListCompat): FileV2? = null + override fun getIcon(localeList: LocaleListCompat): FileV2? = null } - val app3 = object : MinimalApp { - override val repoId = 0L - override val packageName = "net.example" - override val name: String = "App 3" - override val summary: String = "short summary" + val app2 = + object : MinimalApp { + override val repoId = 0L + override val packageName = "com.example" + override val name: String = "App 2 with a name that is even longer than the first app" + override val summary: String = + "Summary of App2 which can also be a bit longer, even longer than other apps." - override fun getIcon(localeList: LocaleListCompat): FileV2? = null + override fun getIcon(localeList: LocaleListCompat): FileV2? = null } - FDroidContent { - AddRepoPreviewScreen( - Fetching(address, repo, listOf(app1, app2, app3), IsNewRepository), - proxyConfig = null, - onAddRepo = { }, - onExistingRepo = {}, - ) + val app3 = + object : MinimalApp { + override val repoId = 0L + override val packageName = "net.example" + override val name: String = "App 3" + override val summary: String = "short summary" + + override fun getIcon(localeList: LocaleListCompat): FileV2? = null } + FDroidContent { + AddRepoPreviewScreen( + Fetching(address, repo, listOf(app1, app2, app3), IsNewRepository), + proxyConfig = null, + onAddRepo = {}, + onExistingRepo = {}, + ) + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/add/AddRepoProgressScreen.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/add/AddRepoProgressScreen.kt index 80aae4260..b2dfbc59d 100644 --- a/app/src/main/kotlin/org/fdroid/ui/repositories/add/AddRepoProgressScreen.kt +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/add/AddRepoProgressScreen.kt @@ -22,25 +22,18 @@ import org.fdroid.ui.FDroidContent @Composable @OptIn(ExperimentalMaterial3ExpressiveApi::class) fun AddRepoProgressScreen(text: String, modifier: Modifier = Modifier) { - Column( - verticalArrangement = spacedBy(16.dp, CenterVertically), - horizontalAlignment = CenterHorizontally, - modifier = modifier - .padding(16.dp) - .fillMaxSize(), - ) { - Text( - text = text, - style = MaterialTheme.typography.headlineSmall, - ) - CircularWavyProgressIndicator(modifier = Modifier.size(64.dp)) - } + Column( + verticalArrangement = spacedBy(16.dp, CenterVertically), + horizontalAlignment = CenterHorizontally, + modifier = modifier.padding(16.dp).fillMaxSize(), + ) { + Text(text = text, style = MaterialTheme.typography.headlineSmall) + CircularWavyProgressIndicator(modifier = Modifier.size(64.dp)) + } } @Preview @Composable private fun Preview() { - FDroidContent { - AddRepoProgressScreen(stringResource(R.string.repo_state_fetching)) - } + FDroidContent { AddRepoProgressScreen(stringResource(R.string.repo_state_fetching)) } } diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/add/AddRepoViewModel.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/add/AddRepoViewModel.kt index 43e7745eb..b128cf5d7 100644 --- a/app/src/main/kotlin/org/fdroid/ui/repositories/add/AddRepoViewModel.kt +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/add/AddRepoViewModel.kt @@ -4,6 +4,7 @@ import android.app.Application import androidx.core.net.toUri import androidx.lifecycle.AndroidViewModel import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.StateFlow @@ -20,54 +21,57 @@ import org.fdroid.repo.None import org.fdroid.settings.SettingsManager import org.fdroid.updates.UpdatesManager import org.fdroid.utils.IoDispatcher -import javax.inject.Inject @HiltViewModel -class AddRepoViewModel @Inject constructor( - app: Application, - networkMonitor: NetworkMonitor, - settingsManager: SettingsManager, - private val repoManager: RepoManager, - private val updateManager: UpdatesManager, - @param:IoDispatcher private val ioScope: CoroutineScope, +class AddRepoViewModel +@Inject +constructor( + app: Application, + networkMonitor: NetworkMonitor, + settingsManager: SettingsManager, + private val repoManager: RepoManager, + private val updateManager: UpdatesManager, + @param:IoDispatcher private val ioScope: CoroutineScope, ) : AndroidViewModel(app) { - private val log = KotlinLogging.logger { } - val state: StateFlow = repoManager.addRepoState + private val log = KotlinLogging.logger {} + val state: StateFlow = repoManager.addRepoState - val proxyConfig = settingsManager.proxyConfig - val networkState = networkMonitor.networkState + val proxyConfig = settingsManager.proxyConfig + val networkState = networkMonitor.networkState - override fun onCleared() { - log.info { "onCleared() abort adding repository" } - repoManager.abortAddingRepository() + override fun onCleared() { + log.info { "onCleared() abort adding repository" } + repoManager.abortAddingRepository() + } + + fun onFetchRepo(uriStr: String) { + val uri = uriStr.trim().toUri() + if (repoManager.isSwapUri(uri)) { + // TODO full only + } else { + repoManager.abortAddingRepository() + repoManager.fetchRepositoryPreview(uri.toString(), proxyConfig) } + } - fun onFetchRepo(uriStr: String) { - val uri = uriStr.trim().toUri() - if (repoManager.isSwapUri(uri)) { - // TODO full only - } else { - repoManager.abortAddingRepository() - repoManager.fetchRepositoryPreview(uri.toString(), proxyConfig) - } - } - - fun addFetchedRepository() { - log.info { "addFetchedRepository()" } - ioScope.launch { - repoManager.addFetchedRepository() - // wait for repo to get added, so we can load updates afterward - repoManager.addRepoState.collect { - when (it) { - is Fetching, Adding, None -> {} - is Added -> { - updateManager.loadUpdates().join() - cancel() - } - is AddRepoError -> cancel() - } - } + fun addFetchedRepository() { + log.info { "addFetchedRepository()" } + ioScope.launch { + repoManager.addFetchedRepository() + // wait for repo to get added, so we can load updates afterward + repoManager.addRepoState.collect { + when (it) { + is Fetching, + Adding, + None -> {} + is Added -> { + updateManager.loadUpdates().join() + cancel() + } + is AddRepoError -> cancel() } + } } + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/add/RepoPreviewHeader.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/add/RepoPreviewHeader.kt index bbd2a33e8..f85476f10 100644 --- a/app/src/main/kotlin/org/fdroid/ui/repositories/add/RepoPreviewHeader.kt +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/add/RepoPreviewHeader.kt @@ -40,171 +40,166 @@ import org.fdroid.ui.utils.getRepository @Composable fun RepoPreviewHeader( - state: Fetching, - proxyConfig: ProxyConfig?, - onAddRepo: () -> Unit, - onExistingRepo: (Long) -> Unit, - modifier: Modifier = Modifier, - localeList: LocaleListCompat, + state: Fetching, + proxyConfig: ProxyConfig?, + onAddRepo: () -> Unit, + onExistingRepo: (Long) -> Unit, + modifier: Modifier = Modifier, + localeList: LocaleListCompat, ) { - val repo = state.receivedRepo ?: error("repo was null") - val isDevPreview = LocalInspectionMode.current + val repo = state.receivedRepo ?: error("repo was null") + val isDevPreview = LocalInspectionMode.current - val buttonText = when (state.fetchResult) { - is IsNewRepository -> stringResource(R.string.repo_add_new_title) - is IsNewRepoAndNewMirror -> stringResource(R.string.repo_add_repo_and_mirror) - is IsNewMirror -> stringResource(R.string.repo_add_mirror) - is IsExistingRepository, is IsExistingMirror -> stringResource(R.string.repo_view_repo) - else -> error("Unexpected fetch state: ${state.fetchResult}") + val buttonText = + when (state.fetchResult) { + is IsNewRepository -> stringResource(R.string.repo_add_new_title) + is IsNewRepoAndNewMirror -> stringResource(R.string.repo_add_repo_and_mirror) + is IsNewMirror -> stringResource(R.string.repo_add_mirror) + is IsExistingRepository, + is IsExistingMirror -> stringResource(R.string.repo_view_repo) + else -> error("Unexpected fetch state: ${state.fetchResult}") } - val buttonAction: () -> Unit = when (val res = state.fetchResult) { - is IsNewRepository, is IsNewRepoAndNewMirror, is IsNewMirror -> onAddRepo - is IsExistingRepository -> { - { onExistingRepo(res.existingRepoId) } - } - is IsExistingMirror -> { - { onExistingRepo(res.existingRepoId) } - } - else -> error("Unexpected fetch state: ${state.fetchResult}") + val buttonAction: () -> Unit = + when (val res = state.fetchResult) { + is IsNewRepository, + is IsNewRepoAndNewMirror, + is IsNewMirror -> onAddRepo + is IsExistingRepository -> { + { onExistingRepo(res.existingRepoId) } + } + is IsExistingMirror -> { + { onExistingRepo(res.existingRepoId) } + } + else -> error("Unexpected fetch state: ${state.fetchResult}") } - val warningText: String? = when (state.fetchResult) { - is IsNewRepository -> null - is IsNewRepoAndNewMirror -> stringResource( - R.string.repo_and_mirror_add_both_info, - state.fetchUrl + val warningText: String? = + when (state.fetchResult) { + is IsNewRepository -> null + is IsNewRepoAndNewMirror -> + stringResource(R.string.repo_and_mirror_add_both_info, state.fetchUrl) + is IsNewMirror -> stringResource(R.string.repo_mirror_add_info, state.fetchUrl) + is IsExistingRepository -> stringResource(R.string.repo_exists) + is IsExistingMirror -> stringResource(R.string.repo_mirror_exists, state.fetchUrl) + else -> error("Unexpected fetch state: ${state.fetchResult}") + } + + Column(verticalArrangement = spacedBy(16.dp), modifier = modifier.fillMaxWidth()) { + Row(horizontalArrangement = spacedBy(16.dp), verticalAlignment = CenterVertically) { + RepoIcon(repo, proxyConfig, Modifier.size(48.dp)) + Column(horizontalAlignment = Alignment.Start) { + Text( + text = repo.getName(localeList) ?: "Unknown Repository", + maxLines = 1, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.bodyLarge, ) - is IsNewMirror -> stringResource(R.string.repo_mirror_add_info, state.fetchUrl) - is IsExistingRepository -> stringResource(R.string.repo_exists) - is IsExistingMirror -> stringResource(R.string.repo_mirror_exists, state.fetchUrl) - else -> error("Unexpected fetch state: ${state.fetchResult}") + Text( + text = repo.address.replaceFirst("https://", ""), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = repo.timestamp.asRelativeTimeString(), + style = MaterialTheme.typography.bodyMedium, + ) + } } - Column( - verticalArrangement = spacedBy(16.dp), - modifier = modifier.fillMaxWidth(), - ) { - Row( - horizontalArrangement = spacedBy(16.dp), - verticalAlignment = CenterVertically, - ) { - RepoIcon(repo, proxyConfig, Modifier.size(48.dp)) - Column(horizontalAlignment = Alignment.Start) { - Text( - text = repo.getName(localeList) ?: "Unknown Repository", - maxLines = 1, - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.bodyLarge, - ) - Text( - text = repo.address.replaceFirst("https://", ""), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = repo.timestamp.asRelativeTimeString(), - style = MaterialTheme.typography.bodyMedium, - ) - } - } - - if (warningText != null) Box( - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.inverseSurface), - contentAlignment = Alignment.Center, - ) { - Text( - modifier = Modifier - .padding(8.dp), - text = warningText, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.inverseOnSurface, - ) - } - - FDroidButton( - text = buttonText, - onClick = buttonAction, - modifier = Modifier.align(End), + if (warningText != null) + Box( + modifier = Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.inverseSurface), + contentAlignment = Alignment.Center, + ) { + Text( + modifier = Modifier.padding(8.dp), + text = warningText, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.inverseOnSurface, ) + } - val description = if (isDevPreview) { - LoremIpsum(42).values.joinToString(" ") - } else { - repo.getDescription(localeList) - } - if (description != null) Text( - // repos are still messing up their line breaks - text = description.replace("\n", " "), - style = MaterialTheme.typography.bodyMedium, - ) - } + FDroidButton(text = buttonText, onClick = buttonAction, modifier = Modifier.align(End)) + + val description = + if (isDevPreview) { + LoremIpsum(42).values.joinToString(" ") + } else { + repo.getDescription(localeList) + } + if (description != null) + Text( + // repos are still messing up their line breaks + text = description.replace("\n", " "), + style = MaterialTheme.typography.bodyMedium, + ) + } } @Composable @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = 720, heightDp = 360) fun RepoPreviewScreenNewMirrorPreview() { - val repo = getRepository("https://example.org") - FDroidContent { - RepoPreviewHeader( - Fetching("https://mirror.example.org", repo, emptyList(), IsNewMirror(0L)), - onAddRepo = { }, - onExistingRepo = {}, - localeList = LocaleListCompat.getDefault(), - proxyConfig = null, - ) - } + val repo = getRepository("https://example.org") + FDroidContent { + RepoPreviewHeader( + Fetching("https://mirror.example.org", repo, emptyList(), IsNewMirror(0L)), + onAddRepo = {}, + onExistingRepo = {}, + localeList = LocaleListCompat.getDefault(), + proxyConfig = null, + ) + } } @Composable @Preview fun RepoPreviewScreenNewRepoAndNewMirrorPreview() { - val repo = getRepository("https://example.org") - FDroidContent { - RepoPreviewHeader( - state = Fetching( - fetchUrl = "https://mirror.example.org", - receivedRepo = repo, - apps = emptyList(), - fetchResult = IsNewRepoAndNewMirror, - ), - onAddRepo = { }, - onExistingRepo = {}, - localeList = LocaleListCompat.getDefault(), - proxyConfig = null, - ) - } + val repo = getRepository("https://example.org") + FDroidContent { + RepoPreviewHeader( + state = + Fetching( + fetchUrl = "https://mirror.example.org", + receivedRepo = repo, + apps = emptyList(), + fetchResult = IsNewRepoAndNewMirror, + ), + onAddRepo = {}, + onExistingRepo = {}, + localeList = LocaleListCompat.getDefault(), + proxyConfig = null, + ) + } } @Preview @Composable fun RepoPreviewScreenExistingRepoPreview() { - val address = "https://example.org" - val repo = getRepository(address) - FDroidContent { - RepoPreviewHeader( - Fetching(address, repo, emptyList(), IsExistingRepository(0L)), - onAddRepo = { }, - onExistingRepo = {}, - localeList = LocaleListCompat.getDefault(), - proxyConfig = null, - ) - } + val address = "https://example.org" + val repo = getRepository(address) + FDroidContent { + RepoPreviewHeader( + Fetching(address, repo, emptyList(), IsExistingRepository(0L)), + onAddRepo = {}, + onExistingRepo = {}, + localeList = LocaleListCompat.getDefault(), + proxyConfig = null, + ) + } } @Preview @Composable fun RepoPreviewScreenExistingMirrorPreview() { - val repo = getRepository("https://example.org") - FDroidContent { - RepoPreviewHeader( - Fetching("https://mirror.example.org", repo, emptyList(), IsExistingMirror(0L)), - onAddRepo = { }, - onExistingRepo = {}, - localeList = LocaleListCompat.getDefault(), - proxyConfig = null, - ) - } + val repo = getRepository("https://example.org") + FDroidContent { + RepoPreviewHeader( + Fetching("https://mirror.example.org", repo, emptyList(), IsExistingMirror(0L)), + onAddRepo = {}, + onExistingRepo = {}, + localeList = LocaleListCompat.getDefault(), + proxyConfig = null, + ) + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/details/BasicAuth.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/details/BasicAuth.kt index 171a3bda1..f815cb7fe 100644 --- a/app/src/main/kotlin/org/fdroid/ui/repositories/details/BasicAuth.kt +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/details/BasicAuth.kt @@ -31,55 +31,46 @@ import org.fdroid.ui.utils.FDroidOutlineButton @Composable fun BasicAuth( - username: String, - modifier: Modifier = Modifier, - onEditCredentials: (String, String) -> Unit, + username: String, + modifier: Modifier = Modifier, + onEditCredentials: (String, String) -> Unit, ) { - val usernameState = rememberTextFieldState(initialText = username) - val passwordState = rememberTextFieldState() - var showSaveButton by remember { mutableStateOf(true) } - Column( - verticalArrangement = spacedBy(8.dp), - modifier = modifier - .fillMaxWidth() - .imePadding() + val usernameState = rememberTextFieldState(initialText = username) + val passwordState = rememberTextFieldState() + var showSaveButton by remember { mutableStateOf(true) } + Column(verticalArrangement = spacedBy(8.dp), modifier = modifier.fillMaxWidth().imePadding()) { + Text( + text = stringResource(R.string.repo_basic_auth_title), + style = MaterialTheme.typography.titleMedium, + ) + TextField( + state = usernameState, + label = { Text(stringResource(R.string.repo_basicauth_username)) }, + ) + TextField( + state = passwordState, + label = { Text(stringResource(R.string.repo_basicauth_password)) }, + ) + AnimatedVisibility( + visible = showSaveButton, + modifier = Modifier.align(Alignment.End).semantics { liveRegion = LiveRegionMode.Polite }, ) { - Text( - text = stringResource(R.string.repo_basic_auth_title), - style = MaterialTheme.typography.titleMedium, - ) - TextField( - state = usernameState, - label = { Text(stringResource(R.string.repo_basicauth_username)) } - ) - TextField( - state = passwordState, - label = { Text(stringResource(R.string.repo_basicauth_password)) } - ) - AnimatedVisibility( - visible = showSaveButton, - modifier = Modifier - .align(Alignment.End) - .semantics { liveRegion = LiveRegionMode.Polite }, - ) { - FDroidOutlineButton( - text = stringResource(R.string.repo_basicauth_edit), - onClick = { - val username = usernameState.text.toString() - val password = passwordState.text.toString() - onEditCredentials(username, password) - showSaveButton = false - }, - imageVector = Icons.Default.Save, - ) - } + FDroidOutlineButton( + text = stringResource(R.string.repo_basicauth_edit), + onClick = { + val username = usernameState.text.toString() + val password = passwordState.text.toString() + onEditCredentials(username, password) + showSaveButton = false + }, + imageVector = Icons.Default.Save, + ) } + } } @Composable @Preview fun BasicAuthCardPreview() { - FDroidContent { - BasicAuth("username", Modifier.padding(16.dp)) { _, _ -> } - } + FDroidContent { BasicAuth("username", Modifier.padding(16.dp)) { _, _ -> } } } diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/details/DeleteDialog.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/details/DeleteDialog.kt index e93c8f1cd..00e50ae1e 100644 --- a/app/src/main/kotlin/org/fdroid/ui/repositories/details/DeleteDialog.kt +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/details/DeleteDialog.kt @@ -12,26 +12,17 @@ import org.fdroid.R @Composable @OptIn(ExperimentalMaterial3ExpressiveApi::class) fun DeleteDialog(onDismissDialog: () -> Unit, onDelete: () -> Unit) { - AlertDialog( - title = { - Text(text = stringResource(R.string.repo_confirm_delete_title)) - }, - text = { - Text(text = stringResource(R.string.repo_confirm_delete_body)) - }, - onDismissRequest = onDismissDialog, - confirmButton = { - TextButton(onClick = onDelete) { - Text( - text = stringResource(R.string.delete), - color = MaterialTheme.colorScheme.error, - ) - } - }, - dismissButton = { - TextButton(onClick = onDismissDialog) { - Text(stringResource(android.R.string.cancel)) - } - } - ) + AlertDialog( + title = { Text(text = stringResource(R.string.repo_confirm_delete_title)) }, + text = { Text(text = stringResource(R.string.repo_confirm_delete_body)) }, + onDismissRequest = onDismissDialog, + confirmButton = { + TextButton(onClick = onDelete) { + Text(text = stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = onDismissDialog) { Text(stringResource(android.R.string.cancel)) } + }, + ) } diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/details/OfficialMirrors.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/details/OfficialMirrors.kt index a1ff4a819..5351c833b 100644 --- a/app/src/main/kotlin/org/fdroid/ui/repositories/details/OfficialMirrors.kt +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/details/OfficialMirrors.kt @@ -25,95 +25,79 @@ import org.fdroid.ui.utils.ExpandableSection @Composable fun OfficialMirrors( - mirrors: List, - setMirrorEnabled: (Mirror, Boolean) -> Unit, + mirrors: List, + setMirrorEnabled: (Mirror, Boolean) -> Unit, ) { - ExpandableSection( - icon = rememberVectorPainter(Icons.Default.Public), - title = stringResource(R.string.repo_official_mirrors), - modifier = Modifier.padding(horizontal = 16.dp), - ) { - Column { - mirrors.forEach { m -> - OfficialMirrorRow( - item = m, - setMirrorEnabled = { m, enabled -> - setMirrorEnabled(m, enabled) - }, - ) - } - } + ExpandableSection( + icon = rememberVectorPainter(Icons.Default.Public), + title = stringResource(R.string.repo_official_mirrors), + modifier = Modifier.padding(horizontal = 16.dp), + ) { + Column { + mirrors.forEach { m -> + OfficialMirrorRow( + item = m, + setMirrorEnabled = { m, enabled -> setMirrorEnabled(m, enabled) }, + ) + } } + } } @Composable private fun OfficialMirrorRow( - item: OfficialMirrorItem, - setMirrorEnabled: (Mirror, Boolean) -> Unit, - modifier: Modifier = Modifier, + item: OfficialMirrorItem, + setMirrorEnabled: (Mirror, Boolean) -> Unit, + modifier: Modifier = Modifier, ) { - ListItem( - leadingContent = { - Text( - text = item.emoji, - modifier = Modifier.width(20.dp), - ) - }, - headlineContent = { - Text(item.url) - }, - trailingContent = { - Switch( - checked = item.isEnabled, - onCheckedChange = null, - ) - }, - colors = ListItemDefaults.colors(MaterialTheme.colorScheme.background), - modifier = modifier.toggleable( - value = item.isEnabled, - role = Role.Switch, - onValueChange = { checked -> setMirrorEnabled(item.mirror, checked) }, - ), - ) + ListItem( + leadingContent = { Text(text = item.emoji, modifier = Modifier.width(20.dp)) }, + headlineContent = { Text(item.url) }, + trailingContent = { Switch(checked = item.isEnabled, onCheckedChange = null) }, + colors = ListItemDefaults.colors(MaterialTheme.colorScheme.background), + modifier = + modifier.toggleable( + value = item.isEnabled, + role = Role.Switch, + onValueChange = { checked -> setMirrorEnabled(item.mirror, checked) }, + ), + ) } @Preview @Composable fun OfficialMirrorsPreview() { - FDroidContent { - val mirrors = listOf( - OfficialMirrorItem( - mirror = Mirror(baseUrl = "https://mirror.example.com/fdroid/repo"), - isEnabled = true, - isRepoAddress = true, - ), - OfficialMirrorItem( - mirror = Mirror("https://mirror.example.com/foo/bar/fdroid/repo", "de"), - isEnabled = false, - isRepoAddress = false, - ), - OfficialMirrorItem( - mirror = Mirror("https://foobar.onion"), - isEnabled = true, - isRepoAddress = false, - ), - OfficialMirrorItem( - mirror = Mirror( - "https://mirror.example.org/with/a/very/long/url/that/wraps/repo", - "fr" - ), - isEnabled = true, - isRepoAddress = false, - ), - OfficialMirrorItem( - mirror = Mirror("https://mirror.example.net/repo"), - isEnabled = false, - isRepoAddress = false, - ), - ).sorted() - OfficialMirrors( - mirrors = mirrors, - setMirrorEnabled = { _, _ -> }, + FDroidContent { + val mirrors = + listOf( + OfficialMirrorItem( + mirror = Mirror(baseUrl = "https://mirror.example.com/fdroid/repo"), + isEnabled = true, + isRepoAddress = true, + ), + OfficialMirrorItem( + mirror = Mirror("https://mirror.example.com/foo/bar/fdroid/repo", "de"), + isEnabled = false, + isRepoAddress = false, + ), + OfficialMirrorItem( + mirror = Mirror("https://foobar.onion"), + isEnabled = true, + isRepoAddress = false, + ), + OfficialMirrorItem( + mirror = + Mirror("https://mirror.example.org/with/a/very/long/url/that/wraps/repo", "fr"), + isEnabled = true, + isRepoAddress = false, + ), + OfficialMirrorItem( + mirror = Mirror("https://mirror.example.net/repo"), + isEnabled = false, + isRepoAddress = false, + ), ) - } + .sorted() + OfficialMirrors(mirrors = mirrors, setMirrorEnabled = { _, _ -> }) + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/details/QrCodeDialog.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/details/QrCodeDialog.kt index 814fae819..da7c71df3 100644 --- a/app/src/main/kotlin/org/fdroid/ui/repositories/details/QrCodeDialog.kt +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/details/QrCodeDialog.kt @@ -24,36 +24,23 @@ import org.fdroid.R @Composable @OptIn(ExperimentalMaterial3ExpressiveApi::class) fun QrCodeDialog(onDismissDialog: () -> Unit, generateQrCode: suspend () -> Bitmap?) { - val qrCodeBitmap: ImageBitmap? by produceState(null) { - value = generateQrCode()?.asImageBitmap() - } - AlertDialog( - title = { - Text(text = stringResource(R.string.share_repository)) - }, - text = { - val bitmap = qrCodeBitmap - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 128.dp) - ) { - if (bitmap == null) { - LoadingIndicator() - } else { - Image( - bitmap = bitmap, - contentDescription = stringResource(R.string.swap_scan_qr) - ) - } - } - }, - onDismissRequest = onDismissDialog, - confirmButton = { - TextButton(onClick = onDismissDialog) { - Text(stringResource(R.string.ok)) - } - }, - ) + val qrCodeBitmap: ImageBitmap? by produceState(null) { value = generateQrCode()?.asImageBitmap() } + AlertDialog( + title = { Text(text = stringResource(R.string.share_repository)) }, + text = { + val bitmap = qrCodeBitmap + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth().heightIn(min = 128.dp), + ) { + if (bitmap == null) { + LoadingIndicator() + } else { + Image(bitmap = bitmap, contentDescription = stringResource(R.string.swap_scan_qr)) + } + } + }, + onDismissRequest = onDismissDialog, + confirmButton = { TextButton(onClick = onDismissDialog) { Text(stringResource(R.string.ok)) } }, + ) } diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoDetails.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoDetails.kt index 5d59dd2f6..d8f309c10 100644 --- a/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoDetails.kt +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoDetails.kt @@ -49,121 +49,118 @@ import org.fdroid.ui.utils.getRepoDetailsInfo @Composable @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) fun RepoDetails( - info: RepoDetailsInfo, - onShowAppsClicked: (String, Long) -> Unit, - onBackNav: (() -> Unit)?, + info: RepoDetailsInfo, + onShowAppsClicked: (String, Long) -> Unit, + onBackNav: (() -> Unit)?, ) { - val context = LocalContext.current - val repo = info.model.repo + val context = LocalContext.current + val repo = info.model.repo - val hintController = rememberHintController( - overlay = getHintOverlayColor(), - ) - val hint = rememberHint { - Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { - OnboardingCard( - title = stringResource(R.string.repo_details), - message = stringResource(R.string.repo_details_info_text), - modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), - onGotIt = { - info.actions.onOnboardingSeen() - hintController.dismiss() - }, - ) - } + val hintController = rememberHintController(overlay = getHintOverlayColor()) + val hint = rememberHint { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { + OnboardingCard( + title = stringResource(R.string.repo_details), + message = stringResource(R.string.repo_details_info_text), + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + onGotIt = { + info.actions.onOnboardingSeen() + hintController.dismiss() + }, + ) } - val hintAnchor = rememberHintAnchorState(hint) - LaunchedEffect(info.model.showOnboarding) { - if (info.model.showOnboarding) { - hintController.show(hintAnchor) - info.actions.onOnboardingSeen() - } + } + val hintAnchor = rememberHintAnchorState(hint) + LaunchedEffect(info.model.showOnboarding) { + if (info.model.showOnboarding) { + hintController.show(hintAnchor) + info.actions.onOnboardingSeen() } + } - var qrCodeDialog by remember { mutableStateOf(false) } - var deleteDialog by remember { mutableStateOf(false) } - var showMeteredDialog by remember { mutableStateOf<(() -> Unit)?>(null) } - // QrCode dialog - if (repo != null && qrCodeDialog) QrCodeDialog({ qrCodeDialog = false }) { - info.actions.generateQrCode(repo) + var qrCodeDialog by remember { mutableStateOf(false) } + var deleteDialog by remember { mutableStateOf(false) } + var showMeteredDialog by remember { mutableStateOf<(() -> Unit)?>(null) } + // QrCode dialog + if (repo != null && qrCodeDialog) + QrCodeDialog({ qrCodeDialog = false }) { info.actions.generateQrCode(repo) } + // Repo delete dialog + if (repo != null && deleteDialog) + DeleteDialog({ deleteDialog = false }) { + info.actions.deleteRepository() + deleteDialog = false + onBackNav?.invoke() } - // Repo delete dialog - if (repo != null && deleteDialog) DeleteDialog({ deleteDialog = false }) { - info.actions.deleteRepository() - deleteDialog = false - onBackNav?.invoke() - } - // Metered warning dialog - val meteredLambda = showMeteredDialog - if (meteredLambda != null) MeteredConnectionDialog( - numBytes = null, - onConfirm = { meteredLambda() }, - onDismiss = { showMeteredDialog = null }, + // Metered warning dialog + val meteredLambda = showMeteredDialog + if (meteredLambda != null) + MeteredConnectionDialog( + numBytes = null, + onConfirm = { meteredLambda() }, + onDismiss = { showMeteredDialog = null }, ) - Scaffold(topBar = { - TopAppBar( - navigationIcon = { - if (onBackNav != null) BackButton(onClick = onBackNav) + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { if (onBackNav != null) BackButton(onClick = onBackNav) }, + title = {}, + actions = { + if (repo == null) return@TopAppBar + TopAppBarButton( + imageVector = Icons.Default.Share, + contentDescription = stringResource(R.string.share_repository), + onClick = { info.model.shareRepo(context) }, + ) + TopAppBarButton( + imageVector = Icons.Default.QrCode, + contentDescription = stringResource(R.string.show_repository_qr), + onClick = { qrCodeDialog = true }, + ) + IconButton( + enabled = info.model.isUpdateButtonEnabled, + onClick = { + if (info.model.networkState.isMetered) + showMeteredDialog = { RepoUpdateWorker.updateNow(context, repo.repoId) } + else RepoUpdateWorker.updateNow(context, repo.repoId) }, - title = { }, - actions = { - if (repo == null) return@TopAppBar - TopAppBarButton( - imageVector = Icons.Default.Share, - contentDescription = stringResource(R.string.share_repository), - onClick = { info.model.shareRepo(context) }, + ) { + Icon( + imageVector = Icons.Default.Sync, + contentDescription = stringResource(R.string.repo_force_update), + ) + } + TopAppBarOverflowButton { onDismissRequest -> + DropdownMenuItem( + text = { Text(stringResource(R.string.delete)) }, + onClick = { + onDismissRequest() + deleteDialog = true + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + modifier = Modifier.semantics { hideFromAccessibility() }, ) - TopAppBarButton( - imageVector = Icons.Default.QrCode, - contentDescription = stringResource(R.string.show_repository_qr), - onClick = { qrCodeDialog = true }, - ) - IconButton( - enabled = info.model.isUpdateButtonEnabled, - onClick = { - if (info.model.networkState.isMetered) showMeteredDialog = { - RepoUpdateWorker.updateNow(context, repo.repoId) - } else RepoUpdateWorker.updateNow(context, repo.repoId) - }, - ) { - Icon( - imageVector = Icons.Default.Sync, - contentDescription = stringResource(R.string.repo_force_update) - ) - } - TopAppBarOverflowButton { onDismissRequest -> - DropdownMenuItem( - text = { Text(stringResource(R.string.delete)) }, - onClick = { - onDismissRequest() - deleteDialog = true - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = null, - modifier = Modifier.semantics { hideFromAccessibility() }, - ) - } - ) - } - }) - }) { paddingValues -> - if (repo == null) BigLoadingIndicator() - else RepoDetailsContent( - info = info, - onShowAppsClicked = onShowAppsClicked, - modifier = Modifier.padding(paddingValues) - ) + }, + ) + } + }, + ) } + ) { paddingValues -> + if (repo == null) BigLoadingIndicator() + else + RepoDetailsContent( + info = info, + onShowAppsClicked = onShowAppsClicked, + modifier = Modifier.padding(paddingValues), + ) + } } @Preview @Composable fun RepoDetailsScreenPreview() { - HintHost { - FDroidContent { - RepoDetails(getRepoDetailsInfo(), { _, _ -> }, {}) - } - } + HintHost { FDroidContent { RepoDetails(getRepoDetailsInfo(), { _, _ -> }, {}) } } } diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoDetailsContent.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoDetailsContent.kt index ac436c05e..e479598c1 100644 --- a/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoDetailsContent.kt +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoDetailsContent.kt @@ -33,88 +33,74 @@ import org.fdroid.ui.utils.getRepoDetailsInfo @Composable @OptIn(ExperimentalMaterial3ExpressiveApi::class) fun RepoDetailsContent( - info: RepoDetailsInfo, - onShowAppsClicked: (String, Long) -> Unit, - modifier: Modifier, + info: RepoDetailsInfo, + onShowAppsClicked: (String, Long) -> Unit, + modifier: Modifier, ) { - val repo = info.model.repo as Repository - val context = LocalContext.current - Column( - verticalArrangement = spacedBy(16.dp), - modifier = modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()), - ) { - // show progress here as well, if repo is currently updating - val updateState = info.model.updateState - if (updateState is RepoUpdateProgress) { - val animatedProgress by animateFloatAsState(targetValue = updateState.progress) - LinearWavyProgressIndicator( - progress = { animatedProgress }, - stopSize = 0.dp, - modifier = Modifier.fillMaxWidth() - ) - } - RepoDetailsHeader( - repo = repo, - numberOfApps = info.model.numberApps, - proxy = info.model.proxy, - onShowAppsClicked = onShowAppsClicked, - ) - if (info.model.showOfficialMirrors) { - OfficialMirrors( - mirrors = info.model.officialMirrors, - setMirrorEnabled = { m, e -> - info.actions.setMirrorEnabled(m, e) - }, - ) - } - if (info.model.showUserMirrors) { - UserMirrors( - mirrors = info.model.userMirrors, - setMirrorEnabled = { m, e -> - info.actions.setMirrorEnabled(m, e) - }, - onShareMirror = { mirror -> - mirror.share(context, repo.fingerprint) - }, - onDeleteMirror = { info.actions.deleteUserMirror(it) }, - ) - } - FingerprintExpandable(repo.fingerprint) - RepoSettings( - repo = repo, - archiveState = info.model.archiveState, - onToggleArchiveClicked = info.actions::setArchiveRepoEnabled, - onCredentialsUpdated = info.actions::updateUsernameAndPassword, - ) + val repo = info.model.repo as Repository + val context = LocalContext.current + Column( + verticalArrangement = spacedBy(16.dp), + modifier = modifier.fillMaxWidth().verticalScroll(rememberScrollState()), + ) { + // show progress here as well, if repo is currently updating + val updateState = info.model.updateState + if (updateState is RepoUpdateProgress) { + val animatedProgress by animateFloatAsState(targetValue = updateState.progress) + LinearWavyProgressIndicator( + progress = { animatedProgress }, + stopSize = 0.dp, + modifier = Modifier.fillMaxWidth(), + ) } + RepoDetailsHeader( + repo = repo, + numberOfApps = info.model.numberApps, + proxy = info.model.proxy, + onShowAppsClicked = onShowAppsClicked, + ) + if (info.model.showOfficialMirrors) { + OfficialMirrors( + mirrors = info.model.officialMirrors, + setMirrorEnabled = { m, e -> info.actions.setMirrorEnabled(m, e) }, + ) + } + if (info.model.showUserMirrors) { + UserMirrors( + mirrors = info.model.userMirrors, + setMirrorEnabled = { m, e -> info.actions.setMirrorEnabled(m, e) }, + onShareMirror = { mirror -> mirror.share(context, repo.fingerprint) }, + onDeleteMirror = { info.actions.deleteUserMirror(it) }, + ) + } + FingerprintExpandable(repo.fingerprint) + RepoSettings( + repo = repo, + archiveState = info.model.archiveState, + onToggleArchiveClicked = info.actions::setArchiveRepoEnabled, + onCredentialsUpdated = info.actions::updateUsernameAndPassword, + ) + } } @Composable -private fun FingerprintExpandable( - fingerprint: String, -) { - ExpandableSection( - icon = rememberVectorPainter(Icons.Default.Fingerprint), - title = stringResource(R.string.repo_fingerprint), - modifier = Modifier.padding(horizontal = 16.dp), - ) { - Text( - text = fingerprint, - fontFamily = FontFamily.Monospace, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(8.dp), - ) - } +private fun FingerprintExpandable(fingerprint: String) { + ExpandableSection( + icon = rememberVectorPainter(Icons.Default.Fingerprint), + title = stringResource(R.string.repo_fingerprint), + modifier = Modifier.padding(horizontal = 16.dp), + ) { + Text( + text = fingerprint, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(8.dp), + ) + } } @Composable @Preview private fun Preview() { - HintHost { - FDroidContent { - RepoDetails(getRepoDetailsInfo(), { _, _ -> }, {}) - } - } + HintHost { FDroidContent { RepoDetails(getRepoDetailsInfo(), { _, _ -> }, {}) } } } diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoDetailsHeader.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoDetailsHeader.kt index 1f19c65eb..f85cf9f2e 100644 --- a/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoDetailsHeader.kt +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoDetailsHeader.kt @@ -40,107 +40,85 @@ import org.fdroid.ui.utils.getRepository @Composable @OptIn(ExperimentalMaterial3ExpressiveApi::class) fun RepoDetailsHeader( - repo: Repository, - numberOfApps: Int?, - proxy: ProxyConfig?, - onShowAppsClicked: (String, Long) -> Unit, + repo: Repository, + numberOfApps: Int?, + proxy: ProxyConfig?, + onShowAppsClicked: (String, Long) -> Unit, ) { - val localeList = LocaleListCompat.getDefault() - val name = repo.getName(LocaleListCompat.getDefault()) ?: "Unknown Repo" - val description = repo.getDescription(localeList)?.replace("\n", " ") + val localeList = LocaleListCompat.getDefault() + val name = repo.getName(LocaleListCompat.getDefault()) ?: "Unknown Repo" + val description = repo.getDescription(localeList)?.replace("\n", " ") - val lastIndexTime = if (repo.timestamp < 0) { - stringResource(R.string.repositories_last_update_never) + val lastIndexTime = + if (repo.timestamp < 0) { + stringResource(R.string.repositories_last_update_never) } else { - repo.timestamp.asRelativeTimeString() + repo.timestamp.asRelativeTimeString() } - val lastPublishedTime = stringResource(R.string.repo_last_update_upstream, lastIndexTime) + val lastPublishedTime = stringResource(R.string.repo_last_update_upstream, lastIndexTime) - val lastDownloadedTime = repo.lastUpdated?.asRelativeTimeString() - ?: stringResource(R.string.repositories_last_update_never) - val lastUpdated = stringResource(R.string.repo_last_downloaded, lastDownloadedTime) + val lastDownloadedTime = + repo.lastUpdated?.asRelativeTimeString() + ?: stringResource(R.string.repositories_last_update_never) + val lastUpdated = stringResource(R.string.repo_last_downloaded, lastDownloadedTime) - Column( - verticalArrangement = spacedBy(8.dp), - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - ) { - Row( - horizontalArrangement = spacedBy(8.dp), - ) { - RepoIcon(repo, proxy, Modifier.size(64.dp)) - Column(horizontalAlignment = Alignment.Start) { - Text( - text = name, - maxLines = 1, - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.headlineMediumEmphasized, - ) - Text( - text = repo.addressForUi, - style = MaterialTheme.typography.bodyMedium, - ) - if (numberOfApps != null) Text( - text = pluralStringResource( - R.plurals.repo_num_apps_text, - numberOfApps, - numberOfApps, - ), - style = MaterialTheme.typography.bodySmall, - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = lastPublishedTime, - style = MaterialTheme.typography.bodySmall, - ) - Text( - text = lastUpdated, - style = MaterialTheme.typography.bodySmall, - ) - } - } - if (repo.lastError != null) ElevatedCard( - colors = CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - ), - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - ) { - Row( - horizontalArrangement = spacedBy(16.dp), - verticalAlignment = CenterVertically, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - ) { - Icon(Icons.Default.WarningAmber, null) - SelectionContainer { - Text( - text = stringResource(R.string.repo_has_update_error_intro) + - "\n\n${repo.lastError}", - style = MaterialTheme.typography.bodyLarge - ) - } - } - } - if (repo.enabled) FDroidOutlineButton( - stringResource(R.string.repo_num_apps_button), - onClick = { onShowAppsClicked(name, repo.repoId) }, - modifier = Modifier.fillMaxWidth(), + Column(verticalArrangement = spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Row(horizontalArrangement = spacedBy(8.dp)) { + RepoIcon(repo, proxy, Modifier.size(64.dp)) + Column(horizontalAlignment = Alignment.Start) { + Text( + text = name, + maxLines = 1, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.headlineMediumEmphasized, ) - if (description?.isNotBlank() == true) { - Text( - text = description, - style = MaterialTheme.typography.bodyMedium, - ) - } + Text(text = repo.addressForUi, style = MaterialTheme.typography.bodyMedium) + if (numberOfApps != null) + Text( + text = pluralStringResource(R.plurals.repo_num_apps_text, numberOfApps, numberOfApps), + style = MaterialTheme.typography.bodySmall, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text(text = lastPublishedTime, style = MaterialTheme.typography.bodySmall) + Text(text = lastUpdated, style = MaterialTheme.typography.bodySmall) + } } + if (repo.lastError != null) + ElevatedCard( + colors = + CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ), + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + ) { + Row( + horizontalArrangement = spacedBy(16.dp), + verticalAlignment = CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Icon(Icons.Default.WarningAmber, null) + SelectionContainer { + Text( + text = stringResource(R.string.repo_has_update_error_intro) + "\n\n${repo.lastError}", + style = MaterialTheme.typography.bodyLarge, + ) + } + } + } + if (repo.enabled) + FDroidOutlineButton( + stringResource(R.string.repo_num_apps_button), + onClick = { onShowAppsClicked(name, repo.repoId) }, + modifier = Modifier.fillMaxWidth(), + ) + if (description?.isNotBlank() == true) { + Text(text = description, style = MaterialTheme.typography.bodyMedium) + } + } } @Preview @Composable private fun Preview() { - FDroidContent { - RepoDetailsHeader(getRepository(), 45, null) { _, _ -> } - } + FDroidContent { RepoDetailsHeader(getRepository(), 45, null) { _, _ -> } } } diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoDetailsInfo.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoDetailsInfo.kt index ace1b452e..e0ac6f31b 100644 --- a/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoDetailsInfo.kt +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoDetailsInfo.kt @@ -17,122 +17,126 @@ import org.fdroid.ui.utils.generateQrBitmap import org.fdroid.ui.utils.startActivitySafe interface RepoDetailsInfo { - val model: RepoDetailsModel - val actions: RepoDetailsActions + val model: RepoDetailsModel + val actions: RepoDetailsActions } interface RepoDetailsActions { - fun deleteRepository() - fun updateUsernameAndPassword(username: String, password: String) - fun setMirrorEnabled(mirror: Mirror, enabled: Boolean) - fun deleteUserMirror(mirror: Mirror) - fun setArchiveRepoEnabled(enabled: Boolean) - fun onOnboardingSeen() - suspend fun generateQrCode(repo: Repository): Bitmap? { - if (repo.address.startsWith("content://") || repo.address.startsWith("file://")) { - // no need to show a QR Code, it is not shareable - return null - } - return generateQrBitmap(repo.shareUri) + fun deleteRepository() + + fun updateUsernameAndPassword(username: String, password: String) + + fun setMirrorEnabled(mirror: Mirror, enabled: Boolean) + + fun deleteUserMirror(mirror: Mirror) + + fun setArchiveRepoEnabled(enabled: Boolean) + + fun onOnboardingSeen() + + suspend fun generateQrCode(repo: Repository): Bitmap? { + if (repo.address.startsWith("content://") || repo.address.startsWith("file://")) { + // no need to show a QR Code, it is not shareable + return null } + return generateQrBitmap(repo.shareUri) + } } data class RepoDetailsModel( - val repo: Repository?, - val numberApps: Int?, - val officialMirrors: List, - val userMirrors: List, - val archiveState: ArchiveState, - val showOnboarding: Boolean, - val updateState: RepoUpdateState?, - val networkState: NetworkState, - val proxy: ProxyConfig?, + val repo: Repository?, + val numberApps: Int?, + val officialMirrors: List, + val userMirrors: List, + val archiveState: ArchiveState, + val showOnboarding: Boolean, + val updateState: RepoUpdateState?, + val networkState: NetworkState, + val proxy: ProxyConfig?, ) { - /** - * The repo's address is currently also an official mirror. - * So if there is only one mirror, this is the address => don't show this section. - * If there are 2 or more official mirrors, it makes sense to allow users - * to disable the canonical address. - */ - val showOfficialMirrors: Boolean = officialMirrors.size >= 2 + /** + * The repo's address is currently also an official mirror. So if there is only one mirror, this + * is the address => don't show this section. If there are 2 or more official mirrors, it makes + * sense to allow users to disable the canonical address. + */ + val showOfficialMirrors: Boolean = officialMirrors.size >= 2 - val showUserMirrors: Boolean = userMirrors.isNotEmpty() + val showUserMirrors: Boolean = userMirrors.isNotEmpty() - val isUpdateButtonEnabled: Boolean = repo?.enabled == true && updateState !is RepoUpdateProgress + val isUpdateButtonEnabled: Boolean = repo?.enabled == true && updateState !is RepoUpdateProgress - fun shareRepo(context: Context) { - require(repo != null) { "repo was null when sharing it" } - val intent = Intent(ACTION_SEND).apply { - type = "text/plain" - putExtra(EXTRA_TEXT, repo.shareUri) - } - val chooserTitle = context.getString(R.string.share_repository) - context.startActivitySafe( - Intent.createChooser(intent, chooserTitle) - ) - } + fun shareRepo(context: Context) { + require(repo != null) { "repo was null when sharing it" } + val intent = + Intent(ACTION_SEND).apply { + type = "text/plain" + putExtra(EXTRA_TEXT, repo.shareUri) + } + val chooserTitle = context.getString(R.string.share_repository) + context.startActivitySafe(Intent.createChooser(intent, chooserTitle)) + } } data class OfficialMirrorItem( - val mirror: Mirror, - val isEnabled: Boolean, - val isRepoAddress: Boolean, + val mirror: Mirror, + val isEnabled: Boolean, + val isRepoAddress: Boolean, ) : MirrorItem(mirror.baseUrl), Comparable { - private val isOnion = mirror.isOnion() + private val isOnion = mirror.isOnion() - val emoji: String = if (isOnion) { - "🧅" + val emoji: String = + if (isOnion) { + "🧅" } else if (mirror.countryCode == null) { - if (isRepoAddress) "⭐" else "" + if (isRepoAddress) "⭐" else "" } else { - mirror.countryCode?.flagEmoji ?: "" + mirror.countryCode?.flagEmoji ?: "" } - override fun compareTo(other: OfficialMirrorItem): Int { - return if (isRepoAddress && !other.isRepoAddress) -1 - else if (!isRepoAddress && other.isRepoAddress) 1 - else if (isOnion && !other.isOnion) 1 - else if (!isOnion && other.isOnion) -1 - else if (isOnion) mirror.baseUrl.compareTo(other.mirror.baseUrl) - else if (mirror.countryCode == other.mirror.countryCode) { - mirror.baseUrl.compareTo(other.mirror.baseUrl) - } else { - val countryCode = mirror.countryCode ?: "" - val otherCountryCode = other.mirror.countryCode ?: "" - countryCode.compareTo(otherCountryCode) - } + override fun compareTo(other: OfficialMirrorItem): Int { + return if (isRepoAddress && !other.isRepoAddress) -1 + else if (!isRepoAddress && other.isRepoAddress) 1 + else if (isOnion && !other.isOnion) 1 + else if (!isOnion && other.isOnion) -1 + else if (isOnion) mirror.baseUrl.compareTo(other.mirror.baseUrl) + else if (mirror.countryCode == other.mirror.countryCode) { + mirror.baseUrl.compareTo(other.mirror.baseUrl) + } else { + val countryCode = mirror.countryCode ?: "" + val otherCountryCode = other.mirror.countryCode ?: "" + countryCode.compareTo(otherCountryCode) } + } } -data class UserMirrorItem( - val mirror: Mirror, - val isEnabled: Boolean, -) : MirrorItem(mirror.baseUrl) { - fun share(context: Context, fingerprint: String) { - val uri = mirror.getFDroidLinkUrl(fingerprint) - val intent = Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, uri) - } - context.startActivitySafe( - Intent.createChooser(intent, context.getString(R.string.share_mirror)) - ) - } +data class UserMirrorItem(val mirror: Mirror, val isEnabled: Boolean) : MirrorItem(mirror.baseUrl) { + fun share(context: Context, fingerprint: String) { + val uri = mirror.getFDroidLinkUrl(fingerprint) + val intent = + Intent(ACTION_SEND).apply { + type = "text/plain" + putExtra(EXTRA_TEXT, uri) + } + context.startActivitySafe( + Intent.createChooser(intent, context.getString(R.string.share_mirror)) + ) + } } abstract class MirrorItem(baseUrl: String) { - val url: String = baseUrl - .removePrefix("https://") - .removePrefix("http://") - .removeSuffix("/fdroid/repo") - .removeSuffix("/repo") - .removeSuffix("/") + val url: String = + baseUrl + .removePrefix("https://") + .removePrefix("http://") + .removeSuffix("/fdroid/repo") + .removeSuffix("/repo") + .removeSuffix("/") } enum class ArchiveState { - ENABLED, - DISABLED, - LOADING, - UNKNOWN, + ENABLED, + DISABLED, + LOADING, + UNKNOWN, } diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoDetailsPresenter.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoDetailsPresenter.kt index 31221961a..0618deb3e 100644 --- a/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoDetailsPresenter.kt +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoDetailsPresenter.kt @@ -11,33 +11,38 @@ import org.fdroid.repo.RepoUpdateState @Composable fun RepoDetailsPresenter( - repoFlow: Flow, - numAppsFlow: Flow, - archiveStateFlow: StateFlow, - showOnboardingFlow: StateFlow, - updateFlow: Flow, - networkStateFlow: StateFlow, - proxyConfig: ProxyConfig?, + repoFlow: Flow, + numAppsFlow: Flow, + archiveStateFlow: StateFlow, + showOnboardingFlow: StateFlow, + updateFlow: Flow, + networkStateFlow: StateFlow, + proxyConfig: ProxyConfig?, ): RepoDetailsModel { - val repo = repoFlow.collectAsState(null).value - return RepoDetailsModel( - repo = repo, - numberApps = numAppsFlow.collectAsState(null).value, - officialMirrors = repo?.allOfficialMirrors?.map { mirror -> - val disabledMirrors = repo.disabledMirrors - OfficialMirrorItem( - mirror = mirror, - isEnabled = !disabledMirrors.contains(mirror.baseUrl), - isRepoAddress = repo.address == mirror.baseUrl, - ) - }?.sorted() ?: emptyList(), - userMirrors = repo?.allUserMirrors?.map { mirror -> - UserMirrorItem(mirror, !repo.disabledMirrors.contains(mirror.baseUrl)) - } ?: emptyList(), - archiveState = archiveStateFlow.collectAsState().value, - showOnboarding = showOnboardingFlow.collectAsState().value, - updateState = updateFlow.collectAsState(null).value, - networkState = networkStateFlow.collectAsState().value, - proxy = proxyConfig, - ) + val repo = repoFlow.collectAsState(null).value + return RepoDetailsModel( + repo = repo, + numberApps = numAppsFlow.collectAsState(null).value, + officialMirrors = + repo + ?.allOfficialMirrors + ?.map { mirror -> + val disabledMirrors = repo.disabledMirrors + OfficialMirrorItem( + mirror = mirror, + isEnabled = !disabledMirrors.contains(mirror.baseUrl), + isRepoAddress = repo.address == mirror.baseUrl, + ) + } + ?.sorted() ?: emptyList(), + userMirrors = + repo?.allUserMirrors?.map { mirror -> + UserMirrorItem(mirror, !repo.disabledMirrors.contains(mirror.baseUrl)) + } ?: emptyList(), + archiveState = archiveStateFlow.collectAsState().value, + showOnboarding = showOnboardingFlow.collectAsState().value, + updateState = updateFlow.collectAsState(null).value, + networkState = networkStateFlow.collectAsState().value, + proxy = proxyConfig, + ) } diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoDetailsViewModel.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoDetailsViewModel.kt index d71e1b936..b3e6baea2 100644 --- a/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoDetailsViewModel.kt +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoDetailsViewModel.kt @@ -38,140 +38,142 @@ import org.fdroid.updates.UpdatesManager import org.fdroid.utils.IoDispatcher @HiltViewModel(assistedFactory = RepoDetailsViewModel.Factory::class) -class RepoDetailsViewModel @AssistedInject constructor( - app: Application, - @Assisted private val repoId: Long, - networkMonitor: NetworkMonitor, - private val db: FDroidDatabase, - private val repoManager: RepoManager, - private val updateManager: UpdatesManager, - repoUpdateManager: RepoUpdateManager, - private val settingsManager: SettingsManager, - private val dnsCache: DnsCache, - private val onboardingManager: OnboardingManager, - @param:IoDispatcher private val ioScope: CoroutineScope, +class RepoDetailsViewModel +@AssistedInject +constructor( + app: Application, + @Assisted private val repoId: Long, + networkMonitor: NetworkMonitor, + private val db: FDroidDatabase, + private val repoManager: RepoManager, + private val updateManager: UpdatesManager, + repoUpdateManager: RepoUpdateManager, + private val settingsManager: SettingsManager, + private val dnsCache: DnsCache, + private val onboardingManager: OnboardingManager, + @param:IoDispatcher private val ioScope: CoroutineScope, ) : AndroidViewModel(app), RepoDetailsActions { - private val log = KotlinLogging.logger {} - private val moleculeScope = - CoroutineScope(viewModelScope.coroutineContext + AndroidUiDispatcher.Main) + private val log = KotlinLogging.logger {} + private val moleculeScope = + CoroutineScope(viewModelScope.coroutineContext + AndroidUiDispatcher.Main) - private val repoFlow = MutableStateFlow(null) - private val numAppsFlow: Flow = repoFlow.map { repo -> + private val repoFlow = MutableStateFlow(null) + private val numAppsFlow: Flow = + repoFlow + .map { repo -> if (repo != null) { - db.getAppDao().getNumberOfAppsInRepository(repo.repoId) + db.getAppDao().getNumberOfAppsInRepository(repo.repoId) } else null - }.flowOn(Dispatchers.IO).distinctUntilChanged() - private val archiveStateFlow = MutableStateFlow(UNKNOWN) - private val showOnboarding = onboardingManager.showRepoDetailsOnboarding - private val updateFlow = repoUpdateManager.repoUpdateState.map { - if (it?.repoId == repoId) it else null + } + .flowOn(Dispatchers.IO) + .distinctUntilChanged() + private val archiveStateFlow = MutableStateFlow(UNKNOWN) + private val showOnboarding = onboardingManager.showRepoDetailsOnboarding + private val updateFlow = + repoUpdateManager.repoUpdateState.map { if (it?.repoId == repoId) it else null } + + val model: StateFlow by + lazy(LazyThreadSafetyMode.NONE) { + moleculeScope.launchMolecule(mode = ContextClock) { + RepoDetailsPresenter( + repoFlow = repoFlow, + numAppsFlow = numAppsFlow, + archiveStateFlow = archiveStateFlow, + showOnboardingFlow = showOnboarding, + updateFlow = updateFlow, + networkStateFlow = networkMonitor.networkState, + proxyConfig = settingsManager.proxyConfig, + ) + } } - val model: StateFlow by lazy(LazyThreadSafetyMode.NONE) { - moleculeScope.launchMolecule(mode = ContextClock) { - RepoDetailsPresenter( - repoFlow = repoFlow, - numAppsFlow = numAppsFlow, - archiveStateFlow = archiveStateFlow, - showOnboardingFlow = showOnboarding, - updateFlow = updateFlow, - networkStateFlow = networkMonitor.networkState, - proxyConfig = settingsManager.proxyConfig, - ) + init { + viewModelScope.launch { + repoManager.repositoriesState.collect { repos -> + val repo = repos.find { it.repoId == repoId } + onRepoChanged(repo) + } + } + } + + private fun onRepoChanged(repo: Repository?) { + repoFlow.update { repo } + archiveStateFlow.update { repo?.archiveState() ?: UNKNOWN } + } + + override fun deleteRepository() { + ioScope.launch { + // before deleting a repo, clear the related entries from the dns cache + val repo = repoManager.getRepository(repoId) + if (repo != null) { + val mirrors = repo.getMirrors() + for (mirror in mirrors) { + dnsCache.remove(mirror.url.host) } + } + repoManager.deleteRepository(repoId) + updateManager.loadUpdates() } + } - init { - viewModelScope.launch { - repoManager.repositoriesState.collect { repos -> - val repo = repos.find { it.repoId == repoId } - onRepoChanged(repo) - } - } + override fun updateUsernameAndPassword(username: String, password: String) { + ioScope.launch { + repoManager.updateUsernameAndPassword(repoId, username, password) + withContext(Dispatchers.Main) { RepoUpdateWorker.updateNow(application, repoId) } } + } - private fun onRepoChanged(repo: Repository?) { - repoFlow.update { repo } - archiveStateFlow.update { repo?.archiveState() ?: UNKNOWN } + override fun setMirrorEnabled(mirror: Mirror, enabled: Boolean) { + ioScope.launch { repoManager.setMirrorEnabled(repoId, mirror, enabled) } + } + + override fun deleteUserMirror(mirror: Mirror) { + ioScope.launch { repoManager.deleteUserMirror(repoId, mirror) } + } + + override fun setArchiveRepoEnabled(enabled: Boolean) { + ioScope.launch { + val repo = repoFlow.value ?: return@launch + archiveStateFlow.value = ArchiveState.LOADING + try { + val archiveRepoId = + repoManager.setArchiveRepoEnabled( + repository = repo, + enabled = enabled, + proxy = settingsManager.proxyConfig, + ) + archiveStateFlow.value = enabled.toArchiveState() + if (enabled && archiveRepoId != null) + withContext(Dispatchers.Main) { RepoUpdateWorker.updateNow(application, archiveRepoId) } + } catch (e: Exception) { + log.error(e) { "Error toggling archive repo: " } + archiveStateFlow.value = repo.archiveState() + } } + } - override fun deleteRepository() { - ioScope.launch { - // before deleting a repo, clear the related entries from the dns cache - val repo = repoManager.getRepository(repoId) - if (repo != null) { - val mirrors = repo.getMirrors() - for (mirror in mirrors) { - dnsCache.remove(mirror.url.host) - } - } - repoManager.deleteRepository(repoId) - updateManager.loadUpdates() - } + override fun onOnboardingSeen() = onboardingManager.onRepoDetailsOnboardingSeen() + + private fun Repository.archiveState(): ArchiveState { + val isEnabled = + repoManager + .getRepositories() + .find { r -> r.isArchiveRepo && r.certificate == certificate } + ?.enabled + return when (isEnabled) { + true -> ArchiveState.ENABLED + false -> ArchiveState.DISABLED + null -> UNKNOWN } + } - override fun updateUsernameAndPassword(username: String, password: String) { - ioScope.launch { - repoManager.updateUsernameAndPassword(repoId, username, password) - withContext(Dispatchers.Main) { - RepoUpdateWorker.updateNow(application, repoId) - } - } - } + private fun Boolean.toArchiveState(): ArchiveState { + return if (this) ArchiveState.ENABLED else ArchiveState.DISABLED + } - override fun setMirrorEnabled(mirror: Mirror, enabled: Boolean) { - ioScope.launch { - repoManager.setMirrorEnabled(repoId, mirror, enabled) - } - } - - override fun deleteUserMirror(mirror: Mirror) { - ioScope.launch { - repoManager.deleteUserMirror(repoId, mirror) - } - } - - override fun setArchiveRepoEnabled(enabled: Boolean) { - ioScope.launch { - val repo = repoFlow.value ?: return@launch - archiveStateFlow.value = ArchiveState.LOADING - try { - val archiveRepoId = repoManager.setArchiveRepoEnabled( - repository = repo, - enabled = enabled, - proxy = settingsManager.proxyConfig, - ) - archiveStateFlow.value = enabled.toArchiveState() - if (enabled && archiveRepoId != null) withContext(Dispatchers.Main) { - RepoUpdateWorker.updateNow(application, archiveRepoId) - } - } catch (e: Exception) { - log.error(e) { "Error toggling archive repo: " } - archiveStateFlow.value = repo.archiveState() - } - } - } - - override fun onOnboardingSeen() = onboardingManager.onRepoDetailsOnboardingSeen() - - private fun Repository.archiveState(): ArchiveState { - val isEnabled = repoManager.getRepositories().find { r -> - r.isArchiveRepo && r.certificate == certificate - }?.enabled - return when (isEnabled) { - true -> ArchiveState.ENABLED - false -> ArchiveState.DISABLED - null -> UNKNOWN - } - } - - private fun Boolean.toArchiveState(): ArchiveState { - return if (this) ArchiveState.ENABLED else ArchiveState.DISABLED - } - - @AssistedFactory - interface Factory { - fun create(repoId: Long): RepoDetailsViewModel - } + @AssistedFactory + interface Factory { + fun create(repoId: Long): RepoDetailsViewModel + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoSettings.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoSettings.kt index fcecb8f50..d7b698d6e 100644 --- a/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoSettings.kt +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoSettings.kt @@ -26,85 +26,68 @@ import org.fdroid.ui.utils.getRepository @Composable @OptIn(ExperimentalMaterial3ExpressiveApi::class) fun RepoSettings( - repo: Repository, - archiveState: ArchiveState, - onToggleArchiveClicked: (Boolean) -> Unit, - onCredentialsUpdated: (String, String) -> Unit, + repo: Repository, + archiveState: ArchiveState, + onToggleArchiveClicked: (Boolean) -> Unit, + onCredentialsUpdated: (String, String) -> Unit, ) { - ExpandableSection( - icon = rememberVectorPainter(Icons.Default.Settings), - title = stringResource(R.string.menu_settings), - modifier = Modifier.padding(horizontal = 16.dp), - ) { - Column(verticalArrangement = spacedBy(16.dp)) { - when (archiveState) { - ArchiveState.UNKNOWN -> { - Column( - verticalArrangement = spacedBy(8.dp), - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(R.string.repo_archive_unknown)) - FDroidOutlineButton( - text = stringResource(R.string.repo_archive_check), - onClick = { onToggleArchiveClicked(true) }, - modifier = Modifier.fillMaxWidth(), - ) - } - } - ArchiveState.LOADING -> { - LinearWavyProgressIndicator( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 24.dp), - ) - } - else -> { - FDroidSwitchRow( - text = stringResource(R.string.repo_archive_toggle_description), - checked = archiveState == ArchiveState.ENABLED, - enabled = true, - onCheckedChange = onToggleArchiveClicked, - ) - } - } - val username = repo.username - if (username != null && username.isNotBlank()) { - BasicAuth(username) { username, password -> - onCredentialsUpdated(username, password) - } - } + ExpandableSection( + icon = rememberVectorPainter(Icons.Default.Settings), + title = stringResource(R.string.menu_settings), + modifier = Modifier.padding(horizontal = 16.dp), + ) { + Column(verticalArrangement = spacedBy(16.dp)) { + when (archiveState) { + ArchiveState.UNKNOWN -> { + Column(verticalArrangement = spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.repo_archive_unknown)) + FDroidOutlineButton( + text = stringResource(R.string.repo_archive_check), + onClick = { onToggleArchiveClicked(true) }, + modifier = Modifier.fillMaxWidth(), + ) + } } + ArchiveState.LOADING -> { + LinearWavyProgressIndicator(modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp)) + } + else -> { + FDroidSwitchRow( + text = stringResource(R.string.repo_archive_toggle_description), + checked = archiveState == ArchiveState.ENABLED, + enabled = true, + onCheckedChange = onToggleArchiveClicked, + ) + } + } + val username = repo.username + if (!username.isNullOrBlank()) { + BasicAuth(username) { username, password -> onCredentialsUpdated(username, password) } + } } + } } @Preview @Composable private fun PreviewUnknown() { - FDroidContent { - RepoSettings(getRepository(), ArchiveState.UNKNOWN, {}) { _, _ -> } - } + FDroidContent { RepoSettings(getRepository(), ArchiveState.UNKNOWN, {}) { _, _ -> } } } @Preview @Composable private fun PreviewLoading() { - FDroidContent { - RepoSettings(getRepository(), ArchiveState.LOADING, {}) { _, _ -> } - } + FDroidContent { RepoSettings(getRepository(), ArchiveState.LOADING, {}) { _, _ -> } } } @Preview @Composable private fun PreviewEnabled() { - FDroidContent { - RepoSettings(getRepository(), ArchiveState.ENABLED, {}) { _, _ -> } - } + FDroidContent { RepoSettings(getRepository(), ArchiveState.ENABLED, {}) { _, _ -> } } } @Preview @Composable private fun PreviewDisabled() { - FDroidContent { - RepoSettings(getRepository(), ArchiveState.DISABLED, {}) { _, _ -> } - } + FDroidContent { RepoSettings(getRepository(), ArchiveState.DISABLED, {}) { _, _ -> } } } diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/details/UserMirrors.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/details/UserMirrors.kt index 73d9e4bd6..2b5e5a1d0 100644 --- a/app/src/main/kotlin/org/fdroid/ui/repositories/details/UserMirrors.kt +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/details/UserMirrors.kt @@ -35,118 +35,105 @@ import org.fdroid.ui.utils.FDroidOutlineButton @Composable fun UserMirrors( - mirrors: List, - setMirrorEnabled: (Mirror, Boolean) -> Unit, - onShareMirror: (UserMirrorItem) -> Unit, - onDeleteMirror: (Mirror) -> Unit, + mirrors: List, + setMirrorEnabled: (Mirror, Boolean) -> Unit, + onShareMirror: (UserMirrorItem) -> Unit, + onDeleteMirror: (Mirror) -> Unit, ) { - var showDeleteDialog by remember { mutableStateOf(null) } - if (showDeleteDialog != null) AlertDialog( - title = { - Text(text = stringResource(R.string.repo_confirm_delete_mirror_title)) - }, - text = { - Text(text = stringResource(R.string.repo_confirm_delete_mirror_body)) - }, - onDismissRequest = { showDeleteDialog = null }, - confirmButton = { - TextButton(onClick = { - onDeleteMirror(showDeleteDialog!!) - showDeleteDialog = null - }) { - Text( - text = stringResource(R.string.delete), - color = MaterialTheme.colorScheme.error, - ) - } - }, - dismissButton = { - TextButton(onClick = { showDeleteDialog = null }) { - Text(stringResource(android.R.string.cancel)) - } + var showDeleteDialog by remember { mutableStateOf(null) } + if (showDeleteDialog != null) + AlertDialog( + title = { Text(text = stringResource(R.string.repo_confirm_delete_mirror_title)) }, + text = { Text(text = stringResource(R.string.repo_confirm_delete_mirror_body)) }, + onDismissRequest = { showDeleteDialog = null }, + confirmButton = { + TextButton( + onClick = { + onDeleteMirror(showDeleteDialog!!) + showDeleteDialog = null + } + ) { + Text(text = stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog = null }) { + Text(stringResource(android.R.string.cancel)) + } + }, ) - ExpandableSection( - icon = rememberVectorPainter(Icons.Default.Dns), - title = stringResource(R.string.repo_user_mirrors), - modifier = Modifier.padding(horizontal = 16.dp), - ) { - Column { - mirrors.forEach { m -> - UserMirrorRow( - item = m, - setMirrorEnabled = setMirrorEnabled, - onShareMirror = onShareMirror, - onDeleteMirror = { showDeleteDialog = m.mirror }, - ) - } - } + ExpandableSection( + icon = rememberVectorPainter(Icons.Default.Dns), + title = stringResource(R.string.repo_user_mirrors), + modifier = Modifier.padding(horizontal = 16.dp), + ) { + Column { + mirrors.forEach { m -> + UserMirrorRow( + item = m, + setMirrorEnabled = setMirrorEnabled, + onShareMirror = onShareMirror, + onDeleteMirror = { showDeleteDialog = m.mirror }, + ) + } } + } } @Composable private fun UserMirrorRow( - item: UserMirrorItem, - setMirrorEnabled: (Mirror, Boolean) -> Unit, - onShareMirror: (UserMirrorItem) -> Unit, - onDeleteMirror: (Mirror) -> Unit, - modifier: Modifier = Modifier, + item: UserMirrorItem, + setMirrorEnabled: (Mirror, Boolean) -> Unit, + onShareMirror: (UserMirrorItem) -> Unit, + onDeleteMirror: (Mirror) -> Unit, + modifier: Modifier = Modifier, ) { - ListItem( - headlineContent = { - Text(item.url) - }, - supportingContent = { - Row( - horizontalArrangement = spacedBy(16.dp), - ) { - FDroidOutlineButton( - text = stringResource(R.string.menu_share), - imageVector = Icons.Default.Share, - onClick = { onShareMirror(item) }, - ) - FDroidOutlineButton( - text = stringResource(R.string.delete), - imageVector = Icons.Default.Delete, - onClick = { onDeleteMirror(item.mirror) }, - color = MaterialTheme.colorScheme.error, - ) - } - }, - trailingContent = { - Switch( - checked = item.isEnabled, - onCheckedChange = null, - ) - }, - colors = ListItemDefaults.colors(MaterialTheme.colorScheme.background), - modifier = modifier.toggleable( - value = item.isEnabled, - role = Role.Switch, - onValueChange = { checked -> setMirrorEnabled(item.mirror, checked) }, - ), - ) + ListItem( + headlineContent = { Text(item.url) }, + supportingContent = { + Row(horizontalArrangement = spacedBy(16.dp)) { + FDroidOutlineButton( + text = stringResource(R.string.menu_share), + imageVector = Icons.Default.Share, + onClick = { onShareMirror(item) }, + ) + FDroidOutlineButton( + text = stringResource(R.string.delete), + imageVector = Icons.Default.Delete, + onClick = { onDeleteMirror(item.mirror) }, + color = MaterialTheme.colorScheme.error, + ) + } + }, + trailingContent = { Switch(checked = item.isEnabled, onCheckedChange = null) }, + colors = ListItemDefaults.colors(MaterialTheme.colorScheme.background), + modifier = + modifier.toggleable( + value = item.isEnabled, + role = Role.Switch, + onValueChange = { checked -> setMirrorEnabled(item.mirror, checked) }, + ), + ) } @Preview @Composable fun UserMirrorsPreview() { - FDroidContent { - val mirrors = listOf( - UserMirrorItem(Mirror("https://mirror.example.com/fdroid/repo"), true), - UserMirrorItem( - Mirror( - "https://mirror.example.org/with/a/very/long/url/that/wraps/repo", - "fr" - ), true - ), - UserMirrorItem(Mirror("https://mirror.example.com/foo/bar/fdroid/repo"), false), - ) - UserMirrors( - mirrors = mirrors, - setMirrorEnabled = { _, _ -> }, - onShareMirror = { }, - onDeleteMirror = { }, - ) - } + FDroidContent { + val mirrors = + listOf( + UserMirrorItem(Mirror("https://mirror.example.com/fdroid/repo"), true), + UserMirrorItem( + Mirror("https://mirror.example.org/with/a/very/long/url/that/wraps/repo", "fr"), + true, + ), + UserMirrorItem(Mirror("https://mirror.example.com/foo/bar/fdroid/repo"), false), + ) + UserMirrors( + mirrors = mirrors, + setMirrorEnabled = { _, _ -> }, + onShareMirror = {}, + onDeleteMirror = {}, + ) + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/search/AppSearchInputField.kt b/app/src/main/kotlin/org/fdroid/ui/search/AppSearchInputField.kt index 54ba4a9d5..7e722dc85 100644 --- a/app/src/main/kotlin/org/fdroid/ui/search/AppSearchInputField.kt +++ b/app/src/main/kotlin/org/fdroid/ui/search/AppSearchInputField.kt @@ -27,45 +27,43 @@ import org.fdroid.R @Composable @OptIn(ExperimentalMaterial3Api::class, FlowPreview::class) fun AppSearchInputField( - searchBarState: SearchBarState, - textFieldState: TextFieldState, - onSearch: suspend (String) -> Unit, - onSearchCleared: () -> Unit, - modifier: Modifier = Modifier, + searchBarState: SearchBarState, + textFieldState: TextFieldState, + onSearch: suspend (String) -> Unit, + onSearchCleared: () -> Unit, + modifier: Modifier = Modifier, ) { - val scope = rememberCoroutineScope() - // set-up search as you type - LaunchedEffect(Unit) { - textFieldState.edit { placeCursorAtEnd() } - snapshotFlow { textFieldState.text } - .distinctUntilChanged() - .debounce(500) - .collectLatest { - if (it.isEmpty()) { - onSearchCleared() - } else if (it.length >= SEARCH_THRESHOLD) { - onSearch(textFieldState.text.toString()) - } - } - } - SearchBarDefaults.InputField( - modifier = modifier, - searchBarState = searchBarState, - textFieldState = textFieldState, - textStyle = MaterialTheme.typography.bodyLarge, - onSearch = { - scope.launch { onSearch(it) } - }, - placeholder = { Text(stringResource(R.string.search_placeholder)) }, - trailingIcon = { - if (textFieldState.text.isNotEmpty()) { - IconButton(onClick = onSearchCleared) { - Icon( - imageVector = Icons.Filled.Clear, - contentDescription = stringResource(R.string.clear_search), - ) - } - } + val scope = rememberCoroutineScope() + // set-up search as you type + LaunchedEffect(Unit) { + textFieldState.edit { placeCursorAtEnd() } + snapshotFlow { textFieldState.text } + .distinctUntilChanged() + .debounce(500) + .collectLatest { + if (it.isEmpty()) { + onSearchCleared() + } else if (it.length >= SEARCH_THRESHOLD) { + onSearch(textFieldState.text.toString()) } - ) + } + } + SearchBarDefaults.InputField( + modifier = modifier, + searchBarState = searchBarState, + textFieldState = textFieldState, + textStyle = MaterialTheme.typography.bodyLarge, + onSearch = { scope.launch { onSearch(it) } }, + placeholder = { Text(stringResource(R.string.search_placeholder)) }, + trailingIcon = { + if (textFieldState.text.isNotEmpty()) { + IconButton(onClick = onSearchCleared) { + Icon( + imageVector = Icons.Filled.Clear, + contentDescription = stringResource(R.string.clear_search), + ) + } + } + }, + ) } diff --git a/app/src/main/kotlin/org/fdroid/ui/search/AppsSearch.kt b/app/src/main/kotlin/org/fdroid/ui/search/AppsSearch.kt index c5f30bcdc..0ef1f90f4 100644 --- a/app/src/main/kotlin/org/fdroid/ui/search/AppsSearch.kt +++ b/app/src/main/kotlin/org/fdroid/ui/search/AppsSearch.kt @@ -24,60 +24,51 @@ import org.fdroid.R import org.fdroid.ui.FDroidContent import org.fdroid.ui.navigation.NavigationKey -/** - * The minimum amount of characters we start auto-searching for. - */ +/** The minimum amount of characters we start auto-searching for. */ const val SEARCH_THRESHOLD = 2 @Composable @OptIn(ExperimentalMaterial3Api::class) fun AppsSearch( - textFieldState: TextFieldState, - onNav: (NavigationKey) -> Unit, - modifier: Modifier = Modifier, + textFieldState: TextFieldState, + onNav: (NavigationKey) -> Unit, + modifier: Modifier = Modifier, ) { - val searchBarState = rememberSearchBarState() - SearchBar( - state = searchBarState, - inputField = { - // InputField is different from ExpandedFullScreenSearchBar to separate onSearch() - SearchBarDefaults.InputField( - searchBarState = searchBarState, - textFieldState = textFieldState, - placeholder = { - Text( - text = stringResource(R.string.search_placeholder), - // we hide the placeholder, because TalkBack is already saying "Search" - modifier = Modifier.semantics { hideFromAccessibility() }, - ) - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Search, - contentDescription = null, - modifier = Modifier.semantics { hideFromAccessibility() }, - ) - }, - onSearch = { }, - modifier = Modifier - .onFocusChanged { - if (it.isFocused) onNav(NavigationKey.Search) - }, - ) + val searchBarState = rememberSearchBarState() + SearchBar( + state = searchBarState, + inputField = { + // InputField is different from ExpandedFullScreenSearchBar to separate onSearch() + SearchBarDefaults.InputField( + searchBarState = searchBarState, + textFieldState = textFieldState, + placeholder = { + Text( + text = stringResource(R.string.search_placeholder), + // we hide the placeholder, because TalkBack is already saying "Search" + modifier = Modifier.semantics { hideFromAccessibility() }, + ) }, - modifier = modifier.clickable { - onNav(NavigationKey.Search) + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null, + modifier = Modifier.semantics { hideFromAccessibility() }, + ) }, - ) + onSearch = {}, + modifier = Modifier.onFocusChanged { if (it.isFocused) onNav(NavigationKey.Search) }, + ) + }, + modifier = modifier.clickable { onNav(NavigationKey.Search) }, + ) } @Preview @Composable private fun Preview() { - FDroidContent { - val textFieldState = rememberTextFieldState() - Box(Modifier.fillMaxSize()) { - AppsSearch(textFieldState, {}) - } - } + FDroidContent { + val textFieldState = rememberTextFieldState() + Box(Modifier.fillMaxSize()) { AppsSearch(textFieldState, {}) } + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/search/ExpandedSearch.kt b/app/src/main/kotlin/org/fdroid/ui/search/ExpandedSearch.kt index 37586f386..9cc56a7d9 100644 --- a/app/src/main/kotlin/org/fdroid/ui/search/ExpandedSearch.kt +++ b/app/src/main/kotlin/org/fdroid/ui/search/ExpandedSearch.kt @@ -20,97 +20,98 @@ import org.fdroid.ui.navigation.NavigationKey @Composable @OptIn(ExperimentalMaterial3Api::class) fun ExpandedSearch( - textFieldState: TextFieldState, - searchResults: SearchResults?, - onSearch: suspend (String) -> Unit, - onNav: (NavigationKey) -> Unit, - onBack: () -> Unit, - onSearchCleared: () -> Unit, + textFieldState: TextFieldState, + searchResults: SearchResults?, + onSearch: suspend (String) -> Unit, + onNav: (NavigationKey) -> Unit, + onBack: () -> Unit, + onSearchCleared: () -> Unit, ) { - Scaffold( - topBar = { - TopSearchBar( - searchFieldState = textFieldState, - onSearch = onSearch, - onSearchCleared = onSearchCleared, - onHideSearch = onBack, - ) - } - ) { paddingValues -> - HorizontalDivider( - color = SearchBarDefaults.colors().dividerColor, - modifier = Modifier.padding(top = paddingValues.calculateTopPadding()) - ) - SearchResults( - searchResults = searchResults, - textFieldState = textFieldState, - onNav = onNav, - paddingValues = paddingValues, - modifier = Modifier - ) + Scaffold( + topBar = { + TopSearchBar( + searchFieldState = textFieldState, + onSearch = onSearch, + onSearchCleared = onSearchCleared, + onHideSearch = onBack, + ) } + ) { paddingValues -> + HorizontalDivider( + color = SearchBarDefaults.colors().dividerColor, + modifier = Modifier.padding(top = paddingValues.calculateTopPadding()), + ) + SearchResults( + searchResults = searchResults, + textFieldState = textFieldState, + onNav = onNav, + paddingValues = paddingValues, + modifier = Modifier, + ) + } } @Preview @Composable @OptIn(ExperimentalMaterial3Api::class) private fun AppsSearchLoadingPreview() { - FDroidContent { - val textFieldState = rememberTextFieldState("foo bar") - Box(Modifier.fillMaxSize()) { - ExpandedSearch(textFieldState, null, {}, {}, {}, {}) - } - } + FDroidContent { + val textFieldState = rememberTextFieldState("foo bar") + Box(Modifier.fillMaxSize()) { ExpandedSearch(textFieldState, null, {}, {}, {}, {}) } + } } @Preview @Composable @OptIn(ExperimentalMaterial3Api::class) private fun AppsSearchEmptyPreview() { - FDroidContent { - val textFieldState = rememberTextFieldState("foo") - Box(Modifier.fillMaxSize()) { - ExpandedSearch(textFieldState, SearchResults(emptyList(), emptyList()), {}, {}, {}, {}) - } + FDroidContent { + val textFieldState = rememberTextFieldState("foo") + Box(Modifier.fillMaxSize()) { + ExpandedSearch(textFieldState, SearchResults(emptyList(), emptyList()), {}, {}, {}, {}) } + } } @Preview @Composable @OptIn(ExperimentalMaterial3Api::class) private fun AppsSearchOnlyCategoriesPreview() { - FDroidContent { - val textFieldState = rememberTextFieldState() - Box(Modifier.fillMaxSize()) { - val categories = listOf( - CategoryItem("Bookmark", "Bookmark"), - CategoryItem("Browser", "Browser"), - CategoryItem("Calculator", "Calc"), - CategoryItem("Money", "Money"), - ) - ExpandedSearch(textFieldState, SearchResults(emptyList(), categories), {}, {}, {}, {}) - } + FDroidContent { + val textFieldState = rememberTextFieldState() + Box(Modifier.fillMaxSize()) { + val categories = + listOf( + CategoryItem("Bookmark", "Bookmark"), + CategoryItem("Browser", "Browser"), + CategoryItem("Calculator", "Calc"), + CategoryItem("Money", "Money"), + ) + ExpandedSearch(textFieldState, SearchResults(emptyList(), categories), {}, {}, {}, {}) } + } } @Preview @Composable @OptIn(ExperimentalMaterial3Api::class) private fun AppsSearchPreview() { - FDroidContent { - val textFieldState = rememberTextFieldState() - Box(Modifier.fillMaxSize()) { - val categories = listOf( - CategoryItem("Bookmark", "Bookmark"), - CategoryItem("Browser", "Browser"), - CategoryItem("Calculator", "Calc"), - CategoryItem("Money", "Money"), - ) - val apps = listOf( - AppListItem(1, "1", "This is app 1", "It has summary 2", 0, false, true), - AppListItem(2, "2", "This is app 2", "It has summary 2", 0, true, true), - ) - ExpandedSearch(textFieldState, SearchResults(apps, categories), {}, {}, {}, {}) - } + FDroidContent { + val textFieldState = rememberTextFieldState() + Box(Modifier.fillMaxSize()) { + val categories = + listOf( + CategoryItem("Bookmark", "Bookmark"), + CategoryItem("Browser", "Browser"), + CategoryItem("Calculator", "Calc"), + CategoryItem("Money", "Money"), + ) + val apps = + listOf( + AppListItem(1, "1", "This is app 1", "It has summary 2", 0, false, true), + AppListItem(2, "2", "This is app 2", "It has summary 2", 0, true, true), + ) + ExpandedSearch(textFieldState, SearchResults(apps, categories), {}, {}, {}, {}) } + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/search/SearchManager.kt b/app/src/main/kotlin/org/fdroid/ui/search/SearchManager.kt index 9960e75ca..dc8100624 100644 --- a/app/src/main/kotlin/org/fdroid/ui/search/SearchManager.kt +++ b/app/src/main/kotlin/org/fdroid/ui/search/SearchManager.kt @@ -4,6 +4,11 @@ import android.database.sqlite.SQLiteException import androidx.compose.foundation.text.input.TextFieldState import androidx.core.os.LocaleListCompat import androidx.lifecycle.asFlow +import java.text.Collator +import java.util.Locale +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.measureTimedValue import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -23,107 +28,110 @@ import org.fdroid.ui.categories.CategoryItem import org.fdroid.ui.lists.AppListItem import org.fdroid.ui.utils.normalize import org.fdroid.utils.IoDispatcher -import java.text.Collator -import java.util.Locale -import javax.inject.Inject -import javax.inject.Singleton -import kotlin.time.measureTimedValue @Singleton -class SearchManager @Inject constructor( - private val db: FDroidDatabase, - private val repoManager: RepoManager, - private val settingsManager: SettingsManager, - private val installedAppsCache: InstalledAppsCache, - @param:IoDispatcher private val ioScope: CoroutineScope, +class SearchManager +@Inject +constructor( + private val db: FDroidDatabase, + private val repoManager: RepoManager, + private val settingsManager: SettingsManager, + private val installedAppsCache: InstalledAppsCache, + @param:IoDispatcher private val ioScope: CoroutineScope, ) { - private val log = KotlinLogging.logger { } - private val localeList = LocaleListCompat.getDefault() - private val collator = Collator.getInstance(Locale.getDefault()) - private val _searchResults = MutableStateFlow(null) - val textFieldState = TextFieldState() - val searchResults = _searchResults.asStateFlow() + private val log = KotlinLogging.logger {} + private val localeList = LocaleListCompat.getDefault() + private val collator = Collator.getInstance(Locale.getDefault()) + private val _searchResults = MutableStateFlow(null) + val textFieldState = TextFieldState() + val searchResults = _searchResults.asStateFlow() - private val categories = db.getRepositoryDao().getLiveCategories().asFlow().map { categories -> - categories.map { category -> - CategoryItem( - id = category.id, - name = category.getName(localeList) ?: "Unknown Category", - ) - }.sortedWith { c1, c2 -> collator.compare(c1.name, c2.name) } + private val categories = + db.getRepositoryDao().getLiveCategories().asFlow().map { categories -> + categories + .map { category -> + CategoryItem(id = category.id, name = category.getName(localeList) ?: "Unknown Category") + } + .sortedWith { c1, c2 -> collator.compare(c1.name, c2.name) } } - suspend fun search(term: String) = withContext(ioScope.coroutineContext) { - // we need a way to make the app crash for testing, e.g. the crash reporter - if (term == "CrashMe") error("BOOOOOOOOM!!!") + suspend fun search(term: String) = + withContext(ioScope.coroutineContext) { + // we need a way to make the app crash for testing, e.g. the crash reporter + if (term == "CrashMe") error("BOOOOOOOOM!!!") - val sanitized = term.replace(Regex.fromLiteral("\""), "") - val splits = sanitized.split(' ').filter { it.isNotBlank() } - val query = splits.joinToString(" ") { word -> + val sanitized = term.replace(Regex.fromLiteral("\""), "") + val splits = sanitized.split(' ').filter { it.isNotBlank() } + val query = + splits + .joinToString(" ") { word -> var isCjk = false // go through word and separate CJK chars (if needed) - val newString = word.toList().joinToString("") { + val newString = + word.toList().joinToString("") { if (Character.isIdeographic(it.code)) { - isCjk = true - "$it* " + isCjk = true + "$it* " } else "$it" - } + } // add * to enable prefix matches if (isCjk) newString else "$newString*" - }.let { firstPassQuery -> + } + .let { firstPassQuery -> // if we had more than one word, make a more complex query if (splits.size > 1) { - "$firstPassQuery " + // search* term* (implicit AND and prefix search) - "OR ${splits.joinToString("")}* " + // camel case prefix - "OR \"${splits.joinToString("* ")}*\"" // phrase query + "$firstPassQuery " + // search* term* (implicit AND and prefix search) + "OR ${splits.joinToString("")}* " + // camel case prefix + "OR \"${splits.joinToString("* ")}*\"" // phrase query } else firstPassQuery + } + log.info { "Searching for: $query" } + val timedApps = measureTimedValue { + try { + val proxyConfig = settingsManager.proxyConfig + db.getAppDao().getAppSearchItems(query).sortedDescending().mapNotNull { + val repository = repoManager.getRepository(it.repoId) ?: return@mapNotNull null + val iconModel = + it.getIcon(localeList)?.getImageModel(repository, proxyConfig) as? DownloadRequest + val isInstalled = installedAppsCache.isInstalled(it.packageName) + AppListItem( + repoId = it.repoId, + packageName = it.packageName, + name = it.name.getBestLocale(localeList) ?: "Unknown", + summary = it.summary.getBestLocale(localeList) ?: "", + lastUpdated = it.lastUpdated, + isInstalled = isInstalled, + isCompatible = true, // doesn't matter here, as we don't filter + iconModel = + if (isInstalled) { + PackageName(it.packageName, iconModel) + } else { + iconModel + }, + categoryIds = it.categories?.toSet(), + ) + } + } catch (e: SQLiteException) { + log.error(e) { "Error searching for $query: " } + emptyList() } - log.info { "Searching for: $query" } - val timedApps = measureTimedValue { - try { - val proxyConfig = settingsManager.proxyConfig - db.getAppDao().getAppSearchItems(query).sortedDescending().mapNotNull { - val repository = repoManager.getRepository(it.repoId) ?: return@mapNotNull null - val iconModel = it.getIcon(localeList)?.getImageModel(repository, proxyConfig) - as? DownloadRequest - val isInstalled = installedAppsCache.isInstalled(it.packageName) - AppListItem( - repoId = it.repoId, - packageName = it.packageName, - name = it.name.getBestLocale(localeList) ?: "Unknown", - summary = it.summary.getBestLocale(localeList) ?: "", - lastUpdated = it.lastUpdated, - isInstalled = isInstalled, - isCompatible = true, // doesn't matter here, as we don't filter - iconModel = if (isInstalled) { - PackageName(it.packageName, iconModel) - } else { - iconModel - }, - categoryIds = it.categories?.toSet(), - ) - } - } catch (e: SQLiteException) { - log.error(e) { "Error searching for $query: " } - emptyList() - } - } - val timedCategories = measureTimedValue { - categories.first().filter { - // normalization removed diacritics, so searches without them work - it.name.normalize().contains(sanitized.normalize(), ignoreCase = true) - } - } - _searchResults.value = SearchResults(timedApps.value, timedCategories.value) - log.debug { - val numResults = _searchResults.value?.apps?.size ?: 0 - "Search for $query had $numResults results " + - "and took ${timedApps.duration} and ${timedCategories.duration}" + } + val timedCategories = measureTimedValue { + categories.first().filter { + // normalization removed diacritics, so searches without them work + it.name.normalize().contains(sanitized.normalize(), ignoreCase = true) } + } + _searchResults.value = SearchResults(timedApps.value, timedCategories.value) + log.debug { + val numResults = _searchResults.value?.apps?.size ?: 0 + "Search for $query had $numResults results " + + "and took ${timedApps.duration} and ${timedCategories.duration}" + } } - fun onSearchCleared() { - _searchResults.value = null - } + fun onSearchCleared() { + _searchResults.value = null + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/search/SearchResults.kt b/app/src/main/kotlin/org/fdroid/ui/search/SearchResults.kt index 550998f59..819403778 100644 --- a/app/src/main/kotlin/org/fdroid/ui/search/SearchResults.kt +++ b/app/src/main/kotlin/org/fdroid/ui/search/SearchResults.kt @@ -30,119 +30,92 @@ import org.fdroid.ui.lists.AppListType import org.fdroid.ui.navigation.NavigationKey import org.fdroid.ui.utils.BigLoadingIndicator -data class SearchResults( - val apps: List, - val categories: List, -) +data class SearchResults(val apps: List, val categories: List) @Composable fun SearchResults( - searchResults: SearchResults?, - textFieldState: TextFieldState, - onNav: (NavigationKey) -> Unit, - paddingValues: PaddingValues, - modifier: Modifier, + searchResults: SearchResults?, + textFieldState: TextFieldState, + onNav: (NavigationKey) -> Unit, + paddingValues: PaddingValues, + modifier: Modifier, ) { - // rememberLazyListState done differently, so it refreshes for different searchResults - val listState = rememberSaveable(searchResults, saver = LazyListState.Saver) { - LazyListState(0, 0) + // rememberLazyListState done differently, so it refreshes for different searchResults + val listState = + rememberSaveable(searchResults, saver = LazyListState.Saver) { LazyListState(0, 0) } + if (searchResults == null) { + if (textFieldState.text.length >= SEARCH_THRESHOLD) { + BigLoadingIndicator(modifier.padding(paddingValues).imePadding()) } - if (searchResults == null) { - if (textFieldState.text.length >= SEARCH_THRESHOLD) { - BigLoadingIndicator( - modifier - .padding(paddingValues) - .imePadding() - ) + } else if (searchResults.apps.isEmpty() && textFieldState.text.length >= SEARCH_THRESHOLD) { + Column(modifier = modifier.padding(paddingValues).imePadding()) { + if (searchResults.categories.isNotEmpty()) { + CategoriesFlowRow(searchResults.categories, onNav) + } + Text( + text = stringResource(R.string.search_no_results), + textAlign = TextAlign.Center, + modifier = Modifier.padding(16.dp).fillMaxWidth(), + ) + } + } else { + LazyColumn( + state = listState, + contentPadding = paddingValues, + modifier = modifier.fillMaxSize().imePadding(), + ) { + if (searchResults.categories.isNotEmpty()) { + item(key = "categories", contentType = "category") { + CategoriesFlowRow(searchResults.categories, onNav) } - } else if (searchResults.apps.isEmpty() && textFieldState.text.length >= SEARCH_THRESHOLD) { - Column( - modifier = modifier - .padding(paddingValues) - .imePadding() - ) { + } + if (searchResults.apps.isNotEmpty()) { + item(key = "appsHeader", contentType = "appsHeader") { + Column { if (searchResults.categories.isNotEmpty()) { - CategoriesFlowRow(searchResults.categories, onNav) + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp)) } Text( - text = stringResource(R.string.search_no_results), - textAlign = TextAlign.Center, - modifier = Modifier - .padding(16.dp) - .fillMaxWidth() + text = stringResource(R.string.apps), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp), ) + } } - } else { - LazyColumn( - state = listState, - contentPadding = paddingValues, - modifier = modifier - .fillMaxSize() - .imePadding() - ) { - if (searchResults.categories.isNotEmpty()) { - item( - key = "categories", - contentType = "category", - ) { - CategoriesFlowRow(searchResults.categories, onNav) - } - } - if (searchResults.apps.isNotEmpty()) { - item( - key = "appsHeader", - contentType = "appsHeader", - ) { - Column { - if (searchResults.categories.isNotEmpty()) { - HorizontalDivider( - modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp) - ) - } - Text( - text = stringResource(R.string.apps), - style = MaterialTheme.typography.labelLarge, - modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp) - ) - } - } - } - items( - searchResults.apps, - key = { it.packageName }, - contentType = { "app" }, - ) { item -> - AppListRow( - item = item, - isSelected = false, - modifier = Modifier - .fillMaxWidth() - .animateItem() - .clickable { - onNav(NavigationKey.AppDetails(item.packageName)) - } - ) - } - } + } + items(searchResults.apps, key = { it.packageName }, contentType = { "app" }) { item -> + AppListRow( + item = item, + isSelected = false, + modifier = + Modifier.fillMaxWidth().animateItem().clickable { + onNav(NavigationKey.AppDetails(item.packageName)) + }, + ) + } } + } } @Composable private fun CategoriesFlowRow(categories: List, onNav: (NavigationKey) -> Unit) { - Column(modifier = Modifier.padding(horizontal = 8.dp)) { - Text( - text = stringResource(R.string.main_menu__categories), - style = MaterialTheme.typography.labelLarge, - modifier = Modifier.padding(8.dp) + Column(modifier = Modifier.padding(horizontal = 8.dp)) { + Text( + text = stringResource(R.string.main_menu__categories), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(8.dp), + ) + ChipFlowRow { + categories.forEach { item -> + CategoryChip( + categoryItem = item, + onClick = { + val type = AppListType.Category(item.name, item.id) + val navKey = NavigationKey.AppList(type) + onNav(navKey) + }, ) - ChipFlowRow { - categories.forEach { item -> - CategoryChip(categoryItem = item, onClick = { - val type = AppListType.Category(item.name, item.id) - val navKey = NavigationKey.AppList(type) - onNav(navKey) - }) - } - } + } } + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/search/SearchViewModel.kt b/app/src/main/kotlin/org/fdroid/ui/search/SearchViewModel.kt index bc3387d90..893e341f8 100644 --- a/app/src/main/kotlin/org/fdroid/ui/search/SearchViewModel.kt +++ b/app/src/main/kotlin/org/fdroid/ui/search/SearchViewModel.kt @@ -6,15 +6,14 @@ import dagger.hilt.android.lifecycle.HiltViewModel import jakarta.inject.Inject @HiltViewModel -class SearchViewModel @Inject constructor( - private val app: Application, - private val searchManager: SearchManager, -) : AndroidViewModel(app) { +class SearchViewModel +@Inject +constructor(app: Application, private val searchManager: SearchManager) : AndroidViewModel(app) { - val textFieldState = searchManager.textFieldState - val searchResults = searchManager.searchResults + val textFieldState = searchManager.textFieldState + val searchResults = searchManager.searchResults - suspend fun search(term: String) = searchManager.search(term) + suspend fun search(term: String) = searchManager.search(term) - fun onSearchCleared() = searchManager.onSearchCleared() + fun onSearchCleared() = searchManager.onSearchCleared() } diff --git a/app/src/main/kotlin/org/fdroid/ui/search/TopSearchBar.kt b/app/src/main/kotlin/org/fdroid/ui/search/TopSearchBar.kt index 546dcd48f..4fd1e9fe6 100644 --- a/app/src/main/kotlin/org/fdroid/ui/search/TopSearchBar.kt +++ b/app/src/main/kotlin/org/fdroid/ui/search/TopSearchBar.kt @@ -24,34 +24,32 @@ import org.fdroid.ui.utils.BackButton @Composable @OptIn(ExperimentalMaterial3Api::class, FlowPreview::class) fun TopSearchBar( - searchFieldState: TextFieldState = rememberTextFieldState(), - actions: @Composable (RowScope.() -> Unit) = {}, - onSearch: suspend (String) -> Unit, - onSearchCleared: () -> Unit, - onHideSearch: () -> Unit, + searchFieldState: TextFieldState = rememberTextFieldState(), + actions: @Composable (RowScope.() -> Unit) = {}, + onSearch: suspend (String) -> Unit, + onSearchCleared: () -> Unit, + onHideSearch: () -> Unit, ) { - val focusRequester = remember { FocusRequester() } - val keyboardController = LocalSoftwareKeyboardController.current - TopAppBar( - navigationIcon = { - BackButton(onClick = onHideSearch) + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + TopAppBar( + navigationIcon = { BackButton(onClick = onHideSearch) }, + title = { + AppSearchInputField( + searchBarState = rememberSearchBarState(), + textFieldState = searchFieldState, + onSearch = onSearch, + onSearchCleared = { + searchFieldState.setTextAndPlaceCursorAtEnd("") + onSearchCleared() }, - title = { - AppSearchInputField( - searchBarState = rememberSearchBarState(), - textFieldState = searchFieldState, - onSearch = onSearch, - onSearchCleared = { - searchFieldState.setTextAndPlaceCursorAtEnd("") - onSearchCleared() - }, - modifier = Modifier.focusRequester(focusRequester) - ) - }, - actions = actions, - ) - LaunchedEffect(Unit) { - focusRequester.requestFocus() - keyboardController?.show() - } + modifier = Modifier.focusRequester(focusRequester), + ) + }, + actions = actions, + ) + LaunchedEffect(Unit) { + focusRequester.requestFocus() + keyboardController?.show() + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/settings/PreferenceProxy.kt b/app/src/main/kotlin/org/fdroid/ui/settings/PreferenceProxy.kt index a8bb30fdb..dc29c5c66 100644 --- a/app/src/main/kotlin/org/fdroid/ui/settings/PreferenceProxy.kt +++ b/app/src/main/kotlin/org/fdroid/ui/settings/PreferenceProxy.kt @@ -21,111 +21,108 @@ import androidx.compose.ui.semantics.hideFromAccessibility import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.tooling.preview.Preview +import java.net.InetSocketAddress import me.zhanghai.compose.preference.ProvidePreferenceLocals import me.zhanghai.compose.preference.textFieldPreference import org.fdroid.R import org.fdroid.settings.SettingsConstants.PREF_DEFAULT_PROXY import org.fdroid.settings.SettingsConstants.PREF_KEY_PROXY import org.fdroid.ui.FDroidContent -import java.net.InetSocketAddress fun LazyListScope.preferenceProxy( - proxyState: MutableState, - showError: MutableState, + proxyState: MutableState, + showError: MutableState, ) { - textFieldPreference( - key = PREF_KEY_PROXY, - defaultValue = PREF_DEFAULT_PROXY, - rememberState = { proxyState }, - icon = { - Icon( - imageVector = Icons.Default.VpnLock, - contentDescription = null, - modifier = Modifier.semantics { hideFromAccessibility() }, - ) - }, - title = { - Text(stringResource(R.string.pref_proxy_title)) - }, - summary = { - val value = proxyState.value - val s = if (value.isBlank()) { - stringResource(R.string.pref_proxy_disabled) - } else { - stringResource(R.string.pref_proxy_enabled, value) + textFieldPreference( + key = PREF_KEY_PROXY, + defaultValue = PREF_DEFAULT_PROXY, + rememberState = { proxyState }, + icon = { + Icon( + imageVector = Icons.Default.VpnLock, + contentDescription = null, + modifier = Modifier.semantics { hideFromAccessibility() }, + ) + }, + title = { Text(stringResource(R.string.pref_proxy_title)) }, + summary = { + val value = proxyState.value + val s = + if (value.isBlank()) { + stringResource(R.string.pref_proxy_disabled) + } else { + stringResource(R.string.pref_proxy_enabled, value) + } + Text(s) + }, + textToValue = { + if (it.isBlank() || isProxyValid(it)) { + showError.value = false + it + } else { + showError.value = true + // null is currently treated as an error and won't cause an update + null + } + }, + textField = { value, onValueChange, onOk -> + OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier.fillMaxWidth(), + keyboardActions = KeyboardActions { onOk() }, + singleLine = true, + trailingIcon = { + if (value.text.isNotBlank()) { + IconButton(onClick = { onValueChange(TextFieldValue("")) }) { + Icon(Icons.Default.Clear, stringResource(R.string.clear)) } - Text(s) + } }, - textToValue = { - if (it.isBlank() || isProxyValid(it)) { - showError.value = false - it + isError = showError.value, + supportingText = { + val s = + if (showError.value) { + stringResource(R.string.pref_proxy_error) } else { - showError.value = true - // null is currently treated as an error and won't cause an update - null + stringResource(R.string.pref_proxy_hint) } + Text(s) }, - textField = { value, onValueChange, onOk -> - OutlinedTextField( - value = value, - onValueChange = onValueChange, - modifier = Modifier.fillMaxWidth(), - keyboardActions = KeyboardActions { onOk() }, - singleLine = true, - trailingIcon = { - if (value.text.isNotBlank()) { - IconButton(onClick = { onValueChange(TextFieldValue("")) }) { - Icon(Icons.Default.Clear, stringResource(R.string.clear)) - } - } - }, - isError = showError.value, - supportingText = { - val s = if (showError.value) { - stringResource(R.string.pref_proxy_error) - } else { - stringResource(R.string.pref_proxy_hint) - } - Text(s) - }, - ) - }, - ) + ) + }, + ) } -private fun isProxyValid(proxyStr: String): Boolean = try { +private fun isProxyValid(proxyStr: String): Boolean = + try { val (host, port) = proxyStr.split(':') InetSocketAddress.createUnresolved(host, port.toInt()) true -} catch (_: Exception) { + } catch (_: Exception) { false -} + } @Preview @Composable private fun PreviewDefault() { - FDroidContent { - ProvidePreferenceLocals { - val showProxyError = remember { mutableStateOf(false) } - val proxyState = remember { mutableStateOf(PREF_DEFAULT_PROXY) } - LazyColumn { - preferenceProxy(proxyState, showProxyError) - } - } + FDroidContent { + ProvidePreferenceLocals { + val showProxyError = remember { mutableStateOf(false) } + val proxyState = remember { mutableStateOf(PREF_DEFAULT_PROXY) } + LazyColumn { preferenceProxy(proxyState, showProxyError) } } + } } @Preview @Composable private fun PreviewProxySet() { - FDroidContent { - ProvidePreferenceLocals { - val showProxyError = remember { mutableStateOf(false) } - val proxyState = remember { mutableStateOf("127.0.0.1:8000") } - LazyColumn { - preferenceProxy(proxyState, showProxyError) - } - } + FDroidContent { + ProvidePreferenceLocals { + val showProxyError = remember { mutableStateOf(false) } + val proxyState = remember { mutableStateOf("127.0.0.1:8000") } + LazyColumn { preferenceProxy(proxyState, showProxyError) } } + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/settings/Settings.kt b/app/src/main/kotlin/org/fdroid/ui/settings/Settings.kt index e64799319..98bf2c97d 100644 --- a/app/src/main/kotlin/org/fdroid/ui/settings/Settings.kt +++ b/app/src/main/kotlin/org/fdroid/ui/settings/Settings.kt @@ -47,6 +47,8 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import java.lang.System.currentTimeMillis +import java.util.concurrent.TimeUnit.HOURS import kotlinx.coroutines.flow.MutableStateFlow import me.zhanghai.compose.preference.MapPreferences import me.zhanghai.compose.preference.ProvidePreferenceLocals @@ -84,341 +86,308 @@ import org.fdroid.ui.utils.BackButton import org.fdroid.ui.utils.asRelativeTimeString import org.fdroid.ui.utils.startActivitySafe import org.fdroid.utils.getLogName -import java.lang.System.currentTimeMillis -import java.util.concurrent.TimeUnit.HOURS @Composable @OptIn(ExperimentalMaterial3Api::class) -fun Settings( - model: SettingsModel, - onSaveLogcat: (Uri?) -> Unit, - onBackClicked: () -> Unit, -) { - Scaffold( - topBar = { - TopAppBar( - navigationIcon = { - BackButton(onClick = onBackClicked) - }, - title = { - Text(stringResource(R.string.menu_settings)) - }, - ) - }, - ) { paddingValues -> - val launcher = rememberLauncherForActivityResult(CreateDocument("text/plain")) { - onSaveLogcat(it) - } - val context = LocalContext.current - val res = LocalResources.current - ProvidePreferenceLocals(model.prefsFlow) { - val showProxyError = remember { mutableStateOf(false) } - val proxyState = rememberPreferenceState(PREF_KEY_PROXY, PREF_DEFAULT_PROXY) - LazyColumn( - modifier = Modifier - .padding(paddingValues) - .fillMaxSize() - ) { - preferenceCategory( - key = "pref_category_display", - title = { Text(stringResource(R.string.display)) }, - ) - val themeToString = { value: String -> - AnnotatedString( - when (value) { - "light" -> res.getString(R.string.theme_light) - "dark" -> res.getString(R.string.theme_dark) - "followSystem" -> res.getString(R.string.theme_follow_system) - else -> error("Unknown value: $value") - } - ) - } - listPreference( - key = PREF_KEY_THEME, - values = listOf( - "light", - "dark", - "followSystem", - ), - valueToText = themeToString, - defaultValue = PREF_DEFAULT_THEME, - title = { Text(text = stringResource(R.string.theme)) }, - icon = { - Icon( - imageVector = Icons.Default.BrightnessMedium, - contentDescription = null, - modifier = Modifier.semantics { hideFromAccessibility() }, - ) - }, - summary = { Text(text = "${themeToString(it)}") }, - ) - if (SDK_INT >= 31) switchPreference( - key = PREF_KEY_DYNAMIC_COLORS, - defaultValue = PREF_DEFAULT_DYNAMIC_COLORS, - title = { - Text(stringResource(R.string.pref_dyn_colors_title)) - }, - icon = { - Icon( - imageVector = Icons.Default.ColorLens, - contentDescription = null, - modifier = Modifier.semantics { hideFromAccessibility() }, - ) - }, - summary = { - Text(text = stringResource(R.string.pref_dyn_colors_summary)) - }, - ) - if (SDK_INT >= 33) preference( - key = "languages", - icon = { - Icon( - imageVector = Icons.Default.Translate, - contentDescription = null, - modifier = Modifier.semantics { hideFromAccessibility() }, - ) - }, - title = { Text(stringResource(R.string.pref_language)) }, - summary = { Text(stringResource(R.string.pref_language_summary)) }, - onClick = { - val intent = Intent(ACTION_APP_LOCALE_SETTINGS).apply { - setData(Uri.fromParts("package", context.packageName, null)) - } - context.startActivitySafe(intent) - }, - ) - if (SDK_INT >= 26) preference( - key = "notifications", - icon = { - Icon( - imageVector = Icons.Default.Notifications, - contentDescription = null, - modifier = Modifier.semantics { hideFromAccessibility() }, - ) - }, - title = { Text(stringResource(R.string.notification_title)) }, - summary = { Text(stringResource(R.string.notification_summary)) }, - onClick = { - val intent = Intent(ACTION_APP_NOTIFICATION_SETTINGS).apply { - putExtra(EXTRA_APP_PACKAGE, context.packageName) - } - context.startActivitySafe(intent) - }, - ) - preferenceCategory( - key = "pref_category_updates", - title = { Text(stringResource(R.string.updates)) }, - ) - listPreference( - key = PREF_KEY_REPO_UPDATES, - defaultValue = PREF_DEFAULT_REPO_UPDATES, - title = { - Text(stringResource(R.string.pref_repo_updates_title)) - }, - icon = { strValue -> - if (strValue != Never.name) Icon( - imageVector = Icons.Default.SystemSecurityUpdate, - contentDescription = null, - modifier = Modifier.semantics { hideFromAccessibility() }, - ) else Icon( - imageVector = Icons.Default.SystemSecurityUpdateWarning, - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - modifier = Modifier.semantics { hideFromAccessibility() }, - ) - }, - summary = { strValue -> - if (strValue != Never.name) { - val nextUpdate = - model.nextRepoUpdateFlow.collectAsState(Long.MAX_VALUE).value - val nextUpdateStr = if (nextUpdate == Long.MAX_VALUE) { - stringResource( - R.string.auto_update_time, - stringResource(R.string.repositories_last_update_never) - ) - } else if (nextUpdate - currentTimeMillis() <= 0) { - stringResource(R.string.auto_update_time_past) - } else { - stringResource( - R.string.auto_update_time, - nextUpdate.asRelativeTimeString() - ) - } - val s = if (strValue == OnlyWifi.name) { - stringResource(R.string.pref_repo_updates_summary_only_wifi) - } else if (strValue == Always.name) { - stringResource(R.string.pref_repo_updates_summary_always) - } else error("Unknown value: $strValue") - Text(s + "\n" + nextUpdateStr) - } else { - Text( - text = stringResource(R.string.pref_repo_updates_summary_never), - color = MaterialTheme.colorScheme.error, - ) - } - }, - values = AutoUpdateValues.entries.map { it.name }, - valueToText = { value: String -> - AnnotatedString( - when (value.toAutoUpdateValue()) { - OnlyWifi -> res.getString(R.string.pref_auto_updates_only_wifi) - Always -> res.getString(R.string.pref_auto_updates_only_always) - Never -> res.getString(R.string.pref_auto_updates_only_never) - } - ) - }, - ) - listPreference( - key = PREF_KEY_AUTO_UPDATES, - defaultValue = PREF_DEFAULT_AUTO_UPDATES, - title = { - Text(stringResource(R.string.update_auto_install)) - }, - icon = { strValue -> - Icon( - imageVector = if (strValue != Never.name) { - Icons.Default.Update - } else { - Icons.Default.UpdateDisabled - }, - contentDescription = null, - modifier = Modifier.semantics { hideFromAccessibility() }, - ) - }, - summary = { strValue -> - val s = if (strValue != Never.name) { - val nextUpdate = - model.nextAppUpdateFlow.collectAsState(Long.MAX_VALUE).value - val nextUpdateStr = if (nextUpdate == Long.MAX_VALUE) { - stringResource( - R.string.auto_update_time, - stringResource(R.string.repositories_last_update_never) - ) - } else if (nextUpdate - currentTimeMillis() <= 0) { - stringResource(R.string.auto_update_time_past) - } else { - stringResource( - R.string.auto_update_time, - nextUpdate.asRelativeTimeString() - ) - } - val s = if (strValue == OnlyWifi.name) { - stringResource(R.string.pref_auto_updates_summary_only_wifi) - } else if (strValue == Always.name) { - stringResource(R.string.pref_auto_updates_summary_always) - } else error("Unknown value: $strValue") - s + "\n" + nextUpdateStr - } else { - stringResource(R.string.pref_auto_updates_summary_never) - } - Text(s) - }, - values = AutoUpdateValues.entries.map { it.name }, - valueToText = { value: String -> - AnnotatedString( - when (value.toAutoUpdateValue()) { - OnlyWifi -> res.getString(R.string.pref_auto_updates_only_wifi) - Always -> res.getString(R.string.pref_auto_updates_only_always) - Never -> res.getString(R.string.pref_auto_updates_only_never) - } - ) - }, - ) - preferenceCategory( - key = "pref_category_network", - title = { Text(stringResource(R.string.pref_category_network)) }, - ) - listPreference( - key = PREF_KEY_MIRROR_CHOOSER, - defaultValue = PREF_DEFAULT_MIRROR_CHOOSER, - title = { - Text(stringResource(R.string.pref_mirror_chooser_title)) - }, - icon = { - Icon( - imageVector = Icons.Default.Lan, - contentDescription = null, - modifier = Modifier.semantics { hideFromAccessibility() }, - ) - }, - summary = { strValue -> - val strRes = strValue.toMirrorChooserValue().res - Text(stringResource(strRes)) - }, - values = MirrorChooserValues.entries.map { it.name }, - valueToText = { value: String -> - val strRes = value.toMirrorChooserValue().res - AnnotatedString(res.getString(strRes)) - }, - ) - preferenceProxy(proxyState, showProxyError) - extraNetworkSettings(context) - preferenceCategory( - key = "pref_category_privacy", - title = { Text(stringResource(R.string.privacy)) }, - ) - switchPreference( - key = PREF_USE_DNS_CACHE, - defaultValue = PREF_USE_DNS_CACHE_DEFAULT, - title = { - Text(stringResource(R.string.useDnsCache)) - }, - icon = { - Icon( - imageVector = Icons.Default.Dns, - contentDescription = null, - modifier = Modifier.semantics { hideFromAccessibility() }, - ) - }, - summary = { - Text(stringResource(R.string.useDnsCacheSummary)) - }, - ) - switchPreference( - key = PREF_KEY_PREVENT_SCREENSHOTS, - defaultValue = PREF_DEFAULT_PREVENT_SCREENSHOTS, - title = { - Text(stringResource(R.string.preventScreenshots_title)) - }, - icon = { - Icon( - imageVector = Icons.Default.Screenshot, - contentDescription = null, - modifier = Modifier.semantics { hideFromAccessibility() }, - ) - }, - summary = { - Text(text = stringResource(R.string.preventScreenshots_summary)) - }, - ) - extraPrivacySettings(context) - item { - OutlinedButton( - onClick = { launcher.launch("${getLogName(context)}.txt") }, - modifier = Modifier - .padding(16.dp) - ) { - Icon(Icons.Default.Save, null) - Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) - Text( - text = stringResource(R.string.pref_export_log_title), - ) - } - } - } - } +fun Settings(model: SettingsModel, onSaveLogcat: (Uri?) -> Unit, onBackClicked: () -> Unit) { + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { BackButton(onClick = onBackClicked) }, + title = { Text(stringResource(R.string.menu_settings)) }, + ) } + ) { paddingValues -> + val launcher = + rememberLauncherForActivityResult(CreateDocument("text/plain")) { onSaveLogcat(it) } + val context = LocalContext.current + val res = LocalResources.current + ProvidePreferenceLocals(model.prefsFlow) { + val showProxyError = remember { mutableStateOf(false) } + val proxyState = rememberPreferenceState(PREF_KEY_PROXY, PREF_DEFAULT_PROXY) + LazyColumn(modifier = Modifier.padding(paddingValues).fillMaxSize()) { + preferenceCategory( + key = "pref_category_display", + title = { Text(stringResource(R.string.display)) }, + ) + val themeToString = { value: String -> + AnnotatedString( + when (value) { + "light" -> res.getString(R.string.theme_light) + "dark" -> res.getString(R.string.theme_dark) + "followSystem" -> res.getString(R.string.theme_follow_system) + else -> error("Unknown value: $value") + } + ) + } + listPreference( + key = PREF_KEY_THEME, + values = listOf("light", "dark", "followSystem"), + valueToText = themeToString, + defaultValue = PREF_DEFAULT_THEME, + title = { Text(text = stringResource(R.string.theme)) }, + icon = { + Icon( + imageVector = Icons.Default.BrightnessMedium, + contentDescription = null, + modifier = Modifier.semantics { hideFromAccessibility() }, + ) + }, + summary = { Text(text = "${themeToString(it)}") }, + ) + if (SDK_INT >= 31) + switchPreference( + key = PREF_KEY_DYNAMIC_COLORS, + defaultValue = PREF_DEFAULT_DYNAMIC_COLORS, + title = { Text(stringResource(R.string.pref_dyn_colors_title)) }, + icon = { + Icon( + imageVector = Icons.Default.ColorLens, + contentDescription = null, + modifier = Modifier.semantics { hideFromAccessibility() }, + ) + }, + summary = { Text(text = stringResource(R.string.pref_dyn_colors_summary)) }, + ) + if (SDK_INT >= 33) + preference( + key = "languages", + icon = { + Icon( + imageVector = Icons.Default.Translate, + contentDescription = null, + modifier = Modifier.semantics { hideFromAccessibility() }, + ) + }, + title = { Text(stringResource(R.string.pref_language)) }, + summary = { Text(stringResource(R.string.pref_language_summary)) }, + onClick = { + val intent = + Intent(ACTION_APP_LOCALE_SETTINGS).apply { + setData(Uri.fromParts("package", context.packageName, null)) + } + context.startActivitySafe(intent) + }, + ) + if (SDK_INT >= 26) + preference( + key = "notifications", + icon = { + Icon( + imageVector = Icons.Default.Notifications, + contentDescription = null, + modifier = Modifier.semantics { hideFromAccessibility() }, + ) + }, + title = { Text(stringResource(R.string.notification_title)) }, + summary = { Text(stringResource(R.string.notification_summary)) }, + onClick = { + val intent = + Intent(ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(EXTRA_APP_PACKAGE, context.packageName) + } + context.startActivitySafe(intent) + }, + ) + preferenceCategory( + key = "pref_category_updates", + title = { Text(stringResource(R.string.updates)) }, + ) + listPreference( + key = PREF_KEY_REPO_UPDATES, + defaultValue = PREF_DEFAULT_REPO_UPDATES, + title = { Text(stringResource(R.string.pref_repo_updates_title)) }, + icon = { strValue -> + if (strValue != Never.name) + Icon( + imageVector = Icons.Default.SystemSecurityUpdate, + contentDescription = null, + modifier = Modifier.semantics { hideFromAccessibility() }, + ) + else + Icon( + imageVector = Icons.Default.SystemSecurityUpdateWarning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.semantics { hideFromAccessibility() }, + ) + }, + summary = { strValue -> + if (strValue != Never.name) { + val nextUpdate = model.nextRepoUpdateFlow.collectAsState(Long.MAX_VALUE).value + val nextUpdateStr = + if (nextUpdate == Long.MAX_VALUE) { + stringResource( + R.string.auto_update_time, + stringResource(R.string.repositories_last_update_never), + ) + } else if (nextUpdate - currentTimeMillis() <= 0) { + stringResource(R.string.auto_update_time_past) + } else { + stringResource(R.string.auto_update_time, nextUpdate.asRelativeTimeString()) + } + val s = + if (strValue == OnlyWifi.name) { + stringResource(R.string.pref_repo_updates_summary_only_wifi) + } else if (strValue == Always.name) { + stringResource(R.string.pref_repo_updates_summary_always) + } else error("Unknown value: $strValue") + Text(s + "\n" + nextUpdateStr) + } else { + Text( + text = stringResource(R.string.pref_repo_updates_summary_never), + color = MaterialTheme.colorScheme.error, + ) + } + }, + values = AutoUpdateValues.entries.map { it.name }, + valueToText = { value: String -> + AnnotatedString( + when (value.toAutoUpdateValue()) { + OnlyWifi -> res.getString(R.string.pref_auto_updates_only_wifi) + Always -> res.getString(R.string.pref_auto_updates_only_always) + Never -> res.getString(R.string.pref_auto_updates_only_never) + } + ) + }, + ) + listPreference( + key = PREF_KEY_AUTO_UPDATES, + defaultValue = PREF_DEFAULT_AUTO_UPDATES, + title = { Text(stringResource(R.string.update_auto_install)) }, + icon = { strValue -> + Icon( + imageVector = + if (strValue != Never.name) { + Icons.Default.Update + } else { + Icons.Default.UpdateDisabled + }, + contentDescription = null, + modifier = Modifier.semantics { hideFromAccessibility() }, + ) + }, + summary = { strValue -> + val s = + if (strValue != Never.name) { + val nextUpdate = model.nextAppUpdateFlow.collectAsState(Long.MAX_VALUE).value + val nextUpdateStr = + if (nextUpdate == Long.MAX_VALUE) { + stringResource( + R.string.auto_update_time, + stringResource(R.string.repositories_last_update_never), + ) + } else if (nextUpdate - currentTimeMillis() <= 0) { + stringResource(R.string.auto_update_time_past) + } else { + stringResource(R.string.auto_update_time, nextUpdate.asRelativeTimeString()) + } + val s = + if (strValue == OnlyWifi.name) { + stringResource(R.string.pref_auto_updates_summary_only_wifi) + } else if (strValue == Always.name) { + stringResource(R.string.pref_auto_updates_summary_always) + } else error("Unknown value: $strValue") + s + "\n" + nextUpdateStr + } else { + stringResource(R.string.pref_auto_updates_summary_never) + } + Text(s) + }, + values = AutoUpdateValues.entries.map { it.name }, + valueToText = { value: String -> + AnnotatedString( + when (value.toAutoUpdateValue()) { + OnlyWifi -> res.getString(R.string.pref_auto_updates_only_wifi) + Always -> res.getString(R.string.pref_auto_updates_only_always) + Never -> res.getString(R.string.pref_auto_updates_only_never) + } + ) + }, + ) + preferenceCategory( + key = "pref_category_network", + title = { Text(stringResource(R.string.pref_category_network)) }, + ) + listPreference( + key = PREF_KEY_MIRROR_CHOOSER, + defaultValue = PREF_DEFAULT_MIRROR_CHOOSER, + title = { Text(stringResource(R.string.pref_mirror_chooser_title)) }, + icon = { + Icon( + imageVector = Icons.Default.Lan, + contentDescription = null, + modifier = Modifier.semantics { hideFromAccessibility() }, + ) + }, + summary = { strValue -> + val strRes = strValue.toMirrorChooserValue().res + Text(stringResource(strRes)) + }, + values = MirrorChooserValues.entries.map { it.name }, + valueToText = { value: String -> + val strRes = value.toMirrorChooserValue().res + AnnotatedString(res.getString(strRes)) + }, + ) + preferenceProxy(proxyState, showProxyError) + extraNetworkSettings(context) + preferenceCategory( + key = "pref_category_privacy", + title = { Text(stringResource(R.string.privacy)) }, + ) + switchPreference( + key = PREF_USE_DNS_CACHE, + defaultValue = PREF_USE_DNS_CACHE_DEFAULT, + title = { Text(stringResource(R.string.useDnsCache)) }, + icon = { + Icon( + imageVector = Icons.Default.Dns, + contentDescription = null, + modifier = Modifier.semantics { hideFromAccessibility() }, + ) + }, + summary = { Text(stringResource(R.string.useDnsCacheSummary)) }, + ) + switchPreference( + key = PREF_KEY_PREVENT_SCREENSHOTS, + defaultValue = PREF_DEFAULT_PREVENT_SCREENSHOTS, + title = { Text(stringResource(R.string.preventScreenshots_title)) }, + icon = { + Icon( + imageVector = Icons.Default.Screenshot, + contentDescription = null, + modifier = Modifier.semantics { hideFromAccessibility() }, + ) + }, + summary = { Text(text = stringResource(R.string.preventScreenshots_summary)) }, + ) + extraPrivacySettings(context) + item { + OutlinedButton( + onClick = { launcher.launch("${getLogName(context)}.txt") }, + modifier = Modifier.padding(16.dp), + ) { + Icon(Icons.Default.Save, null) + Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) + Text(text = stringResource(R.string.pref_export_log_title)) + } + } + } + } + } } @Preview @Composable fun SettingsPreview() { - FDroidContent { - val model = SettingsModel( - prefsFlow = MutableStateFlow(MapPreferences()), - nextRepoUpdateFlow = MutableStateFlow(Long.MAX_VALUE), - nextAppUpdateFlow = MutableStateFlow(currentTimeMillis() - HOURS.toMillis(12)), - ) - Settings(model, {}, {}) - } + FDroidContent { + val model = + SettingsModel( + prefsFlow = MutableStateFlow(MapPreferences()), + nextRepoUpdateFlow = MutableStateFlow(Long.MAX_VALUE), + nextAppUpdateFlow = MutableStateFlow(currentTimeMillis() - HOURS.toMillis(12)), + ) + Settings(model, {}, {}) + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/settings/SettingsModel.kt b/app/src/main/kotlin/org/fdroid/ui/settings/SettingsModel.kt index 33b8d0f8b..4e9541f95 100644 --- a/app/src/main/kotlin/org/fdroid/ui/settings/SettingsModel.kt +++ b/app/src/main/kotlin/org/fdroid/ui/settings/SettingsModel.kt @@ -5,7 +5,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import me.zhanghai.compose.preference.Preferences data class SettingsModel( - val prefsFlow: MutableStateFlow, - val nextRepoUpdateFlow: Flow, - val nextAppUpdateFlow: Flow, + val prefsFlow: MutableStateFlow, + val nextRepoUpdateFlow: Flow, + val nextAppUpdateFlow: Flow, ) diff --git a/app/src/main/kotlin/org/fdroid/ui/settings/SettingsViewModel.kt b/app/src/main/kotlin/org/fdroid/ui/settings/SettingsViewModel.kt index 67c6c7779..e404f80bb 100644 --- a/app/src/main/kotlin/org/fdroid/ui/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/org/fdroid/ui/settings/SettingsViewModel.kt @@ -10,6 +10,9 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.application import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import java.io.IOException +import java.lang.Runtime.getRuntime +import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch @@ -21,72 +24,68 @@ import org.fdroid.settings.SettingsManager import org.fdroid.ui.utils.applyNewTheme import org.fdroid.updates.AppUpdateWorker import org.fdroid.updates.UpdatesManager -import java.io.IOException -import java.lang.Runtime.getRuntime -import javax.inject.Inject @HiltViewModel -class SettingsViewModel @Inject constructor( - app: Application, - updatesManager: UpdatesManager, - private val settingsManager: SettingsManager, +class SettingsViewModel +@Inject +constructor( + app: Application, + updatesManager: UpdatesManager, + private val settingsManager: SettingsManager, ) : AndroidViewModel(app) { - private val log = KotlinLogging.logger {} + private val log = KotlinLogging.logger {} - val model = SettingsModel( - prefsFlow = settingsManager.prefsFlow, - nextRepoUpdateFlow = updatesManager.nextRepoUpdateFlow, - nextAppUpdateFlow = updatesManager.nextAppUpdateFlow, + val model = + SettingsModel( + prefsFlow = settingsManager.prefsFlow, + nextRepoUpdateFlow = updatesManager.nextRepoUpdateFlow, + nextAppUpdateFlow = updatesManager.nextAppUpdateFlow, ) - init { - viewModelScope.launch { - // react to theme changes right away - settingsManager.themeFlow.drop(1).collect { - if (it != null) applyNewTheme(it) - } - } - viewModelScope.launch { - // react to repo auto update changes - settingsManager.repoUpdatesFlow.drop(1).collect { value -> - RepoUpdateWorker.scheduleOrCancel(application, value) - } - } - viewModelScope.launch { - // react to app auto update changes - settingsManager.autoUpdateAppsFlow.drop(1).collect { value -> - AppUpdateWorker.scheduleOrCancel(application, value) - } - } - } - - fun onSaveLogcat(uri: Uri?) = viewModelScope.launch(Dispatchers.IO) { - if (uri == null) { - sendToast(R.string.export_log_error) - return@launch - } - // support for --pid was introduced in SDK 24 - val command = "logcat -d --pid=" + Process.myPid() + " *:V" - try { - application.contentResolver.openOutputStream(uri, "wt")?.use { outputStream -> - getRuntime().exec(command).inputStream.use { inputStream -> - // first log command, so we see if it is correct, e.g. has our own pid - outputStream.write("$command\n\n".toByteArray()) - inputStream.copyTo(outputStream) - } - } ?: throw IOException("OutputStream was null") - sendToast(R.string.export_log_success) - } catch (e: Exception) { - log.error(e) { "Error saving logcat " } - sendToast(R.string.export_log_error) - } - } - - private suspend fun sendToast(@StringRes s: Int, duration: Int = LENGTH_SHORT) { - withContext(Dispatchers.Main) { - Toast.makeText(application, s, duration).show() - } + init { + viewModelScope.launch { + // react to theme changes right away + settingsManager.themeFlow.drop(1).collect { if (it != null) applyNewTheme(it) } + } + viewModelScope.launch { + // react to repo auto update changes + settingsManager.repoUpdatesFlow.drop(1).collect { value -> + RepoUpdateWorker.scheduleOrCancel(application, value) + } + } + viewModelScope.launch { + // react to app auto update changes + settingsManager.autoUpdateAppsFlow.drop(1).collect { value -> + AppUpdateWorker.scheduleOrCancel(application, value) + } + } + } + + fun onSaveLogcat(uri: Uri?) = + viewModelScope.launch(Dispatchers.IO) { + if (uri == null) { + sendToast(R.string.export_log_error) + return@launch + } + // support for --pid was introduced in SDK 24 + val command = "logcat -d --pid=" + Process.myPid() + " *:V" + try { + application.contentResolver.openOutputStream(uri, "wt")?.use { outputStream -> + getRuntime().exec(command).inputStream.use { inputStream -> + // first log command, so we see if it is correct, e.g. has our own pid + outputStream.write("$command\n\n".toByteArray()) + inputStream.copyTo(outputStream) + } + } ?: throw IOException("OutputStream was null") + sendToast(R.string.export_log_success) + } catch (e: Exception) { + log.error(e) { "Error saving logcat " } + sendToast(R.string.export_log_error) + } } + private suspend fun sendToast(@StringRes s: Int, duration: Int = LENGTH_SHORT) { + withContext(Dispatchers.Main) { Toast.makeText(application, s, duration).show() } + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/utils/AsyncShimmerImage.kt b/app/src/main/kotlin/org/fdroid/ui/utils/AsyncShimmerImage.kt index 8257aa8e7..3f2698419 100644 --- a/app/src/main/kotlin/org/fdroid/ui/utils/AsyncShimmerImage.kt +++ b/app/src/main/kotlin/org/fdroid/ui/utils/AsyncShimmerImage.kt @@ -29,90 +29,84 @@ import org.fdroid.ui.FDroidContent @Composable fun AsyncShimmerImage( - model: Any?, - contentDescription: String?, - modifier: Modifier = Modifier, - colorFilter: ColorFilter? = null, - contentScale: ContentScale = ContentScale.Fit, - error: Painter = painterResource(R.drawable.ic_repo_app_default), - placeholder: Painter = error, + model: Any?, + contentDescription: String?, + modifier: Modifier = Modifier, + colorFilter: ColorFilter? = null, + contentScale: ContentScale = ContentScale.Fit, + error: Painter = painterResource(R.drawable.ic_repo_app_default), + placeholder: Painter = error, ) { - Box(modifier = modifier) { - SubcomposeAsyncImage( - model = model, - loading = { - Image( - painter = placeholder, - contentDescription = contentDescription, - colorFilter = colorFilter ?: tint(MaterialTheme.colorScheme.onSurface), - contentScale = contentScale, - modifier = Modifier - .matchParentSize() - .shimmer(), - ) - }, - error = { - Image( - painter = error, - contentDescription = contentDescription, - colorFilter = colorFilter ?: tint(MaterialTheme.colorScheme.onSurface), - contentScale = contentScale, - modifier = Modifier.matchParentSize(), - ) - }, - colorFilter = colorFilter, - contentScale = contentScale, - contentDescription = contentDescription, - modifier = Modifier.matchParentSize() + Box(modifier = modifier) { + SubcomposeAsyncImage( + model = model, + loading = { + Image( + painter = placeholder, + contentDescription = contentDescription, + colorFilter = colorFilter ?: tint(MaterialTheme.colorScheme.onSurface), + contentScale = contentScale, + modifier = Modifier.matchParentSize().shimmer(), ) - } + }, + error = { + Image( + painter = error, + contentDescription = contentDescription, + colorFilter = colorFilter ?: tint(MaterialTheme.colorScheme.onSurface), + contentScale = contentScale, + modifier = Modifier.matchParentSize(), + ) + }, + colorFilter = colorFilter, + contentScale = contentScale, + contentDescription = contentDescription, + modifier = Modifier.matchParentSize(), + ) + } } @Composable @Preview private fun Preview() { - FDroidContent { - Image( - painter = painterResource(R.drawable.ic_repo_app_default), - contentDescription = null, - modifier = Modifier - .size(128.dp) - .shimmer(), - ) - } + FDroidContent { + Image( + painter = painterResource(R.drawable.ic_repo_app_default), + contentDescription = null, + modifier = Modifier.size(128.dp).shimmer(), + ) + } } @Composable fun Modifier.shimmer(): Modifier { - val shimmerColors = listOf( - Color.Transparent, - MaterialTheme.colorScheme.background.copy(alpha = 0.6f), - Color.Transparent + val shimmerColors = + listOf( + Color.Transparent, + MaterialTheme.colorScheme.background.copy(alpha = 0.6f), + Color.Transparent, ) - val transition = rememberInfiniteTransition(label = "Shimmer") - val translateAnim by transition.animateFloat( - initialValue = -400f, - targetValue = 1200f, - animationSpec = infiniteRepeatable( - animation = tween( - durationMillis = 2000, - easing = FastOutLinearInEasing, - ) + val transition = rememberInfiniteTransition(label = "Shimmer") + val translateAnim by + transition.animateFloat( + initialValue = -400f, + targetValue = 1200f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 2000, easing = FastOutLinearInEasing) ), - label = "Translate" + label = "Translate", ) - return this.drawWithCache { - val brush = Brush.linearGradient( - colors = shimmerColors, - start = Offset(translateAnim, 0f), - end = Offset(translateAnim + size.width / 1.5f, size.height) - ) - onDrawWithContent { - drawContent() - drawRect( - brush = brush, - size = size, - ) - } + return this.drawWithCache { + val brush = + Brush.linearGradient( + colors = shimmerColors, + start = Offset(translateAnim, 0f), + end = Offset(translateAnim + size.width / 1.5f, size.height), + ) + onDrawWithContent { + drawContent() + drawRect(brush = brush, size = size) } + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/utils/BadgeIcon.kt b/app/src/main/kotlin/org/fdroid/ui/utils/BadgeIcon.kt index 4ec975f15..50d889136 100644 --- a/app/src/main/kotlin/org/fdroid/ui/utils/BadgeIcon.kt +++ b/app/src/main/kotlin/org/fdroid/ui/utils/BadgeIcon.kt @@ -26,57 +26,57 @@ import org.fdroid.ui.FDroidContent @Composable fun BadgeIcon( - icon: ImageVector, - contentDescription: String, - color: Color = MaterialTheme.colorScheme.error -) = Icon( + icon: ImageVector, + contentDescription: String, + color: Color = MaterialTheme.colorScheme.error, +) = + Icon( imageVector = icon, tint = color, contentDescription = contentDescription, - modifier = Modifier - .clip(CircleShape) + modifier = + Modifier.clip(CircleShape) .background(MaterialTheme.colorScheme.surface) .padding(1.dp) - .size(24.dp) -) + .size(24.dp), + ) @Composable -fun InstalledBadge() = BadgeIcon( +fun InstalledBadge() = + BadgeIcon( icon = Icons.Filled.CheckCircle, contentDescription = stringResource(R.string.app_installed), color = MaterialTheme.colorScheme.secondary, -) + ) @Preview @Composable private fun Preview() { - FDroidContent { - Column { - BadgedBox(badge = { - BadgeIcon( - icon = Icons.Filled.SecurityUpdate, - contentDescription = stringResource(R.string.app_installed), - color = MaterialTheme.colorScheme.error - ) - }, modifier = Modifier.padding(16.dp)) { - Image( - painter = painterResource(R.drawable.ic_launcher_foreground), - contentDescription = null, - modifier = Modifier - .size(48.dp), - ) - } - BadgedBox( - badge = { InstalledBadge() }, - modifier = Modifier.padding(16.dp) - ) { - Image( - painter = painterResource(R.drawable.ic_launcher_foreground), - contentDescription = null, - modifier = Modifier - .size(48.dp), - ) - } - } + FDroidContent { + Column { + BadgedBox( + badge = { + BadgeIcon( + icon = Icons.Filled.SecurityUpdate, + contentDescription = stringResource(R.string.app_installed), + color = MaterialTheme.colorScheme.error, + ) + }, + modifier = Modifier.padding(16.dp), + ) { + Image( + painter = painterResource(R.drawable.ic_launcher_foreground), + contentDescription = null, + modifier = Modifier.size(48.dp), + ) + } + BadgedBox(badge = { InstalledBadge() }, modifier = Modifier.padding(16.dp)) { + Image( + painter = painterResource(R.drawable.ic_launcher_foreground), + contentDescription = null, + modifier = Modifier.size(48.dp), + ) + } } + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/utils/BigLoadingIndicator.kt b/app/src/main/kotlin/org/fdroid/ui/utils/BigLoadingIndicator.kt index b598b9245..3b49db8ce 100644 --- a/app/src/main/kotlin/org/fdroid/ui/utils/BigLoadingIndicator.kt +++ b/app/src/main/kotlin/org/fdroid/ui/utils/BigLoadingIndicator.kt @@ -15,18 +15,13 @@ import org.fdroid.ui.FDroidContent @Composable @OptIn(ExperimentalMaterial3ExpressiveApi::class) fun BigLoadingIndicator(modifier: Modifier = Modifier) { - Box( - contentAlignment = Alignment.Center, - modifier = modifier.fillMaxSize() - ) { - LoadingIndicator(Modifier.size(128.dp)) - } + Box(contentAlignment = Alignment.Center, modifier = modifier.fillMaxSize()) { + LoadingIndicator(Modifier.size(128.dp)) + } } @Preview @Composable private fun Preview() { - FDroidContent { - BigLoadingIndicator() - } + FDroidContent { BigLoadingIndicator() } } diff --git a/app/src/main/kotlin/org/fdroid/ui/utils/DragDropState.kt b/app/src/main/kotlin/org/fdroid/ui/utils/DragDropState.kt index dd9a4b3c4..d2fbb505e 100644 --- a/app/src/main/kotlin/org/fdroid/ui/utils/DragDropState.kt +++ b/app/src/main/kotlin/org/fdroid/ui/utils/DragDropState.kt @@ -55,171 +55,163 @@ import kotlinx.coroutines.launch @Composable fun rememberDragDropState( - lazyListState: LazyListState, - onMove: (Any, Any) -> Unit, - onEnd: (Any, Any) -> Unit + lazyListState: LazyListState, + onMove: (Any, Any) -> Unit, + onEnd: (Any, Any) -> Unit, ): DragDropState { - val scope = rememberCoroutineScope() - val state = remember(lazyListState) { - DragDropState(state = lazyListState, onMove = onMove, onEnd = onEnd, scope = scope) + val scope = rememberCoroutineScope() + val state = + remember(lazyListState) { + DragDropState(state = lazyListState, onMove = onMove, onEnd = onEnd, scope = scope) } - LaunchedEffect(state) { - while (true) { - val diff = state.scrollChannel.receive() - lazyListState.scrollBy(diff) - } + LaunchedEffect(state) { + while (true) { + val diff = state.scrollChannel.receive() + lazyListState.scrollBy(diff) } - return state + } + return state } -class DragDropState internal constructor( - private val state: LazyListState, - private val scope: CoroutineScope, - private val onMove: (Any, Any) -> Unit, - private val onEnd: (Any, Any) -> Unit, +class DragDropState +internal constructor( + private val state: LazyListState, + private val scope: CoroutineScope, + private val onMove: (Any, Any) -> Unit, + private val onEnd: (Any, Any) -> Unit, ) { - private var movedFrom: LazyListItemInfo? = null - private var movedTo: LazyListItemInfo? = null - var draggingItemIndex by mutableStateOf(null) - private set + private var movedFrom: LazyListItemInfo? = null + private var movedTo: LazyListItemInfo? = null + var draggingItemIndex by mutableStateOf(null) + private set - internal val scrollChannel = Channel() + internal val scrollChannel = Channel() - private var draggingItemDraggedDelta by mutableFloatStateOf(0f) - private var draggingItemInitialOffset by mutableIntStateOf(0) - internal val draggingItemOffset: Float - get() = - draggingItemLayoutInfo?.let { item -> - draggingItemInitialOffset + draggingItemDraggedDelta - item.offset - } ?: 0f + private var draggingItemDraggedDelta by mutableFloatStateOf(0f) + private var draggingItemInitialOffset by mutableIntStateOf(0) + internal val draggingItemOffset: Float + get() = + draggingItemLayoutInfo?.let { item -> + draggingItemInitialOffset + draggingItemDraggedDelta - item.offset + } ?: 0f - private val draggingItemLayoutInfo: LazyListItemInfo? - get() = state.layoutInfo.visibleItemsInfo.firstOrNull { it.index == draggingItemIndex } + private val draggingItemLayoutInfo: LazyListItemInfo? + get() = state.layoutInfo.visibleItemsInfo.firstOrNull { it.index == draggingItemIndex } - internal var previousIndexOfDraggedItem by mutableStateOf(null) - private set + internal var previousIndexOfDraggedItem by mutableStateOf(null) + private set - internal var previousItemOffset = Animatable(0f) - private set + internal var previousItemOffset = Animatable(0f) + private set - internal fun onDragStart(offset: Offset) { - state.layoutInfo.visibleItemsInfo - .firstOrNull { item -> offset.y.toInt() in item.offset..(item.offset + item.size) } - ?.also { - movedFrom = it - draggingItemIndex = it.index - draggingItemInitialOffset = it.offset - } + internal fun onDragStart(offset: Offset) { + state.layoutInfo.visibleItemsInfo + .firstOrNull { item -> offset.y.toInt() in item.offset..(item.offset + item.size) } + ?.also { + movedFrom = it + draggingItemIndex = it.index + draggingItemInitialOffset = it.offset + } + } + + internal fun onDragInterrupted() { + if (draggingItemIndex != null) { + previousIndexOfDraggedItem = draggingItemIndex + val startOffset = draggingItemOffset + scope.launch { + previousItemOffset.snapTo(startOffset) + previousItemOffset.animateTo( + 0f, + spring(stiffness = Spring.StiffnessMediumLow, visibilityThreshold = 1f), + ) + previousIndexOfDraggedItem = null + } } + draggingItemDraggedDelta = 0f + draggingItemIndex = null + draggingItemInitialOffset = 0 + movedFrom = null + movedTo = null + } - internal fun onDragInterrupted() { - if (draggingItemIndex != null) { - previousIndexOfDraggedItem = draggingItemIndex - val startOffset = draggingItemOffset - scope.launch { - previousItemOffset.snapTo(startOffset) - previousItemOffset.animateTo( - 0f, - spring(stiffness = Spring.StiffnessMediumLow, visibilityThreshold = 1f), - ) - previousIndexOfDraggedItem = null - } + internal fun onDragEnd() { + val from = movedFrom ?: error("Moved from was null") + val to = movedTo ?: from + if (from.key != to.key && from.index != to.index) onEnd(from.key, to.key) + onDragInterrupted() + } + + internal fun onDrag(offset: Offset) { + draggingItemDraggedDelta += offset.y + + val draggingItem = draggingItemLayoutInfo ?: return + val startOffset = draggingItem.offset + draggingItemOffset + val endOffset = startOffset + draggingItem.size + val middleOffset = startOffset + (endOffset - startOffset) / 2f + + val targetItem = + state.layoutInfo.visibleItemsInfo.find { item -> + middleOffset.toInt() in item.offset..item.offsetEnd && draggingItem.index != item.index + } + if (targetItem != null) { + if ( + draggingItem.index == state.firstVisibleItemIndex || + targetItem.index == state.firstVisibleItemIndex + ) { + state.requestScrollToItem(state.firstVisibleItemIndex, state.firstVisibleItemScrollOffset) + } + onMove(draggingItem.key, targetItem.key) + draggingItemIndex = targetItem.index + movedTo = targetItem + } else { + val overscroll = + when { + draggingItemDraggedDelta > 0 -> + (endOffset - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f) + draggingItemDraggedDelta < 0 -> + (startOffset - state.layoutInfo.viewportStartOffset).coerceAtMost(0f) + else -> 0f } - draggingItemDraggedDelta = 0f - draggingItemIndex = null - draggingItemInitialOffset = 0 - movedFrom = null - movedTo = null + if (overscroll != 0f) { + scrollChannel.trySend(overscroll) + } } + } - internal fun onDragEnd() { - val from = movedFrom ?: error("Moved from was null") - val to = movedTo ?: from - if (from.key != to.key && from.index != to.index) onEnd(from.key, to.key) - onDragInterrupted() - } - - internal fun onDrag(offset: Offset) { - draggingItemDraggedDelta += offset.y - - val draggingItem = draggingItemLayoutInfo ?: return - val startOffset = draggingItem.offset + draggingItemOffset - val endOffset = startOffset + draggingItem.size - val middleOffset = startOffset + (endOffset - startOffset) / 2f - - val targetItem = - state.layoutInfo.visibleItemsInfo.find { item -> - middleOffset.toInt() in item.offset..item.offsetEnd && - draggingItem.index != item.index - } - if (targetItem != null) { - if ( - draggingItem.index == state.firstVisibleItemIndex || - targetItem.index == state.firstVisibleItemIndex - ) { - state.requestScrollToItem( - state.firstVisibleItemIndex, - state.firstVisibleItemScrollOffset, - ) - } - onMove(draggingItem.key, targetItem.key) - draggingItemIndex = targetItem.index - movedTo = targetItem - } else { - val overscroll = - when { - draggingItemDraggedDelta > 0 -> - (endOffset - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f) - draggingItemDraggedDelta < 0 -> - (startOffset - state.layoutInfo.viewportStartOffset).coerceAtMost(0f) - else -> 0f - } - if (overscroll != 0f) { - scrollChannel.trySend(overscroll) - } - } - } - - private val LazyListItemInfo.offsetEnd: Int - get() = this.offset + this.size + private val LazyListItemInfo.offsetEnd: Int + get() = this.offset + this.size } fun Modifier.dragContainer(dragDropState: DragDropState): Modifier { - return pointerInput(dragDropState) { - detectDragGesturesAfterLongPress( - onDrag = { change, offset -> - change.consume() - dragDropState.onDrag(offset = offset) - }, - onDragStart = { offset -> dragDropState.onDragStart(offset) }, - onDragEnd = { dragDropState.onDragEnd() }, - onDragCancel = { dragDropState.onDragInterrupted() }, - ) - } + return pointerInput(dragDropState) { + detectDragGesturesAfterLongPress( + onDrag = { change, offset -> + change.consume() + dragDropState.onDrag(offset = offset) + }, + onDragStart = { offset -> dragDropState.onDragStart(offset) }, + onDragEnd = { dragDropState.onDragEnd() }, + onDragCancel = { dragDropState.onDragInterrupted() }, + ) + } } @Composable fun LazyItemScope.DraggableItem( - dragDropState: DragDropState, - index: Int, - modifier: Modifier = Modifier, - content: @Composable ColumnScope.(isDragging: Boolean) -> Unit, + dragDropState: DragDropState, + index: Int, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.(isDragging: Boolean) -> Unit, ) { - val dragging = index == dragDropState.draggingItemIndex - val draggingModifier = - if (dragging) { - LocalHapticFeedback.current.performHapticFeedback(HapticFeedbackType.LongPress) - Modifier - .zIndex(1f) - .graphicsLayer { translationY = dragDropState.draggingItemOffset } - } else if (index == dragDropState.previousIndexOfDraggedItem) { - Modifier - .zIndex(1f) - .graphicsLayer { - translationY = dragDropState.previousItemOffset.value - } - } else { - Modifier // not animating here as this caused strange item jumps - } - Column(modifier = modifier.then(draggingModifier)) { content(dragging) } + val dragging = index == dragDropState.draggingItemIndex + val draggingModifier = + if (dragging) { + LocalHapticFeedback.current.performHapticFeedback(HapticFeedbackType.LongPress) + Modifier.zIndex(1f).graphicsLayer { translationY = dragDropState.draggingItemOffset } + } else if (index == dragDropState.previousIndexOfDraggedItem) { + Modifier.zIndex(1f).graphicsLayer { translationY = dragDropState.previousItemOffset.value } + } else { + Modifier // not animating here as this caused strange item jumps + } + Column(modifier = modifier.then(draggingModifier)) { content(dragging) } } diff --git a/app/src/main/kotlin/org/fdroid/ui/utils/ExpandIcon.kt b/app/src/main/kotlin/org/fdroid/ui/utils/ExpandIcon.kt index e4f77f372..e7d2d00b9 100644 --- a/app/src/main/kotlin/org/fdroid/ui/utils/ExpandIcon.kt +++ b/app/src/main/kotlin/org/fdroid/ui/utils/ExpandIcon.kt @@ -10,32 +10,36 @@ import org.fdroid.R @Composable fun ExpandIconChevron(isExpanded: Boolean) { - Icon( - imageVector = if (isExpanded) { - Icons.Default.ExpandLess - } else { - Icons.Default.ExpandMore - }, - contentDescription = if (isExpanded) { - stringResource(R.string.collapse) - } else { - stringResource(R.string.expand) - }, - ) + Icon( + imageVector = + if (isExpanded) { + Icons.Default.ExpandLess + } else { + Icons.Default.ExpandMore + }, + contentDescription = + if (isExpanded) { + stringResource(R.string.collapse) + } else { + stringResource(R.string.expand) + }, + ) } @Composable fun ExpandIconArrow(isExpanded: Boolean) { - Icon( - imageVector = if (isExpanded) { - Icons.Default.ExpandLess - } else { - Icons.Default.ExpandMore - }, - contentDescription = if (isExpanded) { - stringResource(R.string.collapse) - } else { - stringResource(R.string.expand) - }, - ) + Icon( + imageVector = + if (isExpanded) { + Icons.Default.ExpandLess + } else { + Icons.Default.ExpandMore + }, + contentDescription = + if (isExpanded) { + stringResource(R.string.collapse) + } else { + stringResource(R.string.expand) + }, + ) } diff --git a/app/src/main/kotlin/org/fdroid/ui/utils/ExpandableSection.kt b/app/src/main/kotlin/org/fdroid/ui/utils/ExpandableSection.kt index bb2576b29..9c5b7c083 100644 --- a/app/src/main/kotlin/org/fdroid/ui/utils/ExpandableSection.kt +++ b/app/src/main/kotlin/org/fdroid/ui/utils/ExpandableSection.kt @@ -28,40 +28,40 @@ import androidx.compose.ui.unit.dp @Composable fun ExpandableSection( - icon: Painter?, - title: String, - modifier: Modifier = Modifier, - initiallyExpanded: Boolean = LocalInspectionMode.current, - content: @Composable () -> Unit, + icon: Painter?, + title: String, + modifier: Modifier = Modifier, + initiallyExpanded: Boolean = LocalInspectionMode.current, + content: @Composable () -> Unit, ) { - var sectionExpanded by rememberSaveable { mutableStateOf(initiallyExpanded) } - Column(modifier = modifier.fillMaxWidth()) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = spacedBy(8.dp), - modifier = Modifier - .heightIn(min = 48.dp) - .clickable(onClick = { sectionExpanded = !sectionExpanded }) - ) { - if (icon != null) Icon( - painter = icon, - contentDescription = null, - modifier = Modifier.semantics { hideFromAccessibility() }, - ) - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.weight(1f), - ) - IconButton(onClick = { sectionExpanded = !sectionExpanded }) { - ExpandIconArrow(sectionExpanded) - } - } - AnimatedVisibility( - visible = sectionExpanded, - modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, - ) { - content() - } + var sectionExpanded by rememberSaveable { mutableStateOf(initiallyExpanded) } + Column(modifier = modifier.fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = spacedBy(8.dp), + modifier = + Modifier.heightIn(min = 48.dp).clickable(onClick = { sectionExpanded = !sectionExpanded }), + ) { + if (icon != null) + Icon( + painter = icon, + contentDescription = null, + modifier = Modifier.semantics { hideFromAccessibility() }, + ) + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = { sectionExpanded = !sectionExpanded }) { + ExpandIconArrow(sectionExpanded) + } } + AnimatedVisibility( + visible = sectionExpanded, + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, + ) { + content() + } + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/utils/FDroidButton.kt b/app/src/main/kotlin/org/fdroid/ui/utils/FDroidButton.kt index 66300a6ee..fd2e13e58 100644 --- a/app/src/main/kotlin/org/fdroid/ui/utils/FDroidButton.kt +++ b/app/src/main/kotlin/org/fdroid/ui/utils/FDroidButton.kt @@ -18,50 +18,50 @@ import androidx.compose.ui.unit.dp @Composable fun FDroidButton( - text: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, - imageVector: ImageVector? = null, + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + imageVector: ImageVector? = null, ) { - Button( - onClick = onClick, - shape = RoundedCornerShape(32.dp), - modifier = modifier.heightIn(min = ButtonDefaults.MinHeight) - ) { - if (imageVector != null) { - Icon( - imageVector = imageVector, - contentDescription = text, - modifier = Modifier.size(ButtonDefaults.IconSize), - ) - Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing)) - } - Text(text = text) + Button( + onClick = onClick, + shape = RoundedCornerShape(32.dp), + modifier = modifier.heightIn(min = ButtonDefaults.MinHeight), + ) { + if (imageVector != null) { + Icon( + imageVector = imageVector, + contentDescription = text, + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing)) } + Text(text = text) + } } @Composable fun FDroidOutlineButton( - text: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, - imageVector: ImageVector? = null, - color: Color = MaterialTheme.colorScheme.primary, + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + imageVector: ImageVector? = null, + color: Color = MaterialTheme.colorScheme.primary, ) { - OutlinedButton( - onClick = onClick, - shape = RoundedCornerShape(32.dp), - modifier = modifier.heightIn(min = ButtonDefaults.MinHeight), - colors = ButtonDefaults.outlinedButtonColors(contentColor = color), - ) { - if (imageVector != null) { - Icon( - imageVector = imageVector, - contentDescription = text, - modifier = Modifier.size(ButtonDefaults.IconSize), - ) - Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing)) - } - Text(text = text, maxLines = 1) + OutlinedButton( + onClick = onClick, + shape = RoundedCornerShape(32.dp), + modifier = modifier.heightIn(min = ButtonDefaults.MinHeight), + colors = ButtonDefaults.outlinedButtonColors(contentColor = color), + ) { + if (imageVector != null) { + Icon( + imageVector = imageVector, + contentDescription = text, + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing)) } + Text(text = text, maxLines = 1) + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/utils/FDroidSwitchRow.kt b/app/src/main/kotlin/org/fdroid/ui/utils/FDroidSwitchRow.kt index 4be3f521a..5b0ddbc1d 100644 --- a/app/src/main/kotlin/org/fdroid/ui/utils/FDroidSwitchRow.kt +++ b/app/src/main/kotlin/org/fdroid/ui/utils/FDroidSwitchRow.kt @@ -19,48 +19,35 @@ import org.fdroid.ui.FDroidContent @Composable fun FDroidSwitchRow( - text: String, - leadingContent: (@Composable () -> Unit)? = null, - checked: Boolean, - onCheckedChange: (Boolean) -> Unit = {}, - enabled: Boolean = true, - verticalPadding: Dp = 8.dp, + text: String, + leadingContent: (@Composable () -> Unit)? = null, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit = {}, + enabled: Boolean = true, + verticalPadding: Dp = 8.dp, ) { - Row( - horizontalArrangement = spacedBy(8.dp), - verticalAlignment = CenterVertically, - modifier = Modifier - .fillMaxWidth() - .toggleable( - value = checked, - enabled = enabled, - role = Role.Switch, - onValueChange = onCheckedChange, - ) - // add padding after toggleable to have a larger touch area - .padding(vertical = verticalPadding), - ) { - leadingContent?.invoke() - Text( - text = text, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1f), + Row( + horizontalArrangement = spacedBy(8.dp), + verticalAlignment = CenterVertically, + modifier = + Modifier.fillMaxWidth() + .toggleable( + value = checked, + enabled = enabled, + role = Role.Switch, + onValueChange = onCheckedChange, ) - Switch( - checked = checked, - onCheckedChange = null, - enabled = enabled, - ) - } + // add padding after toggleable to have a larger touch area + .padding(vertical = verticalPadding), + ) { + leadingContent?.invoke() + Text(text = text, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) + Switch(checked = checked, onCheckedChange = null, enabled = enabled) + } } @Composable @Preview fun FDroidSwitchRowPreview() { - FDroidContent { - FDroidSwitchRow( - text = "Important setting", - checked = true, - ) - } + FDroidContent { FDroidSwitchRow(text = "Important setting", checked = true) } } diff --git a/app/src/main/kotlin/org/fdroid/ui/utils/MeteredConnectionDialog.kt b/app/src/main/kotlin/org/fdroid/ui/utils/MeteredConnectionDialog.kt index 6aaccfca3..d32516784 100644 --- a/app/src/main/kotlin/org/fdroid/ui/utils/MeteredConnectionDialog.kt +++ b/app/src/main/kotlin/org/fdroid/ui/utils/MeteredConnectionDialog.kt @@ -13,41 +13,40 @@ import org.fdroid.R @Composable fun MeteredConnectionDialog(numBytes: Long?, onConfirm: () -> Unit, onDismiss: () -> Unit) { - AlertDialog( - title = { - Text(text = stringResource(R.string.dialog_metered_title)) - }, - text = { - val s = if (numBytes == null) { - stringResource(R.string.dialog_metered_text_no_size) - } else { - val sizeStr = Formatter.formatFileSize(LocalContext.current, numBytes) - stringResource(R.string.dialog_metered_text, sizeStr) - } - Text(text = s) - }, - onDismissRequest = onDismiss, - confirmButton = { - TextButton(onClick = { - onDismiss() - onConfirm() - }) { - Text( - text = stringResource(R.string.dialog_metered_button), - color = MaterialTheme.colorScheme.error, - ) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(android.R.string.cancel)) - } + AlertDialog( + title = { Text(text = stringResource(R.string.dialog_metered_title)) }, + text = { + val s = + if (numBytes == null) { + stringResource(R.string.dialog_metered_text_no_size) + } else { + val sizeStr = Formatter.formatFileSize(LocalContext.current, numBytes) + stringResource(R.string.dialog_metered_text, sizeStr) } - ) + Text(text = s) + }, + onDismissRequest = onDismiss, + confirmButton = { + TextButton( + onClick = { + onDismiss() + onConfirm() + } + ) { + Text( + text = stringResource(R.string.dialog_metered_button), + color = MaterialTheme.colorScheme.error, + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } + }, + ) } @Preview @Composable private fun Preview() { - MeteredConnectionDialog(9_999_999, {}, {}) + MeteredConnectionDialog(9_999_999, {}, {}) } diff --git a/app/src/main/kotlin/org/fdroid/ui/utils/OfflineBar.kt b/app/src/main/kotlin/org/fdroid/ui/utils/OfflineBar.kt index a416028fc..d509d8ec8 100644 --- a/app/src/main/kotlin/org/fdroid/ui/utils/OfflineBar.kt +++ b/app/src/main/kotlin/org/fdroid/ui/utils/OfflineBar.kt @@ -17,24 +17,20 @@ import org.fdroid.ui.FDroidContent @Composable fun OfflineBar(modifier: Modifier = Modifier) { - Box( - contentAlignment = Alignment.Center, - modifier = modifier - .background(MaterialTheme.colorScheme.errorContainer) - .fillMaxWidth() - ) { - Text( - text = stringResource(R.string.banner_no_internet), - style = MaterialTheme.typography.labelMedium, - modifier = Modifier.padding(4.dp) - ) - } + Box( + contentAlignment = Alignment.Center, + modifier = modifier.background(MaterialTheme.colorScheme.errorContainer).fillMaxWidth(), + ) { + Text( + text = stringResource(R.string.banner_no_internet), + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(4.dp), + ) + } } @Preview @Composable private fun Preview() { - FDroidContent { - OfflineBar() - } + FDroidContent { OfflineBar() } } diff --git a/app/src/main/kotlin/org/fdroid/ui/utils/OnboardingCard.kt b/app/src/main/kotlin/org/fdroid/ui/utils/OnboardingCard.kt index 08ec5dd59..df24a65fb 100644 --- a/app/src/main/kotlin/org/fdroid/ui/utils/OnboardingCard.kt +++ b/app/src/main/kotlin/org/fdroid/ui/utils/OnboardingCard.kt @@ -23,48 +23,45 @@ import org.fdroid.ui.FDroidContent @Composable @OptIn(ExperimentalMaterial3Api::class) fun OnboardingCard( - title: String, - message: String, - modifier: Modifier = Modifier, - onGotIt: () -> Unit = {}, + title: String, + message: String, + modifier: Modifier = Modifier, + onGotIt: () -> Unit = {}, ) { - val focusRequester = remember { FocusRequester() } - ElevatedCard( - modifier = modifier - .widthIn(max = TooltipDefaults.richTooltipMaxWidth) + val focusRequester = remember { FocusRequester() } + ElevatedCard(modifier = modifier.widthIn(max = TooltipDefaults.richTooltipMaxWidth)) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp, bottom = 8.dp), + ) + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 16.dp), + ) + TextButton( + onClick = onGotIt, + modifier = + Modifier.focusRequester(focusRequester).padding(vertical = 8.dp, horizontal = 16.dp), ) { - Text( - text = title, - style = MaterialTheme.typography.titleSmall, - modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp, bottom = 8.dp) - ) - Text( - text = message, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(horizontal = 16.dp) - ) - TextButton( - onClick = onGotIt, - modifier = Modifier - .focusRequester(focusRequester) - .padding(vertical = 8.dp, horizontal = 16.dp) - ) { - Text(text = stringResource(R.string.got_it)) - } + Text(text = stringResource(R.string.got_it)) } - LaunchedEffect(Unit) { focusRequester.requestFocus() } + } + LaunchedEffect(Unit) { focusRequester.requestFocus() } } @Preview @Composable private fun Preview() { - FDroidContent { - OnboardingCard( - title = "Filter", - message = "Here you can apply filters to the list of apps," + - " e.g. showing only apps within a certain category or repository. " + - "Changing the sort order is also possible.", - modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), - ) { } - } + FDroidContent { + OnboardingCard( + title = "Filter", + message = + "Here you can apply filters to the list of apps," + + " e.g. showing only apps within a certain category or repository. " + + "Changing the sort order is also possible.", + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) {} + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/utils/PreviewUtils.kt b/app/src/main/kotlin/org/fdroid/ui/utils/PreviewUtils.kt index f75bcc870..45a9ea42d 100644 --- a/app/src/main/kotlin/org/fdroid/ui/utils/PreviewUtils.kt +++ b/app/src/main/kotlin/org/fdroid/ui/utils/PreviewUtils.kt @@ -3,6 +3,7 @@ package org.fdroid.ui.utils import android.content.Intent import androidx.annotation.RestrictTo import androidx.compose.ui.tooling.preview.datasource.LoremIpsum +import java.util.concurrent.TimeUnit.DAYS import org.fdroid.database.AppListSortOrder import org.fdroid.database.AppMetadata import org.fdroid.database.AppPrefs @@ -43,75 +44,81 @@ import org.fdroid.ui.repositories.details.RepoDetailsActions import org.fdroid.ui.repositories.details.RepoDetailsInfo import org.fdroid.ui.repositories.details.RepoDetailsModel import org.fdroid.ui.repositories.details.UserMirrorItem -import java.util.concurrent.TimeUnit.DAYS object Names { - val randomName: String get() = names.random() - val names = listOf( - "Anstop", - "PipePipe", - "A2DP Volume", - "Com-Phone Story Maker", - "Lightning", - "BitAC - Bitcoin Address Checker", - "Text Launcher", - "Polaris", - "Chubby Click - Metronome", - "SUSI.AI", - "Moon Phase", - "Export Contacts", - "Import Contacts", - "DNG Processor", - "Rootless Pixel Launcher", - "AndroDNS", - "androidVNC", - "PrBoom For Android", - "FakeStandby", - "eBooks", - "ANONguard", - "Acrylic Paint", - "Immich", + val randomName: String + get() = names.random() + + val names = + listOf( + "Anstop", + "PipePipe", + "A2DP Volume", + "Com-Phone Story Maker", + "Lightning", + "BitAC - Bitcoin Address Checker", + "Text Launcher", + "Polaris", + "Chubby Click - Metronome", + "SUSI.AI", + "Moon Phase", + "Export Contacts", + "Import Contacts", + "DNG Processor", + "Rootless Pixel Launcher", + "AndroDNS", + "androidVNC", + "PrBoom For Android", + "FakeStandby", + "eBooks", + "ANONguard", + "Acrylic Paint", + "Immich", ) } -val testVersion1 = object : PackageVersion { +val testVersion1 = + object : PackageVersion { override val versionCode: Long = 42 override val versionName: String = "42.23.0-alpha1337-33d2252b90" override val added: Long = System.currentTimeMillis() - DAYS.toMillis(4) override val size: Long = 1024 * 1024 * 42 - override val signer: SignerV2 = SignerV2( - listOf("271721a9cddc96660336c19a39ae3cca4375072c80d3c8170860c333d2252b90") - ) + override val signer: SignerV2 = + SignerV2(listOf("271721a9cddc96660336c19a39ae3cca4375072c80d3c8170860c333d2252b90")) override val releaseChannels: List? = null - override val packageManifest: PackageManifest = object : PackageManifest { + override val packageManifest: PackageManifest = + object : PackageManifest { override val minSdkVersion: Int = 2 override val targetSdkVersion: Int = 13 override val maxSdkVersion: Int? = null override val featureNames: List? = null override val nativecode: List = listOf("amd64", "x86") - } + } override val hasKnownVulnerability: Boolean = false -} -val testVersion2 = object : PackageVersion { + } +val testVersion2 = + object : PackageVersion { override val versionCode: Long = 23 override val versionName: String = "23.42.0" override val added: Long = System.currentTimeMillis() - DAYS.toMillis(4) override val size: Long = 1024 * 1024 * 23 - override val signer: SignerV2 = SignerV2( - listOf("271721a9cddc96660336c19a39ae3cca4375072c80d3c8170860c333d2252b90") - ) + override val signer: SignerV2 = + SignerV2(listOf("271721a9cddc96660336c19a39ae3cca4375072c80d3c8170860c333d2252b90")) override val releaseChannels: List? = null - override val packageManifest: PackageManifest = object : PackageManifest { + override val packageManifest: PackageManifest = + object : PackageManifest { override val minSdkVersion: Int? = null override val targetSdkVersion: Int = 13 override val maxSdkVersion: Int = 99 override val featureNames: List? = null override val nativecode: List? = null - } + } override val hasKnownVulnerability: Boolean = false -} -val testApp = AppDetailsItem( - app = AppMetadata( + } +val testApp = + AppDetailsItem( + app = + AppMetadata( repoId = 1, packageName = "org.schabi.newpipe", added = 1441756800000, @@ -137,8 +144,9 @@ val testApp = AppDetailsItem( flattrID = null, categories = listOf("Internet", "Multimedia"), isCompatible = true, - ), - actions = AppDetailsActions( + ), + actions = + AppDetailsActions( installAction = { _, _, _ -> }, requestUserConfirmation = { _ -> }, checkUserConfirmation = { _ -> }, @@ -153,310 +161,361 @@ val testApp = AppDetailsItem( uninstallIntent = Intent(), launchIntent = Intent(), shareIntent = Intent(), - ), + ), installState = InstallState.Unknown, networkState = NetworkState(isOnline = false, isMetered = false), appPrefs = AppPrefs("org.schabi.newpipe"), name = "New Pipe", summary = "Lightweight YouTube frontend", - description = "NewPipe does not use any Google framework libraries, or the YouTube API. " + + description = + "NewPipe does not use any Google framework libraries, or the YouTube API. " + "It only parses the website in order to gain the information it needs. " + "Therefore this app can be used on devices without Google Services installed. " + "Also, you don't need a YouTube account to use NewPipe, and it's FLOSS.\n\n" + LoremIpsum(128).values.joinToString(" "), - categories = listOf( + categories = + listOf( CategoryItem("Multimedia", "Multimedia"), CategoryItem("Internet", "Internet"), CategoryItem("Cloud Storage & File Sync", "Cloud Storage & File Sync"), CategoryItem("Connectivity", "Connectivity"), CategoryItem("Development", "Development"), - ), - antiFeatures = listOf( + ), + antiFeatures = + listOf( AntiFeature( - id = "NonFreeNet", - icon = null, - name = "This app promotes or depends entirely on a non-free network service", - reason = "Depends on Youtube for videos.", + id = "NonFreeNet", + icon = null, + name = "This app promotes or depends entirely on a non-free network service", + reason = "Depends on Youtube for videos.", ), AntiFeature( - id = "FooBar", - icon = null, - name = "This app promotes or depends entirely on a non-free network service", - reason = "Depends on Youtube for videos.", + id = "FooBar", + icon = null, + name = "This app promotes or depends entirely on a non-free network service", + reason = "Depends on Youtube for videos.", ), - ), - whatsNew = "This release fixes YouTube only providing a 360p stream.\n\n" + + ), + whatsNew = + "This release fixes YouTube only providing a 360p stream.\n\n" + "Note that the solution employed in this version is likely temporary, " + "and in the long run the SABR video protocol needs to be implemented, " + "but TeamNewPipe members are currently busy so any help would be greatly appreciated! " + "https://github.com/TeamNewPipe/NewPipe/issues/12248", authorHasMoreThanOneApp = true, - versions = listOf( + versions = + listOf( VersionItem( - testVersion1, - isInstalled = false, - isSuggested = true, - isCompatible = true, - isSignerCompatible = true, - showInstallButton = true, + testVersion1, + isInstalled = false, + isSuggested = true, + isCompatible = true, + isSignerCompatible = true, + showInstallButton = true, ), VersionItem( - testVersion1, - isInstalled = false, - isSuggested = false, - isCompatible = true, - isSignerCompatible = false, - showInstallButton = false, + testVersion1, + isInstalled = false, + isSuggested = false, + isCompatible = true, + isSignerCompatible = false, + showInstallButton = false, ), VersionItem( - testVersion2, - isInstalled = false, - isSuggested = false, - isCompatible = false, - isSignerCompatible = true, - showInstallButton = true, + testVersion2, + isInstalled = false, + isSuggested = false, + isCompatible = false, + isSignerCompatible = true, + showInstallButton = true, ), VersionItem( - testVersion2, - isInstalled = true, - isSuggested = false, - isCompatible = true, - isSignerCompatible = true, - showInstallButton = false, + testVersion2, + isInstalled = true, + isSuggested = false, + isCompatible = true, + isSignerCompatible = true, + showInstallButton = false, ), - ), + ), installedVersion = testVersion2, installedVersionCode = testVersion2.versionCode, installedVersionName = testVersion2.versionName, suggestedVersion = null, possibleUpdate = testVersion1, proxy = null, -) + ) -fun getPreviewVersion(versionName: String, size: Long? = null) = object : PackageVersion { +fun getPreviewVersion(versionName: String, size: Long? = null) = + object : PackageVersion { override val versionCode: Long = 23 override val versionName: String = versionName override val added: Long = System.currentTimeMillis() - DAYS.toMillis(3) override val size: Long? = size override val signer: SignerV2? = null override val releaseChannels: List? = null - override val packageManifest: PackageManifest = object : PackageManifest { + override val packageManifest: PackageManifest = + object : PackageManifest { override val minSdkVersion: Int? = null override val maxSdkVersion: Int? = null override val featureNames: List? = null override val nativecode: List? = null override val targetSdkVersion: Int? = null - } + } override val hasKnownVulnerability: Boolean = false -} + } -fun getAppListInfo(model: AppListModel) = object : AppListInfo { +fun getAppListInfo(model: AppListModel) = + object : AppListInfo { override val model: AppListModel = model - override val actions: AppListActions = object : AppListActions { + override val actions: AppListActions = + object : AppListActions { override fun toggleFilterVisibility() {} + override fun sortBy(sort: AppListSortOrder) {} + override fun toggleFilterIncompatible() {} + override fun addCategory(categoryId: String) {} + override fun removeCategory(categoryId: String) {} + override fun addAntiFeature(antiFeatureId: String) {} + override fun removeAntiFeature(antiFeatureId: String) {} + override fun addRepository(repoId: Long) {} + override fun removeRepository(repoId: Long) {} + override fun saveFilters() {} + override fun clearFilters() {} + override fun onSearch(query: String) {} + override fun onOnboardingSeen() {} - } + } override val list: AppListType = AppListType.New("New") override val showFilters: Boolean = false override val showOnboarding: Boolean = false -} + } -fun getMyAppsInfo(model: MyAppsModel): MyAppsInfo = object : MyAppsInfo { +fun getMyAppsInfo(model: MyAppsModel): MyAppsInfo = + object : MyAppsInfo { override val model = model override val actions: MyAppsActions - get() = object : MyAppsActions { - override fun updateAll() {} - override fun changeSortOrder(sort: AppListSortOrder) {} - override fun search(query: String) {} - override fun confirmAppInstall(packageName: String, state: InstallConfirmationState) {} - override fun ignoreAppIssue(item: AppWithIssueItem) {} - override fun onAppIssueHintSeen() {} - override fun exportInstalledApps() {} + get() = + object : MyAppsActions { + override fun updateAll() {} + + override fun changeSortOrder(sort: AppListSortOrder) {} + + override fun search(query: String) {} + + override fun confirmAppInstall(packageName: String, state: InstallConfirmationState) {} + + override fun ignoreAppIssue(item: AppWithIssueItem) {} + + override fun onAppIssueHintSeen() {} + + override fun exportInstalledApps() {} } -} + } @RestrictTo(RestrictTo.Scope.TESTS) -internal val myAppsModel = MyAppsModel( - appUpdates = listOf( +internal val myAppsModel = + MyAppsModel( + appUpdates = + listOf( AppUpdateItem( - repoId = 1, - packageName = "B1", - name = "App Update 123", - installedVersionName = "1.0.1", - update = getPreviewVersion("1.1.0", 123456789), - whatsNew = "This is new, all is new, nothing old.", + repoId = 1, + packageName = "B1", + name = "App Update 123", + installedVersionName = "1.0.1", + update = getPreviewVersion("1.1.0", 123456789), + whatsNew = "This is new, all is new, nothing old.", ), AppUpdateItem( - repoId = 2, - packageName = "B2", - name = Names.randomName, - installedVersionName = "3.0.1", - update = getPreviewVersion("3.1.0", 9876543), - whatsNew = null, - ) - ), - installingApps = listOf( + repoId = 2, + packageName = "B2", + name = Names.randomName, + installedVersionName = "3.0.1", + update = getPreviewVersion("3.1.0", 9876543), + whatsNew = null, + ), + ), + installingApps = + listOf( InstallingAppItem( - packageName = "A1", - installState = InstallState.Downloading( - name = "Installing App 1", - versionName = "1.0.4", - currentVersionName = null, - lastUpdated = 23, - iconModel = null, - downloadedBytes = 25, - totalBytes = 100, - startMillis = System.currentTimeMillis(), - ) + packageName = "A1", + installState = + InstallState.Downloading( + name = "Installing App 1", + versionName = "1.0.4", + currentVersionName = null, + lastUpdated = 23, + iconModel = null, + downloadedBytes = 25, + totalBytes = 100, + startMillis = System.currentTimeMillis(), + ), ) - ), - appsWithIssue = listOf( + ), + appsWithIssue = + listOf( AppWithIssueItem( - packageName = "C1", - name = Names.randomName, - installedVersionName = "1", - installedVersionCode = 1, - issue = KnownVulnerability(true), - lastUpdated = System.currentTimeMillis() - DAYS.toMillis(5) + packageName = "C1", + name = Names.randomName, + installedVersionName = "1", + installedVersionCode = 1, + issue = KnownVulnerability(true), + lastUpdated = System.currentTimeMillis() - DAYS.toMillis(5), ), AppWithIssueItem( - packageName = "C2", - name = Names.randomName, - installedVersionName = "2", - installedVersionCode = 2, - issue = NotAvailable, - lastUpdated = System.currentTimeMillis() - DAYS.toMillis(7) + packageName = "C2", + name = Names.randomName, + installedVersionName = "2", + installedVersionCode = 2, + issue = NotAvailable, + lastUpdated = System.currentTimeMillis() - DAYS.toMillis(7), ), - ), - installedApps = listOf( + ), + installedApps = + listOf( InstalledAppItem( - packageName = "D1", - name = Names.randomName, - installedVersionName = "1", - installedVersionCode = 1, - lastUpdated = System.currentTimeMillis() - DAYS.toMillis(1) + packageName = "D1", + name = Names.randomName, + installedVersionName = "1", + installedVersionCode = 1, + lastUpdated = System.currentTimeMillis() - DAYS.toMillis(1), ), InstalledAppItem( - packageName = "D2", - name = Names.randomName, - installedVersionName = "2", - installedVersionCode = 2, - lastUpdated = System.currentTimeMillis() - DAYS.toMillis(2) + packageName = "D2", + name = Names.randomName, + installedVersionName = "2", + installedVersionCode = 2, + lastUpdated = System.currentTimeMillis() - DAYS.toMillis(2), ), InstalledAppItem( - packageName = "D3", - name = Names.randomName, - installedVersionName = "3", - installedVersionCode = 3, - lastUpdated = System.currentTimeMillis() - DAYS.toMillis(3) - ) - ), + packageName = "D3", + name = Names.randomName, + installedVersionName = "3", + installedVersionCode = 3, + lastUpdated = System.currentTimeMillis() - DAYS.toMillis(3), + ), + ), showAppIssueHint = true, sortOrder = AppListSortOrder.NAME, networkState = NetworkState(isOnline = false, isMetered = false), appUpdatesBytes = null, -) + ) -val repoItems = listOf( +val repoItems = + listOf( RepositoryItem( - repoId = 1, - address = "http://example.org", - name = "F-Droid", - icon = null, - timestamp = System.currentTimeMillis() - 1_111_111, - lastUpdated = System.currentTimeMillis() - 9_999_999, - weight = 1, - enabled = true, - errorCount = 0, + repoId = 1, + address = "http://example.org", + name = "F-Droid", + icon = null, + timestamp = System.currentTimeMillis() - 1_111_111, + lastUpdated = System.currentTimeMillis() - 9_999_999, + weight = 1, + enabled = true, + errorCount = 0, ), RepositoryItem( - repoId = 2, - address = "http://example.org", - name = "Guardian Project Repository", - icon = null, - timestamp = System.currentTimeMillis() - 9_999_999, - lastUpdated = System.currentTimeMillis() - 99_999_999, - weight = 2, - enabled = true, - errorCount = 3, + repoId = 2, + address = "http://example.org", + name = "Guardian Project Repository", + icon = null, + timestamp = System.currentTimeMillis() - 9_999_999, + lastUpdated = System.currentTimeMillis() - 99_999_999, + weight = 2, + enabled = true, + errorCount = 3, ), RepositoryItem( - repoId = 3, - address = "http://example.net", - name = "My first Repo", - icon = null, - timestamp = System.currentTimeMillis() - 888_888, - lastUpdated = System.currentTimeMillis(), - weight = 3, - enabled = true, - errorCount = 1, + repoId = 3, + address = "http://example.net", + name = "My first Repo", + icon = null, + timestamp = System.currentTimeMillis() - 888_888, + lastUpdated = System.currentTimeMillis(), + weight = 3, + enabled = true, + errorCount = 1, ), -) + ) -fun getRepositoriesInfo( - model: RepositoryModel, - currentRepositoryId: Long? = null, -): RepositoryInfo = object : RepositoryInfo { +fun getRepositoriesInfo(model: RepositoryModel, currentRepositoryId: Long? = null): RepositoryInfo = + object : RepositoryInfo { override val model: RepositoryModel = model override val currentRepositoryId: Long? = currentRepositoryId + override fun onOnboardingSeen() {} + override fun onRepositorySelected(repositoryItem: RepositoryItem) {} + override fun onRepositoryEnabled(repoId: Long, enabled: Boolean) {} + override fun onAddRepo() {} + override fun onRepositoryMoved(fromRepoId: Long, toRepoId: Long) {} + override fun onRepositoriesFinishedMoving(fromRepoId: Long, toRepoId: Long) {} -} + } fun getRepoDetailsInfo( - model: RepoDetailsModel = RepoDetailsModel( - repo = getRepository(), - numberApps = 42, - officialMirrors = listOf( - OfficialMirrorItem( - mirror = Mirror(baseUrl = "https://mirror.example.com/fdroid/repo"), - isEnabled = true, - isRepoAddress = true, - ), - OfficialMirrorItem( - mirror = Mirror("https://mirror.example.com/foo/bar/fdroid/repo", "de"), - isEnabled = false, - isRepoAddress = false, - ), + model: RepoDetailsModel = + RepoDetailsModel( + repo = getRepository(), + numberApps = 42, + officialMirrors = + listOf( + OfficialMirrorItem( + mirror = Mirror(baseUrl = "https://mirror.example.com/fdroid/repo"), + isEnabled = true, + isRepoAddress = true, + ), + OfficialMirrorItem( + mirror = Mirror("https://mirror.example.com/foo/bar/fdroid/repo", "de"), + isEnabled = false, + isRepoAddress = false, + ), ), - userMirrors = listOf( - UserMirrorItem(Mirror("https://mirror.example.com/fdroid/repo"), true), - UserMirrorItem(Mirror("https://mirror.example.com/foo/bar/fdroid/repo"), false), + userMirrors = + listOf( + UserMirrorItem(Mirror("https://mirror.example.com/fdroid/repo"), true), + UserMirrorItem(Mirror("https://mirror.example.com/foo/bar/fdroid/repo"), false), ), - archiveState = ArchiveState.LOADING, - showOnboarding = false, - updateState = RepoUpdateProgress(42L, true, 0.75f), - networkState = NetworkState(isOnline = false, isMetered = false), - proxy = null, - ), -) = object : RepoDetailsInfo { + archiveState = ArchiveState.LOADING, + showOnboarding = false, + updateState = RepoUpdateProgress(42L, true, 0.75f), + networkState = NetworkState(isOnline = false, isMetered = false), + proxy = null, + ) +) = + object : RepoDetailsInfo { override val model = model - override val actions: RepoDetailsActions = object : RepoDetailsActions { + override val actions: RepoDetailsActions = + object : RepoDetailsActions { override fun deleteRepository() {} - override fun updateUsernameAndPassword(username: String, password: String) {} - override fun setMirrorEnabled(mirror: Mirror, enabled: Boolean) {} - override fun deleteUserMirror(mirror: Mirror) {} - override fun setArchiveRepoEnabled(enabled: Boolean) {} - override fun onOnboardingSeen() {} - } -} -fun getRepository(address: String = "https://example.org/repo") = Repository( + override fun updateUsernameAndPassword(username: String, password: String) {} + + override fun setMirrorEnabled(mirror: Mirror, enabled: Boolean) {} + + override fun deleteUserMirror(mirror: Mirror) {} + + override fun setArchiveRepoEnabled(enabled: Boolean) {} + + override fun onOnboardingSeen() {} + } + } + +fun getRepository(address: String = "https://example.org/repo") = + Repository( repoId = 42L, address = address, timestamp = 42L, @@ -467,5 +526,5 @@ fun getRepository(address: String = "https://example.org/repo") = Repository( lastUpdated = 1337, username = "foo", password = "bar", - lastError = "NotFoundException FooBar technical blabla" -) + lastError = "NotFoundException FooBar technical blabla", + ) diff --git a/app/src/main/kotlin/org/fdroid/ui/utils/TopAppBarButton.kt b/app/src/main/kotlin/org/fdroid/ui/utils/TopAppBarButton.kt index 69cfe6bab..e90cad6ee 100644 --- a/app/src/main/kotlin/org/fdroid/ui/utils/TopAppBarButton.kt +++ b/app/src/main/kotlin/org/fdroid/ui/utils/TopAppBarButton.kt @@ -17,23 +17,21 @@ import org.fdroid.R @Composable fun TopAppBarButton(imageVector: ImageVector, contentDescription: String, onClick: () -> Unit) { - TooltipBox( - positionProvider = TooltipDefaults.rememberTooltipPositionProvider(Below), - tooltip = { PlainTooltip { Text(contentDescription) } }, - state = rememberTooltipState(), - ) { - IconButton(onClick = onClick) { - Icon( - imageVector = imageVector, - contentDescription = contentDescription, - ) - } + TooltipBox( + positionProvider = TooltipDefaults.rememberTooltipPositionProvider(Below), + tooltip = { PlainTooltip { Text(contentDescription) } }, + state = rememberTooltipState(), + ) { + IconButton(onClick = onClick) { + Icon(imageVector = imageVector, contentDescription = contentDescription) } + } } @Composable -fun BackButton(onClick: () -> Unit) = TopAppBarButton( +fun BackButton(onClick: () -> Unit) = + TopAppBarButton( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back), onClick = onClick, -) + ) diff --git a/app/src/main/kotlin/org/fdroid/ui/utils/TopAppBarOverflowButton.kt b/app/src/main/kotlin/org/fdroid/ui/utils/TopAppBarOverflowButton.kt index 0753a1ef6..4e5156698 100644 --- a/app/src/main/kotlin/org/fdroid/ui/utils/TopAppBarOverflowButton.kt +++ b/app/src/main/kotlin/org/fdroid/ui/utils/TopAppBarOverflowButton.kt @@ -15,20 +15,17 @@ import org.fdroid.R @Composable fun TopAppBarOverflowButton( - menuItems: @Composable (ColumnScope.(onDismissRequest: () -> Unit) -> Unit), + menuItems: @Composable (ColumnScope.(onDismissRequest: () -> Unit) -> Unit) ) { - Box { - var menuExpanded by remember { mutableStateOf(false) } - TopAppBarButton( - Icons.Default.MoreVert, - contentDescription = stringResource(R.string.more), - onClick = { menuExpanded = !menuExpanded }, - ) - DropdownMenu( - expanded = menuExpanded, - onDismissRequest = { menuExpanded = false }, - ) { - menuItems { menuExpanded = false } - } + Box { + var menuExpanded by remember { mutableStateOf(false) } + TopAppBarButton( + Icons.Default.MoreVert, + contentDescription = stringResource(R.string.more), + onClick = { menuExpanded = !menuExpanded }, + ) + DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) { + menuItems { menuExpanded = false } } + } } diff --git a/app/src/main/kotlin/org/fdroid/ui/utils/UiUtils.kt b/app/src/main/kotlin/org/fdroid/ui/utils/UiUtils.kt index 32e1b1d6e..c2b731f39 100644 --- a/app/src/main/kotlin/org/fdroid/ui/utils/UiUtils.kt +++ b/app/src/main/kotlin/org/fdroid/ui/utils/UiUtils.kt @@ -17,114 +17,114 @@ import androidx.core.content.ContextCompat import androidx.core.graphics.createBitmap import com.google.zxing.BarcodeFormat import com.google.zxing.qrcode.QRCodeWriter +import java.text.Normalizer +import java.text.Normalizer.Form.NFKD import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.fdroid.database.Repository -import java.text.Normalizer -import java.text.Normalizer.Form.NFKD @Composable fun getHintOverlayColor() = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f) fun Context.startActivitySafe(i: Intent?) { - if (i == null) return - try { - startActivity(i) - } catch (e: Exception) { - Log.e("Context", "Error opening $i ", e) - } + if (i == null) return + try { + startActivity(i) + } catch (e: Exception) { + Log.e("Context", "Error opening $i ", e) + } } fun applyNewTheme(theme: String) { - val mode = when (theme) { - "light" -> AppCompatDelegate.MODE_NIGHT_NO - "dark" -> AppCompatDelegate.MODE_NIGHT_YES - else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + val mode = + when (theme) { + "light" -> AppCompatDelegate.MODE_NIGHT_NO + "dark" -> AppCompatDelegate.MODE_NIGHT_YES + else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM } - AppCompatDelegate.setDefaultNightMode(mode) + AppCompatDelegate.setDefaultNightMode(mode) } fun UriHandler.openUriSafe(uri: String) { - try { - openUri(uri) - } catch (e: Exception) { - Log.e("UriHandler", "Error opening $uri ", e) - } + try { + openUri(uri) + } catch (e: Exception) { + Log.e("UriHandler", "Error opening $uri ", e) + } } private val normalizerRegex = "\\p{M}".toRegex() -/** - * Normalizes the string by removing any diacritics that may appear. - */ +/** Normalizes the string by removing any diacritics that may appear. */ fun String.normalize(): String { - if (Normalizer.isNormalized(this, NFKD)) return this - return Normalizer.normalize(this, NFKD).replace(normalizerRegex, "") + if (Normalizer.isNormalized(this, NFKD)) return this + return Normalizer.normalize(this, NFKD).replace(normalizerRegex, "") } /** - * Same as the Java function Utils.generateQrBitmap, but using coroutines instead of Single and Disposable. + * Same as the Java function Utils.generateQrBitmap, but using coroutines instead of Single and + * Disposable. */ -suspend fun generateQrBitmap(qrData: String): Bitmap? = withContext(Dispatchers.Default) { +suspend fun generateQrBitmap(qrData: String): Bitmap? = + withContext(Dispatchers.Default) { return@withContext try { - val bitMatrix = QRCodeWriter().encode(qrData, BarcodeFormat.QR_CODE, 800, 800) - val qrCodeWidth = bitMatrix.width - val qrCodeHeight = bitMatrix.height - val pixels = IntArray(qrCodeWidth * qrCodeHeight) - for (y in 0 until qrCodeHeight) { - val offset = y * qrCodeWidth - for (x in 0 until qrCodeWidth) { - pixels[offset + x] = if (bitMatrix.get(x, y)) Color.BLACK else Color.WHITE - } - } - createBitmap(qrCodeWidth, qrCodeHeight).apply { - setPixels(pixels, 0, qrCodeWidth, 0, 0, qrCodeWidth, qrCodeHeight) + val bitMatrix = QRCodeWriter().encode(qrData, BarcodeFormat.QR_CODE, 800, 800) + val qrCodeWidth = bitMatrix.width + val qrCodeHeight = bitMatrix.height + val pixels = IntArray(qrCodeWidth * qrCodeHeight) + for (y in 0 until qrCodeHeight) { + val offset = y * qrCodeWidth + for (x in 0 until qrCodeWidth) { + pixels[offset + x] = if (bitMatrix.get(x, y)) Color.BLACK else Color.WHITE } + } + createBitmap(qrCodeWidth, qrCodeHeight).apply { + setPixels(pixels, 0, qrCodeWidth, 0, 0, qrCodeWidth, qrCodeHeight) + } } catch (e: Exception) { - Log.e("generateQrBitmap", "Could not encode QR as bitmap", e) - null + Log.e("generateQrBitmap", "Could not encode QR as bitmap", e) + null } -} + } fun Long.asRelativeTimeString(): String { - return DateUtils.getRelativeTimeSpanString( - this, - System.currentTimeMillis(), - DateUtils.MINUTE_IN_MILLIS, - DateUtils.FORMAT_ABBREV_ALL - ).toString() + return DateUtils.getRelativeTimeSpanString( + this, + System.currentTimeMillis(), + DateUtils.MINUTE_IN_MILLIS, + DateUtils.FORMAT_ABBREV_ALL, + ) + .toString() } val String.flagEmoji: String? - get() { - if (this.length != 2) { - return null - } - val chars = this.uppercase().toCharArray() - val first = chars[0].code - 0x41 + 0x1F1E6 - val second = chars[1].code - 0x41 + 0x1F1E6 - val flagEmoji = String(Character.toChars(first) + Character.toChars(second)) - return flagEmoji + get() { + if (this.length != 2) { + return null } + val chars = this.uppercase().toCharArray() + val first = chars[0].code - 0x41 + 0x1F1E6 + val second = chars[1].code - 0x41 + 0x1F1E6 + val flagEmoji = String(Character.toChars(first) + Character.toChars(second)) + return flagEmoji + } val Repository.addressForUi: String - get() = address.replaceFirst("https://", "") - .replaceFirst("/repo", "") + get() = address.replaceFirst("https://", "").replaceFirst("/repo", "") fun canStartForegroundService(context: Context): Boolean { - val powerManager = ContextCompat.getSystemService(context, PowerManager::class.java) - ?: return false - return powerManager.isIgnoringBatteryOptimizations(context.packageName) || - context.isAppInForeground() + val powerManager = + ContextCompat.getSystemService(context, PowerManager::class.java) ?: return false + return powerManager.isIgnoringBatteryOptimizations(context.packageName) || + context.isAppInForeground() } fun Context.isAppInForeground(): Boolean { - val activityManager = ContextCompat.getSystemService(this, ActivityManager::class.java) - val runningAppProcesses = activityManager?.runningAppProcesses ?: return false - for (appProcess in runningAppProcesses) { - if (appProcess.importance == IMPORTANCE_FOREGROUND && - appProcess.processName == packageName - ) return true - } - return false + val activityManager = ContextCompat.getSystemService(this, ActivityManager::class.java) + val runningAppProcesses = activityManager?.runningAppProcesses ?: return false + for (appProcess in runningAppProcesses) { + if (appProcess.importance == IMPORTANCE_FOREGROUND && appProcess.processName == packageName) + return true + } + return false } diff --git a/app/src/main/kotlin/org/fdroid/updates/AppUpdateWorker.kt b/app/src/main/kotlin/org/fdroid/updates/AppUpdateWorker.kt index 2e0a4bc93..30ce157e8 100644 --- a/app/src/main/kotlin/org/fdroid/updates/AppUpdateWorker.kt +++ b/app/src/main/kotlin/org/fdroid/updates/AppUpdateWorker.kt @@ -17,6 +17,7 @@ import androidx.work.WorkManager import androidx.work.WorkerParameters import dagger.assisted.Assisted import dagger.assisted.AssistedInject +import java.util.concurrent.TimeUnit import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.Flow @@ -28,115 +29,118 @@ import org.fdroid.install.AppInstallManager import org.fdroid.install.InstallNotificationState import org.fdroid.settings.SettingsConstants.AutoUpdateValues import org.fdroid.ui.utils.canStartForegroundService -import java.util.concurrent.TimeUnit @HiltWorker -class AppUpdateWorker @AssistedInject constructor( - @Assisted appContext: Context, - @Assisted workerParams: WorkerParameters, - private val nm: NotificationManager, - private val updatesManager: UpdatesManager, - private val appInstallManager: AppInstallManager, +class AppUpdateWorker +@AssistedInject +constructor( + @Assisted appContext: Context, + @Assisted workerParams: WorkerParameters, + private val nm: NotificationManager, + private val updatesManager: UpdatesManager, + private val appInstallManager: AppInstallManager, ) : CoroutineWorker(appContext, workerParams) { - companion object { - private val TAG = AppUpdateWorker::class.simpleName + companion object { + private val TAG = AppUpdateWorker::class.simpleName - @VisibleForTesting - internal const val UNIQUE_WORK_NAME_APP_UPDATE = "autoAppUpdate" + @VisibleForTesting internal const val UNIQUE_WORK_NAME_APP_UPDATE = "autoAppUpdate" - @JvmStatic - fun scheduleOrCancel(context: Context, autoUpdate: AutoUpdateValues) { - val workManager = WorkManager.getInstance(context) - if (autoUpdate != AutoUpdateValues.Never) { - Log.i(TAG, "scheduleOrCancel: enqueueUniquePeriodicWork") - val networkType = if (autoUpdate == AutoUpdateValues.Always) { - NetworkType.CONNECTED - } else { - NetworkType.UNMETERED - } - val constraints = Constraints.Builder() - .setRequiresBatteryNotLow(true) - .setRequiresStorageNotLow(true) - .setRequiresDeviceIdle(true) - .setRequiredNetworkType(networkType) - .build() - val workRequest = PeriodicWorkRequestBuilder( - repeatInterval = TimeUnit.HOURS.toMillis(24), - repeatIntervalTimeUnit = TimeUnit.MILLISECONDS, - flexTimeInterval = 60, - flexTimeIntervalUnit = TimeUnit.MINUTES, - ) - .setConstraints(constraints) - .build() - workManager.enqueueUniquePeriodicWork( - uniqueWorkName = UNIQUE_WORK_NAME_APP_UPDATE, - existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.UPDATE, - request = workRequest, - ) - } else { - Log.w(TAG, "Cancelling job due to settings!") - workManager.cancelUniqueWork(UNIQUE_WORK_NAME_APP_UPDATE) - } - } - - fun getAutoUpdateWorkInfo(context: Context): Flow { - return WorkManager.getInstance(context).getWorkInfosForUniqueWorkFlow( - UNIQUE_WORK_NAME_APP_UPDATE - ).map { it.getOrNull(0) } - } - } - - private val log = KotlinLogging.logger { } - - override suspend fun doWork(): Result { - log.info { - if (SDK_INT >= 31) { - "doWork $this stopReason: ${this.stopReason} runAttemptCount: $runAttemptCount" - } else { - "doWork $this runAttemptCount: $runAttemptCount" - } - } - try { - if (canStartForegroundService(applicationContext)) setForeground(getForegroundInfo()) - } catch (e: Exception) { - log.error(e) { "Error while running setForeground: " } - } - return try { - currentCoroutineContext().ensureActive() - nm.cancelAppUpdatesAvailableNotification() - // Updating apps will try start a foreground service - // and it will "share" the same notification. - // This is easier than trying to tell the [AppInstallManager] - // not to start a foreground service in this specific case. - updatesManager.updateAll(false) - // show success notification, if at least one app got installed - val notificationState = appInstallManager.installNotificationState - if (notificationState.numInstalled > 0) { - nm.showInstallSuccessNotification(notificationState) - } - Result.success() - } catch (e: Exception) { - log.error(e) { "Error updating apps: " } - if (runAttemptCount <= 3) { - Result.retry() - } else { - log.warn { "Not retrying, already tried $runAttemptCount times." } - Result.failure() - } - } finally { - log.info { - if (SDK_INT >= 31) "finished doWork $this (stopReason: ${this.stopReason})" - else "finished doWork $this" - } - } - } - - override suspend fun getForegroundInfo(): ForegroundInfo { - return ForegroundInfo( - NOTIFICATION_ID_APP_INSTALLS, - nm.getAppInstallNotification(InstallNotificationState()).build(), - if (SDK_INT >= 29) FOREGROUND_SERVICE_TYPE_MANIFEST else 0 + @JvmStatic + fun scheduleOrCancel(context: Context, autoUpdate: AutoUpdateValues) { + val workManager = WorkManager.getInstance(context) + if (autoUpdate != AutoUpdateValues.Never) { + Log.i(TAG, "scheduleOrCancel: enqueueUniquePeriodicWork") + val networkType = + if (autoUpdate == AutoUpdateValues.Always) { + NetworkType.CONNECTED + } else { + NetworkType.UNMETERED + } + val constraints = + Constraints.Builder() + .setRequiresBatteryNotLow(true) + .setRequiresStorageNotLow(true) + .setRequiresDeviceIdle(true) + .setRequiredNetworkType(networkType) + .build() + val workRequest = + PeriodicWorkRequestBuilder( + repeatInterval = TimeUnit.HOURS.toMillis(24), + repeatIntervalTimeUnit = TimeUnit.MILLISECONDS, + flexTimeInterval = 60, + flexTimeIntervalUnit = TimeUnit.MINUTES, + ) + .setConstraints(constraints) + .build() + workManager.enqueueUniquePeriodicWork( + uniqueWorkName = UNIQUE_WORK_NAME_APP_UPDATE, + existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.UPDATE, + request = workRequest, ) + } else { + Log.w(TAG, "Cancelling job due to settings!") + workManager.cancelUniqueWork(UNIQUE_WORK_NAME_APP_UPDATE) + } } + + fun getAutoUpdateWorkInfo(context: Context): Flow { + return WorkManager.getInstance(context) + .getWorkInfosForUniqueWorkFlow(UNIQUE_WORK_NAME_APP_UPDATE) + .map { it.getOrNull(0) } + } + } + + private val log = KotlinLogging.logger {} + + override suspend fun doWork(): Result { + log.info { + if (SDK_INT >= 31) { + "doWork $this stopReason: ${this.stopReason} runAttemptCount: $runAttemptCount" + } else { + "doWork $this runAttemptCount: $runAttemptCount" + } + } + try { + if (canStartForegroundService(applicationContext)) setForeground(getForegroundInfo()) + } catch (e: Exception) { + log.error(e) { "Error while running setForeground: " } + } + return try { + currentCoroutineContext().ensureActive() + nm.cancelAppUpdatesAvailableNotification() + // Updating apps will try start a foreground service + // and it will "share" the same notification. + // This is easier than trying to tell the [AppInstallManager] + // not to start a foreground service in this specific case. + updatesManager.updateAll(false) + // show success notification, if at least one app got installed + val notificationState = appInstallManager.installNotificationState + if (notificationState.numInstalled > 0) { + nm.showInstallSuccessNotification(notificationState) + } + Result.success() + } catch (e: Exception) { + log.error(e) { "Error updating apps: " } + if (runAttemptCount <= 3) { + Result.retry() + } else { + log.warn { "Not retrying, already tried $runAttemptCount times." } + Result.failure() + } + } finally { + log.info { + if (SDK_INT >= 31) "finished doWork $this (stopReason: ${this.stopReason})" + else "finished doWork $this" + } + } + } + + override suspend fun getForegroundInfo(): ForegroundInfo { + return ForegroundInfo( + NOTIFICATION_ID_APP_INSTALLS, + nm.getAppInstallNotification(InstallNotificationState()).build(), + if (SDK_INT >= 29) FOREGROUND_SERVICE_TYPE_MANIFEST else 0, + ) + } } diff --git a/app/src/main/kotlin/org/fdroid/updates/UpdateNotificationState.kt b/app/src/main/kotlin/org/fdroid/updates/UpdateNotificationState.kt index 6b734fa2f..c12b9904c 100644 --- a/app/src/main/kotlin/org/fdroid/updates/UpdateNotificationState.kt +++ b/app/src/main/kotlin/org/fdroid/updates/UpdateNotificationState.kt @@ -3,27 +3,29 @@ package org.fdroid.updates import android.content.Context import org.fdroid.R -data class UpdateNotificationState( - private val updates: List, -) { - fun getTitle(context: Context) = context.resources.getQuantityString( - R.plurals.notification_summary_app_updates, - updates.size, updates.size, +data class UpdateNotificationState(private val updates: List) { + fun getTitle(context: Context) = + context.resources.getQuantityString( + R.plurals.notification_summary_app_updates, + updates.size, + updates.size, ) - fun getBigText(): String { - return StringBuilder().apply { - updates.forEach { update -> - append("• ${update.name}") - append(" ${update.currentVersionName} → ${update.updateVersionName}\n") - } - }.toString() - } + fun getBigText(): String { + return StringBuilder() + .apply { + updates.forEach { update -> + append("• ${update.name}") + append(" ${update.currentVersionName} → ${update.updateVersionName}\n") + } + } + .toString() + } } data class AppUpdate( - val packageName: String, - val name: String, - val currentVersionName: String, - val updateVersionName: String, + val packageName: String, + val name: String, + val currentVersionName: String, + val updateVersionName: String, ) diff --git a/app/src/main/kotlin/org/fdroid/updates/UpdatesManager.kt b/app/src/main/kotlin/org/fdroid/updates/UpdatesManager.kt index d65d21cfd..7bc0d6136 100644 --- a/app/src/main/kotlin/org/fdroid/updates/UpdatesManager.kt +++ b/app/src/main/kotlin/org/fdroid/updates/UpdatesManager.kt @@ -4,6 +4,9 @@ import android.content.Context import android.content.pm.PackageInfo import androidx.core.os.LocaleListCompat import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.math.min import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive @@ -34,185 +37,194 @@ import org.fdroid.settings.SettingsManager import org.fdroid.ui.apps.AppUpdateItem import org.fdroid.ui.apps.AppWithIssueItem import org.fdroid.utils.IoDispatcher -import javax.inject.Inject -import javax.inject.Singleton -import kotlin.math.min @Singleton -class UpdatesManager @Inject constructor( - @param:ApplicationContext private val context: Context, - private val db: FDroidDatabase, - private val dbAppChecker: DbAppChecker, - private val settingsManager: SettingsManager, - private val repoManager: RepoManager, - private val appInstallManager: AppInstallManager, - private val installedAppsCache: InstalledAppsCache, - private val notificationManager: NotificationManager, - @param:IoDispatcher private val coroutineScope: CoroutineScope, +class UpdatesManager +@Inject +constructor( + @param:ApplicationContext private val context: Context, + private val db: FDroidDatabase, + private val dbAppChecker: DbAppChecker, + private val settingsManager: SettingsManager, + private val repoManager: RepoManager, + private val appInstallManager: AppInstallManager, + private val installedAppsCache: InstalledAppsCache, + private val notificationManager: NotificationManager, + @param:IoDispatcher private val coroutineScope: CoroutineScope, ) { - private val log = KotlinLogging.logger { } + private val log = KotlinLogging.logger {} - private val _updates = MutableStateFlow?>(null) - val updates = _updates.asStateFlow() - private val _appsWithIssues = MutableStateFlow?>(null) - val appsWithIssues = _appsWithIssues.asStateFlow() - private val _numUpdates = MutableStateFlow(0) - val numUpdates = _numUpdates.asStateFlow() + private val _updates = MutableStateFlow?>(null) + val updates = _updates.asStateFlow() + private val _appsWithIssues = MutableStateFlow?>(null) + val appsWithIssues = _appsWithIssues.asStateFlow() + private val _numUpdates = MutableStateFlow(0) + val numUpdates = _numUpdates.asStateFlow() - /** - * The time in milliseconds of the (earliest!) next repository update run. - * This is [Long.MAX_VALUE], if no time is known. - */ - val nextRepoUpdateFlow = RepoUpdateWorker.getAutoUpdateWorkInfo(context).map { workInfo -> - workInfo?.nextScheduleTimeMillis ?: Long.MAX_VALUE + /** + * The time in milliseconds of the (earliest!) next repository update run. This is + * [Long.MAX_VALUE], if no time is known. + */ + val nextRepoUpdateFlow = + RepoUpdateWorker.getAutoUpdateWorkInfo(context).map { workInfo -> + workInfo?.nextScheduleTimeMillis ?: Long.MAX_VALUE } - /** - * The time in milliseconds of the (earliest!) next automatic app update run. - * This is [Long.MAX_VALUE], if no time is known. - */ - val nextAppUpdateFlow = AppUpdateWorker.getAutoUpdateWorkInfo(context).map { workInfo -> - workInfo?.nextScheduleTimeMillis ?: Long.MAX_VALUE + /** + * The time in milliseconds of the (earliest!) next automatic app update run. This is + * [Long.MAX_VALUE], if no time is known. + */ + val nextAppUpdateFlow = + AppUpdateWorker.getAutoUpdateWorkInfo(context).map { workInfo -> + workInfo?.nextScheduleTimeMillis ?: Long.MAX_VALUE } - val notificationStates: UpdateNotificationState - get() = UpdateNotificationState( - updates = updates.value?.map { update -> - AppUpdate( - packageName = update.packageName, - name = update.name, - currentVersionName = update.installedVersionName, - updateVersionName = update.update.versionName, - ) - } ?: emptyList() - ) + val notificationStates: UpdateNotificationState + get() = + UpdateNotificationState( + updates = + updates.value?.map { update -> + AppUpdate( + packageName = update.packageName, + name = update.name, + currentVersionName = update.installedVersionName, + updateVersionName = update.update.versionName, + ) + } ?: emptyList() + ) - init { - coroutineScope.launch { - // refresh updates whenever installed apps change - installedAppsCache.installedApps.collect { - loadUpdates(it) - } + init { + coroutineScope.launch { + // refresh updates whenever installed apps change + installedAppsCache.installedApps.collect { loadUpdates(it) } + } + } + + fun loadUpdates( + packageInfoMap: Map = installedAppsCache.installedApps.value + ) = + coroutineScope.launch { + if (packageInfoMap.isEmpty()) return@launch + val localeList = LocaleListCompat.getDefault() + try { + log.info { "Checking for updates (${packageInfoMap.size} apps)..." } + val proxyConfig = settingsManager.proxyConfig + val apps = dbAppChecker.getApps(packageInfoMap = packageInfoMap) + val updates = + apps.updates.map { update -> + val iconModel = + repoManager.getRepository(update.repoId)?.let { repo -> + update.getIcon(localeList)?.getImageModel(repo, proxyConfig) + } as? DownloadRequest + AppUpdateItem( + repoId = update.repoId, + packageName = update.packageName, + name = update.name ?: "Unknown app", + installedVersionName = update.installedVersionName, + update = update.update, + whatsNew = update.update.getWhatsNew(localeList)?.trim(), + iconModel = PackageName(update.packageName, iconModel), + ) + } + _updates.value = updates + _numUpdates.value = updates.size + // update 'update available' notification, if it is currently showing + if (notificationManager.isAppUpdatesAvailableNotificationShowing) { + if (updates.isEmpty()) notificationManager.cancelAppUpdatesAvailableNotification() + else notificationManager.showAppUpdatesAvailableNotification(notificationStates) } - } - fun loadUpdates( - packageInfoMap: Map = installedAppsCache.installedApps.value, - ) = coroutineScope.launch { - if (packageInfoMap.isEmpty()) return@launch - val localeList = LocaleListCompat.getDefault() - try { - log.info { "Checking for updates (${packageInfoMap.size} apps)..." } - val proxyConfig = settingsManager.proxyConfig - val apps = dbAppChecker.getApps(packageInfoMap = packageInfoMap) - val updates = apps.updates.map { update -> - val iconModel = repoManager.getRepository(update.repoId)?.let { repo -> - update.getIcon(localeList)?.getImageModel(repo, proxyConfig) - } as? DownloadRequest - AppUpdateItem( - repoId = update.repoId, - packageName = update.packageName, - name = update.name ?: "Unknown app", - installedVersionName = update.installedVersionName, - update = update.update, - whatsNew = update.update.getWhatsNew(localeList)?.trim(), - iconModel = PackageName(update.packageName, iconModel), + val issueItems = + apps.issues.mapNotNull { app -> + if (app.packageName in settingsManager.ignoredAppIssues) return@mapNotNull null + when (app) { + is AvailableAppWithIssue -> + AppWithIssueItem( + packageName = app.app.packageName, + name = app.app.getName(localeList) ?: "Unknown app", + installedVersionName = app.installVersionName, + installedVersionCode = app.installVersionCode, + issue = app.issue, + lastUpdated = app.app.lastUpdated, + iconModel = + PackageName( + packageName = app.app.packageName, + iconDownloadRequest = + repoManager.getRepository(app.app.repoId)?.let { + app.app.getIcon(localeList)?.getImageModel(it, proxyConfig) + } as? DownloadRequest, + ), + ) + is UnavailableAppWithIssue -> + AppWithIssueItem( + packageName = app.packageName, + name = app.name.toString(), + installedVersionName = app.installVersionName, + installedVersionCode = app.installVersionCode, + issue = app.issue, + lastUpdated = -1, + iconModel = PackageName(app.packageName, null), ) } - _updates.value = updates - _numUpdates.value = updates.size - // update 'update available' notification, if it is currently showing - if (notificationManager.isAppUpdatesAvailableNotificationShowing) { - if (updates.isEmpty()) notificationManager.cancelAppUpdatesAvailableNotification() - else notificationManager.showAppUpdatesAvailableNotification(notificationStates) - } - - val issueItems = apps.issues.mapNotNull { app -> - if (app.packageName in settingsManager.ignoredAppIssues) return@mapNotNull null - when (app) { - is AvailableAppWithIssue -> AppWithIssueItem( - packageName = app.app.packageName, - name = app.app.getName(localeList) ?: "Unknown app", - installedVersionName = app.installVersionName, - installedVersionCode = app.installVersionCode, - issue = app.issue, - lastUpdated = app.app.lastUpdated, - iconModel = PackageName( - packageName = app.app.packageName, - iconDownloadRequest = repoManager.getRepository(app.app.repoId)?.let { - app.app.getIcon(localeList)?.getImageModel(it, proxyConfig) - } as? DownloadRequest), - ) - is UnavailableAppWithIssue -> AppWithIssueItem( - packageName = app.packageName, - name = app.name.toString(), - installedVersionName = app.installVersionName, - installedVersionCode = app.installVersionCode, - issue = app.issue, - lastUpdated = -1, - iconModel = PackageName(app.packageName, null), - ) - } - } - // don't flag issues too early when we are still at first start - if (!settingsManager.isFirstStart) _appsWithIssues.value = issueItems - } catch (e: Exception) { - log.error(e) { "Error loading updates: " } - return@launch - } + } + // don't flag issues too early when we are still at first start + if (!settingsManager.isFirstStart) _appsWithIssues.value = issueItems + } catch (e: Exception) { + log.error(e) { "Error loading updates: " } + return@launch + } } - suspend fun updateAll(canAskPreApprovalNow: Boolean) { - val appsToUpdate = updates.value ?: updates.first() ?: return - // we could do more in-depth checks regarding pre-approval, but this also works - val preApprovalNow = canAskPreApprovalNow && appsToUpdate.size == 1 - val concurrencyLimit = min(Runtime.getRuntime().availableProcessors(), 8) - val semaphore = Semaphore(concurrencyLimit) - // remember our own app, if it is to be updated as well - val updateLast = appsToUpdate.find { it.packageName == context.packageName } - appsToUpdate.mapNotNull { update -> - // don't update our own app just yet - if (update.packageName == context.packageName) { - // set app to update last to Starting as well, so it doesn't seem stuck - val app = db.getAppDao().getApp(update.repoId, update.packageName) - ?: return@mapNotNull null - appInstallManager.setWaitingState( - packageName = update.packageName, - name = app.metadata.name.getBestLocale(LocaleListCompat.getDefault()) - ?: "Unknown", - versionName = update.update.versionName, - currentVersionName = update.installedVersionName, - lastUpdated = update.update.added, - ) - return@mapNotNull null - } - currentCoroutineContext().ensureActive() - // launch a new co-routine for each app to update - coroutineScope.launch { - // suspend here until we get a permit from the semaphore (there's free workers) - semaphore.withPermit { - currentCoroutineContext().ensureActive() - updateApp(update, preApprovalNow) - } - } - }.joinAll() - currentCoroutineContext().ensureActive() - // now it is time to update our own app - updateLast?.let { - updateApp(it, preApprovalNow) - } - } - - private suspend fun updateApp(update: AppUpdateItem, canAskPreApprovalNow: Boolean) { - val app = db.getAppDao().getApp(update.repoId, update.packageName) ?: return - appInstallManager.install( - appMetadata = app.metadata, - // we know this is true, because we set this above in loadUpdates() - version = update.update as AppVersion, + suspend fun updateAll(canAskPreApprovalNow: Boolean) { + val appsToUpdate = updates.value ?: updates.first() ?: return + // we could do more in-depth checks regarding pre-approval, but this also works + val preApprovalNow = canAskPreApprovalNow && appsToUpdate.size == 1 + val concurrencyLimit = min(Runtime.getRuntime().availableProcessors(), 8) + val semaphore = Semaphore(concurrencyLimit) + // remember our own app, if it is to be updated as well + val updateLast = appsToUpdate.find { it.packageName == context.packageName } + appsToUpdate + .mapNotNull { update -> + // don't update our own app just yet + if (update.packageName == context.packageName) { + // set app to update last to Starting as well, so it doesn't seem stuck + val app = + db.getAppDao().getApp(update.repoId, update.packageName) ?: return@mapNotNull null + appInstallManager.setWaitingState( + packageName = update.packageName, + name = app.metadata.name.getBestLocale(LocaleListCompat.getDefault()) ?: "Unknown", + versionName = update.update.versionName, currentVersionName = update.installedVersionName, - repo = repoManager.getRepository(update.repoId) ?: return, - iconModel = update.iconModel, - canAskPreApprovalNow = canAskPreApprovalNow, - ) - } + lastUpdated = update.update.added, + ) + return@mapNotNull null + } + currentCoroutineContext().ensureActive() + // launch a new co-routine for each app to update + coroutineScope.launch { + // suspend here until we get a permit from the semaphore (there's free workers) + semaphore.withPermit { + currentCoroutineContext().ensureActive() + updateApp(update, preApprovalNow) + } + } + } + .joinAll() + currentCoroutineContext().ensureActive() + // now it is time to update our own app + updateLast?.let { updateApp(it, preApprovalNow) } + } + + private suspend fun updateApp(update: AppUpdateItem, canAskPreApprovalNow: Boolean) { + val app = db.getAppDao().getApp(update.repoId, update.packageName) ?: return + appInstallManager.install( + appMetadata = app.metadata, + // we know this is true, because we set this above in loadUpdates() + version = update.update as AppVersion, + currentVersionName = update.installedVersionName, + repo = repoManager.getRepository(update.repoId) ?: return, + iconModel = update.iconModel, + canAskPreApprovalNow = canAskPreApprovalNow, + ) + } } diff --git a/app/src/main/kotlin/org/fdroid/updates/UpdatesModule.kt b/app/src/main/kotlin/org/fdroid/updates/UpdatesModule.kt index 0d404f95e..4afb828a2 100644 --- a/app/src/main/kotlin/org/fdroid/updates/UpdatesModule.kt +++ b/app/src/main/kotlin/org/fdroid/updates/UpdatesModule.kt @@ -6,36 +6,36 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton import org.fdroid.CompatibilityChecker import org.fdroid.CompatibilityCheckerImpl import org.fdroid.UpdateChecker import org.fdroid.database.DbAppChecker import org.fdroid.database.FDroidDatabase -import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object UpdatesModule { - @Provides - @Singleton - fun provideCompatibilityChecker(@ApplicationContext context: Context): CompatibilityChecker { - return CompatibilityCheckerImpl(context.packageManager) - } + @Provides + @Singleton + fun provideCompatibilityChecker(@ApplicationContext context: Context): CompatibilityChecker { + return CompatibilityCheckerImpl(context.packageManager) + } - @Provides - @Singleton - fun provideUpdateChecker(compatibilityChecker: CompatibilityChecker): UpdateChecker { - return UpdateChecker(compatibilityChecker) - } + @Provides + @Singleton + fun provideUpdateChecker(compatibilityChecker: CompatibilityChecker): UpdateChecker { + return UpdateChecker(compatibilityChecker) + } - @Provides - @Singleton - fun provideDbAppChecker( - @ApplicationContext context: Context, - db: FDroidDatabase, - updateChecker: UpdateChecker, - compatibilityChecker: CompatibilityChecker, - ): DbAppChecker { - return DbAppChecker(db, context, compatibilityChecker, updateChecker) - } + @Provides + @Singleton + fun provideDbAppChecker( + @ApplicationContext context: Context, + db: FDroidDatabase, + updateChecker: UpdateChecker, + compatibilityChecker: CompatibilityChecker, + ): DbAppChecker { + return DbAppChecker(db, context, compatibilityChecker, updateChecker) + } } diff --git a/app/src/main/kotlin/org/fdroid/utils/CoroutinesScopesModule.kt b/app/src/main/kotlin/org/fdroid/utils/CoroutinesScopesModule.kt index b56785fa5..fb257e6b4 100644 --- a/app/src/main/kotlin/org/fdroid/utils/CoroutinesScopesModule.kt +++ b/app/src/main/kotlin/org/fdroid/utils/CoroutinesScopesModule.kt @@ -4,24 +4,22 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import javax.inject.Qualifier +import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob -import javax.inject.Qualifier -import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object CoroutinesScopesModule { - @Singleton - @Provides - @IoDispatcher - fun providesCoroutineIoScope(): CoroutineScope { - // Run this code when providing an instance of CoroutineScope - return CoroutineScope(SupervisorJob() + Dispatchers.IO) - } + @Singleton + @Provides + @IoDispatcher + fun providesCoroutineIoScope(): CoroutineScope { + // Run this code when providing an instance of CoroutineScope + return CoroutineScope(SupervisorJob() + Dispatchers.IO) + } } -@Qualifier -@Retention(AnnotationRetention.RUNTIME) -annotation class IoDispatcher +@Qualifier @Retention(AnnotationRetention.RUNTIME) annotation class IoDispatcher diff --git a/app/src/main/kotlin/org/fdroid/utils/Utils.kt b/app/src/main/kotlin/org/fdroid/utils/Utils.kt index f0e60cc21..307e7a085 100644 --- a/app/src/main/kotlin/org/fdroid/utils/Utils.kt +++ b/app/src/main/kotlin/org/fdroid/utils/Utils.kt @@ -1,32 +1,36 @@ package org.fdroid.utils import android.content.Context -import org.fdroid.BuildConfig.FLAVOR import java.security.MessageDigest import java.security.NoSuchAlgorithmException import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import java.util.TimeZone +import org.fdroid.BuildConfig.FLAVOR @OptIn(ExperimentalStdlibApi::class) fun sha256(bytes: ByteArray): String { - val messageDigest: MessageDigest = try { - MessageDigest.getInstance("SHA-256") + val messageDigest: MessageDigest = + try { + MessageDigest.getInstance("SHA-256") } catch (e: NoSuchAlgorithmException) { - throw AssertionError(e) + throw AssertionError(e) } - messageDigest.update(bytes) - return messageDigest.digest().toHexString() + messageDigest.update(bytes) + return messageDigest.digest().toHexString() } fun getLogName(context: Context): String { - val sdf = SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'", Locale.US).apply { - timeZone = TimeZone.getTimeZone("UTC") + val sdf = + SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") } - val time = sdf.format(Date()) - return "${context.packageName}-$time" + val time = sdf.format(Date()) + return "${context.packageName}-$time" } -val isFull: Boolean get() = FLAVOR.startsWith("full") -val isBasic: Boolean get() = FLAVOR.startsWith("basic") +val isFull: Boolean + get() = FLAVOR.startsWith("full") +val isBasic: Boolean + get() = FLAVOR.startsWith("basic") diff --git a/app/src/screenshotTest/kotlin/org/fdroid/ui/ScreenshotTest.kt b/app/src/screenshotTest/kotlin/org/fdroid/ui/ScreenshotTest.kt index 485eced5f..e4b5cc975 100644 --- a/app/src/screenshotTest/kotlin/org/fdroid/ui/ScreenshotTest.kt +++ b/app/src/screenshotTest/kotlin/org/fdroid/ui/ScreenshotTest.kt @@ -7,20 +7,20 @@ import org.fdroid.ui.navigation.NavigationKey @Composable fun ScreenshotTest( - showBottomBar: Boolean = true, - currentNavKey: NavKey = NavigationKey.Discover, - numUpdates: Int = 3, - hasAppIssues: Boolean = true, - content: @Composable (Modifier) -> Unit, + showBottomBar: Boolean = true, + currentNavKey: NavKey = NavigationKey.Discover, + numUpdates: Int = 3, + hasAppIssues: Boolean = true, + content: @Composable (Modifier) -> Unit, ) { - MainContent( - isBigScreen = false, - dynamicColors = false, - showBottomBar = showBottomBar, - currentNavKey = currentNavKey, - numUpdates = numUpdates, - hasAppIssues = hasAppIssues, - onNav = {}, - content = content, - ) + MainContent( + isBigScreen = false, + dynamicColors = false, + showBottomBar = showBottomBar, + currentNavKey = currentNavKey, + numUpdates = numUpdates, + hasAppIssues = hasAppIssues, + onNav = {}, + content = content, + ) } diff --git a/app/src/screenshotTest/kotlin/org/fdroid/ui/discover/DiscoverTest.kt b/app/src/screenshotTest/kotlin/org/fdroid/ui/discover/DiscoverTest.kt index 4cd86f830..8d5828330 100644 --- a/app/src/screenshotTest/kotlin/org/fdroid/ui/discover/DiscoverTest.kt +++ b/app/src/screenshotTest/kotlin/org/fdroid/ui/discover/DiscoverTest.kt @@ -14,70 +14,64 @@ import org.fdroid.ui.categories.CategoryItem @PreviewTest @Preview(showBackground = true, showSystemUi = true) fun DiscoverFirstStartTest() = ScreenshotTest { - Discover( - discoverModel = FirstStartDiscoverModel( - networkState = NetworkState(isOnline = true, isMetered = false), - repoUpdateState = RepoUpdateProgress(1, true, 0.25f), - ), - onListTap = {}, - onAppTap = {}, - onNav = {}, - ) + Discover( + discoverModel = + FirstStartDiscoverModel( + networkState = NetworkState(isOnline = true, isMetered = false), + repoUpdateState = RepoUpdateProgress(1, true, 0.25f), + ), + onListTap = {}, + onAppTap = {}, + onNav = {}, + ) } @Composable @PreviewTest @Preview(showBackground = true, showSystemUi = true) private fun DiscoverNoEnabledReposTest() = ScreenshotTest { - Discover( - discoverModel = NoEnabledReposDiscoverModel, - onListTap = {}, - onAppTap = {}, - onNav = {}, - ) + Discover(discoverModel = NoEnabledReposDiscoverModel, onListTap = {}, onAppTap = {}, onNav = {}) } @Composable @PreviewTest @Preview(showBackground = true, showSystemUi = true) private fun DiscoverTest() { - val app1 = AppDiscoverItem( - packageName = "foo bar", - name = "New App!", - isInstalled = false, - imageModel = null, - lastUpdated = 10, + val app1 = + AppDiscoverItem( + packageName = "foo bar", + name = "New App!", + isInstalled = false, + imageModel = null, + lastUpdated = 10, ) - val app2 = AppDiscoverItem( - packageName = "bar foo", - name = "Nice App!", - isInstalled = false, - imageModel = null, - lastUpdated = 9, + val app2 = + AppDiscoverItem( + packageName = "bar foo", + name = "Nice App!", + isInstalled = false, + imageModel = null, + lastUpdated = 9, ) - val app3 = AppDiscoverItem( - packageName = "org.example", - name = "Downloaded App!", - isInstalled = false, - imageModel = null, - lastUpdated = 8, + val app3 = + AppDiscoverItem( + packageName = "org.example", + name = "Downloaded App!", + isInstalled = false, + imageModel = null, + lastUpdated = 8, ) - ScreenshotTest { - val model = LoadedDiscoverModel( - newApps = listOf(app1), - recentlyUpdatedApps = listOf(app2), - mostDownloadedApps = listOf(app3), - categories = mapOf( - CategoryGroups.productivity to listOf(CategoryItem("Calculator", "Calculator")) - ), - searchTextFieldState = rememberTextFieldState(), - hasRepoIssues = false, - ) - Discover( - discoverModel = model, - onListTap = {}, - onAppTap = {}, - onNav = {}, - ) - } + ScreenshotTest { + val model = + LoadedDiscoverModel( + newApps = listOf(app1), + recentlyUpdatedApps = listOf(app2), + mostDownloadedApps = listOf(app3), + categories = + mapOf(CategoryGroups.productivity to listOf(CategoryItem("Calculator", "Calculator"))), + searchTextFieldState = rememberTextFieldState(), + hasRepoIssues = false, + ) + Discover(discoverModel = model, onListTap = {}, onAppTap = {}, onNav = {}) + } } diff --git a/app/src/test/java/org/fdroid/history/HistoryManagerTest.kt b/app/src/test/java/org/fdroid/history/HistoryManagerTest.kt index b5ba06187..eb9bb591d 100644 --- a/app/src/test/java/org/fdroid/history/HistoryManagerTest.kt +++ b/app/src/test/java/org/fdroid/history/HistoryManagerTest.kt @@ -6,106 +6,97 @@ import android.content.Context.MODE_PRIVATE import io.mockk.every import io.mockk.mockk import io.mockk.verify -import org.fdroid.settings.SettingsManager -import org.junit.Rule -import org.junit.rules.TemporaryFolder import java.io.FileOutputStream import kotlin.random.Random import kotlin.test.Test import kotlin.test.assertEquals +import org.fdroid.settings.SettingsManager +import org.junit.Rule +import org.junit.rules.TemporaryFolder private const val MAX_EVENTS = 10 internal class HistoryManagerTest { - @get:Rule - val tempFolder = TemporaryFolder() + @get:Rule val tempFolder = TemporaryFolder() - private val context: Context = mockk() - private val settingsManager: SettingsManager = mockk() - private val manager = HistoryManager(context, settingsManager, MAX_EVENTS) + private val context: Context = mockk() + private val settingsManager: SettingsManager = mockk() + private val manager = HistoryManager(context, settingsManager, MAX_EVENTS) - @Test - fun testAppendGetAndClear() { - val file = tempFolder.newFile() - every { context.openFileOutput(any(), MODE_APPEND) } answers { - FileOutputStream(file, true) - } - every { context.openFileInput(any()) } answers { - file.inputStream() - } - every { settingsManager.useInstallHistory } returns true + @Test + fun testAppendGetAndClear() { + val file = tempFolder.newFile() + every { context.openFileOutput(any(), MODE_APPEND) } answers { FileOutputStream(file, true) } + every { context.openFileInput(any()) } answers { file.inputStream() } + every { settingsManager.useInstallHistory } returns true - val installEvent = InstallEvent( - time = Random.nextLong(), - packageName = "foo.bar", - name = "Foo Bar", - versionName = "1.0.3", - oldVersionName = if (Random.nextBoolean()) null else "1.0.1", - ) - val uninstallEvent = UninstallEvent( - time = Random.nextLong(), - packageName = "org.example", - name = if (Random.nextBoolean()) null else "2.0.3", - ) - manager.append(installEvent) - manager.append(installEvent) - manager.append(uninstallEvent) - assertEquals( - listOf(installEvent, installEvent, uninstallEvent), - manager.getEvents(), - ) + val installEvent = + InstallEvent( + time = Random.nextLong(), + packageName = "foo.bar", + name = "Foo Bar", + versionName = "1.0.3", + oldVersionName = if (Random.nextBoolean()) null else "1.0.1", + ) + val uninstallEvent = + UninstallEvent( + time = Random.nextLong(), + packageName = "org.example", + name = if (Random.nextBoolean()) null else "2.0.3", + ) + manager.append(installEvent) + manager.append(installEvent) + manager.append(uninstallEvent) + assertEquals(listOf(installEvent, installEvent, uninstallEvent), manager.getEvents()) - // delete file - every { context.deleteFile(any()) } returns true - manager.clearAll() - verify { context.deleteFile(any()) } + // delete file + every { context.deleteFile(any()) } returns true + manager.clearAll() + verify { context.deleteFile(any()) } + } + + @Test + fun testNoAppendWhenDisabled() { + val uninstallEvent = + UninstallEvent( + time = Random.nextLong(), + packageName = "org.example", + name = if (Random.nextBoolean()) null else "2.0.3", + ) + every { settingsManager.useInstallHistory } returns false + manager.append(uninstallEvent) + } + + @Test + fun testPrune() { + val file = tempFolder.newFile() + every { context.openFileOutput(any(), MODE_APPEND) } answers { FileOutputStream(file, true) } + every { context.openFileInput(any()) } answers { file.inputStream() } + every { settingsManager.useInstallHistory } returns true + + val installEvent = + InstallEvent( + time = Random.nextLong(), + packageName = "foo.bar", + name = "Foo Bar", + versionName = "1.0.3", + oldVersionName = if (Random.nextBoolean()) null else "1.0.1", + ) + val uninstallEvent = + UninstallEvent( + time = Random.nextLong(), + packageName = "org.example", + name = if (Random.nextBoolean()) null else "2.0.3", + ) + repeat((0 until MAX_EVENTS).count()) { + manager.append(installEvent) + manager.append(uninstallEvent) } + assertEquals(MAX_EVENTS * 2, manager.getEvents().size) - @Test - fun testNoAppendWhenDisabled() { - val uninstallEvent = UninstallEvent( - time = Random.nextLong(), - packageName = "org.example", - name = if (Random.nextBoolean()) null else "2.0.3", - ) - every { settingsManager.useInstallHistory } returns false - manager.append(uninstallEvent) - } - - @Test - fun testPrune() { - val file = tempFolder.newFile() - every { context.openFileOutput(any(), MODE_APPEND) } answers { - FileOutputStream(file, true) - } - every { context.openFileInput(any()) } answers { - file.inputStream() - } - every { settingsManager.useInstallHistory } returns true - - val installEvent = InstallEvent( - time = Random.nextLong(), - packageName = "foo.bar", - name = "Foo Bar", - versionName = "1.0.3", - oldVersionName = if (Random.nextBoolean()) null else "1.0.1", - ) - val uninstallEvent = UninstallEvent( - time = Random.nextLong(), - packageName = "org.example", - name = if (Random.nextBoolean()) null else "2.0.3", - ) - repeat((0 until MAX_EVENTS).count()) { - manager.append(installEvent) - manager.append(uninstallEvent) - } - assertEquals(MAX_EVENTS * 2, manager.getEvents().size) - - every { context.openFileOutput(any(), MODE_PRIVATE) } answers { - FileOutputStream(file, false) - } - manager.pruneEvents() - assertEquals(MAX_EVENTS, manager.getEvents().size) - } + every { context.openFileOutput(any(), MODE_PRIVATE) } answers { FileOutputStream(file, false) } + manager.pruneEvents() + assertEquals(MAX_EVENTS, manager.getEvents().size) + } } diff --git a/app/src/test/java/org/fdroid/ui/details/HtmlDescriptionTest.kt b/app/src/test/java/org/fdroid/ui/details/HtmlDescriptionTest.kt index 7da6d113f..310ae7bdf 100644 --- a/app/src/test/java/org/fdroid/ui/details/HtmlDescriptionTest.kt +++ b/app/src/test/java/org/fdroid/ui/details/HtmlDescriptionTest.kt @@ -1,69 +1,73 @@ package org.fdroid.ui.details -import org.junit.Test import kotlin.test.assertEquals +import org.junit.Test -/** - * Tests modifications to app details descriptions done in [getHtmlDescription]. - */ +/** Tests modifications to app details descriptions done in [getHtmlDescription]. */ class HtmlDescriptionTest { - @Test - fun testLinks() { - val description = """ + @Test + fun testLinks() { + val description = + """ 2. If you have experience with Java and the Android SDK, then we look forward to your contributions! More info: https://mediawiki.org/wiki/Wikimedia_Apps/Team/Android/App_hacking 3. Explanation of permissions needed by the app: https://mediawiki.org/wiki/Wikimedia_Apps/Android_FAQ#Security_and_Permissions """ - val expectedDescription = """
+ val expectedDescription = + """
2. If you have experience with Java and the Android SDK, then we look forward to your contributions! More info: https://mediawiki.org/wiki/Wikimedia_Apps/Team/Android/App_hacking

3. Explanation of permissions needed by the app: https://mediawiki.org/wiki/Wikimedia_Apps/Android_FAQ#Security_and_Permissions
""" - assertEquals(expectedDescription, getHtmlDescription(description)) - } + assertEquals(expectedDescription, getHtmlDescription(description)) + } - @Test - fun testLinkAtTheVeryEnd() { - val description = """ + @Test + fun testLinkAtTheVeryEnd() { + val description = + """ Project page: https://github.com/lukaspieper/Gcam-Services-Provider""" - val expectedDescription = """
+ val expectedDescription = + """
Project page: https://github.com/lukaspieper/Gcam-Services-Provider""" - assertEquals(expectedDescription, getHtmlDescription(description)) - } + assertEquals(expectedDescription, getHtmlDescription(description)) + } - @Test - fun testLinkWithDotAtTheEnd() { - val description = """please visit our website: https://wikimediafoundation.org/.""" + @Test + fun testLinkWithDotAtTheEnd() { + val description = """please visit our website: https://wikimediafoundation.org/.""" - @Suppress("ktlint:standard:max-line-length") - val expectedDescription = """please visit our website: https://wikimediafoundation.org/.""" - assertEquals(expectedDescription, getHtmlDescription(description)) - } + val expectedDescription = + """please visit our website: https://wikimediafoundation.org/.""" + assertEquals(expectedDescription, getHtmlDescription(description)) + } - @Test - fun testLinkInRoundBrackets() { - val description = """our link (https://wikimediafoundation.org/).""" + @Test + fun testLinkInRoundBrackets() { + val description = """our link (https://wikimediafoundation.org/).""" - @Suppress("ktlint:standard:max-line-length") - val expectedDescription = """our link (https://wikimediafoundation.org/).""" - assertEquals(expectedDescription, getHtmlDescription(description)) - } + val expectedDescription = + """our link (https://wikimediafoundation.org/).""" + assertEquals(expectedDescription, getHtmlDescription(description)) + } - @Test - fun testHeadlineRemoval() { - val description = """

SimpleX - the first messaging platform that has no user identifiers, not even random numbers!

+ @Test + fun testHeadlineRemoval() { + val description = + """

SimpleX - the first messaging platform that has no user identifiers, not even random numbers!

Security assessment was done by Trail of Bits in November 2022.

SimpleX Chat features:

  • end-to-end encrypted messages, with editing, replies and deletion of messages.
  • sending end-to-end encrypted images and files.
  • """ - val expectedDescription = """SimpleX - the first messaging platform that has no user identifiers, not even random numbers!
    + val expectedDescription = + """SimpleX - the first messaging platform that has no user identifiers, not even random numbers!

    Security assessment was done by Trail of Bits in November 2022.

    SimpleX Chat features:

    • end-to-end encrypted messages, with editing, replies and deletion of messages.
    • sending end-to-end encrypted images and files.
    • """ - assertEquals(expectedDescription, getHtmlDescription(description)) - } + assertEquals(expectedDescription, getHtmlDescription(description)) + } } diff --git a/app/src/test/java/org/fdroid/ui/navigation/IntentRouterTest.kt b/app/src/test/java/org/fdroid/ui/navigation/IntentRouterTest.kt index ecc88781f..e78c38f2d 100644 --- a/app/src/test/java/org/fdroid/ui/navigation/IntentRouterTest.kt +++ b/app/src/test/java/org/fdroid/ui/navigation/IntentRouterTest.kt @@ -8,102 +8,105 @@ import android.content.Intent.EXTRA_PACKAGE_NAME import androidx.compose.runtime.mutableStateOf import androidx.core.net.toUri import androidx.navigation3.runtime.NavBackStack +import kotlin.test.assertEquals import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config -import kotlin.test.assertEquals @Config(sdk = [24]) // needed for ACTION_SHOW_APP_INFO @RunWith(RobolectricTestRunner::class) class IntentRouterTest { - val navigationState = NavigationState( - startRoute = NavigationKey.Discover, - topLevelRoute = mutableStateOf(NavigationKey.Discover), - backStacks = topLevelRoutes.associateWith { key -> - NavBackStack(key) - } + val navigationState = + NavigationState( + startRoute = NavigationKey.Discover, + topLevelRoute = mutableStateOf(NavigationKey.Discover), + backStacks = topLevelRoutes.associateWith { key -> NavBackStack(key) }, ) - val navigator = Navigator(navigationState) - private val intentRouter = IntentRouter(navigator) + val navigator = Navigator(navigationState) + private val intentRouter = IntentRouter(navigator) - @Test - fun testBrowserUris() { - val packageName = "org.fdroid.fdroid" - listOf( - "https://f-droid.org/packages/$packageName/", - "https://f-droid.org/de/packages/$packageName/", - "https://f-droid.org/en-US/packages/$packageName", - "https://cloudflare.f-droid.org/zh_Hans/packages/$packageName", - "market://details?id=$packageName", - ).forEach { url -> - val i = Intent().apply { - action = ACTION_VIEW - addCategory(CATEGORY_BROWSABLE) - data = url.toUri() - } - intentRouter.accept(i) - assertEquals(NavigationKey.AppDetails(packageName), navigator.last) - } - } - - @Test - fun testMalformedBrowserUris() { - val packageName = "fdroid') DROP TABLE apps; --" - listOf( - "https://f-droid.org/packages/$packageName/", - "https://f-droid.org/de/packages/$packageName/", - "https://f-droid.org/en-US/packages/$packageName", - "https://cloudflare.f-droid.org/zh_Hans/packages/$packageName", - "market://details?id=$packageName", - ).forEach { url -> - val i = Intent().apply { - action = ACTION_VIEW - addCategory(CATEGORY_BROWSABLE) - data = url.toUri() - } - intentRouter.accept(i) - assertEquals(NavigationKey.Discover, navigator.last) - } - } - - @Test - fun testShowAppInfo() { - val packageName = "org.fdroid.fdroid" - val i = Intent().apply { - action = ACTION_SHOW_APP_INFO - putExtra(EXTRA_PACKAGE_NAME, packageName) - } + @Test + fun testBrowserUris() { + val packageName = "org.fdroid.fdroid" + listOf( + "https://f-droid.org/packages/$packageName/", + "https://f-droid.org/de/packages/$packageName/", + "https://f-droid.org/en-US/packages/$packageName", + "https://cloudflare.f-droid.org/zh_Hans/packages/$packageName", + "market://details?id=$packageName", + ) + .forEach { url -> + val i = + Intent().apply { + action = ACTION_VIEW + addCategory(CATEGORY_BROWSABLE) + data = url.toUri() + } intentRouter.accept(i) assertEquals(NavigationKey.AppDetails(packageName), navigator.last) - } + } + } - @Test - fun testShowAppInfoMalformed() { - val packageName = "fdroid') DROP TABLE apps; --" - val i = Intent().apply { - action = ACTION_SHOW_APP_INFO - putExtra(EXTRA_PACKAGE_NAME, packageName) - } + @Test + fun testMalformedBrowserUris() { + val packageName = "fdroid') DROP TABLE apps; --" + listOf( + "https://f-droid.org/packages/$packageName/", + "https://f-droid.org/de/packages/$packageName/", + "https://f-droid.org/en-US/packages/$packageName", + "https://cloudflare.f-droid.org/zh_Hans/packages/$packageName", + "market://details?id=$packageName", + ) + .forEach { url -> + val i = + Intent().apply { + action = ACTION_VIEW + addCategory(CATEGORY_BROWSABLE) + data = url.toUri() + } intentRouter.accept(i) assertEquals(NavigationKey.Discover, navigator.last) - } + } + } - @Test - fun testRepoUris() { - listOf( - "fdroidrepos://example.org/repo", - "FDROIDREPOS://example.org/repo", - "https://fdroid.link/#repo=https://f-droid.org/repo", - "https://fdroid.link/#foo/bar", - ).forEach { uri -> - val i = Intent(ACTION_VIEW).apply { - data = uri.toUri() - } - intentRouter.accept(i) - assertEquals(NavigationKey.AddRepo(uri), navigator.last) - } - } + @Test + fun testShowAppInfo() { + val packageName = "org.fdroid.fdroid" + val i = + Intent().apply { + action = ACTION_SHOW_APP_INFO + putExtra(EXTRA_PACKAGE_NAME, packageName) + } + intentRouter.accept(i) + assertEquals(NavigationKey.AppDetails(packageName), navigator.last) + } + @Test + fun testShowAppInfoMalformed() { + val packageName = "fdroid') DROP TABLE apps; --" + val i = + Intent().apply { + action = ACTION_SHOW_APP_INFO + putExtra(EXTRA_PACKAGE_NAME, packageName) + } + intentRouter.accept(i) + assertEquals(NavigationKey.Discover, navigator.last) + } + + @Test + fun testRepoUris() { + listOf( + "fdroidrepos://example.org/repo", + "FDROIDREPOS://example.org/repo", + "https://fdroid.link/#repo=https://f-droid.org/repo", + "https://fdroid.link/#foo/bar", + ) + .forEach { uri -> + val i = Intent(ACTION_VIEW).apply { data = uri.toUri() } + intentRouter.accept(i) + assertEquals(NavigationKey.AddRepo(uri), navigator.last) + } + } } diff --git a/build.gradle b/build.gradle index 5ffaaf1b4..5c7f3daab 100644 --- a/build.gradle +++ b/build.gradle @@ -14,9 +14,9 @@ plugins { alias libs.plugins.jetbrains.kotlin.plugin.serialization apply false alias libs.plugins.jetbrains.compose.compiler apply false alias libs.plugins.jetbrains.dokka apply false - alias libs.plugins.jlleitschuh.ktlint apply false alias libs.plugins.vanniktech.maven.publish apply false alias libs.plugins.screenshot apply false + alias libs.plugins.ktfmt apply false } allprojects { repositories { @@ -24,13 +24,3 @@ allprojects { maven { url 'https://maven.google.com/' } } } -subprojects { - apply plugin: "org.jlleitschuh.gradle.ktlint" - - ktlint { - version = "1.3.1" - android = true - enableExperimentalRules = false - verbose = true - } -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d061db58d..24c5d4316 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,8 +8,8 @@ hiltWork = "1.3.0" hiltNavigationCompose = "1.3.0" dokka = "2.1.0" mavenPublish = "0.35.0" -jlleitschuhKtlint = "14.0.1" screenshot = "0.0.1-alpha13" +ktfmt = "0.25.0" kotlinxSerializationCore = "1.10.0" kotlinxSerializationJson = "1.10.0" @@ -214,5 +214,5 @@ jetbrains-kotlin-plugin-serialization = { id = "org.jetbrains.kotlin.plugin.seri jetbrains-compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } jetbrains-dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } vanniktech-maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" } -jlleitschuh-ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "jlleitschuhKtlint" } screenshot = { id = "com.android.compose.screenshot", version.ref = "screenshot"} +ktfmt = { id = "com.ncorti.ktfmt.gradle", version.ref = "ktfmt"} diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 910314692..e0123177f 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -9580,6 +9580,11 @@ + + + + + @@ -10767,6 +10772,11 @@ + + + + + @@ -10870,6 +10880,11 @@ + + + + + @@ -11365,6 +11380,11 @@ + + + + + @@ -12169,6 +12189,11 @@ + + + + + @@ -15793,6 +15818,11 @@ + + + + + @@ -19745,6 +19775,9 @@ + + + diff --git a/libs/core/build.gradle.kts b/libs/core/build.gradle.kts index 05b9624ef..c5dd5996f 100644 --- a/libs/core/build.gradle.kts +++ b/libs/core/build.gradle.kts @@ -1,79 +1,63 @@ plugins { - alias(libs.plugins.jetbrains.kotlin.multiplatform) - alias(libs.plugins.android.library) - alias(libs.plugins.jetbrains.dokka) - alias(libs.plugins.vanniktech.maven.publish) + alias(libs.plugins.jetbrains.kotlin.multiplatform) + alias(libs.plugins.android.library) + alias(libs.plugins.jetbrains.dokka) + alias(libs.plugins.vanniktech.maven.publish) + alias(libs.plugins.ktfmt) } + kotlin { - androidTarget { - compilerOptions { - jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 - } - publishLibraryVariants("release") - } - explicitApi() - @OptIn(org.jetbrains.kotlin.gradle.dsl.abi.ExperimentalAbiValidation::class) - abiValidation { - enabled = true - } - compilerOptions { - optIn.add("kotlin.RequiresOptIn") - } - sourceSets { - commonMain { - dependencies { - } - } - commonTest { - dependencies { - implementation(kotlin("test")) - } - } - } + androidTarget { + compilerOptions { jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 } + publishLibraryVariants("release") + } + explicitApi() + @OptIn(org.jetbrains.kotlin.gradle.dsl.abi.ExperimentalAbiValidation::class) + abiValidation { enabled = true } + compilerOptions { optIn.add("kotlin.RequiresOptIn") } + sourceSets { + commonMain { dependencies {} } + commonTest { dependencies { implementation(kotlin("test")) } } + } } android { - namespace = "org.fdroid.core" - @Suppress("ktlint:standard:chain-method-continuation") - compileSdk = libs.versions.compileSdk.get().toInt() - defaultConfig { - minSdk = 21 - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - testInstrumentationRunnerArguments["disableAnalytics"] = "true" - } - buildTypes { - getByName("release") { - isMinifyEnabled = false - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro", - ) - } - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + namespace = "org.fdroid.core" + compileSdk = libs.versions.compileSdk.get().toInt() + defaultConfig { + minSdk = 21 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunnerArguments["disableAnalytics"] = "true" + } + buildTypes { + getByName("release") { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } } -signing { - useGpgCmd() -} +ktfmt { googleStyle() } + +signing { useGpgCmd() } mavenPublishing { - @Suppress("ktlint:standard:chain-method-continuation") - configure( - com.vanniktech.maven.publish.KotlinMultiplatform( - javadocJar = com.vanniktech.maven.publish.JavadocJar.Dokka("dokkaHtml"), - sourcesJar = true, - androidVariantsToPublish = listOf("release"), - ), + configure( + com.vanniktech.maven.publish.KotlinMultiplatform( + javadocJar = com.vanniktech.maven.publish.JavadocJar.Dokka("dokkaHtml"), + sourcesJar = true, + androidVariantsToPublish = listOf("release"), ) + ) } dokka { - pluginsConfiguration.html { - customAssets.from("${file("${rootProject.rootDir}/logo-icon.svg")}") - footerMessage.set("© 2010-2025 F-Droid Limited and Contributors") - } + pluginsConfiguration.html { + customAssets.from("${file("${rootProject.rootDir}/logo-icon.svg")}") + footerMessage.set("© 2010-2025 F-Droid Limited and Contributors") + } } diff --git a/libs/core/src/commonMain/kotlin/org/fdroid/IndexFile.kt b/libs/core/src/commonMain/kotlin/org/fdroid/IndexFile.kt index 84b646754..942f521c2 100644 --- a/libs/core/src/commonMain/kotlin/org/fdroid/IndexFile.kt +++ b/libs/core/src/commonMain/kotlin/org/fdroid/IndexFile.kt @@ -1,10 +1,10 @@ package org.fdroid public interface IndexFile { - public val name: String - public val sha256: String? - public val size: Long? - public val ipfsCidV1: String? + public val name: String + public val sha256: String? + public val size: Long? + public val ipfsCidV1: String? - public fun serialize(): String + public fun serialize(): String } diff --git a/libs/database/build.gradle.kts b/libs/database/build.gradle.kts index db3f0e6e8..2f5cdc3cf 100644 --- a/libs/database/build.gradle.kts +++ b/libs/database/build.gradle.kts @@ -1,139 +1,128 @@ plugins { - alias(libs.plugins.jetbrains.kotlin.android) - alias(libs.plugins.android.library) - alias(libs.plugins.android.ksp) - alias(libs.plugins.jetbrains.dokka) - alias(libs.plugins.vanniktech.maven.publish) + alias(libs.plugins.jetbrains.kotlin.android) + alias(libs.plugins.android.library) + alias(libs.plugins.android.ksp) + alias(libs.plugins.jetbrains.dokka) + alias(libs.plugins.vanniktech.maven.publish) + alias(libs.plugins.ktfmt) } android { - namespace = "org.fdroid.database" - @Suppress("ktlint:standard:chain-method-continuation") - compileSdk = libs.versions.compileSdk.get().toInt() + namespace = "org.fdroid.database" + compileSdk = libs.versions.compileSdk.get().toInt() - defaultConfig { - minSdk = 23 - consumerProguardFiles("consumer-rules.pro") - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - testInstrumentationRunnerArguments["disableAnalytics"] = "true" - } + defaultConfig { + minSdk = 23 + consumerProguardFiles("consumer-rules.pro") + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunnerArguments["disableAnalytics"] = "true" + } - buildTypes { - getByName("release") { - isMinifyEnabled = false - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro", - ) - } + buildTypes { + getByName("release") { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } - sourceSets { - getByName("androidTest") { - java.srcDirs("src/dbTest/java") - // Adds exported schema location as test app assets. - assets.srcDirs(files("$projectDir/schemas")) - } - getByName("test") { - java.srcDirs("src/dbTest/java") - // Adds exported schema location as test app assets. - assets.srcDirs(files("$projectDir/schemas")) - } + } + sourceSets { + getByName("androidTest") { + java.srcDirs("src/dbTest/java") + // Adds exported schema location as test app assets. + assets.srcDirs(files("$projectDir/schemas")) } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + getByName("test") { + java.srcDirs("src/dbTest/java") + // Adds exported schema location as test app assets. + assets.srcDirs(files("$projectDir/schemas")) } - testOptions { - targetSdk = 34 // relevant for instrumentation tests (targetSdk 21 fails on Android 14) - unitTests { - isIncludeAndroidResources = true - } - } - androidResources { - // needed only for instrumentation tests: assets.openFd() - noCompress += "json" - } - packaging { - resources { - excludes.add("META-INF/AL2.0") - excludes.add("META-INF/LGPL2.1") - excludes.add("META-INF/LICENSE.md") - excludes.add("META-INF/LICENSE-notice.md") - } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + testOptions { + targetSdk = 34 // relevant for instrumentation tests (targetSdk 21 fails on Android 14) + unitTests { isIncludeAndroidResources = true } + } + androidResources { + // needed only for instrumentation tests: assets.openFd() + noCompress += "json" + } + packaging { + resources { + excludes.add("META-INF/AL2.0") + excludes.add("META-INF/LGPL2.1") + excludes.add("META-INF/LICENSE.md") + excludes.add("META-INF/LICENSE-notice.md") } + } } kotlin { - explicitApi() - @OptIn(org.jetbrains.kotlin.gradle.dsl.abi.ExperimentalAbiValidation::class) - abiValidation { - enabled = true - } - compilerOptions { - jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 - optIn.add("kotlin.RequiresOptIn") - } + explicitApi() + @OptIn(org.jetbrains.kotlin.gradle.dsl.abi.ExperimentalAbiValidation::class) + abiValidation { enabled = true } + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + optIn.add("kotlin.RequiresOptIn") + } } dependencies { - implementation(project(":libs:core")) - implementation(project(":libs:index")) - implementation(project(":libs:download")) // needed for updater code + implementation(project(":libs:core")) + implementation(project(":libs:index")) + implementation(project(":libs:download")) // needed for updater code - implementation(libs.androidx.core.ktx) - implementation(libs.androidx.lifecycle.livedata.ktx) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.livedata.ktx) - implementation(libs.androidx.room.runtime) - implementation(libs.androidx.room.ktx) - ksp(libs.androidx.room.compiler) + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + ksp(libs.androidx.room.compiler) - implementation(libs.microutils.kotlin.logging) - implementation(libs.kotlinx.serialization.json) + implementation(libs.microutils.kotlin.logging) + implementation(libs.kotlinx.serialization.json) - testImplementation(project(":libs:sharedTest")) - testImplementation(libs.junit) - testImplementation(libs.mockk) - testImplementation(libs.kotlin.test) - testImplementation(libs.androidx.test.core.ktx) - testImplementation(libs.androidx.test.ext.junit) - testImplementation(libs.androidx.core.testing) - testImplementation(libs.androidx.room.testing) - testImplementation(libs.robolectric) - testImplementation(libs.commons.io) - testImplementation(libs.logback.classic) - testImplementation(libs.kotlinx.coroutines.test) - testImplementation(libs.turbine) - testImplementation(libs.okhttp) + testImplementation(project(":libs:sharedTest")) + testImplementation(libs.junit) + testImplementation(libs.mockk) + testImplementation(libs.kotlin.test) + testImplementation(libs.androidx.test.core.ktx) + testImplementation(libs.androidx.test.ext.junit) + testImplementation(libs.androidx.core.testing) + testImplementation(libs.androidx.room.testing) + testImplementation(libs.robolectric) + testImplementation(libs.commons.io) + testImplementation(libs.logback.classic) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.turbine) + testImplementation(libs.okhttp) - androidTestImplementation(project(":libs:sharedTest")) - androidTestImplementation(libs.mockk.android) - androidTestImplementation(libs.kotlin.test) - androidTestImplementation(libs.androidx.test.ext.junit) - androidTestImplementation(libs.androidx.core.testing) - androidTestImplementation(libs.androidx.espresso.core) - androidTestImplementation(libs.androidx.room.testing) - androidTestImplementation(libs.commons.io) + androidTestImplementation(project(":libs:sharedTest")) + androidTestImplementation(libs.mockk.android) + androidTestImplementation(libs.kotlin.test) + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.core.testing) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.androidx.room.testing) + androidTestImplementation(libs.commons.io) } -ksp { - arg(RoomSchemaArgProvider(File(projectDir, "schemas"))) -} +ksp { arg(RoomSchemaArgProvider(File(projectDir, "schemas"))) } -signing { - useGpgCmd() -} +ktfmt { googleStyle() } + +signing { useGpgCmd() } dokka { - pluginsConfiguration.html { - customAssets.from("${file("${rootProject.rootDir}/logo-icon.svg")}") - footerMessage.set("© 2010-2025 F-Droid Limited and Contributors") - } + pluginsConfiguration.html { + customAssets.from("${file("${rootProject.rootDir}/logo-icon.svg")}") + footerMessage.set("© 2010-2025 F-Droid Limited and Contributors") + } } class RoomSchemaArgProvider( - @get:InputDirectory - @get:PathSensitive(PathSensitivity.RELATIVE) - val schemaDir: File, + @get:InputDirectory @get:PathSensitive(PathSensitivity.RELATIVE) val schemaDir: File ) : CommandLineArgumentProvider { - override fun asArguments(): Iterable = listOf("room.schemaLocation=${schemaDir.path}") + override fun asArguments(): Iterable = listOf("room.schemaLocation=${schemaDir.path}") } diff --git a/libs/database/src/main/java/org/fdroid/database/App.kt b/libs/database/src/main/java/org/fdroid/database/App.kt index a9609f9f2..f43bc6161 100644 --- a/libs/database/src/main/java/org/fdroid/database/App.kt +++ b/libs/database/src/main/java/org/fdroid/database/App.kt @@ -22,77 +22,80 @@ import org.fdroid.index.v2.MetadataV2 import org.fdroid.index.v2.Screenshots public interface MinimalApp { - public val repoId: Long - public val packageName: String - public val name: String? - public val summary: String? - public fun getIcon(localeList: LocaleListCompat): FileV2? + public val repoId: Long + public val packageName: String + public val name: String? + public val summary: String? + + public fun getIcon(localeList: LocaleListCompat): FileV2? } /** - * The detailed metadata for an app. - * Almost all fields are optional. - * This largely represents [MetadataV2] in a database table. + * The detailed metadata for an app. Almost all fields are optional. This largely represents + * [MetadataV2] in a database table. */ @Entity( - tableName = AppMetadata.TABLE, - primaryKeys = ["repoId", "packageName"], - foreignKeys = [ForeignKey( + tableName = AppMetadata.TABLE, + primaryKeys = ["repoId", "packageName"], + foreignKeys = + [ + ForeignKey( entity = CoreRepository::class, parentColumns = ["repoId"], childColumns = ["repoId"], onDelete = ForeignKey.CASCADE, - )], + ) + ], ) public data class AppMetadata( - public val repoId: Long, - public val packageName: String, - public val added: Long, - public val lastUpdated: Long, - public val name: LocalizedTextV2? = null, - public val summary: LocalizedTextV2? = null, - public val description: LocalizedTextV2? = null, - public val localizedName: String? = null, - public val localizedSummary: String? = null, - public val webSite: String? = null, - public val changelog: String? = null, - public val license: String? = null, - public val sourceCode: String? = null, - public val issueTracker: String? = null, - public val translation: String? = null, - public val preferredSigner: String? = null, - public val video: LocalizedTextV2? = null, - public val authorName: String? = null, - public val authorEmail: String? = null, - public val authorWebSite: String? = null, - public val authorPhone: String? = null, - public val donate: List? = null, - public val liberapayID: String? = null, - public val liberapay: String? = null, - public val openCollective: String? = null, - public val bitcoin: String? = null, - public val litecoin: String? = null, - public val flattrID: String? = null, - public val categories: List? = null, - /** - * Whether the app is compatible with the current device. - * This value will be computed and is always false until that happened. - * So to always get correct data, this MUST happen within the same transaction - * that adds the [AppMetadata]. - */ - public val isCompatible: Boolean, + public val repoId: Long, + public val packageName: String, + public val added: Long, + public val lastUpdated: Long, + public val name: LocalizedTextV2? = null, + public val summary: LocalizedTextV2? = null, + public val description: LocalizedTextV2? = null, + public val localizedName: String? = null, + public val localizedSummary: String? = null, + public val webSite: String? = null, + public val changelog: String? = null, + public val license: String? = null, + public val sourceCode: String? = null, + public val issueTracker: String? = null, + public val translation: String? = null, + public val preferredSigner: String? = null, + public val video: LocalizedTextV2? = null, + public val authorName: String? = null, + public val authorEmail: String? = null, + public val authorWebSite: String? = null, + public val authorPhone: String? = null, + public val donate: List? = null, + public val liberapayID: String? = null, + public val liberapay: String? = null, + public val openCollective: String? = null, + public val bitcoin: String? = null, + public val litecoin: String? = null, + public val flattrID: String? = null, + public val categories: List? = null, + /** + * Whether the app is compatible with the current device. This value will be computed and is + * always false until that happened. So to always get correct data, this MUST happen within the + * same transaction that adds the [AppMetadata]. + */ + public val isCompatible: Boolean, ) { - internal companion object { - const val TABLE = "AppMetadata" - } + internal companion object { + const val TABLE = "AppMetadata" + } } internal fun MetadataV2.toAppMetadata( - repoId: Long, - packageName: String, - isCompatible: Boolean = false, - locales: LocaleListCompat = getLocales(Resources.getSystem().configuration), -) = AppMetadata( + repoId: Long, + packageName: String, + isCompatible: Boolean = false, + locales: LocaleListCompat = getLocales(Resources.getSystem().configuration), +) = + AppMetadata( repoId = repoId, packageName = packageName, added = added, @@ -123,420 +126,418 @@ internal fun MetadataV2.toAppMetadata( flattrID = flattrID, categories = categories, isCompatible = isCompatible, -) + ) /** - * Introduce zero whitespace for CJK (Chinese, Japanese, Korean) languages. - * This is needed, because the sqlite tokenizers available to us either handle those languages - * or do diacritics removals. - * Since we can't remove diacritics here ourselves, - * we help the tokenizer for CJK languages instead. + * Introduce zero whitespace for CJK (Chinese, Japanese, Korean) languages. This is needed, because + * the sqlite tokenizers available to us either handle those languages or do diacritics removals. + * Since we can't remove diacritics here ourselves, we help the tokenizer for CJK languages instead. */ internal fun LocalizedTextV2?.zero(): LocalizedTextV2? { - if (this == null) return null - return toMutableMap().mapValues { (locale, text) -> - if (locale.startsWith("zh") || locale.startsWith("ja") || locale.startsWith("ko")) { - StringBuilder().apply { - text.forEachIndexed { i, char -> - if (Character.isIdeographic(char.code) && i + 1 < text.length) { - append(char) - append("\u200B") - } else { - append(char) - } - } - }.toString() - } else { - text + if (this == null) return null + return toMutableMap().mapValues { (locale, text) -> + if (locale.startsWith("zh") || locale.startsWith("ja") || locale.startsWith("ko")) { + StringBuilder() + .apply { + text.forEachIndexed { i, char -> + if (Character.isIdeographic(char.code) && i + 1 < text.length) { + append(char) + append("\u200B") + } else { + append(char) + } + } } + .toString() + } else { + text } + } } @Entity(tableName = AppMetadataFts.TABLE) @Fts4( - contentEntity = AppMetadata::class, - // make FTS for non-ASCII characters case insensitive, CJK languages are handled separately, - // because there's no tokenizer available that handles everything - tokenizer = FtsOptions.TOKENIZER_UNICODE61, - // can't use remove_diacritics=2 because it is SDK_INT >=30 - // see: https://www.twisterrob.net/blog/2023/10/sqlite-unicode61-remove-diacritics-2.html - // separators=. is mainly for package name search - // tokenchars=- is so that searching for F-Droid works as expected - tokenizerArgs = ["remove_diacritics=1", "separators=.", "tokenchars=-"], - notIndexed = ["repoId"], + contentEntity = AppMetadata::class, + // make FTS for non-ASCII characters case insensitive, CJK languages are handled separately, + // because there's no tokenizer available that handles everything + tokenizer = FtsOptions.TOKENIZER_UNICODE61, + // can't use remove_diacritics=2 because it is SDK_INT >=30 + // see: https://www.twisterrob.net/blog/2023/10/sqlite-unicode61-remove-diacritics-2.html + // separators=. is mainly for package name search + // tokenchars=- is so that searching for F-Droid works as expected + tokenizerArgs = ["remove_diacritics=1", "separators=.", "tokenchars=-"], + notIndexed = ["repoId"], ) internal data class AppMetadataFts( - val repoId: Long, - val name: String? = null, - val summary: String? = null, - val description: String? = null, - val authorName: String? = null, - val packageName: String, + val repoId: Long, + val name: String? = null, + val summary: String? = null, + val description: String? = null, + val authorName: String? = null, + val packageName: String, ) { - internal companion object { - const val TABLE = "AppMetadataFts" - } + internal companion object { + const val TABLE = "AppMetadataFts" + } } /** - * A class to represent all data of an App. - * It combines the metadata and localized filed such as icons and screenshots. + * A class to represent all data of an App. It combines the metadata and localized filed such as + * icons and screenshots. */ @ConsistentCopyVisibility -public data class App internal constructor( - @Embedded public val metadata: AppMetadata, - @Relation( - parentColumn = "packageName", - entityColumn = "packageName", - ) - private val localizedFiles: List? = null, - @Relation( - parentColumn = "packageName", - entityColumn = "packageName", - ) - private val localizedFileLists: List? = null, +public data class App +internal constructor( + @Embedded public val metadata: AppMetadata, + @Relation(parentColumn = "packageName", entityColumn = "packageName") + private val localizedFiles: List? = null, + @Relation(parentColumn = "packageName", entityColumn = "packageName") + private val localizedFileLists: List? = null, ) : MinimalApp { - public override val repoId: Long get() = metadata.repoId - override val packageName: String get() = metadata.packageName - public val authorName: String? get() = metadata.authorName - internal val icon: LocalizedFileV2? get() = getLocalizedFile("icon") - internal val featureGraphic: LocalizedFileV2? get() = getLocalizedFile("featureGraphic") - internal val promoGraphic: LocalizedFileV2? get() = getLocalizedFile("promoGraphic") - internal val tvBanner: LocalizedFileV2? get() = getLocalizedFile("tvBanner") - internal val screenshots: Screenshots? - get() = if (localizedFileLists.isNullOrEmpty()) null else Screenshots( + public override val repoId: Long + get() = metadata.repoId + + override val packageName: String + get() = metadata.packageName + + public val authorName: String? + get() = metadata.authorName + + internal val icon: LocalizedFileV2? + get() = getLocalizedFile("icon") + + internal val featureGraphic: LocalizedFileV2? + get() = getLocalizedFile("featureGraphic") + + internal val promoGraphic: LocalizedFileV2? + get() = getLocalizedFile("promoGraphic") + + internal val tvBanner: LocalizedFileV2? + get() = getLocalizedFile("tvBanner") + + internal val screenshots: Screenshots? + get() = + if (localizedFileLists.isNullOrEmpty()) null + else + Screenshots( phone = getLocalizedFileList("phone"), sevenInch = getLocalizedFileList("sevenInch"), tenInch = getLocalizedFileList("tenInch"), wear = getLocalizedFileList("wear"), tv = getLocalizedFileList("tv"), - ).takeIf { !it.isNull } + ) + .takeIf { !it.isNull } - private fun getLocalizedFile(type: String): LocalizedFileV2? { - return localizedFiles?.filter { localizedFile -> - localizedFile.repoId == metadata.repoId && localizedFile.type == type - }?.toLocalizedFileV2() + private fun getLocalizedFile(type: String): LocalizedFileV2? { + return localizedFiles + ?.filter { localizedFile -> + localizedFile.repoId == metadata.repoId && localizedFile.type == type + } + ?.toLocalizedFileV2() + } + + private fun getLocalizedFileList(type: String): LocalizedFileListV2? { + val map = HashMap>() + localizedFileLists?.iterator()?.forEach { file -> + if (file.repoId != metadata.repoId || file.type != type) return@forEach + val list = map.getOrPut(file.locale) { ArrayList() } as ArrayList + list.add( + FileV2(name = file.name, sha256 = file.sha256, size = file.size, ipfsCidV1 = file.ipfsCidV1) + ) } + return map.ifEmpty { null } + } - private fun getLocalizedFileList(type: String): LocalizedFileListV2? { - val map = HashMap>() - localizedFileLists?.iterator()?.forEach { file -> - if (file.repoId != metadata.repoId || file.type != type) return@forEach - val list = map.getOrPut(file.locale) { ArrayList() } as ArrayList - list.add( - FileV2( - name = file.name, - sha256 = file.sha256, - size = file.size, - ipfsCidV1 = file.ipfsCidV1, - ) - ) - } - return map.ifEmpty { null } - } + public override val name: String? + get() = metadata.localizedName - public override val name: String? get() = metadata.localizedName - public override val summary: String? get() = metadata.localizedSummary - public fun getDescription(localeList: LocaleListCompat): String? = - metadata.description.getBestLocale(localeList) + public override val summary: String? + get() = metadata.localizedSummary - public fun getVideo(localeList: LocaleListCompat): String? = - metadata.video.getBestLocale(localeList) + public fun getDescription(localeList: LocaleListCompat): String? = + metadata.description.getBestLocale(localeList) - public override fun getIcon(localeList: LocaleListCompat): FileV2? = - icon.getBestLocale(localeList) + public fun getVideo(localeList: LocaleListCompat): String? = + metadata.video.getBestLocale(localeList) - public fun getFeatureGraphic(localeList: LocaleListCompat): FileV2? = - featureGraphic.getBestLocale(localeList) + public override fun getIcon(localeList: LocaleListCompat): FileV2? = + icon.getBestLocale(localeList) - public fun getPromoGraphic(localeList: LocaleListCompat): FileV2? = - promoGraphic.getBestLocale(localeList) + public fun getFeatureGraphic(localeList: LocaleListCompat): FileV2? = + featureGraphic.getBestLocale(localeList) - public fun getTvBanner(localeList: LocaleListCompat): FileV2? = - tvBanner.getBestLocale(localeList) + public fun getPromoGraphic(localeList: LocaleListCompat): FileV2? = + promoGraphic.getBestLocale(localeList) - public fun getPhoneScreenshots(localeList: LocaleListCompat): List = - screenshots?.phone.getBestLocale(localeList) ?: emptyList() + public fun getTvBanner(localeList: LocaleListCompat): FileV2? = tvBanner.getBestLocale(localeList) - public fun getSevenInchScreenshots(localeList: LocaleListCompat): List = - screenshots?.sevenInch.getBestLocale(localeList) ?: emptyList() + public fun getPhoneScreenshots(localeList: LocaleListCompat): List = + screenshots?.phone.getBestLocale(localeList) ?: emptyList() - public fun getTenInchScreenshots(localeList: LocaleListCompat): List = - screenshots?.tenInch.getBestLocale(localeList) ?: emptyList() + public fun getSevenInchScreenshots(localeList: LocaleListCompat): List = + screenshots?.sevenInch.getBestLocale(localeList) ?: emptyList() - public fun getTvScreenshots(localeList: LocaleListCompat): List = - screenshots?.tv.getBestLocale(localeList) ?: emptyList() + public fun getTenInchScreenshots(localeList: LocaleListCompat): List = + screenshots?.tenInch.getBestLocale(localeList) ?: emptyList() - public fun getWearScreenshots(localeList: LocaleListCompat): List = - screenshots?.wear.getBestLocale(localeList) ?: emptyList() + public fun getTvScreenshots(localeList: LocaleListCompat): List = + screenshots?.tv.getBestLocale(localeList) ?: emptyList() + + public fun getWearScreenshots(localeList: LocaleListCompat): List = + screenshots?.wear.getBestLocale(localeList) ?: emptyList() } /** * A lightweight variant of [App] with minimal data, usually used to provide an overview of apps - * without going into all details that get presented on a dedicated screen. - * The reduced data footprint helps with fast loading many items at once. + * without going into all details that get presented on a dedicated screen. The reduced data + * footprint helps with fast loading many items at once. * * It includes [antiFeatureKeys] so some clients can apply filters to them. */ @ConsistentCopyVisibility -public data class AppOverviewItem internal constructor( - public override val repoId: Long, - public override val packageName: String, - public val added: Long, - public val lastUpdated: Long, - @ColumnInfo(name = "localizedName") - @Deprecated("Use getName() method instead.") - public override val name: String? = null, - @ColumnInfo(name = "localizedSummary") - @Deprecated("Use getSummary() method instead.") - public override val summary: String? = null, - @ColumnInfo(name = "name") - internal val internalName: LocalizedTextV2? = null, - @ColumnInfo(name = "summary") - internal val internalSummary: LocalizedTextV2? = null, - public val categories: List? = null, - internal val antiFeatures: Map? = null, - @Relation( - parentColumn = "packageName", - entityColumn = "packageName", - ) - internal val localizedIcon: List? = null, - /** - * If true, this this app has at least one version that is compatible with this device. - */ - public val isCompatible: Boolean, +public data class AppOverviewItem +internal constructor( + public override val repoId: Long, + public override val packageName: String, + public val added: Long, + public val lastUpdated: Long, + @ColumnInfo(name = "localizedName") + @Deprecated("Use getName() method instead.") + public override val name: String? = null, + @ColumnInfo(name = "localizedSummary") + @Deprecated("Use getSummary() method instead.") + public override val summary: String? = null, + @ColumnInfo(name = "name") internal val internalName: LocalizedTextV2? = null, + @ColumnInfo(name = "summary") internal val internalSummary: LocalizedTextV2? = null, + public val categories: List? = null, + internal val antiFeatures: Map? = null, + @Relation(parentColumn = "packageName", entityColumn = "packageName") + internal val localizedIcon: List? = null, + /** If true, this app has at least one version that is compatible with this device. */ + public val isCompatible: Boolean, ) : MinimalApp { - public fun getName(localeList: LocaleListCompat): String? { - return internalName.getBestLocale(localeList) - } + public fun getName(localeList: LocaleListCompat): String? { + return internalName.getBestLocale(localeList) + } - public fun getSummary(localeList: LocaleListCompat): String? { - return internalSummary.getBestLocale(localeList) - } + public fun getSummary(localeList: LocaleListCompat): String? { + return internalSummary.getBestLocale(localeList) + } - public override fun getIcon(localeList: LocaleListCompat): FileV2? { - return localizedIcon?.filter { icon -> - icon.repoId == repoId - }?.toLocalizedFileV2().getBestLocale(localeList) - } + public override fun getIcon(localeList: LocaleListCompat): FileV2? { + return localizedIcon + ?.filter { icon -> icon.repoId == repoId } + ?.toLocalizedFileV2() + .getBestLocale(localeList) + } - public val antiFeatureKeys: List get() = antiFeatures?.map { it.key } ?: emptyList() + public val antiFeatureKeys: List + get() = antiFeatures?.map { it.key } ?: emptyList() } /** - * Similar to [AppOverviewItem], this is a lightweight version of [App] - * meant to show a list of apps. + * Similar to [AppOverviewItem], this is a lightweight version of [App] meant to show a list of + * apps. * - * There is additional information about [installedVersionCode] and [installedVersionName] - * as well as [isCompatible]. + * There is additional information about [installedVersionCode] and [installedVersionName] as well + * as [isCompatible]. * * It includes [antiFeatureKeys] of the highest version, so some clients can apply filters to them. */ @ConsistentCopyVisibility -public data class AppListItem internal constructor( - public override val repoId: Long, - public override val packageName: String, - @ColumnInfo(name = "localizedName") - public override val name: String? = null, - @ColumnInfo(name = "localizedSummary") - public override val summary: String? = null, - public val lastUpdated: Long, - public val categories: List? = null, - internal val antiFeatures: String?, - @Relation( - parentColumn = "packageName", - entityColumn = "packageName", - ) - internal val localizedIcon: List?, - /** - * If true, this this app has at least one version that is compatible with this device. - */ - public val isCompatible: Boolean, - /** - * The signer, this app prefers to use for new installs. - */ - public val preferredSigner: String? = null, - /** - * The name of the installed version, null if this app is not installed. - */ - @get:Ignore - public val installedVersionName: String? = null, - /** - * The version code of the installed version, null if this app is not installed. - */ - @get:Ignore - public val installedVersionCode: Long? = null, +public data class AppListItem +internal constructor( + public override val repoId: Long, + public override val packageName: String, + @ColumnInfo(name = "localizedName") public override val name: String? = null, + @ColumnInfo(name = "localizedSummary") public override val summary: String? = null, + public val lastUpdated: Long, + public val categories: List? = null, + internal val antiFeatures: String?, + @Relation(parentColumn = "packageName", entityColumn = "packageName") + internal val localizedIcon: List?, + /** If true, this this app has at least one version that is compatible with this device. */ + public val isCompatible: Boolean, + /** The signer, this app prefers to use for new installs. */ + public val preferredSigner: String? = null, + /** The name of the installed version, null if this app is not installed. */ + @get:Ignore public val installedVersionName: String? = null, + /** The version code of the installed version, null if this app is not installed. */ + @get:Ignore public val installedVersionCode: Long? = null, ) : MinimalApp { - @delegate:Ignore - private val antiFeaturesDecoded by lazy { - fromStringToMapOfLocalizedTextV2(antiFeatures) - } + @delegate:Ignore + private val antiFeaturesDecoded by lazy { fromStringToMapOfLocalizedTextV2(antiFeatures) } - public override fun getIcon(localeList: LocaleListCompat): FileV2? { - return localizedIcon?.filter { icon -> - icon.repoId == repoId - }?.toLocalizedFileV2().getBestLocale(localeList) - } + public override fun getIcon(localeList: LocaleListCompat): FileV2? { + return localizedIcon + ?.filter { icon -> icon.repoId == repoId } + ?.toLocalizedFileV2() + .getBestLocale(localeList) + } - public val antiFeatureKeys: List - get() = antiFeaturesDecoded?.map { it.key } ?: emptyList() + public val antiFeatureKeys: List + get() = antiFeaturesDecoded?.map { it.key } ?: emptyList() - public fun getAntiFeatureReason(antiFeatureKey: String, localeList: LocaleListCompat): String? { - return antiFeaturesDecoded?.get(antiFeatureKey)?.getBestLocale(localeList) - } + public fun getAntiFeatureReason(antiFeatureKey: String, localeList: LocaleListCompat): String? { + return antiFeaturesDecoded?.get(antiFeatureKey)?.getBestLocale(localeList) + } } -/** - * An app that has an [update] available. - * It is meant to display available updates in the UI. - */ +/** An app that has an [update] available. It is meant to display available updates in the UI. */ @ConsistentCopyVisibility -public data class UpdatableApp internal constructor( - public override val repoId: Long, - public override val packageName: String, - public val installedVersionCode: Long, - public val installedVersionName: String, - public val update: AppVersion, - @Deprecated("Use AppWithIssue instead: UpdateInOtherRepo") - public val isFromPreferredRepo: Boolean, - /** - * If true, this is not necessarily an update (contrary to the class name), - * but an app with the `KnownVuln` anti-feature. - */ - @Deprecated("Use AppWithIssue instead: KnownVulnerability") - public val hasKnownVulnerability: Boolean, - public override val name: String? = null, - public override val summary: String? = null, - internal val localizedIcon: List? = null, +public data class UpdatableApp +internal constructor( + public override val repoId: Long, + public override val packageName: String, + public val installedVersionCode: Long, + public val installedVersionName: String, + public val update: AppVersion, + @Deprecated("Use AppWithIssue instead: UpdateInOtherRepo") + public val isFromPreferredRepo: Boolean, + /** + * If true, this is not necessarily an update (contrary to the class name), but an app with the + * `KnownVuln` anti-feature. + */ + @Deprecated("Use AppWithIssue instead: KnownVulnerability") + public val hasKnownVulnerability: Boolean, + public override val name: String? = null, + public override val summary: String? = null, + internal val localizedIcon: List? = null, ) : MinimalApp { - public override fun getIcon(localeList: LocaleListCompat): FileV2? { - return localizedIcon?.filter { icon -> - icon.repoId == update.repoId - }?.toLocalizedFileV2().getBestLocale(localeList) - } + public override fun getIcon(localeList: LocaleListCompat): FileV2? { + return localizedIcon + ?.filter { icon -> icon.repoId == update.repoId } + ?.toLocalizedFileV2() + .getBestLocale(localeList) + } } internal interface IFile { - val type: String - val locale: String - val name: String - val sha256: String? - val size: Long? - val ipfsCidV1: String? + val type: String + val locale: String + val name: String + val sha256: String? + val size: Long? + val ipfsCidV1: String? } @Entity( - tableName = LocalizedFile.TABLE, - primaryKeys = ["repoId", "packageName", "type", "locale"], - foreignKeys = [ForeignKey( + tableName = LocalizedFile.TABLE, + primaryKeys = ["repoId", "packageName", "type", "locale"], + foreignKeys = + [ + ForeignKey( entity = AppMetadata::class, parentColumns = ["repoId", "packageName"], childColumns = ["repoId", "packageName"], onDelete = ForeignKey.CASCADE, - )], + ) + ], ) internal data class LocalizedFile( - val repoId: Long, - val packageName: String, - override val type: String, - override val locale: String, - override val name: String, - override val sha256: String? = null, - override val size: Long? = null, - override val ipfsCidV1: String? = null, + val repoId: Long, + val packageName: String, + override val type: String, + override val locale: String, + override val name: String, + override val sha256: String? = null, + override val size: Long? = null, + override val ipfsCidV1: String? = null, ) : IFile { - internal companion object { - const val TABLE = "LocalizedFile" - } + internal companion object { + const val TABLE = "LocalizedFile" + } } internal fun LocalizedFileV2.toLocalizedFile( - repoId: Long, - packageName: String, - type: String, + repoId: Long, + packageName: String, + type: String, ): List = map { (locale, file) -> - LocalizedFile( - repoId = repoId, - packageName = packageName, - type = type, - locale = locale, - name = file.name, - sha256 = file.sha256, - size = file.size, - ipfsCidV1 = file.ipfsCidV1, - ) + LocalizedFile( + repoId = repoId, + packageName = packageName, + type = type, + locale = locale, + name = file.name, + sha256 = file.sha256, + size = file.size, + ipfsCidV1 = file.ipfsCidV1, + ) } -internal fun List.toLocalizedFileV2(): LocalizedFileV2? = associate { file -> - file.locale to FileV2( - name = file.name, - sha256 = file.sha256, - size = file.size, - ipfsCidV1 = file.ipfsCidV1, - ) -}.ifEmpty { null } +internal fun List.toLocalizedFileV2(): LocalizedFileV2? = + associate { file -> + file.locale to + FileV2(name = file.name, sha256 = file.sha256, size = file.size, ipfsCidV1 = file.ipfsCidV1) + } + .ifEmpty { null } // We can't restrict this query further (e.g. only from enabled repos or max weight), // because we are using this via @Relation on packageName for specific repos. // When filtering the result for only the repoId we are interested in, we'd get no icons. @DatabaseView( - viewName = LocalizedIcon.TABLE, - value = "SELECT * FROM ${LocalizedFile.TABLE} WHERE type='icon'", + viewName = LocalizedIcon.TABLE, + value = "SELECT * FROM ${LocalizedFile.TABLE} WHERE type='icon'", ) internal data class LocalizedIcon( - val repoId: Long, - val packageName: String, - override val type: String, - override val locale: String, - override val name: String, - override val sha256: String? = null, - override val size: Long? = null, - override val ipfsCidV1: String? = null, + val repoId: Long, + val packageName: String, + override val type: String, + override val locale: String, + override val name: String, + override val sha256: String? = null, + override val size: Long? = null, + override val ipfsCidV1: String? = null, ) : IFile { - internal companion object { - const val TABLE = "LocalizedIcon" - } + internal companion object { + const val TABLE = "LocalizedIcon" + } } @Entity( - tableName = LocalizedFileList.TABLE, - primaryKeys = ["repoId", "packageName", "type", "locale", "name"], - foreignKeys = [ForeignKey( + tableName = LocalizedFileList.TABLE, + primaryKeys = ["repoId", "packageName", "type", "locale", "name"], + foreignKeys = + [ + ForeignKey( entity = AppMetadata::class, parentColumns = ["repoId", "packageName"], childColumns = ["repoId", "packageName"], onDelete = ForeignKey.CASCADE, - )], + ) + ], ) internal data class LocalizedFileList( - val repoId: Long, - val packageName: String, - val type: String, - val locale: String, - val name: String, - val sha256: String? = null, - val size: Long? = null, - val ipfsCidV1: String? = null, + val repoId: Long, + val packageName: String, + val type: String, + val locale: String, + val name: String, + val sha256: String? = null, + val size: Long? = null, + val ipfsCidV1: String? = null, ) { - internal companion object { - const val TABLE = "LocalizedFileList" - } + internal companion object { + const val TABLE = "LocalizedFileList" + } } internal fun LocalizedFileListV2.toLocalizedFileList( - repoId: Long, - packageName: String, - type: String, + repoId: Long, + packageName: String, + type: String, ): List = flatMap { (locale, files) -> - files.map { file -> file.toLocalizedFileList(repoId, packageName, type, locale) } + files.map { file -> file.toLocalizedFileList(repoId, packageName, type, locale) } } internal fun FileV2.toLocalizedFileList( - repoId: Long, - packageName: String, - type: String, - locale: String, -) = LocalizedFileList( + repoId: Long, + packageName: String, + type: String, + locale: String, +) = + LocalizedFileList( repoId = repoId, packageName = packageName, type = type, @@ -545,4 +546,4 @@ internal fun FileV2.toLocalizedFileList( sha256 = sha256, size = size, ipfsCidV1 = ipfsCidV1, -) + ) diff --git a/libs/database/src/main/java/org/fdroid/database/AppCheckResult.kt b/libs/database/src/main/java/org/fdroid/database/AppCheckResult.kt index e642cf47b..c39724284 100644 --- a/libs/database/src/main/java/org/fdroid/database/AppCheckResult.kt +++ b/libs/database/src/main/java/org/fdroid/database/AppCheckResult.kt @@ -1,54 +1,48 @@ package org.fdroid.database -public data class AppCheckResult( - val updates: List, - val issues: List, -) +public data class AppCheckResult(val updates: List, val issues: List) public sealed interface AppWithIssue { - public val packageName: String - public val installVersionName: String - public val issue: AppIssue + public val packageName: String + public val installVersionName: String + public val issue: AppIssue } public data class AvailableAppWithIssue( - val app: AppOverviewItem, - override val installVersionName: String, - val installVersionCode: Long, - override val issue: AppIssue, + val app: AppOverviewItem, + override val installVersionName: String, + val installVersionCode: Long, + override val issue: AppIssue, ) : AppWithIssue { - override val packageName: String = app.packageName + override val packageName: String = app.packageName } public data class UnavailableAppWithIssue( - override val packageName: String, - val name: CharSequence?, - override val installVersionName: String, - val installVersionCode: Long, + override val packageName: String, + val name: CharSequence?, + override val installVersionName: String, + val installVersionCode: Long, ) : AppWithIssue { - override val issue: AppIssue = NotAvailable + override val issue: AppIssue = NotAvailable } public sealed interface AppIssue -/** - * An app that we installed in the past, but is no longer available in any (enabled) repository. - */ +/** An app that we installed in the past, but is no longer available in any (enabled) repository. */ public data object NotAvailable : AppIssue /** - * An app that can not get updated, because all versions have an incompatible signer. - * There may be compatible versions in another repo. + * An app that can not get updated, because all versions have an incompatible signer. There may be + * compatible versions in another repo. */ public data class NoCompatibleSigner(val repoIdWithCompatibleSigner: Long? = null) : AppIssue -/** - * An app that could get updated, but only from another repo. - */ +/** An app that could get updated, but only from another repo. */ public data class UpdateInOtherRepo(val repoIdWithUpdate: Long) : AppIssue /** * Has a known vulnerability and should either get updated or uninstalled. + * * @param fromPreferredRepo true if the preferred repo had marked the app with known vulnerability. */ public data class KnownVulnerability(val fromPreferredRepo: Boolean) : AppIssue diff --git a/libs/database/src/main/java/org/fdroid/database/AppDao.kt b/libs/database/src/main/java/org/fdroid/database/AppDao.kt index 8dc055689..20752f616 100644 --- a/libs/database/src/main/java/org/fdroid/database/AppDao.kt +++ b/libs/database/src/main/java/org/fdroid/database/AppDao.kt @@ -22,6 +22,7 @@ import androidx.room.RoomWarnings.Companion.QUERY_MISMATCH import androidx.room.Transaction import androidx.room.Update import androidx.sqlite.SQLiteStatement +import java.util.concurrent.TimeUnit import kotlinx.coroutines.flow.Flow import kotlinx.serialization.SerializationException import kotlinx.serialization.json.JsonNull @@ -38,197 +39,167 @@ import org.fdroid.index.v2.LocalizedFileListV2 import org.fdroid.index.v2.LocalizedFileV2 import org.fdroid.index.v2.MetadataV2 import org.fdroid.index.v2.ReflectionDiffer.applyDiff -import java.util.concurrent.TimeUnit public interface AppDao { - /** - * Inserts an app into the DB. - * This is usually from a full index v2 via [MetadataV2]. - * - * Note: The app is considered to be not compatible until [Version]s are added - * and [updateCompatibility] was called. - * - * @param locales supported by the current system configuration. - */ - public fun insert( - repoId: Long, - packageName: String, - app: MetadataV2, - locales: LocaleListCompat = getLocales(Resources.getSystem().configuration), - ) + /** + * Inserts an app into the DB. This is usually from a full index v2 via [MetadataV2]. + * + * Note: The app is considered to be not compatible until [Version]s are added and + * [updateCompatibility] was called. + * + * @param locales supported by the current system configuration. + */ + public fun insert( + repoId: Long, + packageName: String, + app: MetadataV2, + locales: LocaleListCompat = getLocales(Resources.getSystem().configuration), + ) - /** - * Updates the [AppMetadata.isCompatible] flag - * based on whether at least one [AppVersion] is compatible. - * This needs to run within the transaction that adds [AppMetadata] to the DB (e.g. [insert]). - * Otherwise the compatibility is wrong. - */ - public fun updateCompatibility(repoId: Long) + /** + * Updates the [AppMetadata.isCompatible] flag based on whether at least one [AppVersion] is + * compatible. This needs to run within the transaction that adds [AppMetadata] to the DB (e.g. + * [insert]). Otherwise the compatibility is wrong. + */ + public fun updateCompatibility(repoId: Long) - /** - * Gets the app from the DB. If more than one app with this [packageName] exists, - * the one from the repository with the highest weight is returned. - */ - public fun getApp(packageName: String): LiveData + /** + * Gets the app from the DB. If more than one app with this [packageName] exists, the one from the + * repository with the highest weight is returned. + */ + public fun getApp(packageName: String): LiveData - /** - * Gets an app from a specific [Repository] or null, - * if none is found with the given [packageName], - */ - public fun getApp(repoId: Long, packageName: String): App? + /** + * Gets an app from a specific [Repository] or null, if none is found with the given + * [packageName], + */ + public fun getApp(repoId: Long, packageName: String): App? - /** - * Returns a list of all enabled repositories identified by their [Repository.repoId] - * that contain the app identified by the given [packageName]. - */ - public fun getRepositoryIdsForApp(packageName: String): List + /** + * Returns a list of all enabled repositories identified by their [Repository.repoId] that contain + * the app identified by the given [packageName]. + */ + public fun getRepositoryIdsForApp(packageName: String): List - /** - * Returns a limited number of apps with limited data. - * Apps without name, icon or summary are at the end (or excluded if limit is too small). - * Includes anti-features from the version with the highest version code. - */ - @Deprecated("Use getNewAppsFlow and getRecentlyUpdatedAppsFlow instead") - public fun getAppOverviewItems(limit: Int = 200): LiveData> + /** + * Returns a limited number of apps with limited data. Apps without name, icon or summary are at + * the end (or excluded if limit is too small). Includes anti-features from the version with the + * highest version code. + */ + @Deprecated("Use getNewAppsFlow and getRecentlyUpdatedAppsFlow instead") + public fun getAppOverviewItems(limit: Int = 200): LiveData> - /** - * Returns a limited number of apps with limited data within the given [category]. - */ - @Deprecated("Use getAppsByCategory instead") - public fun getAppOverviewItems( - category: String, - limit: Int = 50, - ): LiveData> + /** Returns a limited number of apps with limited data within the given [category]. */ + @Deprecated("Use getAppsByCategory instead") + public fun getAppOverviewItems(category: String, limit: Int = 50): LiveData> - /** - * Returns all apps from the database. - */ - public suspend fun getAllApps(): List + /** Returns all apps from the database. */ + public suspend fun getAllApps(): List - /** - * Returns all apps whose author is set exactly to [authorName]. - */ - public suspend fun getAppsByAuthor(authorName: String): List + /** Returns all apps whose author is set exactly to [authorName]. */ + public suspend fun getAppsByAuthor(authorName: String): List - /** - * Returns all apps that are in the category with [categoryId]. - */ - public suspend fun getAppsByCategory(categoryId: String): List + /** Returns all apps that are in the category with [categoryId]. */ + public suspend fun getAppsByCategory(categoryId: String): List - /** - * Returns apps that are new. This means that they were added and last updated at the same time. - * @param maxAgeInDays the number of days that is still considered "new". - * Apps older than this won't be returned. - */ - public suspend fun getNewApps(maxAgeInDays: Long = 14): List + /** + * Returns apps that are new. This means that they were added and last updated at the same time. + * + * @param maxAgeInDays the number of days that is still considered "new". Apps older than this + * won't be returned. + */ + public suspend fun getNewApps(maxAgeInDays: Long = 14): List - /** - * Get apps that were recently updated. - * This excludes apps returned by [getNewApps]. - * @param limit only return that many apps and not more. - */ - public suspend fun getRecentlyUpdatedApps(limit: Int = 200): List + /** + * Get apps that were recently updated. This excludes apps returned by [getNewApps]. + * + * @param limit only return that many apps and not more. + */ + public suspend fun getRecentlyUpdatedApps(limit: Int = 200): List - /** - * Get all apps from the repository identified by [repoId]. - */ - public suspend fun getAppsByRepository(repoId: Long): List + /** Get all apps from the repository identified by [repoId]. */ + public suspend fun getAppsByRepository(repoId: Long): List - /** - * Returns apps for the given [packageNames]. - */ - public suspend fun getApps(packageNames: List): List + /** Returns apps for the given [packageNames]. */ + public suspend fun getApps(packageNames: List): List - /** - * Same as [getNewApps], but returns an observable [Flow]. - */ - public fun getNewAppsFlow(maxAgeInDays: Long = 14): Flow> + /** Same as [getNewApps], but returns an observable [Flow]. */ + public fun getNewAppsFlow(maxAgeInDays: Long = 14): Flow> - /** - * Same as [getRecentlyUpdatedApps], but returns an observable [Flow]. - */ - public fun getRecentlyUpdatedAppsFlow(limit: Int = 200): Flow> + /** Same as [getRecentlyUpdatedApps], but returns an observable [Flow]. */ + public fun getRecentlyUpdatedAppsFlow(limit: Int = 200): Flow> - /** - * Returns apps for the given [packageNames]. - */ - public fun getAppsFlow(packageNames: List): Flow> + /** Returns apps for the given [packageNames]. */ + public fun getAppsFlow(packageNames: List): Flow> - /** - * Returns a list of all [AppListItem] sorted by the given [sortOrder], - * or a subset of [AppListItem]s filtered by the given [searchQuery] if it is non-null. - * In the later case, the [sortOrder] gets ignored. - */ - public fun getAppListItems( - packageManager: PackageManager, - searchQuery: String?, - sortOrder: AppListSortOrder, - ): LiveData> + /** + * Returns a list of all [AppListItem] sorted by the given [sortOrder], or a subset of + * [AppListItem]s filtered by the given [searchQuery] if it is non-null. In the later case, the + * [sortOrder] gets ignored. + */ + public fun getAppListItems( + packageManager: PackageManager, + searchQuery: String?, + sortOrder: AppListSortOrder, + ): LiveData> - /** - * Like [getAppListItems], but further filter items by the given [category]. - */ - public fun getAppListItems( - packageManager: PackageManager, - category: String, - searchQuery: String?, - sortOrder: AppListSortOrder, - ): LiveData> + /** Like [getAppListItems], but further filter items by the given [category]. */ + public fun getAppListItems( + packageManager: PackageManager, + category: String, + searchQuery: String?, + sortOrder: AppListSortOrder, + ): LiveData> - /** - * Like [getAppListItems], but further filter items by the given [repoId]. - */ - public fun getAppListItems( - packageManager: PackageManager, - repoId: Long, - searchQuery: String?, - sortOrder: AppListSortOrder, - ): LiveData> + /** Like [getAppListItems], but further filter items by the given [repoId]. */ + public fun getAppListItems( + packageManager: PackageManager, + repoId: Long, + searchQuery: String?, + sortOrder: AppListSortOrder, + ): LiveData> - /** - * Like [getAppListItems], but filters items by the given [author] - */ - public fun getAppListItemsForAuthor( - packageManager: PackageManager, - author: String, - searchQuery: String?, - sortOrder: AppListSortOrder - ): LiveData> + /** Like [getAppListItems], but filters items by the given [author] */ + public fun getAppListItemsForAuthor( + packageManager: PackageManager, + author: String, + searchQuery: String?, + sortOrder: AppListSortOrder, + ): LiveData> - /** - * Returns `true` if the given [author] has at least two apps in the database. - */ - public fun hasAuthorMoreThanOneApp(author: String): LiveData + /** Returns `true` if the given [author] has at least two apps in the database. */ + public fun hasAuthorMoreThanOneApp(author: String): LiveData - public fun getInstalledAppListItems(packageManager: PackageManager): LiveData> - public fun getInstalledAppListItems( - packageInfoMap: Map, - ): Flow> + public fun getInstalledAppListItems(packageManager: PackageManager): LiveData> - public suspend fun getAppSearchItems(searchQuery: String): List + public fun getInstalledAppListItems( + packageInfoMap: Map + ): Flow> - public fun getNumberOfAppsInCategory(category: String): Int + public suspend fun getAppSearchItems(searchQuery: String): List - public fun getNumberOfAppsInRepository(repoId: Long): Int + public fun getNumberOfAppsInCategory(category: String): Int + + public fun getNumberOfAppsInRepository(repoId: Long): Int } public enum class AppListSortOrder { - LAST_UPDATED, - NAME, + LAST_UPDATED, + NAME, } /** * A list of unknown fields in [MetadataV2] that we don't allow for [AppMetadata]. * - * We are applying reflection diffs against internal database classes - * and need to prevent the untrusted external JSON input to modify internal fields in those classes. - * This list must always hold the names of all those internal FIELDS for [AppMetadata]. + * We are applying reflection diffs against internal database classes and need to prevent the + * untrusted external JSON input to modify internal fields in those classes. This list must always + * hold the names of all those internal FIELDS for [AppMetadata]. */ private val DENY_LIST = listOf("packageName", "repoId") /** - * A list of unknown fields in [LocalizedFileV2] or [LocalizedFileListV2] - * that we don't allow for [LocalizedFile] or [LocalizedFileList]. + * A list of unknown fields in [LocalizedFileV2] or [LocalizedFileListV2] that we don't allow for + * [LocalizedFile] or [LocalizedFileList]. * * Similar to [DENY_LIST]. */ @@ -237,208 +208,214 @@ private val DENY_FILE_LIST = listOf("packageName", "repoId", "type") @Dao internal interface AppDaoInt : AppDao { - @Transaction - override fun insert( - repoId: Long, - packageName: String, - app: MetadataV2, - locales: LocaleListCompat, - ) { - insert(app.toAppMetadata(repoId, packageName, false, locales)) - app.icon.insert(repoId, packageName, "icon") - app.featureGraphic.insert(repoId, packageName, "featureGraphic") - app.promoGraphic.insert(repoId, packageName, "promoGraphic") - app.tvBanner.insert(repoId, packageName, "tvBanner") - app.screenshots?.let { - it.phone.insert(repoId, packageName, "phone") - it.sevenInch.insert(repoId, packageName, "sevenInch") - it.tenInch.insert(repoId, packageName, "tenInch") - it.wear.insert(repoId, packageName, "wear") - it.tv.insert(repoId, packageName, "tv") - } + @Transaction + override fun insert( + repoId: Long, + packageName: String, + app: MetadataV2, + locales: LocaleListCompat, + ) { + insert(app.toAppMetadata(repoId, packageName, false, locales)) + app.icon.insert(repoId, packageName, "icon") + app.featureGraphic.insert(repoId, packageName, "featureGraphic") + app.promoGraphic.insert(repoId, packageName, "promoGraphic") + app.tvBanner.insert(repoId, packageName, "tvBanner") + app.screenshots?.let { + it.phone.insert(repoId, packageName, "phone") + it.sevenInch.insert(repoId, packageName, "sevenInch") + it.tenInch.insert(repoId, packageName, "tenInch") + it.wear.insert(repoId, packageName, "wear") + it.tv.insert(repoId, packageName, "tv") } + } - private fun LocalizedFileV2?.insert(repoId: Long, packageName: String, type: String) { - this?.toLocalizedFile(repoId, packageName, type)?.let { files -> - insert(files) - } + private fun LocalizedFileV2?.insert(repoId: Long, packageName: String, type: String) { + this?.toLocalizedFile(repoId, packageName, type)?.let { files -> insert(files) } + } + + @JvmName("insertLocalizedFileListV2") + private fun LocalizedFileListV2?.insert(repoId: Long, packageName: String, type: String) { + this?.toLocalizedFileList(repoId, packageName, type)?.let { files -> + insertLocalizedFileLists(files) } + } - @JvmName("insertLocalizedFileListV2") - private fun LocalizedFileListV2?.insert(repoId: Long, packageName: String, type: String) { - this?.toLocalizedFileList(repoId, packageName, type)?.let { files -> - insertLocalizedFileLists(files) - } + @Insert(onConflict = REPLACE) fun insert(appMetadata: AppMetadata) + + @Insert(onConflict = REPLACE) fun insert(localizedFiles: List) + + @Insert(onConflict = REPLACE) + fun insertLocalizedFileLists(localizedFiles: List) + + @Transaction + fun updateApp( + repoId: Long, + packageName: String, + jsonObject: JsonObject?, + locales: LocaleListCompat, + ) { + if (jsonObject == null) { + // this app is gone, we need to delete it + deleteAppMetadata(repoId, packageName) + return } - - @Insert(onConflict = REPLACE) - fun insert(appMetadata: AppMetadata) - - @Insert(onConflict = REPLACE) - fun insert(localizedFiles: List) - - @Insert(onConflict = REPLACE) - fun insertLocalizedFileLists(localizedFiles: List) - - @Transaction - fun updateApp( - repoId: Long, - packageName: String, - jsonObject: JsonObject?, - locales: LocaleListCompat, - ) { - if (jsonObject == null) { - // this app is gone, we need to delete it - deleteAppMetadata(repoId, packageName) - return - } - val metadata = getAppMetadata(repoId, packageName) - if (metadata == null) { // new app - val metadataV2: MetadataV2 = json.decodeFromJsonElement(jsonObject) - insert(repoId, packageName, metadataV2) - } else { // diff against existing app - // ensure that diff does not include internal keys - DENY_LIST.forEach { forbiddenKey -> - if (jsonObject.containsKey(forbiddenKey)) throw SerializationException(forbiddenKey) - } - // diff metadata - val diffedApp = applyDiff(metadata, jsonObject) - val containsName = jsonObject.containsKey("name") - val containsSummary = jsonObject.containsKey("summary") - val containsDescription = jsonObject.containsKey("description") - val updatedApp = if (containsName || containsSummary || containsDescription) { - diffedApp.copy( - name = if (containsName) diffedApp.name.zero() else diffedApp.name, - summary = if (containsSummary) diffedApp.summary.zero() else diffedApp.summary, - description = if (containsDescription) diffedApp.description.zero() - else diffedApp.description, - localizedName = diffedApp.name.getBestLocale(locales), - localizedSummary = diffedApp.summary.getBestLocale(locales), - ) - } else diffedApp - updateAppMetadata(updatedApp) - // diff localizedFiles - val localizedFiles = getLocalizedFiles(repoId, packageName) - localizedFiles.diffAndUpdate(repoId, packageName, "icon", jsonObject) - localizedFiles.diffAndUpdate(repoId, packageName, "featureGraphic", jsonObject) - localizedFiles.diffAndUpdate(repoId, packageName, "promoGraphic", jsonObject) - localizedFiles.diffAndUpdate(repoId, packageName, "tvBanner", jsonObject) - // diff localizedFileLists - val screenshots = jsonObject["screenshots"] - if (screenshots is JsonNull) { - deleteLocalizedFileLists(repoId, packageName) - } else if (screenshots is JsonObject) { - diffAndUpdateLocalizedFileList(repoId, packageName, "phone", screenshots) - diffAndUpdateLocalizedFileList(repoId, packageName, "sevenInch", screenshots) - diffAndUpdateLocalizedFileList(repoId, packageName, "tenInch", screenshots) - diffAndUpdateLocalizedFileList(repoId, packageName, "wear", screenshots) - diffAndUpdateLocalizedFileList(repoId, packageName, "tv", screenshots) - } - } + val metadata = getAppMetadata(repoId, packageName) + if (metadata == null) { // new app + val metadataV2: MetadataV2 = json.decodeFromJsonElement(jsonObject) + insert(repoId, packageName, metadataV2) + } else { // diff against existing app + // ensure that diff does not include internal keys + DENY_LIST.forEach { forbiddenKey -> + if (jsonObject.containsKey(forbiddenKey)) throw SerializationException(forbiddenKey) + } + // diff metadata + val diffedApp = applyDiff(metadata, jsonObject) + val containsName = jsonObject.containsKey("name") + val containsSummary = jsonObject.containsKey("summary") + val containsDescription = jsonObject.containsKey("description") + val updatedApp = + if (containsName || containsSummary || containsDescription) { + diffedApp.copy( + name = if (containsName) diffedApp.name.zero() else diffedApp.name, + summary = if (containsSummary) diffedApp.summary.zero() else diffedApp.summary, + description = + if (containsDescription) diffedApp.description.zero() else diffedApp.description, + localizedName = diffedApp.name.getBestLocale(locales), + localizedSummary = diffedApp.summary.getBestLocale(locales), + ) + } else diffedApp + updateAppMetadata(updatedApp) + // diff localizedFiles + val localizedFiles = getLocalizedFiles(repoId, packageName) + localizedFiles.diffAndUpdate(repoId, packageName, "icon", jsonObject) + localizedFiles.diffAndUpdate(repoId, packageName, "featureGraphic", jsonObject) + localizedFiles.diffAndUpdate(repoId, packageName, "promoGraphic", jsonObject) + localizedFiles.diffAndUpdate(repoId, packageName, "tvBanner", jsonObject) + // diff localizedFileLists + val screenshots = jsonObject["screenshots"] + if (screenshots is JsonNull) { + deleteLocalizedFileLists(repoId, packageName) + } else if (screenshots is JsonObject) { + diffAndUpdateLocalizedFileList(repoId, packageName, "phone", screenshots) + diffAndUpdateLocalizedFileList(repoId, packageName, "sevenInch", screenshots) + diffAndUpdateLocalizedFileList(repoId, packageName, "tenInch", screenshots) + diffAndUpdateLocalizedFileList(repoId, packageName, "wear", screenshots) + diffAndUpdateLocalizedFileList(repoId, packageName, "tv", screenshots) + } } + } - private fun List.diffAndUpdate( - repoId: Long, - packageName: String, - type: String, - jsonObject: JsonObject, - ) = diffAndUpdateTable( - jsonObject = jsonObject, - jsonObjectKey = type, - itemList = filter { it.type == type }, - itemFinder = { locale, item -> item.locale == locale }, - newItem = { locale -> LocalizedFile(repoId, packageName, type, locale, "") }, - deleteAll = { deleteLocalizedFiles(repoId, packageName, type) }, - deleteOne = { locale -> deleteLocalizedFile(repoId, packageName, type, locale) }, - insertReplace = { list -> insert(list) }, - isNewItemValid = { it.name.isNotEmpty() }, - keyDenyList = DENY_FILE_LIST, + private fun List.diffAndUpdate( + repoId: Long, + packageName: String, + type: String, + jsonObject: JsonObject, + ) = + diffAndUpdateTable( + jsonObject = jsonObject, + jsonObjectKey = type, + itemList = filter { it.type == type }, + itemFinder = { locale, item -> item.locale == locale }, + newItem = { locale -> LocalizedFile(repoId, packageName, type, locale, "") }, + deleteAll = { deleteLocalizedFiles(repoId, packageName, type) }, + deleteOne = { locale -> deleteLocalizedFile(repoId, packageName, type, locale) }, + insertReplace = { list -> insert(list) }, + isNewItemValid = { it.name.isNotEmpty() }, + keyDenyList = DENY_FILE_LIST, ) - private fun diffAndUpdateLocalizedFileList( - repoId: Long, - packageName: String, - type: String, - jsonObject: JsonObject, - ) { - diffAndUpdateListTable( - jsonObject = jsonObject, - jsonObjectKey = type, - listParser = { locale, jsonArray -> - json.decodeFromJsonElement>(jsonArray).map { - it.toLocalizedFileList(repoId, packageName, type, locale) - } - }, - deleteAll = { deleteLocalizedFileLists(repoId, packageName, type) }, - deleteList = { locale -> deleteLocalizedFileList(repoId, packageName, type, locale) }, - insertNewList = { _, fileLists -> insertLocalizedFileLists(fileLists) }, - ) - } + private fun diffAndUpdateLocalizedFileList( + repoId: Long, + packageName: String, + type: String, + jsonObject: JsonObject, + ) { + diffAndUpdateListTable( + jsonObject = jsonObject, + jsonObjectKey = type, + listParser = { locale, jsonArray -> + json.decodeFromJsonElement>(jsonArray).map { + it.toLocalizedFileList(repoId, packageName, type, locale) + } + }, + deleteAll = { deleteLocalizedFileLists(repoId, packageName, type) }, + deleteList = { locale -> deleteLocalizedFileList(repoId, packageName, type, locale) }, + insertNewList = { _, fileLists -> insertLocalizedFileLists(fileLists) }, + ) + } - /** - * This is needed to support v1 streaming and shouldn't be used for something else. - */ - @Deprecated("Only for v1 index") - @Query("""UPDATE ${AppMetadata.TABLE} SET preferredSigner = :preferredSigner - WHERE repoId = :repoId AND packageName = :packageName""") - fun updatePreferredSigner(repoId: Long, packageName: String, preferredSigner: String?) + /** This is needed to support v1 streaming and shouldn't be used for something else. */ + @Deprecated("Only for v1 index") + @Query( + """UPDATE ${AppMetadata.TABLE} SET preferredSigner = :preferredSigner + WHERE repoId = :repoId AND packageName = :packageName""" + ) + fun updatePreferredSigner(repoId: Long, packageName: String, preferredSigner: String?) - @Query("""UPDATE ${AppMetadata.TABLE} + @Query( + """UPDATE ${AppMetadata.TABLE} SET isCompatible = ( SELECT TOTAL(isCompatible) > 0 FROM ${Version.TABLE} WHERE repoId = :repoId AND ${AppMetadata.TABLE}.packageName = ${Version.TABLE}.packageName ) - WHERE repoId = :repoId""") - override fun updateCompatibility(repoId: Long) + WHERE repoId = :repoId""" + ) + override fun updateCompatibility(repoId: Long) - @Deprecated("Will be removed in future version") - @Query("""UPDATE ${AppMetadata.TABLE} SET localizedName = :name, localizedSummary = :summary - WHERE repoId = :repoId AND packageName = :packageName""") - fun updateAppMetadata(repoId: Long, packageName: String, name: String?, summary: String?) + @Deprecated("Will be removed in future version") + @Query( + """UPDATE ${AppMetadata.TABLE} SET localizedName = :name, localizedSummary = :summary + WHERE repoId = :repoId AND packageName = :packageName""" + ) + fun updateAppMetadata(repoId: Long, packageName: String, name: String?, summary: String?) - @Update - fun updateAppMetadata(appMetadata: AppMetadata): Int + @Update fun updateAppMetadata(appMetadata: AppMetadata): Int - @Transaction - @Query("""SELECT ${AppMetadata.TABLE}.* FROM ${AppMetadata.TABLE} + @Transaction + @Query( + """SELECT ${AppMetadata.TABLE}.* FROM ${AppMetadata.TABLE} JOIN RepositoryPreferences AS pref USING (repoId) JOIN PreferredRepo USING (packageName) WHERE packageName = :packageName AND pref.enabled = 1 AND repoId = preferredRepoId - ORDER BY pref.weight DESC LIMIT 1""") - override fun getApp(packageName: String): LiveData + ORDER BY pref.weight DESC LIMIT 1""" + ) + override fun getApp(packageName: String): LiveData - @Transaction - @Query("""SELECT * FROM ${AppMetadata.TABLE} - WHERE repoId = :repoId AND packageName = :packageName""") - override fun getApp(repoId: Long, packageName: String): App? + @Transaction + @Query( + """SELECT * FROM ${AppMetadata.TABLE} + WHERE repoId = :repoId AND packageName = :packageName""" + ) + override fun getApp(repoId: Long, packageName: String): App? - @Query("""SELECT repoId FROM ${AppMetadata.TABLE} + @Query( + """SELECT repoId FROM ${AppMetadata.TABLE} JOIN RepositoryPreferences AS pref USING (repoId) - WHERE pref.enabled = 1 AND packageName = :packageName""") - override fun getRepositoryIdsForApp(packageName: String): List + WHERE pref.enabled = 1 AND packageName = :packageName""" + ) + override fun getRepositoryIdsForApp(packageName: String): List - /** - * Used for diffing. - */ - @Query("""SELECT * FROM ${AppMetadata.TABLE} - WHERE repoId = :repoId AND packageName = :packageName""") - fun getAppMetadata(repoId: Long, packageName: String): AppMetadata? + /** Used for diffing. */ + @Query( + """SELECT * FROM ${AppMetadata.TABLE} + WHERE repoId = :repoId AND packageName = :packageName""" + ) + fun getAppMetadata(repoId: Long, packageName: String): AppMetadata? - /** - * Used for updating best locales. - */ - @Query("SELECT * FROM ${AppMetadata.TABLE}") - fun getAppMetadata(): List + /** Used for updating best locales. */ + @Query("SELECT * FROM ${AppMetadata.TABLE}") fun getAppMetadata(): List - /** - * used for diffing - */ - @Query("""SELECT * FROM ${LocalizedFile.TABLE} - WHERE repoId = :repoId AND packageName = :packageName""") - fun getLocalizedFiles(repoId: Long, packageName: String): List + /** used for diffing */ + @Query( + """SELECT * FROM ${LocalizedFile.TABLE} + WHERE repoId = :repoId AND packageName = :packageName""" + ) + fun getLocalizedFiles(repoId: Long, packageName: String): List - @Transaction - @Query("""SELECT repoId, packageName, app.added, app.lastUpdated, localizedName, + @Deprecated("Use getNewAppsFlow and getRecentlyUpdatedAppsFlow instead") + @Transaction + @Query( + """SELECT repoId, packageName, app.added, app.lastUpdated, localizedName, localizedSummary, app.name, summary, categories, version.antiFeatures, app.isCompatible FROM ${AppMetadata.TABLE} AS app JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) @@ -449,11 +426,14 @@ internal interface AppDaoInt : AppDao { GROUP BY packageName HAVING MAX(pref.weight) ORDER BY localizedName IS NULL ASC, icon.packageName IS NULL ASC, localizedSummary IS NULL ASC, app.lastUpdated DESC - LIMIT :limit""") - override fun getAppOverviewItems(limit: Int): LiveData> + LIMIT :limit""" + ) + override fun getAppOverviewItems(limit: Int): LiveData> - @Transaction - @Query("""SELECT repoId, packageName, app.added, app.lastUpdated, localizedName, + @Deprecated("Use getAppsByCategory instead") + @Transaction + @Query( + """SELECT repoId, packageName, app.added, app.lastUpdated, localizedName, localizedSummary, app.name, summary, categories, version.antiFeatures, app.isCompatible FROM ${AppMetadata.TABLE} AS app JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) @@ -465,176 +445,163 @@ internal interface AppDaoInt : AppDao { GROUP BY packageName HAVING MAX(pref.weight) ORDER BY localizedName IS NULL ASC, icon.packageName IS NULL ASC, localizedSummary IS NULL ASC, app.lastUpdated DESC - LIMIT :limit""") - override fun getAppOverviewItems(category: String, limit: Int): LiveData> + LIMIT :limit""" + ) + override fun getAppOverviewItems(category: String, limit: Int): LiveData> - /** - * Used by [DbUpdateChecker] to get specific apps with available updates. - */ - @Transaction - @SuppressWarnings(QUERY_MISMATCH) // no anti-features needed here - @Query("""SELECT repoId, packageName, added, app.lastUpdated, localizedName, + /** Used by [DbUpdateChecker] to get specific apps with available updates. */ + @Transaction + @SuppressWarnings(QUERY_MISMATCH) // no anti-features needed here + @Query( + """SELECT repoId, packageName, added, app.lastUpdated, localizedName, localizedSummary, name, summary, categories, app.isCompatible - FROM ${AppMetadata.TABLE} AS app WHERE repoId = :repoId AND packageName = :packageName""") - fun getAppOverviewItem(repoId: Long, packageName: String): AppOverviewItem? + FROM ${AppMetadata.TABLE} AS app WHERE repoId = :repoId AND packageName = :packageName""" + ) + fun getAppOverviewItem(repoId: Long, packageName: String): AppOverviewItem? - @Transaction - override suspend fun getAllApps(): List { - val query = getAppsQuery("") {} - return getApps(query) - } + @Transaction + override suspend fun getAllApps(): List { + val query = getAppsQuery("") {} + return getApps(query) + } - @Transaction - override suspend fun getAppsByAuthor(authorName: String): List { - val query = getAppsQuery("authorName = ?") { statement -> - statement.bindText(1, authorName) - } - return getApps(query) - } + @Transaction + override suspend fun getAppsByAuthor(authorName: String): List { + val query = getAppsQuery("authorName = ?") { statement -> statement.bindText(1, authorName) } + return getApps(query) + } - @Transaction - override suspend fun getAppsByCategory(categoryId: String): List { - val query = getAppsQuery("categories LIKE '%,' || ? || ',%'") { statement -> - statement.bindText(1, categoryId) - } - return getApps(query) - } + @Transaction + override suspend fun getAppsByCategory(categoryId: String): List { + val query = + getAppsQuery("categories LIKE '%,' || ? || ',%'") { statement -> + statement.bindText(1, categoryId) + } + return getApps(query) + } - @Transaction - override suspend fun getNewApps(maxAgeInDays: Long): List { - val query = - getAppsQuery("app.added = app.lastUpdated AND app.lastUpdated > ?") { statement -> - statement.bindLong( - 1, - System.currentTimeMillis() - TimeUnit.DAYS.toMillis(maxAgeInDays) - ) - } - return getApps(query) - } + @Transaction + override suspend fun getNewApps(maxAgeInDays: Long): List { + val query = + getAppsQuery("app.added = app.lastUpdated AND app.lastUpdated > ?") { statement -> + statement.bindLong(1, System.currentTimeMillis() - TimeUnit.DAYS.toMillis(maxAgeInDays)) + } + return getApps(query) + } - @Transaction - override suspend fun getRecentlyUpdatedApps(limit: Int): List { - val query = getAppsQuery( - "app.added != app.lastUpdated ORDER BY app.lastUpdated DESC LIMIT ?" - ) { statement -> - statement.bindInt(1, limit) - } - return getApps(query) - } + @Transaction + override suspend fun getRecentlyUpdatedApps(limit: Int): List { + val query = + getAppsQuery("app.added != app.lastUpdated ORDER BY app.lastUpdated DESC LIMIT ?") { statement + -> + statement.bindInt(1, limit) + } + return getApps(query) + } - @Transaction - @Query("""SELECT repoId, packageName, app.added, app.lastUpdated, localizedName, + @Transaction + @Query( + """SELECT repoId, packageName, app.added, app.lastUpdated, localizedName, localizedSummary, name, summary, categories, version.antiFeatures, app.isCompatible FROM ${AppMetadata.TABLE} AS app LEFT JOIN ${HighestVersion.TABLE} AS version USING (repoId, packageName) - WHERE repoId = :repoId""") - override suspend fun getAppsByRepository(repoId: Long): List + WHERE repoId = :repoId""" + ) + override suspend fun getAppsByRepository(repoId: Long): List - override suspend fun getApps(packageNames: List): List { - val placeholders = buildString { - repeat(packageNames.size) { append("?,") } - }.trimEnd(',') - val query = getAppsQuery( - "packageName IN ($placeholders) ORDER BY app.lastUpdated DESC" - ) { statement -> - packageNames.forEachIndexed { i, packageName -> - statement.bindText(i + 1, packageName) - } - } - return getApps(query) - } + override suspend fun getApps(packageNames: List): List { + val placeholders = buildString { repeat(packageNames.size) { append("?,") } }.trimEnd(',') + val query = + getAppsQuery("packageName IN ($placeholders) ORDER BY app.lastUpdated DESC") { statement -> + packageNames.forEachIndexed { i, packageName -> statement.bindText(i + 1, packageName) } + } + return getApps(query) + } - override fun getNewAppsFlow(maxAgeInDays: Long): Flow> { - val query = - getAppsQuery( - "app.added = app.lastUpdated AND app.lastUpdated > ? ORDER BY app.added DESC" - ) { statement -> - statement.bindLong( - 1, - System.currentTimeMillis() - TimeUnit.DAYS.toMillis(maxAgeInDays) - ) - } - return getAppsFlow(query) - } + override fun getNewAppsFlow(maxAgeInDays: Long): Flow> { + val query = + getAppsQuery("app.added = app.lastUpdated AND app.lastUpdated > ? ORDER BY app.added DESC") { + statement -> + statement.bindLong(1, System.currentTimeMillis() - TimeUnit.DAYS.toMillis(maxAgeInDays)) + } + return getAppsFlow(query) + } - override fun getRecentlyUpdatedAppsFlow(limit: Int): Flow> { - val query = getAppsQuery( - "app.added != app.lastUpdated ORDER BY app.lastUpdated DESC LIMIT ?" - ) { statement -> - statement.bindInt(1, limit) - } - return getAppsFlow(query) - } + override fun getRecentlyUpdatedAppsFlow(limit: Int): Flow> { + val query = + getAppsQuery("app.added != app.lastUpdated ORDER BY app.lastUpdated DESC LIMIT ?") { statement + -> + statement.bindInt(1, limit) + } + return getAppsFlow(query) + } - override fun getAppsFlow(packageNames: List): Flow> { - val placeholders = buildString { - repeat(packageNames.size) { append("?,") } - }.trimEnd(',') - val query = getAppsQuery( - "packageName IN ($placeholders) ORDER BY app.lastUpdated DESC" - ) { statement -> - packageNames.forEachIndexed { i, packageName -> - statement.bindText(i + 1, packageName) - } - } - return getAppsFlow(query) - } + override fun getAppsFlow(packageNames: List): Flow> { + val placeholders = buildString { repeat(packageNames.size) { append("?,") } }.trimEnd(',') + val query = + getAppsQuery("packageName IN ($placeholders) ORDER BY app.lastUpdated DESC") { statement -> + packageNames.forEachIndexed { i, packageName -> statement.bindText(i + 1, packageName) } + } + return getAppsFlow(query) + } - private fun getAppsQuery( - whereQuery: String, - onBindStatement: (SQLiteStatement) -> Unit, - ): RoomRawQuery { - val queryBuilder = StringBuilder( - """ + private fun getAppsQuery( + whereQuery: String, + onBindStatement: (SQLiteStatement) -> Unit, + ): RoomRawQuery { + val queryBuilder = + StringBuilder( + """ SELECT repoId, packageName, app.added, app.lastUpdated, localizedName, localizedSummary, name, summary, categories, version.antiFeatures, app.isCompatible FROM ${AppMetadata.TABLE} AS app JOIN PreferredRepo USING (packageName) LEFT JOIN ${HighestVersion.TABLE} AS version USING (repoId, packageName) WHERE repoId = preferredRepoId""" - ) - if (whereQuery.isNotEmpty()) queryBuilder.append(" AND ").append(whereQuery) - return RoomRawQuery( - sql = queryBuilder.toString().trimIndent(), - onBindStatement = onBindStatement, - ) - } - - @RawQuery - suspend fun getApps(query: RoomRawQuery): List - - @Transaction - @RawQuery( - observedEntities = [ - AppMetadata::class, Version::class, Repository::class, RepositoryPreferences::class, - ] + ) + if (whereQuery.isNotEmpty()) queryBuilder.append(" AND ").append(whereQuery) + return RoomRawQuery( + sql = queryBuilder.toString().trimIndent(), + onBindStatement = onBindStatement, ) - fun getAppsFlow(query: RoomRawQuery): Flow> + } - // - // AppListItems - // + @RawQuery suspend fun getApps(query: RoomRawQuery): List - override fun getAppListItems( - packageManager: PackageManager, - searchQuery: String?, - sortOrder: AppListSortOrder, - ): LiveData> { - return if (searchQuery.isNullOrEmpty()) when (sortOrder) { - LAST_UPDATED -> getAppListItemsByLastUpdated().map(packageManager) - NAME -> getAppListItemsByName().map(packageManager) - } else getAppListItems(escapeQuery(searchQuery)).map(packageManager) - } + @Transaction + @RawQuery( + observedEntities = + [AppMetadata::class, Version::class, Repository::class, RepositoryPreferences::class] + ) + fun getAppsFlow(query: RoomRawQuery): Flow> - override fun getAppListItems( - packageManager: PackageManager, - category: String, - searchQuery: String?, - sortOrder: AppListSortOrder, - ): LiveData> { - return if (searchQuery.isNullOrEmpty()) { - val queryBuilder = - StringBuilder(""" + // + // AppListItems + // + + override fun getAppListItems( + packageManager: PackageManager, + searchQuery: String?, + sortOrder: AppListSortOrder, + ): LiveData> { + return if (searchQuery.isNullOrEmpty()) + when (sortOrder) { + LAST_UPDATED -> getAppListItemsByLastUpdated().map(packageManager) + NAME -> getAppListItemsByName().map(packageManager) + } + else getAppListItems(escapeQuery(searchQuery)).map(packageManager) + } + + override fun getAppListItems( + packageManager: PackageManager, + category: String, + searchQuery: String?, + sortOrder: AppListSortOrder, + ): LiveData> { + return if (searchQuery.isNullOrEmpty()) { + val queryBuilder = + StringBuilder( + """ SELECT repoId, packageName, localizedName, localizedSummary, app.lastUpdated, categories, version.antiFeatures, app.isCompatible, app.preferredSigner FROM ${AppMetadata.TABLE} AS app @@ -645,52 +612,58 @@ internal interface AppDaoInt : AppDao { WHERE pref.enabled = 1 AND repoId = PreferredRepo.preferredRepoId AND categories LIKE '%,' || ? || ',%' - GROUP BY packageName HAVING MAX(pref.weight)""") - addOrderBy(queryBuilder, sortOrder) - val rawQuery = RoomRawQuery( - sql = queryBuilder.toString().trimIndent(), - onBindStatement = { it.bindText(1, category) } - ) - this.getAppListItems(rawQuery).map(packageManager) - } else { - getAppListItems(category, escapeQuery(searchQuery)).map(packageManager) - } + GROUP BY packageName HAVING MAX(pref.weight)""" + ) + addOrderBy(queryBuilder, sortOrder) + val rawQuery = + RoomRawQuery( + sql = queryBuilder.toString().trimIndent(), + onBindStatement = { it.bindText(1, category) }, + ) + this.getAppListItems(rawQuery).map(packageManager) + } else { + getAppListItems(category, escapeQuery(searchQuery)).map(packageManager) } + } - override fun getAppListItems( - packageManager: PackageManager, - repoId: Long, - searchQuery: String?, - sortOrder: AppListSortOrder, - ): LiveData> { - return if (searchQuery.isNullOrEmpty()) { - val queryBuilder = - StringBuilder(""" + override fun getAppListItems( + packageManager: PackageManager, + repoId: Long, + searchQuery: String?, + sortOrder: AppListSortOrder, + ): LiveData> { + return if (searchQuery.isNullOrEmpty()) { + val queryBuilder = + StringBuilder( + """ SELECT repoId, packageName, localizedName, localizedSummary, app.lastUpdated, categories, version.antiFeatures, app.isCompatible, app.preferredSigner FROM ${AppMetadata.TABLE} AS app LEFT JOIN ${HighestVersion.TABLE} AS version USING (repoId, packageName) - WHERE repoId = :repoId""") - addOrderBy(queryBuilder, sortOrder) - val rawQuery = RoomRawQuery( - sql = queryBuilder.toString().trimIndent(), - onBindStatement = { it.bindLong(1, repoId) } - ) - this.getAppListItems(rawQuery).map(packageManager) - } else { - getAppListItems(repoId, escapeQuery(searchQuery)).map(packageManager) - } + WHERE repoId = :repoId""" + ) + addOrderBy(queryBuilder, sortOrder) + val rawQuery = + RoomRawQuery( + sql = queryBuilder.toString().trimIndent(), + onBindStatement = { it.bindLong(1, repoId) }, + ) + this.getAppListItems(rawQuery).map(packageManager) + } else { + getAppListItems(repoId, escapeQuery(searchQuery)).map(packageManager) } + } - override fun getAppListItemsForAuthor( - packageManager: PackageManager, - authorName: String, - searchQuery: String?, - sortOrder: AppListSortOrder - ): LiveData> { - return if (searchQuery.isNullOrEmpty()) { - val queryBuilder = - StringBuilder(""" + override fun getAppListItemsForAuthor( + packageManager: PackageManager, + author: String, + searchQuery: String?, + sortOrder: AppListSortOrder, + ): LiveData> { + return if (searchQuery.isNullOrEmpty()) { + val queryBuilder = + StringBuilder( + """ SELECT repoId, packageName, localizedName, localizedSummary, app.lastUpdated, categories, version.antiFeatures, app.isCompatible, app.preferredSigner FROM ${AppMetadata.TABLE} AS app @@ -699,55 +672,57 @@ internal interface AppDaoInt : AppDao { LEFT JOIN ${HighestVersion.TABLE} AS version USING (repoId, packageName) WHERE pref.enabled = 1 AND authorName = ? AND PreferredRepo.preferredRepoId = repoId - GROUP BY packageName HAVING MAX(pref.weight)""") - addOrderBy(queryBuilder, sortOrder) - val rawQuery = RoomRawQuery( - sql = queryBuilder.toString().trimIndent(), - onBindStatement = { it.bindText(1, authorName) } - ) - this.getAppListItems(rawQuery).map(packageManager) - } else { - getAppListItemsForAuthor(authorName, escapeQuery(searchQuery)).map(packageManager) - } + GROUP BY packageName HAVING MAX(pref.weight)""" + ) + addOrderBy(queryBuilder, sortOrder) + val rawQuery = + RoomRawQuery( + sql = queryBuilder.toString().trimIndent(), + onBindStatement = { it.bindText(1, author) }, + ) + this.getAppListItems(rawQuery).map(packageManager) + } else { + getAppListItemsForAuthor(author, escapeQuery(searchQuery)).map(packageManager) + } + } + + private fun addOrderBy(queryBuilder: StringBuilder, sortOrder: AppListSortOrder) { + when (sortOrder) { + LAST_UPDATED -> queryBuilder.append(" ORDER BY app.lastUpdated DESC") + NAME -> queryBuilder.append(" ORDER BY localizedName COLLATE NOCASE ASC") + } + } + + private fun escapeQuery(searchQuery: String): String { + val sanitized = searchQuery.replace(Regex.fromLiteral("\""), "\"\"") + return "\"*$sanitized*\"" + } + + private fun LiveData>.map( + packageManager: PackageManager + ): LiveData> { + val installedPackages = + packageManager.getInstalledPackages(0).associateBy { packageInfo -> packageInfo.packageName } + return map(installedPackages) + } + + private fun LiveData>.map(installedPackages: Map) = + map { items -> + items.map { item -> + val packageInfo = installedPackages[item.packageName] + if (packageInfo == null) item + else + item.copy( + installedVersionName = packageInfo.versionName, + installedVersionCode = PackageInfoCompat.getLongVersionCode(packageInfo), + ) + } } - private fun addOrderBy(queryBuilder: StringBuilder, sortOrder: AppListSortOrder) { - when (sortOrder) { - LAST_UPDATED -> queryBuilder.append(" ORDER BY app.lastUpdated DESC") - NAME -> queryBuilder.append(" ORDER BY localizedName COLLATE NOCASE ASC") - } - } - - private fun escapeQuery(searchQuery: String): String { - val sanitized = searchQuery.replace(Regex.fromLiteral("\""), "\"\"") - return "\"*$sanitized*\"" - } - - private fun LiveData>.map( - packageManager: PackageManager, - ): LiveData> { - val installedPackages = packageManager.getInstalledPackages(0) - .associateBy { packageInfo -> packageInfo.packageName } - return map(installedPackages) - } - - private fun LiveData>.map( - installedPackages: Map, - ) = map { items -> - items.map { item -> - val packageInfo = installedPackages[item.packageName] - if (packageInfo == null) item else item.copy( - installedVersionName = packageInfo.versionName, - installedVersionCode = PackageInfoCompat.getLongVersionCode(packageInfo), - ) - } - } - - /** - * Warning: Run [escapeQuery] on the given [searchQuery] before. - */ - @Transaction - @Query(""" + /** Warning: Run [escapeQuery] on the given [searchQuery] before. */ + @Transaction + @Query( + """ SELECT repoId, packageName, app.localizedName, app.localizedSummary, app.lastUpdated, categories, version.antiFeatures, app.isCompatible, app.preferredSigner FROM ${AppMetadata.TABLE} AS app @@ -757,14 +732,14 @@ internal interface AppDaoInt : AppDao { JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) WHERE pref.enabled = 1 AND ${AppMetadataFts.TABLE} MATCH :searchQuery AND repoId = preferredRepoId - GROUP BY packageName HAVING MAX(pref.weight)""") - fun getAppListItems(searchQuery: String): LiveData> + GROUP BY packageName HAVING MAX(pref.weight)""" + ) + fun getAppListItems(searchQuery: String): LiveData> - /** - * Warning: Run [escapeQuery] on the given [searchQuery] before. - */ - @Transaction - @Query(""" + /** Warning: Run [escapeQuery] on the given [searchQuery] before. */ + @Transaction + @Query( + """ SELECT repoId, packageName, app.localizedName, app.localizedSummary, app.lastUpdated, categories, version.antiFeatures, app.isCompatible, app.preferredSigner FROM ${AppMetadata.TABLE} AS app @@ -774,17 +749,19 @@ internal interface AppDaoInt : AppDao { JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) WHERE pref.enabled = 1 AND categories LIKE '%,' || :category || ',%' AND ${AppMetadataFts.TABLE} MATCH :searchQuery AND repoId = preferredRepoId - GROUP BY packageName HAVING MAX(pref.weight)""") - fun getAppListItems(category: String, searchQuery: String): LiveData> + GROUP BY packageName HAVING MAX(pref.weight)""" + ) + fun getAppListItems(category: String, searchQuery: String): LiveData> - /** - * Warning: Run [escapeQuery] on the given [searchQuery] before. - * - * This query is structured differently than the sister query above, - * because of abysmal performance that we couldn't explain. - */ - @Transaction - @Query(""" + /** + * Warning: Run [escapeQuery] on the given [searchQuery] before. + * + * This query is structured differently than the sister query above, because of abysmal + * performance that we couldn't explain. + */ + @Transaction + @Query( + """ SELECT repoId, packageName, app.localizedName, app.localizedSummary, app.lastUpdated, categories, version.antiFeatures, app.isCompatible, app.preferredSigner FROM ${AppMetadata.TABLE} AS app @@ -792,11 +769,13 @@ internal interface AppDaoInt : AppDao { WHERE repoId = :repoId AND app.rowid IN ( SELECT rowid FROM ${AppMetadataFts.TABLE} WHERE repoId = :repoId AND ${AppMetadataFts.TABLE} MATCH :searchQuery - )""") - fun getAppListItems(repoId: Long, searchQuery: String): LiveData> + )""" + ) + fun getAppListItems(repoId: Long, searchQuery: String): LiveData> - @Transaction - @Query(""" + @Transaction + @Query( + """ SELECT repoId, packageName, localizedName, localizedSummary, app.lastUpdated, categories, version.antiFeatures, app.isCompatible, app.preferredSigner FROM ${AppMetadata.TABLE} AS app @@ -805,11 +784,13 @@ internal interface AppDaoInt : AppDao { JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) WHERE pref.enabled = 1 AND repoId = preferredRepoId GROUP BY packageName HAVING MAX(pref.weight) - ORDER BY localizedName COLLATE NOCASE ASC""") - fun getAppListItemsByName(): LiveData> + ORDER BY localizedName COLLATE NOCASE ASC""" + ) + fun getAppListItemsByName(): LiveData> - @Transaction - @Query(""" + @Transaction + @Query( + """ SELECT repoId, packageName, localizedName, localizedSummary, app.lastUpdated, categories, version.antiFeatures, app.isCompatible, app.preferredSigner FROM ${AppMetadata.TABLE} AS app @@ -818,30 +799,31 @@ internal interface AppDaoInt : AppDao { LEFT JOIN ${HighestVersion.TABLE} AS version USING (repoId, packageName) WHERE pref.enabled = 1 AND repoId = PreferredRepo.preferredRepoId GROUP BY packageName HAVING MAX(pref.weight) - ORDER BY app.lastUpdated DESC""") - fun getAppListItemsByLastUpdated(): LiveData> + ORDER BY app.lastUpdated DESC""" + ) + fun getAppListItemsByLastUpdated(): LiveData> - @RawQuery(observedEntities = [AppListItem::class]) - fun getAppListItems(query: RoomRawQuery): LiveData> + @RawQuery(observedEntities = [AppListItem::class]) + fun getAppListItems(query: RoomRawQuery): LiveData> - /** - * Warning: Can not be called with more than 999 [packageNames]. - */ - @Transaction - @SuppressWarnings(QUERY_MISMATCH) // no anti-features needed here - @Query("""SELECT repoId, packageName, localizedName, localizedSummary, app.lastUpdated, + /** Warning: Can not be called with more than 999 [packageNames]. */ + @Transaction + @SuppressWarnings(QUERY_MISMATCH) // no anti-features needed here + @Query( + """SELECT repoId, packageName, localizedName, localizedSummary, app.lastUpdated, categories, app.isCompatible, app.preferredSigner FROM ${AppMetadata.TABLE} AS app JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) JOIN PreferredRepo USING (packageName) WHERE pref.enabled = 1 AND packageName IN (:packageNames) AND repoId = preferredRepoId GROUP BY packageName HAVING MAX(pref.weight) - ORDER BY localizedName COLLATE NOCASE ASC""") - fun getAppListItems(packageNames: List): LiveData> + ORDER BY localizedName COLLATE NOCASE ASC""" + ) + fun getAppListItems(packageNames: List): LiveData> - @Transaction - @Query( - """SELECT repoId, packageName, app.localizedName, app.localizedSummary, app.lastUpdated, + @Transaction + @Query( + """SELECT repoId, packageName, app.localizedName, app.localizedSummary, app.lastUpdated, categories, version.antiFeatures, app.isCompatible, app.preferredSigner FROM ${AppMetadata.TABLE} AS app LEFT JOIN ${HighestVersion.TABLE} AS version USING (repoId, packageName) @@ -849,13 +831,11 @@ internal interface AppDaoInt : AppDao { SELECT rowid FROM ${AppMetadataFts.TABLE} WHERE authorName = :authorName AND ${AppMetadataFts.TABLE} MATCH :searchQuery )""" - ) - fun getAppListItemsForAuthor( - authorName: String, - searchQuery: String - ): LiveData> + ) + fun getAppListItemsForAuthor(authorName: String, searchQuery: String): LiveData> - @Query("""SELECT COUNT(*) = 2 + @Query( + """SELECT COUNT(*) = 2 FROM ( SELECT 1 FROM AppMetadata @@ -863,73 +843,79 @@ internal interface AppDaoInt : AppDao { JOIN PreferredRepo USING (packageName) WHERE authorName = :author AND repoId = preferredRepoId GROUP BY packageName - LIMIT 2)""") - override fun hasAuthorMoreThanOneApp(author: String): LiveData + LIMIT 2)""" + ) + override fun hasAuthorMoreThanOneApp(author: String): LiveData - override fun getInstalledAppListItems( - packageManager: PackageManager, - ): LiveData> { - val installedPackages = packageManager.getInstalledPackages(0) - .associateBy { packageInfo -> packageInfo.packageName } - val packageNames = installedPackages.keys.toList() - // since sqlite 3.32.0 the max variables number was increased to 32766 - return if (packageNames.size <= 999 || SDK_INT >= 31) { - getAppListItems(packageNames).map(installedPackages) - } else { - AppListLiveData().apply { - packageNames.chunked(999) { addSource(getAppListItems(it)) } - }.map(installedPackages) - } + override fun getInstalledAppListItems( + packageManager: PackageManager + ): LiveData> { + val installedPackages = + packageManager.getInstalledPackages(0).associateBy { packageInfo -> packageInfo.packageName } + val packageNames = installedPackages.keys.toList() + // since sqlite 3.32.0 the max variables number was increased to 32766 + return if (packageNames.size <= 999 || SDK_INT >= 31) { + getAppListItems(packageNames).map(installedPackages) + } else { + AppListLiveData() + .apply { packageNames.chunked(999) { addSource(getAppListItems(it)) } } + .map(installedPackages) } + } - override fun getInstalledAppListItems( - packageInfoMap: Map, - ): Flow> { - val packageNames = packageInfoMap.keys.toList() - // since sqlite 3.32.0 the max variables number was increased to 32766 - return if (packageNames.size <= 999 || SDK_INT >= 31) { - getAppListItems(packageNames).map(packageInfoMap) - } else { - AppListLiveData().apply { - packageNames.chunked(999) { addSource(getAppListItems(it)) } - }.map(packageInfoMap) - }.asFlow() + override fun getInstalledAppListItems( + packageInfoMap: Map + ): Flow> { + val packageNames = packageInfoMap.keys.toList() + // since sqlite 3.32.0 the max variables number was increased to 32766 + return if (packageNames.size <= 999 || SDK_INT >= 31) { + getAppListItems(packageNames).map(packageInfoMap) + } else { + AppListLiveData() + .apply { packageNames.chunked(999) { addSource(getAppListItems(it)) } } + .map(packageInfoMap) + } + .asFlow() + } + + private class AppListLiveData : MediatorLiveData>() { + private val list = ArrayList>>() + + /** + * Adds the given [liveData] and updates [getValue] with a union of all lists once all added + * [liveData]s changed to a non-null list value. + */ + fun addSource(liveData: LiveData>) { + list.add(liveData) + addSource(liveData) { + var shouldUpdate = true + val result = + list.flatMap { + it.value + ?: run { + shouldUpdate = false + emptyList() + } + } + if (shouldUpdate) + value = + result + // the chunked query does not return distinct values since room 2.7.0 + .distinct() + // we need to re-sort the result, + // because each liveData is only sorted in itself + .sortedWith { i1, i2 -> + val n1 = i1.name ?: "" + val n2 = i2.name ?: "" + n1.compareTo(n2, ignoreCase = true) + } + } } + } - private class AppListLiveData : MediatorLiveData>() { - private val list = ArrayList>>() - - /** - * Adds the given [liveData] and updates [getValue] with a union of all lists - * once all added [liveData]s changed to a non-null list value. - */ - fun addSource(liveData: LiveData>) { - list.add(liveData) - addSource(liveData) { - var shouldUpdate = true - val result = list.flatMap { - it.value ?: run { - shouldUpdate = false - emptyList() - } - } - if (shouldUpdate) value = result - // the chunked query does not return distinct values since room 2.7.0 - .distinct() - // we need to re-sort the result, - // because each liveData is only sorted in itself - .sortedWith { i1, i2 -> - val n1 = i1.name ?: "" - val n2 = i2.name ?: "" - n1.compareTo(n2, ignoreCase = true) - } - } - } - } - - @Transaction - @Query( - """ + @Transaction + @Query( + """ SELECT repoId, packageName, app.lastUpdated, app.name, app.summary, app.description, app.authorName, app.categories, matchinfo(${AppMetadataFts.TABLE}, 'pcx') @@ -938,66 +924,74 @@ internal interface AppDaoInt : AppDao { JOIN ${AppMetadataFts.TABLE} USING (repoId, packageName) WHERE ${AppMetadataFts.TABLE} MATCH :searchQuery AND repoId = preferredRepoId""" - ) - override suspend fun getAppSearchItems(searchQuery: String): List + ) + override suspend fun getAppSearchItems(searchQuery: String): List - // - // Misc Queries - // + // + // Misc Queries + // - @Query("""SELECT COUNT(DISTINCT packageName) FROM ${AppMetadata.TABLE} + @Query( + """SELECT COUNT(DISTINCT packageName) FROM ${AppMetadata.TABLE} JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) JOIN PreferredRepo USING (packageName) WHERE pref.enabled = 1 AND categories LIKE '%,' || :category || ',%' AND - repoId = preferredRepoId""") - override fun getNumberOfAppsInCategory(category: String): Int + repoId = preferredRepoId""" + ) + override fun getNumberOfAppsInCategory(category: String): Int - @Query("SELECT COUNT(*) FROM ${AppMetadata.TABLE} WHERE repoId = :repoId") - override fun getNumberOfAppsInRepository(repoId: Long): Int + @Query("SELECT COUNT(*) FROM ${AppMetadata.TABLE} WHERE repoId = :repoId") + override fun getNumberOfAppsInRepository(repoId: Long): Int - @Query("DELETE FROM ${AppMetadata.TABLE} WHERE repoId = :repoId AND packageName = :packageName") - fun deleteAppMetadata(repoId: Long, packageName: String) + @Query("DELETE FROM ${AppMetadata.TABLE} WHERE repoId = :repoId AND packageName = :packageName") + fun deleteAppMetadata(repoId: Long, packageName: String) - @Query("""DELETE FROM ${LocalizedFile.TABLE} - WHERE repoId = :repoId AND packageName = :packageName AND type = :type""") - fun deleteLocalizedFiles(repoId: Long, packageName: String, type: String) + @Query( + """DELETE FROM ${LocalizedFile.TABLE} + WHERE repoId = :repoId AND packageName = :packageName AND type = :type""" + ) + fun deleteLocalizedFiles(repoId: Long, packageName: String, type: String) - @Query("""DELETE FROM ${LocalizedFile.TABLE} + @Query( + """DELETE FROM ${LocalizedFile.TABLE} WHERE repoId = :repoId AND packageName = :packageName AND type = :type - AND locale = :locale""") - fun deleteLocalizedFile(repoId: Long, packageName: String, type: String, locale: String) + AND locale = :locale""" + ) + fun deleteLocalizedFile(repoId: Long, packageName: String, type: String, locale: String) - @Query("""DELETE FROM ${LocalizedFileList.TABLE} - WHERE repoId = :repoId AND packageName = :packageName""") - fun deleteLocalizedFileLists(repoId: Long, packageName: String) + @Query( + """DELETE FROM ${LocalizedFileList.TABLE} + WHERE repoId = :repoId AND packageName = :packageName""" + ) + fun deleteLocalizedFileLists(repoId: Long, packageName: String) - @Query("""DELETE FROM ${LocalizedFileList.TABLE} - WHERE repoId = :repoId AND packageName = :packageName AND type = :type""") - fun deleteLocalizedFileLists(repoId: Long, packageName: String, type: String) + @Query( + """DELETE FROM ${LocalizedFileList.TABLE} + WHERE repoId = :repoId AND packageName = :packageName AND type = :type""" + ) + fun deleteLocalizedFileLists(repoId: Long, packageName: String, type: String) - @Query("""DELETE FROM ${LocalizedFileList.TABLE} + @Query( + """DELETE FROM ${LocalizedFileList.TABLE} WHERE repoId = :repoId AND packageName = :packageName AND type = :type - AND locale = :locale""") - fun deleteLocalizedFileList(repoId: Long, packageName: String, type: String, locale: String) + AND locale = :locale""" + ) + fun deleteLocalizedFileList(repoId: Long, packageName: String, type: String, locale: String) - @VisibleForTesting - @Query("SELECT COUNT(*) FROM ${AppMetadata.TABLE}") - fun countApps(): Int + @VisibleForTesting @Query("SELECT COUNT(*) FROM ${AppMetadata.TABLE}") fun countApps(): Int - @VisibleForTesting - @Query("SELECT COUNT(*) FROM ${LocalizedFile.TABLE}") - fun countLocalizedFiles(): Int + @VisibleForTesting + @Query("SELECT COUNT(*) FROM ${LocalizedFile.TABLE}") + fun countLocalizedFiles(): Int - @VisibleForTesting - @Query("SELECT COUNT(*) FROM ${LocalizedFileList.TABLE}") - fun countLocalizedFileLists(): Int + @VisibleForTesting + @Query("SELECT COUNT(*) FROM ${LocalizedFileList.TABLE}") + fun countLocalizedFileLists(): Int - /** - * Removes all apps and associated data such as versions from the database. - * Careful: Doing this without other measures such as calling [RepositoryDaoInt.resetTimestamps] - * and [RepositoryDaoInt.resetETags] will cause things to break - * e.g. application of diffs to fail. - */ - @Query("DELETE FROM ${AppMetadata.TABLE}") - fun clearAll() + /** + * Removes all apps and associated data such as versions from the database. Careful: Doing this + * without other measures such as calling [RepositoryDaoInt.resetTimestamps] and + * [RepositoryDaoInt.resetETags] will cause things to break e.g. application of diffs to fail. + */ + @Query("DELETE FROM ${AppMetadata.TABLE}") fun clearAll() } diff --git a/libs/database/src/main/java/org/fdroid/database/AppPrefs.kt b/libs/database/src/main/java/org/fdroid/database/AppPrefs.kt index 11edeb509..14acff61f 100644 --- a/libs/database/src/main/java/org/fdroid/database/AppPrefs.kt +++ b/libs/database/src/main/java/org/fdroid/database/AppPrefs.kt @@ -6,64 +6,63 @@ import androidx.room.PrimaryKey import org.fdroid.PackagePreference /** - * User-defined preferences related to [App]s that get stored in the database, - * so they can be used for queries. + * User-defined preferences related to [App]s that get stored in the database, so they can be used + * for queries. */ @Entity(tableName = AppPrefs.TABLE) public data class AppPrefs( - @PrimaryKey - val packageName: String, - override val ignoreVersionCodeUpdate: Long = 0, - val preferredRepoId: Long? = null, - // This is named like this, because it hit a Room bug when joining with Version table - // which had exactly the same field. - internal val appPrefReleaseChannels: List? = null, + @PrimaryKey val packageName: String, + override val ignoreVersionCodeUpdate: Long = 0, + val preferredRepoId: Long? = null, + // This is named like this, because it hit a Room bug when joining with Version table + // which had exactly the same field. + internal val appPrefReleaseChannels: List? = null, ) : PackagePreference { - internal companion object { - const val TABLE = "AppPrefs" - } + internal companion object { + const val TABLE = "AppPrefs" + } - public val ignoreAllUpdates: Boolean get() = ignoreVersionCodeUpdate == Long.MAX_VALUE - public override val releaseChannels: List get() = appPrefReleaseChannels ?: emptyList() - public fun shouldIgnoreUpdate(versionCode: Long): Boolean = - ignoreVersionCodeUpdate >= versionCode + public val ignoreAllUpdates: Boolean + get() = ignoreVersionCodeUpdate == Long.MAX_VALUE - /** - * Returns a new instance of [AppPrefs] toggling [ignoreAllUpdates]. - */ - public fun toggleIgnoreAllUpdates(): AppPrefs = copy( - ignoreVersionCodeUpdate = if (ignoreAllUpdates) 0 else Long.MAX_VALUE, - ) + public override val releaseChannels: List + get() = appPrefReleaseChannels ?: emptyList() - /** - * Returns a new instance of [AppPrefs] ignoring the given [versionCode] or stop ignoring it - * if it was already ignored. - */ - public fun toggleIgnoreVersionCodeUpdate(versionCode: Long): AppPrefs = copy( - ignoreVersionCodeUpdate = if (shouldIgnoreUpdate(versionCode)) 0 else versionCode, - ) + public fun shouldIgnoreUpdate(versionCode: Long): Boolean = ignoreVersionCodeUpdate >= versionCode - /** - * Returns a new instance of [AppPrefs] enabling the given [releaseChannel] or disabling it - * if it was already enabled. - */ - public fun toggleReleaseChannel(releaseChannel: String): AppPrefs = copy( - appPrefReleaseChannels = if (appPrefReleaseChannels?.contains(releaseChannel) == true) { - appPrefReleaseChannels.toMutableList().apply { remove(releaseChannel) } + /** Returns a new instance of [AppPrefs] toggling [ignoreAllUpdates]. */ + public fun toggleIgnoreAllUpdates(): AppPrefs = + copy(ignoreVersionCodeUpdate = if (ignoreAllUpdates) 0 else Long.MAX_VALUE) + + /** + * Returns a new instance of [AppPrefs] ignoring the given [versionCode] or stop ignoring it if it + * was already ignored. + */ + public fun toggleIgnoreVersionCodeUpdate(versionCode: Long): AppPrefs = + copy(ignoreVersionCodeUpdate = if (shouldIgnoreUpdate(versionCode)) 0 else versionCode) + + /** + * Returns a new instance of [AppPrefs] enabling the given [releaseChannel] or disabling it if it + * was already enabled. + */ + public fun toggleReleaseChannel(releaseChannel: String): AppPrefs = + copy( + appPrefReleaseChannels = + if (appPrefReleaseChannels?.contains(releaseChannel) == true) { + appPrefReleaseChannels.toMutableList().apply { remove(releaseChannel) } } else { - (appPrefReleaseChannels?.toMutableList() ?: ArrayList()).apply { add(releaseChannel) } - }, + (appPrefReleaseChannels?.toMutableList() ?: ArrayList()).apply { add(releaseChannel) } + } ) } -@DatabaseView("""SELECT packageName, repoId AS preferredRepoId FROM ${AppMetadata.TABLE} +@DatabaseView( + """SELECT packageName, repoId AS preferredRepoId FROM ${AppMetadata.TABLE} JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) LEFT JOIN ${AppPrefs.TABLE} USING (packageName) WHERE pref.enabled = 1 AND (repoId = COALESCE(preferredRepoId, repoId) OR NOT EXISTS (SELECT 1 FROM AppMetadata WHERE repoId=AppPrefs.preferredRepoId AND packageName=AppPrefs.packageName) ) - GROUP BY packageName HAVING MAX(pref.weight)""") -internal class PreferredRepo( - val packageName: String, - val preferredRepoId: Long, + GROUP BY packageName HAVING MAX(pref.weight)""" ) +internal class PreferredRepo(val packageName: String, val preferredRepoId: Long) diff --git a/libs/database/src/main/java/org/fdroid/database/AppPrefsDao.kt b/libs/database/src/main/java/org/fdroid/database/AppPrefsDao.kt index cca5fdd0e..d5a90b092 100644 --- a/libs/database/src/main/java/org/fdroid/database/AppPrefsDao.kt +++ b/libs/database/src/main/java/org/fdroid/database/AppPrefsDao.kt @@ -11,53 +11,45 @@ import androidx.room.OnConflictStrategy.Companion.REPLACE import androidx.room.Query public interface AppPrefsDao { - public fun getAppPrefs(packageName: String): LiveData - public fun update(appPrefs: AppPrefs) + public fun getAppPrefs(packageName: String): LiveData + + public fun update(appPrefs: AppPrefs) } @Dao internal interface AppPrefsDaoInt : AppPrefsDao { - override fun getAppPrefs(packageName: String): LiveData { - return getLiveAppPrefs(packageName).distinctUntilChanged().map { data -> - data ?: AppPrefs(packageName) - } + override fun getAppPrefs(packageName: String): LiveData { + return getLiveAppPrefs(packageName).distinctUntilChanged().map { data -> + data ?: AppPrefs(packageName) } + } - @Query("SELECT * FROM ${AppPrefs.TABLE} WHERE packageName = :packageName") - fun getLiveAppPrefs(packageName: String): LiveData + @Query("SELECT * FROM ${AppPrefs.TABLE} WHERE packageName = :packageName") + fun getLiveAppPrefs(packageName: String): LiveData - @Query("SELECT * FROM ${AppPrefs.TABLE} WHERE packageName = :packageName") - fun getAppPrefsOrNull(packageName: String): AppPrefs? + @Query("SELECT * FROM ${AppPrefs.TABLE} WHERE packageName = :packageName") + fun getAppPrefsOrNull(packageName: String): AppPrefs? - fun getPreferredRepos(packageNames: List): Map { - // since sqlite 3.32.0 the max variables number was increased to 32766 - return if (packageNames.size <= 999 || SDK_INT >= 31) { - getPreferredReposInternal(packageNames) - } else { - HashMap(packageNames.size).also { map -> - packageNames.chunked(999).forEach { map.putAll(getPreferredReposInternal(it)) } - } - } + fun getPreferredRepos(packageNames: List): Map { + // since sqlite 3.32.0 the max variables number was increased to 32766 + return if (packageNames.size <= 999 || SDK_INT >= 31) { + getPreferredReposInternal(packageNames) + } else { + HashMap(packageNames.size).also { map -> + packageNames.chunked(999).forEach { map.putAll(getPreferredReposInternal(it)) } + } } + } - /** - * Use [getPreferredRepos] instead as this handles more than 1000 package names. - */ - @Query( - """SELECT packageName, preferredRepoId FROM PreferredRepo + /** Use [getPreferredRepos] instead as this handles more than 1000 package names. */ + @Query( + """SELECT packageName, preferredRepoId FROM PreferredRepo WHERE packageName IN (:packageNames)""" - ) - fun getPreferredReposInternal( - packageNames: List, - ): Map< - @MapColumn("packageName") - String, - @MapColumn("preferredRepoId") - Long - > - - @Insert(onConflict = REPLACE) - override fun update(appPrefs: AppPrefs) + ) + fun getPreferredReposInternal( + packageNames: List + ): Map<@MapColumn("packageName") String, @MapColumn("preferredRepoId") Long> + @Insert(onConflict = REPLACE) override fun update(appPrefs: AppPrefs) } diff --git a/libs/database/src/main/java/org/fdroid/database/AppSearchItem.kt b/libs/database/src/main/java/org/fdroid/database/AppSearchItem.kt index 1c3c81a46..854f26eb0 100644 --- a/libs/database/src/main/java/org/fdroid/database/AppSearchItem.kt +++ b/libs/database/src/main/java/org/fdroid/database/AppSearchItem.kt @@ -4,95 +4,89 @@ import androidx.core.os.LocaleListCompat import androidx.room.ColumnInfo import androidx.room.Ignore import androidx.room.Relation -import org.fdroid.LocaleChooser.getBestLocale -import org.fdroid.index.v2.FileV2 -import org.fdroid.index.v2.LocalizedTextV2 import java.nio.ByteBuffer import java.nio.ByteOrder import kotlin.math.min +import org.fdroid.LocaleChooser.getBestLocale +import org.fdroid.index.v2.FileV2 +import org.fdroid.index.v2.LocalizedTextV2 @OptIn(ExperimentalUnsignedTypes::class) @ConsistentCopyVisibility -public data class AppSearchItem internal constructor( - public val repoId: Long, - public val packageName: String, - public val lastUpdated: Long, - public val name: LocalizedTextV2? = null, - public val summary: LocalizedTextV2? = null, - public val description: LocalizedTextV2? = null, - public val authorName: String? = null, - public val categories: List? = null, - @Relation( - parentColumn = "packageName", - entityColumn = "packageName", - ) - internal val localizedIcon: List? = null, - @Suppress("ArrayInDataClass") - @ColumnInfo("matchinfo(${AppMetadataFts.TABLE}, 'pcx')") - internal val matchInfo: ByteArray, +public data class AppSearchItem +internal constructor( + public val repoId: Long, + public val packageName: String, + public val lastUpdated: Long, + public val name: LocalizedTextV2? = null, + public val summary: LocalizedTextV2? = null, + public val description: LocalizedTextV2? = null, + public val authorName: String? = null, + public val categories: List? = null, + @Relation(parentColumn = "packageName", entityColumn = "packageName") + internal val localizedIcon: List? = null, + @Suppress("ArrayInDataClass") + @ColumnInfo("matchinfo(${AppMetadataFts.TABLE}, 'pcx')") + internal val matchInfo: ByteArray, ) : Comparable { - public fun getIcon(localeList: LocaleListCompat): FileV2? { - return localizedIcon?.filter { icon -> - icon.repoId == repoId - }?.toLocalizedFileV2().getBestLocale(localeList) - } + public fun getIcon(localeList: LocaleListCompat): FileV2? { + return localizedIcon + ?.filter { icon -> icon.repoId == repoId } + ?.toLocalizedFileV2() + .getBestLocale(localeList) + } - @Ignore - public val score: Double + @Ignore public val score: Double - init { - val info = matchInfo.toIntArray() - val numPhrases = info[0] - val numColumns = info[1] - val scoreMap = mutableMapOf() - for (phrase in 0 until numPhrases) { - val offset = 2 + phrase * numColumns * 3 - // start with 1 below, because we don't care about repoId column - for (column in 1 until numColumns) { - val numHitsInRow = info[offset + 3 * column] - // increase score if this column had a hit - if (numHitsInRow > 0) { - // each hit in a column only contributes to the score once - scoreMap.getOrPut(column) { - weights[column] ?: error("No weight for column $column") - } - } - } + init { + val info = matchInfo.toIntArray() + val numPhrases = info[0] + val numColumns = info[1] + val scoreMap = mutableMapOf() + for (phrase in 0 until numPhrases) { + val offset = 2 + phrase * numColumns * 3 + // start with 1 below, because we don't care about repoId column + for (column in 1 until numColumns) { + val numHitsInRow = info[offset + 3 * column] + // increase score if this column had a hit + if (numHitsInRow > 0) { + // each hit in a column only contributes to the score once + scoreMap.getOrPut(column) { weights[column] ?: error("No weight for column $column") } } - val weeksOld = (System.currentTimeMillis() - lastUpdated) / (1000 * 60 * 60 * 24 * 7) - val punishment = min(100, weeksOld / 3) - score = scoreMap.values.sum().toDouble() - punishment + } } + val weeksOld = (System.currentTimeMillis() - lastUpdated) / (1000 * 60 * 60 * 24 * 7) + val punishment = min(100, weeksOld / 3) + score = scoreMap.values.sum().toDouble() - punishment + } - private fun ByteArray.toIntArray(skipSize: Int = 4): IntArray { - val intArray = IntArray(size / skipSize) - // go through each 4 bytes to turn them into integers - (indices step skipSize).forEachIndexed { intIndex, byteIndex -> - // we are cutting the first two bytes off, because we don't want to deal with UInt - // and expected integers are small enough - intArray[intIndex] = ByteBuffer.wrap(this, byteIndex, 4) - .order(ByteOrder.LITTLE_ENDIAN) - .int - } - return intArray + private fun ByteArray.toIntArray(skipSize: Int = 4): IntArray { + val intArray = IntArray(size / skipSize) + // go through each 4 bytes to turn them into integers + (indices step skipSize).forEachIndexed { intIndex, byteIndex -> + // we are cutting the first two bytes off, because we don't want to deal with UInt + // and expected integers are small enough + intArray[intIndex] = ByteBuffer.wrap(this, byteIndex, 4).order(ByteOrder.LITTLE_ENDIAN).int } + return intArray + } - override fun compareTo(other: AppSearchItem): Int { - val scoreComp = score.compareTo(other.score) - return if (scoreComp == 0) { - lastUpdated.compareTo(other.lastUpdated) - } else { - scoreComp - } + override fun compareTo(other: AppSearchItem): Int { + val scoreComp = score.compareTo(other.score) + return if (scoreComp == 0) { + lastUpdated.compareTo(other.lastUpdated) + } else { + scoreComp } + } } -@Suppress("ktlint:standard:no-multi-spaces") -private val weights = mapOf( +private val weights = + mapOf( // 0 is repoId which we ignore - 1 to 100, // "name" - 2 to 50, // "summary" - 3 to 25, // "description" - 4 to 10, // "authorName" - 5 to 5, // "packageName" -) + 1 to 100, // "name" + 2 to 50, // "summary" + 3 to 25, // "description" + 4 to 10, // "authorName" + 5 to 5, // "packageName" + ) diff --git a/libs/database/src/main/java/org/fdroid/database/Converters.kt b/libs/database/src/main/java/org/fdroid/database/Converters.kt index 774e2e2e9..205104410 100644 --- a/libs/database/src/main/java/org/fdroid/database/Converters.kt +++ b/libs/database/src/main/java/org/fdroid/database/Converters.kt @@ -10,53 +10,49 @@ import org.fdroid.index.v2.LocalizedTextV2 internal object Converters { - private val localizedTextV2Serializer = MapSerializer(String.serializer(), String.serializer()) - private val localizedFileV2Serializer = MapSerializer(String.serializer(), FileV2.serializer()) - private val mapOfLocalizedTextV2Serializer = - MapSerializer(String.serializer(), localizedTextV2Serializer) + private val localizedTextV2Serializer = MapSerializer(String.serializer(), String.serializer()) + private val localizedFileV2Serializer = MapSerializer(String.serializer(), FileV2.serializer()) + private val mapOfLocalizedTextV2Serializer = + MapSerializer(String.serializer(), localizedTextV2Serializer) - @TypeConverter - fun fromStringToLocalizedTextV2(value: String?): LocalizedTextV2? { - return value?.let { json.decodeFromString(localizedTextV2Serializer, it) } - } + @TypeConverter + fun fromStringToLocalizedTextV2(value: String?): LocalizedTextV2? { + return value?.let { json.decodeFromString(localizedTextV2Serializer, it) } + } - @TypeConverter - fun localizedTextV2toString(text: LocalizedTextV2?): String? { - return text?.let { json.encodeToString(localizedTextV2Serializer, it) } - } + @TypeConverter + fun localizedTextV2toString(text: LocalizedTextV2?): String? { + return text?.let { json.encodeToString(localizedTextV2Serializer, it) } + } - @TypeConverter - fun fromStringToLocalizedFileV2(value: String?): LocalizedFileV2? { - return value?.let { json.decodeFromString(localizedFileV2Serializer, it) } - } + @TypeConverter + fun fromStringToLocalizedFileV2(value: String?): LocalizedFileV2? { + return value?.let { json.decodeFromString(localizedFileV2Serializer, it) } + } - @TypeConverter - fun localizedFileV2toString(file: LocalizedFileV2?): String? { - return file?.let { json.encodeToString(localizedFileV2Serializer, it) } - } + @TypeConverter + fun localizedFileV2toString(file: LocalizedFileV2?): String? { + return file?.let { json.encodeToString(localizedFileV2Serializer, it) } + } - @TypeConverter - fun fromStringToMapOfLocalizedTextV2(value: String?): Map? { - return value?.let { json.decodeFromString(mapOfLocalizedTextV2Serializer, it) } - } + @TypeConverter + fun fromStringToMapOfLocalizedTextV2(value: String?): Map? { + return value?.let { json.decodeFromString(mapOfLocalizedTextV2Serializer, it) } + } - @TypeConverter - fun mapOfLocalizedTextV2toString(text: Map?): String? { - return text?.let { json.encodeToString(mapOfLocalizedTextV2Serializer, it) } - } + @TypeConverter + fun mapOfLocalizedTextV2toString(text: Map?): String? { + return text?.let { json.encodeToString(mapOfLocalizedTextV2Serializer, it) } + } - @TypeConverter - fun fromStringToListString(value: String?): List { - return value?.split(',')?.filter { it.isNotEmpty() } ?: emptyList() - } + @TypeConverter + fun fromStringToListString(value: String?): List { + return value?.split(',')?.filter { it.isNotEmpty() } ?: emptyList() + } - @TypeConverter - fun listStringToString(text: List?): String? { - if (text.isNullOrEmpty()) return null - return text.joinToString( - prefix = ",", - separator = ",", - postfix = ",", - ) { it.replace(',', '_') } - } + @TypeConverter + fun listStringToString(text: List?): String? { + if (text.isNullOrEmpty()) return null + return text.joinToString(prefix = ",", separator = ",", postfix = ",") { it.replace(',', '_') } + } } diff --git a/libs/database/src/main/java/org/fdroid/database/DbAppChecker.kt b/libs/database/src/main/java/org/fdroid/database/DbAppChecker.kt index 663e9df3b..0523a0990 100644 --- a/libs/database/src/main/java/org/fdroid/database/DbAppChecker.kt +++ b/libs/database/src/main/java/org/fdroid/database/DbAppChecker.kt @@ -5,286 +5,298 @@ import android.content.pm.ApplicationInfo.FLAG_SYSTEM import android.content.pm.PackageInfo import android.os.Build.VERSION.SDK_INT import androidx.core.content.pm.PackageInfoCompat.getLongVersionCode +import java.util.concurrent.TimeUnit import org.fdroid.CompatibilityChecker import org.fdroid.CompatibilityCheckerImpl import org.fdroid.UpdateChecker import org.fdroid.index.IndexUtils.getPackageSigner -import java.util.concurrent.TimeUnit public class DbAppChecker( - db: FDroidDatabase, - private val context: Context, - compatibilityChecker: CompatibilityChecker = CompatibilityCheckerImpl(context.packageManager), - private val updateChecker: UpdateChecker = UpdateChecker(compatibilityChecker), + db: FDroidDatabase, + private val context: Context, + compatibilityChecker: CompatibilityChecker = CompatibilityCheckerImpl(context.packageManager), + private val updateChecker: UpdateChecker = UpdateChecker(compatibilityChecker), ) { - private val packageManager = context.packageManager - private val appDao = db.getAppDao() as AppDaoInt - private val versionDao = db.getVersionDao() as VersionDaoInt - private val appPrefsDao = db.getAppPrefsDao() as AppPrefsDaoInt + private val packageManager = context.packageManager + private val appDao = db.getAppDao() as AppDaoInt + private val versionDao = db.getVersionDao() as VersionDaoInt + private val appPrefsDao = db.getAppPrefsDao() as AppPrefsDaoInt - /** - * Gets all apps that somehow have a special status that warrants the user's attention. - * These can include apps that: - * * have updates available ([UpdatableApp]) - * * can not be updated, because we don't have those apps anymore ([NotAvailable]) - * * can not be updated, because all versions have incompatible signer ([NoCompatibleSigner]) - * * could get updated from another repo ([UpdateInOtherRepo]) - * * have known vulnerabilities ([KnownVulnerability]) - */ - public fun getApps(packageInfoMap: Map): AppCheckResult { - val updatableApps = ArrayList() - val appsWithIssue = ArrayList() + /** + * Gets all apps that somehow have a special status that warrants the user's attention. These can + * include apps that: + * * have updates available ([UpdatableApp]) + * * can not be updated, because we don't have those apps anymore ([NotAvailable]) + * * can not be updated, because all versions have incompatible signer ([NoCompatibleSigner]) + * * could get updated from another repo ([UpdateInOtherRepo]) + * * have known vulnerabilities ([KnownVulnerability]) + */ + public fun getApps(packageInfoMap: Map): AppCheckResult { + val updatableApps = ArrayList() + val appsWithIssue = ArrayList() - // get all versions for all packages (irrespective of preferred repo) - // and make them accessible per packageName - val packageNames = packageInfoMap.keys.toList() - val versionsByPackage = HashMap>(packageNames.size) - // TODO add test for an app ignoring all updates, this won't return versions here - versionDao.getVersions(packageNames).forEach { version -> - val versions = versionsByPackage.getOrPut(version.packageName) { ArrayList() } - versions.add(version) - } - // go through all apps (packages) and check for updates - val preferredRepos = appPrefsDao.getPreferredRepos(packageNames) - packageInfoMap.forEach packages@{ (packageName, packageInfo) -> - // get versions for this app and try to find an update in them - val versions = versionsByPackage[packageName] - val flags = packageInfo.applicationInfo?.flags ?: 0 - if (versions.isNullOrEmpty() && flags and FLAG_SYSTEM == 0) { - // we have no versions and no system app, - // so check if we maybe had installed this app in the past - getUnavailableApp(packageInfo, preferredRepos)?.let { unavailableApp -> - appsWithIssue.add(unavailableApp) - } - return@packages // continue - } - // we ignore system apps without version - if (versions == null) return@packages // continue - // get all updates from the versions we found - // these can be from other repos, have incompatible signers or just are KnownVuln - val updates = updateChecker.getUpdates( - versions = versions, - allowedSignersGetter = null, // all signers are allowed - installedVersionCode = getLongVersionCode(packageInfo), - allowedReleaseChannels = null, - includeKnownVulnerabilities = true, - preferencesGetter = { appPrefsDao.getAppPrefsOrNull(packageName) }, - ).toList() - // if there are no updates available, there's nothing left to do for us - if (updates.isEmpty()) return@packages - // we have updates, so now get some data for us to judge those updates - - // get preferred repo for the current app - val preferredRepoId = preferredRepos[packageName] - ?: error("No preferred repo for $packageName") - - // get allowed signers for current app - // always gives us the oldest signer, even if they rotated certs by now - @Suppress("DEPRECATION") - val allowedSigners = packageInfo.signatures?.map { - getPackageSigner(it.toByteArray()) - }?.toSet() ?: error("Got no signatures for $packageName") - - // happy path is a preferred and compatible update, so we look for those first - // for simplicity and safety, we tell the user to make those updates first - updates.forEach { update -> - if (update.isOk(preferredRepoId, allowedSigners)) { - getUpdatableApp( - version = update, - installedVersionCode = getLongVersionCode(packageInfo), - installedVersionName = packageInfo.versionName ?: "???", - )?.let { app -> updatableApps.add(app) } - return@packages - } - } - - // we do have update(s), but there's an issue with them, find out what - // for simplicity, we only consider the issue of the most recent version - val update = updates[0] - val updateSigners = update.signer?.sha256?.toSet() - val hasCompatibleSigner = - updateSigners == null || updateSigners.intersect(allowedSigners).isNotEmpty() - val app = appDao.getAppOverviewItem(preferredRepoId, packageName) ?: return@packages - - // find out the specific issue - val appWithIssue = if (update.hasKnownVulnerability) { - AvailableAppWithIssue( - app = app, - installVersionName = packageInfo.versionName ?: "???", - installVersionCode = getLongVersionCode(packageInfo), - issue = KnownVulnerability(preferredRepoId == update.repoId), - ) - } else if (hasCompatibleSigner) { - // the signer is compatible, so the update must come from a non-preferred repo - val now = System.currentTimeMillis() - // Only flag the compatible update in another repo, if older than a week. - // This is to prevent short delays in providing updates causing unneeded UX churn. - if (now - update.added > TimeUnit.DAYS.toMillis(7)) AvailableAppWithIssue( - app = app, - installVersionName = packageInfo.versionName ?: "???", - installVersionCode = getLongVersionCode(packageInfo), - issue = UpdateInOtherRepo(update.repoId), - ) else null - } else { - // no update with compatible signer available - getNoCompatibleSignerApp( - // check if there's a compatible signer available in a non-preferred repo - repoIdWithCompatibleSigner = updates.find { - val signers = it.signer?.sha256?.toSet() - signers == null || signers.intersect(allowedSigners).isNotEmpty() - }?.repoId, - app = app, - versions = versions, - packageInfo = packageInfo, - preferredRepoId = preferredRepoId, - allowedSigners = allowedSigners - ) - } - appWithIssue?.let { appsWithIssue.add(it) } - } - return AppCheckResult( - updates = updatableApps, - issues = appsWithIssue, - ) + // get all versions for all packages (irrespective of preferred repo) + // and make them accessible per packageName + val packageNames = packageInfoMap.keys.toList() + val versionsByPackage = HashMap>(packageNames.size) + // TODO add test for an app ignoring all updates, this won't return versions here + versionDao.getVersions(packageNames).forEach { version -> + val versions = versionsByPackage.getOrPut(version.packageName) { ArrayList() } + versions.add(version) } - - /** - * Returns a [UnavailableAppWithIssue], in case the app provided with [packageInfo] - * was installed by us in the past. - */ - private fun getUnavailableApp( - packageInfo: PackageInfo, - preferredRepos: Map, - ): UnavailableAppWithIssue? { - // check if we installed the app or are the current update owner of this app - val weInstalledApp = if (SDK_INT >= 30) { - val installInfo = packageManager.getInstallSourceInfo(packageInfo.packageName) - context.packageName == installInfo.initiatingPackageName || - context.packageName == installInfo.installingPackageName || - (SDK_INT >= 34 && context.packageName == installInfo.updateOwnerPackageName) - } else { - @Suppress("DEPRECATION") // no other choice to use this for old API versions - val installer = packageManager.getInstallerPackageName(packageInfo.packageName) - context.packageName == installer + // go through all apps (packages) and check for updates + val preferredRepos = appPrefsDao.getPreferredRepos(packageNames) + packageInfoMap.forEach packages@{ (packageName, packageInfo) -> + // get versions for this app and try to find an update in them + val versions = versionsByPackage[packageName] + val flags = packageInfo.applicationInfo?.flags ?: 0 + if (versions.isNullOrEmpty() && flags and FLAG_SYSTEM == 0) { + // we have no versions and no system app, + // so check if we maybe had installed this app in the past + getUnavailableApp(packageInfo, preferredRepos)?.let { unavailableApp -> + appsWithIssue.add(unavailableApp) } - if (weInstalledApp) { - // we had installed this app, check if we maybe just got no versions - val app = preferredRepos[packageInfo.packageName]?.let { repoId -> - appDao.getAppOverviewItem(repoId, packageInfo.packageName) - } - // we still have the app, so we just didn't get versions for it, - // like when the user was ignoring all updates for the app - if (app != null) return null - // warn the user that this app isn't available anymore - val notAvailable = UnavailableAppWithIssue( - packageName = packageInfo.packageName, - name = packageInfo.applicationInfo?.loadLabel(packageManager), - installVersionName = packageInfo.versionName ?: "???", - installVersionCode = getLongVersionCode(packageInfo), + return@packages // continue + } + // we ignore system apps without version + if (versions == null) return@packages // continue + // get all updates from the versions we found + // these can be from other repos, have incompatible signers or just are KnownVuln + val updates = + updateChecker + .getUpdates( + versions = versions, + allowedSignersGetter = null, // all signers are allowed + installedVersionCode = getLongVersionCode(packageInfo), + allowedReleaseChannels = null, + includeKnownVulnerabilities = true, + preferencesGetter = { appPrefsDao.getAppPrefsOrNull(packageName) }, + ) + .toList() + // if there are no updates available, there's nothing left to do for us + if (updates.isEmpty()) return@packages + // we have updates, so now get some data for us to judge those updates + + // get preferred repo for the current app + val preferredRepoId = + preferredRepos[packageName] ?: error("No preferred repo for $packageName") + + // get allowed signers for current app + // always gives us the oldest signer, even if they rotated certs by now + @Suppress("DEPRECATION") + val allowedSigners = + packageInfo.signatures?.map { getPackageSigner(it.toByteArray()) }?.toSet() + ?: error("Got no signatures for $packageName") + + // happy path is a preferred and compatible update, so we look for those first + // for simplicity and safety, we tell the user to make those updates first + updates.forEach { update -> + if (update.isOk(preferredRepoId, allowedSigners)) { + getUpdatableApp( + version = update, + installedVersionCode = getLongVersionCode(packageInfo), + installedVersionName = packageInfo.versionName ?: "???", ) - return notAvailable + ?.let { app -> updatableApps.add(app) } + return@packages } - return null - } + } - /** - * Returns [AvailableAppWithIssue] with [NoCompatibleSigner], - * if the app has an update in a repo, but all versions have an incompatible signer. - * - * @param repoIdWithCompatibleSigner the ID of the [Repository] - * that does have a compatible signer. - * Null if no repository has a compatible signer. - */ - private fun getNoCompatibleSignerApp( - repoIdWithCompatibleSigner: Long?, - app: AppOverviewItem, - versions: ArrayList, - packageInfo: PackageInfo, - preferredRepoId: Long, - allowedSigners: Set, - ): AvailableAppWithIssue? { - return if (repoIdWithCompatibleSigner == null) { - // all updates are not compatible, we only warn about this, - // if all versions in the preferred repo aren't compatible - val allIncompatible = versions.all { version -> - version.repoId != preferredRepoId || - !version.isOk(preferredRepoId, allowedSigners) - } - if (allIncompatible) { - // possibly the wrong repo was preferred, try to find the right one - val repoId = versions.find { - // treat the current repo as preferred, so we only look at signers - it.isOk(it.repoId, allowedSigners) - }?.repoId - if (repoId == null) { - // the app may have been installed with another installer - val installerPackageName = if (SDK_INT >= 30) { - packageManager.getInstallSourceInfo(packageInfo.packageName) - .installingPackageName - } else { - @Suppress("DEPRECATION") // no other choice to use this for old API versions - packageManager.getInstallerPackageName(packageInfo.packageName) - } - // if there is another installer, we don't warn, but leave things to them - if (installerPackageName != null && - installerPackageName != context.packageName - ) return null - } - return AvailableAppWithIssue( - app = app, - installVersionName = packageInfo.versionName ?: "???", - installVersionCode = getLongVersionCode(packageInfo), - issue = NoCompatibleSigner(repoId), - ) - } else { - // there was at least one compatible version in a repo, - // so there's hope the update will arrive there - null - } - } else { + // we do have update(s), but there's an issue with them, find out what + // for simplicity, we only consider the issue of the most recent version + val update = updates[0] + val updateSigners = update.signer?.sha256?.toSet() + val hasCompatibleSigner = + updateSigners == null || updateSigners.intersect(allowedSigners).isNotEmpty() + val app = appDao.getAppOverviewItem(preferredRepoId, packageName) ?: return@packages + + // find out the specific issue + val appWithIssue = + if (update.hasKnownVulnerability) { + AvailableAppWithIssue( + app = app, + installVersionName = packageInfo.versionName ?: "???", + installVersionCode = getLongVersionCode(packageInfo), + issue = KnownVulnerability(preferredRepoId == update.repoId), + ) + } else if (hasCompatibleSigner) { + // the signer is compatible, so the update must come from a non-preferred repo + val now = System.currentTimeMillis() + // Only flag the compatible update in another repo, if older than a week. + // This is to prevent short delays in providing updates causing unneeded UX churn. + if (now - update.added > TimeUnit.DAYS.toMillis(7)) AvailableAppWithIssue( - app = app, - installVersionName = packageInfo.versionName ?: "???", - installVersionCode = getLongVersionCode(packageInfo), - issue = NoCompatibleSigner(repoIdWithCompatibleSigner), + app = app, + installVersionName = packageInfo.versionName ?: "???", + installVersionCode = getLongVersionCode(packageInfo), + issue = UpdateInOtherRepo(update.repoId), ) + else null + } else { + // no update with compatible signer available + getNoCompatibleSignerApp( + // check if there's a compatible signer available in a non-preferred repo + repoIdWithCompatibleSigner = + updates + .find { + val signers = it.signer?.sha256?.toSet() + signers == null || signers.intersect(allowedSigners).isNotEmpty() + } + ?.repoId, + app = app, + versions = versions, + packageInfo = packageInfo, + preferredRepoId = preferredRepoId, + allowedSigners = allowedSigners, + ) } + appWithIssue?.let { appsWithIssue.add(it) } } + return AppCheckResult(updates = updatableApps, issues = appsWithIssue) + } - /** - * @return true if this version is an update from the preferred repo with a compatible signer - * and not a known vulnerable version. - */ - private fun Version.isOk(preferredRepoId: Long, signers: Set): Boolean { - val ourSigners = signer?.sha256?.toSet() - return preferredRepoId == repoId && - !hasKnownVulnerability && - (ourSigners == null || ourSigners.intersect(signers).isNotEmpty()) + /** + * Returns a [UnavailableAppWithIssue], in case the app provided with [packageInfo] was installed + * by us in the past. + */ + private fun getUnavailableApp( + packageInfo: PackageInfo, + preferredRepos: Map, + ): UnavailableAppWithIssue? { + // check if we installed the app or are the current update owner of this app + val weInstalledApp = + if (SDK_INT >= 30) { + val installInfo = packageManager.getInstallSourceInfo(packageInfo.packageName) + context.packageName == installInfo.initiatingPackageName || + context.packageName == installInfo.installingPackageName || + (SDK_INT >= 34 && context.packageName == installInfo.updateOwnerPackageName) + } else { + @Suppress("DEPRECATION") // no other choice to use this for old API versions + val installer = packageManager.getInstallerPackageName(packageInfo.packageName) + context.packageName == installer + } + if (weInstalledApp) { + // we had installed this app, check if we maybe just got no versions + val app = + preferredRepos[packageInfo.packageName]?.let { repoId -> + appDao.getAppOverviewItem(repoId, packageInfo.packageName) + } + // we still have the app, so we just didn't get versions for it, + // like when the user was ignoring all updates for the app + if (app != null) return null + // warn the user that this app isn't available anymore + val notAvailable = + UnavailableAppWithIssue( + packageName = packageInfo.packageName, + name = packageInfo.applicationInfo?.loadLabel(packageManager), + installVersionName = packageInfo.versionName ?: "???", + installVersionCode = getLongVersionCode(packageInfo), + ) + return notAvailable } + return null + } - private fun getUpdatableApp( - version: Version, - installedVersionCode: Long, - installedVersionName: String, - ): UpdatableApp? { - val versionedStrings = versionDao.getVersionedStrings( - repoId = version.repoId, - packageName = version.packageName, - versionId = version.versionId, - ) - val appOverviewItem = - appDao.getAppOverviewItem(version.repoId, version.packageName) ?: return null - return UpdatableApp( - repoId = version.repoId, - packageName = version.packageName, - installedVersionCode = installedVersionCode, - installedVersionName = installedVersionName, - update = version.toAppVersion(versionedStrings), - isFromPreferredRepo = true, - hasKnownVulnerability = version.hasKnownVulnerability, - name = appOverviewItem.name, - summary = appOverviewItem.summary, - localizedIcon = appOverviewItem.localizedIcon, + /** + * Returns [AvailableAppWithIssue] with [NoCompatibleSigner], if the app has an update in a repo, + * but all versions have an incompatible signer. + * + * @param repoIdWithCompatibleSigner the ID of the [Repository] that does have a compatible + * signer. Null if no repository has a compatible signer. + */ + private fun getNoCompatibleSignerApp( + repoIdWithCompatibleSigner: Long?, + app: AppOverviewItem, + versions: ArrayList, + packageInfo: PackageInfo, + preferredRepoId: Long, + allowedSigners: Set, + ): AvailableAppWithIssue? { + return if (repoIdWithCompatibleSigner == null) { + // all updates are not compatible, we only warn about this, + // if all versions in the preferred repo aren't compatible + val allIncompatible = + versions.all { version -> + version.repoId != preferredRepoId || !version.isOk(preferredRepoId, allowedSigners) + } + if (allIncompatible) { + // possibly the wrong repo was preferred, try to find the right one + val repoId = + versions + .find { + // treat the current repo as preferred, so we only look at signers + it.isOk(it.repoId, allowedSigners) + } + ?.repoId + if (repoId == null) { + // the app may have been installed with another installer + val installerPackageName = + if (SDK_INT >= 30) { + packageManager.getInstallSourceInfo(packageInfo.packageName).installingPackageName + } else { + @Suppress("DEPRECATION") // no other choice to use this for old API versions + packageManager.getInstallerPackageName(packageInfo.packageName) + } + // if there is another installer, we don't warn, but leave things to them + if (installerPackageName != null && installerPackageName != context.packageName) + return null + } + AvailableAppWithIssue( + app = app, + installVersionName = packageInfo.versionName ?: "???", + installVersionCode = getLongVersionCode(packageInfo), + issue = NoCompatibleSigner(repoId), ) + } else { + // there was at least one compatible version in a repo, + // so there's hope the update will arrive there + null + } + } else { + AvailableAppWithIssue( + app = app, + installVersionName = packageInfo.versionName ?: "???", + installVersionCode = getLongVersionCode(packageInfo), + issue = NoCompatibleSigner(repoIdWithCompatibleSigner), + ) } + } + + /** + * @return true if this version is an update from the preferred repo with a compatible signer and + * not a known vulnerable version. + */ + private fun Version.isOk(preferredRepoId: Long, signers: Set): Boolean { + val ourSigners = signer?.sha256?.toSet() + return preferredRepoId == repoId && + !hasKnownVulnerability && + (ourSigners == null || ourSigners.intersect(signers).isNotEmpty()) + } + + private fun getUpdatableApp( + version: Version, + installedVersionCode: Long, + installedVersionName: String, + ): UpdatableApp? { + val versionedStrings = + versionDao.getVersionedStrings( + repoId = version.repoId, + packageName = version.packageName, + versionId = version.versionId, + ) + val appOverviewItem = + appDao.getAppOverviewItem(version.repoId, version.packageName) ?: return null + return UpdatableApp( + repoId = version.repoId, + packageName = version.packageName, + installedVersionCode = installedVersionCode, + installedVersionName = installedVersionName, + update = version.toAppVersion(versionedStrings), + isFromPreferredRepo = true, + hasKnownVulnerability = version.hasKnownVulnerability, + name = appOverviewItem.name, + summary = appOverviewItem.summary, + localizedIcon = appOverviewItem.localizedIcon, + ) + } } diff --git a/libs/database/src/main/java/org/fdroid/database/DbDiffUtils.kt b/libs/database/src/main/java/org/fdroid/database/DbDiffUtils.kt index cfc1374f5..eaaf2f0de 100644 --- a/libs/database/src/main/java/org/fdroid/database/DbDiffUtils.kt +++ b/libs/database/src/main/java/org/fdroid/database/DbDiffUtils.kt @@ -10,116 +10,113 @@ import org.fdroid.index.v2.ReflectionDiffer internal object DbDiffUtils { - /** - * Applies the diff from the given [jsonObject] identified by the given [jsonObjectKey] - * to [itemList] and updates the DB as needed. - * - * @param newItem A function to produce a new [T] which typically contains the primary key(s). - */ - @Throws(SerializationException::class) - fun diffAndUpdateTable( - jsonObject: JsonObject, - jsonObjectKey: String, - itemList: List, - itemFinder: (String, T) -> Boolean, - newItem: (String) -> T, - deleteAll: () -> Unit, - deleteOne: (String) -> Unit, - insertReplace: (List) -> Unit, - isNewItemValid: (T) -> Boolean = { true }, - keyDenyList: List? = null, - ) { - if (!jsonObject.containsKey(jsonObjectKey)) return - if (jsonObject[jsonObjectKey] == JsonNull) { - deleteAll() + /** + * Applies the diff from the given [jsonObject] identified by the given [jsonObjectKey] to + * [itemList] and updates the DB as needed. + * + * @param newItem A function to produce a new [T] which typically contains the primary key(s). + */ + @Throws(SerializationException::class) + fun diffAndUpdateTable( + jsonObject: JsonObject, + jsonObjectKey: String, + itemList: List, + itemFinder: (String, T) -> Boolean, + newItem: (String) -> T, + deleteAll: () -> Unit, + deleteOne: (String) -> Unit, + insertReplace: (List) -> Unit, + isNewItemValid: (T) -> Boolean = { true }, + keyDenyList: List? = null, + ) { + if (!jsonObject.containsKey(jsonObjectKey)) return + if (jsonObject[jsonObjectKey] == JsonNull) { + deleteAll() + } else { + val obj = jsonObject[jsonObjectKey]?.jsonObject ?: error("no $jsonObjectKey object") + val list = itemList.toMutableList() + obj.entries.forEach { (key, value) -> + if (value is JsonNull) { + list.removeAll { itemFinder(key, it) } + deleteOne(key) } else { - val obj = jsonObject[jsonObjectKey]?.jsonObject ?: error("no $jsonObjectKey object") - val list = itemList.toMutableList() - obj.entries.forEach { (key, value) -> - if (value is JsonNull) { - list.removeAll { itemFinder(key, it) } - deleteOne(key) - } else { - value.jsonObject.checkDenyList(keyDenyList) - val index = list.indexOfFirst { itemFinder(key, it) } - val item = if (index == -1) null else list[index] - if (item == null) { - val itemToInsert = - ReflectionDiffer.applyDiff(newItem(key), value.jsonObject) - if (!isNewItemValid(itemToInsert)) throw SerializationException("$newItem") - list.add(itemToInsert) - } else { - list[index] = ReflectionDiffer.applyDiff(item, value.jsonObject) - } - } - } - insertReplace(list) + value.jsonObject.checkDenyList(keyDenyList) + val index = list.indexOfFirst { itemFinder(key, it) } + val item = if (index == -1) null else list[index] + if (item == null) { + val itemToInsert = ReflectionDiffer.applyDiff(newItem(key), value.jsonObject) + if (!isNewItemValid(itemToInsert)) throw SerializationException("$newItem") + list.add(itemToInsert) + } else { + list[index] = ReflectionDiffer.applyDiff(item, value.jsonObject) + } } + } + insertReplace(list) } + } - /** - * Applies a list diff from a map of lists. - * The map is identified by the given [jsonObjectKey] in the given [jsonObject]. - * The diff is applied for each key - * by replacing the existing list using [deleteList] and [insertNewList]. - * - * @param listParser returns a list of [T] from the given [JsonArray]. - */ - @Throws(SerializationException::class) - fun diffAndUpdateListTable( - jsonObject: JsonObject, - jsonObjectKey: String, - listParser: (String, JsonArray) -> List, - deleteAll: () -> Unit, - deleteList: (String) -> Unit, - insertNewList: (String, List) -> Unit, - ) { - if (!jsonObject.containsKey(jsonObjectKey)) return - if (jsonObject[jsonObjectKey] == JsonNull) { - deleteAll() + /** + * Applies a list diff from a map of lists. The map is identified by the given [jsonObjectKey] in + * the given [jsonObject]. The diff is applied for each key by replacing the existing list using + * [deleteList] and [insertNewList]. + * + * @param listParser returns a list of [T] from the given [JsonArray]. + */ + @Throws(SerializationException::class) + fun diffAndUpdateListTable( + jsonObject: JsonObject, + jsonObjectKey: String, + listParser: (String, JsonArray) -> List, + deleteAll: () -> Unit, + deleteList: (String) -> Unit, + insertNewList: (String, List) -> Unit, + ) { + if (!jsonObject.containsKey(jsonObjectKey)) return + if (jsonObject[jsonObjectKey] == JsonNull) { + deleteAll() + } else { + val obj = jsonObject[jsonObjectKey]?.jsonObject ?: error("no $jsonObjectKey object") + obj.entries.forEach { (key, list) -> + if (list is JsonNull) { + deleteList(key) } else { - val obj = jsonObject[jsonObjectKey]?.jsonObject ?: error("no $jsonObjectKey object") - obj.entries.forEach { (key, list) -> - if (list is JsonNull) { - deleteList(key) - } else { - val newList = listParser(key, list.jsonArray) - deleteList(key) - insertNewList(key, newList) - } - } + val newList = listParser(key, list.jsonArray) + deleteList(key) + insertNewList(key, newList) } + } } + } - /** - * Applies the list diff from the given [jsonObject] identified by the given [jsonObjectKey] - * by replacing an existing list using [deleteList] and [insertNewList]. - * - * @param listParser returns a list of [T] from the given [JsonArray]. - */ - @Throws(SerializationException::class) - fun diffAndUpdateListTable( - jsonObject: JsonObject, - jsonObjectKey: String, - listParser: (JsonArray) -> List, - deleteList: () -> Unit, - insertNewList: (List) -> Unit, - ) { - if (!jsonObject.containsKey(jsonObjectKey)) return - if (jsonObject[jsonObjectKey] == JsonNull) { - deleteList() - } else { - val jsonArray = jsonObject[jsonObjectKey]?.jsonArray ?: error("no $jsonObjectKey array") - val list = listParser(jsonArray) - deleteList() - insertNewList(list) - } + /** + * Applies the list diff from the given [jsonObject] identified by the given [jsonObjectKey] by + * replacing an existing list using [deleteList] and [insertNewList]. + * + * @param listParser returns a list of [T] from the given [JsonArray]. + */ + @Throws(SerializationException::class) + fun diffAndUpdateListTable( + jsonObject: JsonObject, + jsonObjectKey: String, + listParser: (JsonArray) -> List, + deleteList: () -> Unit, + insertNewList: (List) -> Unit, + ) { + if (!jsonObject.containsKey(jsonObjectKey)) return + if (jsonObject[jsonObjectKey] == JsonNull) { + deleteList() + } else { + val jsonArray = jsonObject[jsonObjectKey]?.jsonArray ?: error("no $jsonObjectKey array") + val list = listParser(jsonArray) + deleteList() + insertNewList(list) } + } - private fun JsonObject.checkDenyList(list: List?) { - list?.forEach { forbiddenKey -> - if (containsKey(forbiddenKey)) throw SerializationException(forbiddenKey) - } + private fun JsonObject.checkDenyList(list: List?) { + list?.forEach { forbiddenKey -> + if (containsKey(forbiddenKey)) throw SerializationException(forbiddenKey) } - + } } diff --git a/libs/database/src/main/java/org/fdroid/database/DbUpdateChecker.kt b/libs/database/src/main/java/org/fdroid/database/DbUpdateChecker.kt index 7af7fde9a..25d1757f8 100644 --- a/libs/database/src/main/java/org/fdroid/database/DbUpdateChecker.kt +++ b/libs/database/src/main/java/org/fdroid/database/DbUpdateChecker.kt @@ -11,176 +11,188 @@ import org.fdroid.PackagePreference import org.fdroid.UpdateChecker @Deprecated("Use DbAppChecker instead") -public class DbUpdateChecker @JvmOverloads constructor( - db: FDroidDatabase, - private val packageManager: PackageManager, - compatibilityChecker: CompatibilityChecker = CompatibilityCheckerImpl(packageManager), - private val updateChecker: UpdateChecker = UpdateChecker(compatibilityChecker), +public class DbUpdateChecker +@JvmOverloads +constructor( + db: FDroidDatabase, + private val packageManager: PackageManager, + compatibilityChecker: CompatibilityChecker = CompatibilityCheckerImpl(packageManager), + private val updateChecker: UpdateChecker = UpdateChecker(compatibilityChecker), ) { - private val appDao = db.getAppDao() as AppDaoInt - private val versionDao = db.getVersionDao() as VersionDaoInt - private val appPrefsDao = db.getAppPrefsDao() as AppPrefsDaoInt + private val appDao = db.getAppDao() as AppDaoInt + private val versionDao = db.getVersionDao() as VersionDaoInt + private val appPrefsDao = db.getAppPrefsDao() as AppPrefsDaoInt - /** - * Returns a list of apps that can be updated. - * @param releaseChannels optional list of release channels to consider on top of stable. - * If this is null or empty, only versions without channel (stable) will be considered. - * @param onlyFromPreferredRepo if true updates coming from repositories that are not preferred, - * either via [AppPrefs.preferredRepoId] or [Repository.weight] will not be returned. - * If false, updates from all enabled repositories will be considered - * and the one with the highest version code returned. - */ - @JvmOverloads - public fun getUpdatableApps( - releaseChannels: List? = null, - onlyFromPreferredRepo: Boolean = false, - includeKnownVulnerabilities: Boolean = false, - ): List { - val updatableApps = ArrayList() + /** + * Returns a list of apps that can be updated. + * + * @param releaseChannels optional list of release channels to consider on top of stable. If this + * is null or empty, only versions without channel (stable) will be considered. + * @param onlyFromPreferredRepo if true updates coming from repositories that are not preferred, + * either via [AppPrefs.preferredRepoId] or [Repository.weight] will not be returned. If false, + * updates from all enabled repositories will be considered and the one with the highest version + * code returned. + */ + @JvmOverloads + public fun getUpdatableApps( + releaseChannels: List? = null, + onlyFromPreferredRepo: Boolean = false, + includeKnownVulnerabilities: Boolean = false, + ): List { + val updatableApps = ArrayList() - @Suppress("DEPRECATION") // we'll use this as long as it works, new one was broken - val installedPackages = packageManager.getInstalledPackages(GET_SIGNATURES) - val packageNames = installedPackages.map { it.packageName } - val preferredRepos = appPrefsDao.getPreferredRepos(packageNames) + @Suppress("DEPRECATION") // we'll use this as long as it works, new one was broken + val installedPackages = packageManager.getInstalledPackages(GET_SIGNATURES) + val packageNames = installedPackages.map { it.packageName } + val preferredRepos = appPrefsDao.getPreferredRepos(packageNames) - val versionsByPackage = HashMap>(packageNames.size) - versionDao.getVersions(packageNames).forEach { version -> - val preferredRepoId = preferredRepos[version.packageName] - ?: error("No preferred repo for ${version.packageName} in repo ${version.repoId}") - // disregard version, if we only want from preferred repo and this version is not - if (onlyFromPreferredRepo && preferredRepoId != version.repoId) return@forEach - val list = versionsByPackage.getOrPut(version.packageName) { ArrayList() } - list.add(version) - } - installedPackages.iterator().forEach { packageInfo -> - val packageName = packageInfo.packageName - val versions = versionsByPackage[packageName] ?: return@forEach // continue - val version = getVersion( - versions = versions, - packageName = packageName, - packageInfo = packageInfo, - preferredSigner = null, - releaseChannels = releaseChannels, - includeKnownVulnerabilities = includeKnownVulnerabilities, - ) - if (version != null) { - val preferredRepoId = preferredRepos[packageName] - ?: error("No preferred repo for $packageName") - val app = getUpdatableApp( - version = version, - installedVersionCode = getLongVersionCode(packageInfo), - installedVersionName = packageInfo.versionName ?: "???", // should never be null - isFromPreferredRepo = preferredRepoId == version.repoId, - ) - if (app != null) updatableApps.add(app) - } - } - return updatableApps + val versionsByPackage = HashMap>(packageNames.size) + versionDao.getVersions(packageNames).forEach { version -> + val preferredRepoId = + preferredRepos[version.packageName] + ?: error("No preferred repo for ${version.packageName} in repo ${version.repoId}") + // disregard version, if we only want from preferred repo and this version is not + if (onlyFromPreferredRepo && preferredRepoId != version.repoId) return@forEach + val list = versionsByPackage.getOrPut(version.packageName) { ArrayList() } + list.add(version) } - - /** - * Returns an [AppVersion] for the given [packageName] that is an update or new install - * or null if there is none. - * @param releaseChannels optional list of release channels to consider on top of stable. - * If this is null or empty, only versions without channel (stable) will be considered. - * @param onlyFromPreferredRepo if true a version from a repository that is not preferred, - * either via [AppPrefs.preferredRepoId] or [Repository.weight] will not be returned. - * If false, versions from all enabled repositories will be considered. - */ - @SuppressLint("PackageManagerGetSignatures") - public fun getSuggestedVersion( - packageName: String, - preferredSigner: String? = null, - releaseChannels: List? = null, - onlyFromPreferredRepo: Boolean = false, - ): AppVersion? { - val preferredRepoId = if (onlyFromPreferredRepo) { - appPrefsDao.getPreferredRepos(listOf(packageName))[packageName] - ?: error("No preferred repo for $packageName") - } else 0L - val versions = if (onlyFromPreferredRepo) { - versionDao.getVersions(listOf(packageName)).filter { version -> - version.repoId == preferredRepoId - } - } else { - versionDao.getVersions(listOf(packageName)) - } - if (versions.isEmpty()) return null - val packageInfo = try { - @Suppress("DEPRECATION") - packageManager.getPackageInfo(packageName, GET_SIGNATURES) - } catch (_: PackageManager.NameNotFoundException) { - null - } - val version = getVersion( - versions = versions, - packageName = packageName, - packageInfo = packageInfo, - preferredSigner = preferredSigner, - releaseChannels = releaseChannels, - ) ?: return null - val versionedStrings = versionDao.getVersionedStrings( - repoId = version.repoId, - packageName = version.packageName, - versionId = version.versionId, + installedPackages.iterator().forEach { packageInfo -> + val packageName = packageInfo.packageName + val versions = versionsByPackage[packageName] ?: return@forEach // continue + val version = + getVersion( + versions = versions, + packageName = packageName, + packageInfo = packageInfo, + preferredSigner = null, + releaseChannels = releaseChannels, + includeKnownVulnerabilities = includeKnownVulnerabilities, ) - return version.toAppVersion(versionedStrings) + if (version != null) { + val preferredRepoId = + preferredRepos[packageName] ?: error("No preferred repo for $packageName") + val app = + getUpdatableApp( + version = version, + installedVersionCode = getLongVersionCode(packageInfo), + installedVersionName = packageInfo.versionName ?: "???", // should never be null + isFromPreferredRepo = preferredRepoId == version.repoId, + ) + if (app != null) updatableApps.add(app) + } } + return updatableApps + } - private fun getVersion( - versions: List, - packageName: String, - packageInfo: PackageInfo?, - preferredSigner: String?, - releaseChannels: List?, - includeKnownVulnerabilities: Boolean = false, - ): Version? { - val preferencesGetter: (() -> PackagePreference?) = { - appPrefsDao.getAppPrefsOrNull(packageName) + /** + * Returns an [AppVersion] for the given [packageName] that is an update or new install or null if + * there is none. + * + * @param releaseChannels optional list of release channels to consider on top of stable. If this + * is null or empty, only versions without channel (stable) will be considered. + * @param onlyFromPreferredRepo if true a version from a repository that is not preferred, either + * via [AppPrefs.preferredRepoId] or [Repository.weight] will not be returned. If false, + * versions from all enabled repositories will be considered. + */ + @SuppressLint("PackageManagerGetSignatures") + public fun getSuggestedVersion( + packageName: String, + preferredSigner: String? = null, + releaseChannels: List? = null, + onlyFromPreferredRepo: Boolean = false, + ): AppVersion? { + val preferredRepoId = + if (onlyFromPreferredRepo) { + appPrefsDao.getPreferredRepos(listOf(packageName))[packageName] + ?: error("No preferred repo for $packageName") + } else 0L + val versions = + if (onlyFromPreferredRepo) { + versionDao.getVersions(listOf(packageName)).filter { version -> + version.repoId == preferredRepoId } - return if (packageInfo == null) { - updateChecker.getSuggestedVersion( - versions = versions, - preferredSigner = preferredSigner, - releaseChannels = releaseChannels, - preferencesGetter = preferencesGetter, - ) - } else { - updateChecker.getUpdate( - versions = versions, - packageInfo = packageInfo, - releaseChannels = releaseChannels, - includeKnownVulnerabilities = includeKnownVulnerabilities, - preferencesGetter = preferencesGetter, - ) - } - } + } else { + versionDao.getVersions(listOf(packageName)) + } + if (versions.isEmpty()) return null + val packageInfo = + try { + @Suppress("DEPRECATION") packageManager.getPackageInfo(packageName, GET_SIGNATURES) + } catch (_: PackageManager.NameNotFoundException) { + null + } + val version = + getVersion( + versions = versions, + packageName = packageName, + packageInfo = packageInfo, + preferredSigner = preferredSigner, + releaseChannels = releaseChannels, + ) ?: return null + val versionedStrings = + versionDao.getVersionedStrings( + repoId = version.repoId, + packageName = version.packageName, + versionId = version.versionId, + ) + return version.toAppVersion(versionedStrings) + } - private fun getUpdatableApp( - version: Version, - installedVersionCode: Long, - installedVersionName: String, - isFromPreferredRepo: Boolean, - ): UpdatableApp? { - val versionedStrings = versionDao.getVersionedStrings( - repoId = version.repoId, - packageName = version.packageName, - versionId = version.versionId, - ) - val appOverviewItem = - appDao.getAppOverviewItem(version.repoId, version.packageName) ?: return null - return UpdatableApp( - repoId = version.repoId, - packageName = version.packageName, - installedVersionCode = installedVersionCode, - installedVersionName = installedVersionName, - update = version.toAppVersion(versionedStrings), - isFromPreferredRepo = isFromPreferredRepo, - hasKnownVulnerability = version.hasKnownVulnerability, - name = appOverviewItem.name, - summary = appOverviewItem.summary, - localizedIcon = appOverviewItem.localizedIcon, - ) + private fun getVersion( + versions: List, + packageName: String, + packageInfo: PackageInfo?, + preferredSigner: String?, + releaseChannels: List?, + includeKnownVulnerabilities: Boolean = false, + ): Version? { + val preferencesGetter: (() -> PackagePreference?) = { + appPrefsDao.getAppPrefsOrNull(packageName) } + return if (packageInfo == null) { + updateChecker.getSuggestedVersion( + versions = versions, + preferredSigner = preferredSigner, + releaseChannels = releaseChannels, + preferencesGetter = preferencesGetter, + ) + } else { + updateChecker.getUpdate( + versions = versions, + packageInfo = packageInfo, + releaseChannels = releaseChannels, + includeKnownVulnerabilities = includeKnownVulnerabilities, + preferencesGetter = preferencesGetter, + ) + } + } + + private fun getUpdatableApp( + version: Version, + installedVersionCode: Long, + installedVersionName: String, + isFromPreferredRepo: Boolean, + ): UpdatableApp? { + val versionedStrings = + versionDao.getVersionedStrings( + repoId = version.repoId, + packageName = version.packageName, + versionId = version.versionId, + ) + val appOverviewItem = + appDao.getAppOverviewItem(version.repoId, version.packageName) ?: return null + return UpdatableApp( + repoId = version.repoId, + packageName = version.packageName, + installedVersionCode = installedVersionCode, + installedVersionName = installedVersionName, + update = version.toAppVersion(versionedStrings), + isFromPreferredRepo = isFromPreferredRepo, + hasKnownVulnerability = version.hasKnownVulnerability, + name = appOverviewItem.name, + summary = appOverviewItem.summary, + localizedIcon = appOverviewItem.localizedIcon, + ) + } } diff --git a/libs/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt b/libs/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt index 27b648c17..99f14f5cf 100644 --- a/libs/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt +++ b/libs/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt @@ -12,48 +12,47 @@ import org.fdroid.index.v2.ReleaseChannelV2 import org.fdroid.index.v2.RepoV2 /** - * Note that this class expects that its [receive] method with [RepoV2] gets called first. - * A different order of calls is not supported. + * Note that this class expects that its [receive] method with [RepoV2] gets called first. A + * different order of calls is not supported. */ @Deprecated("Use DbV2StreamReceiver instead") internal class DbV1StreamReceiver( - private val db: FDroidDatabaseInt, - private val repoId: Long, - private val compatibilityChecker: CompatibilityChecker, + private val db: FDroidDatabaseInt, + private val repoId: Long, + private val compatibilityChecker: CompatibilityChecker, ) : IndexV1StreamReceiver { - private val locales: LocaleListCompat = LocaleListCompat.getDefault() + private val locales: LocaleListCompat = LocaleListCompat.getDefault() - override fun receive(repo: RepoV2, version: Long) { - db.getRepositoryDao().clear(repoId) - db.getRepositoryDao().update(repoId, repo, version, ONE) + override fun receive(repo: RepoV2, version: Long) { + db.getRepositoryDao().clear(repoId) + db.getRepositoryDao().update(repoId, repo, version, ONE) + } + + override fun receive(packageName: String, m: MetadataV2) { + db.getAppDao().insert(repoId, packageName, m, locales) + } + + override fun receive(packageName: String, v: Map) { + db.getVersionDao().insert(repoId, packageName, v) { + compatibilityChecker.isCompatible(it.manifest) } + } - override fun receive(packageName: String, m: MetadataV2) { - db.getAppDao().insert(repoId, packageName, m, locales) - } + override fun updateRepo( + antiFeatures: Map, + categories: Map, + releaseChannels: Map, + ) { + val repoDao = db.getRepositoryDao() + repoDao.insertAntiFeatures(antiFeatures.toRepoAntiFeatures(repoId)) + repoDao.insertCategories(categories.toRepoCategories(repoId)) + repoDao.insertReleaseChannels(releaseChannels.toRepoReleaseChannel(repoId)) - override fun receive(packageName: String, v: Map) { - db.getVersionDao().insert(repoId, packageName, v) { - compatibilityChecker.isCompatible(it.manifest) - } - } - - override fun updateRepo( - antiFeatures: Map, - categories: Map, - releaseChannels: Map, - ) { - val repoDao = db.getRepositoryDao() - repoDao.insertAntiFeatures(antiFeatures.toRepoAntiFeatures(repoId)) - repoDao.insertCategories(categories.toRepoCategories(repoId)) - repoDao.insertReleaseChannels(releaseChannels.toRepoReleaseChannel(repoId)) - - db.afterUpdatingRepo(repoId) - } - - override fun updateAppMetadata(packageName: String, preferredSigner: String?) { - db.getAppDao().updatePreferredSigner(repoId, packageName, preferredSigner) - } + db.afterUpdatingRepo(repoId) + } + override fun updateAppMetadata(packageName: String, preferredSigner: String?) { + db.getAppDao().updatePreferredSigner(repoId, packageName, preferredSigner) + } } diff --git a/libs/database/src/main/java/org/fdroid/database/DbV2DiffStreamReceiver.kt b/libs/database/src/main/java/org/fdroid/database/DbV2DiffStreamReceiver.kt index e3294646a..73b6aad5a 100644 --- a/libs/database/src/main/java/org/fdroid/database/DbV2DiffStreamReceiver.kt +++ b/libs/database/src/main/java/org/fdroid/database/DbV2DiffStreamReceiver.kt @@ -6,33 +6,32 @@ import org.fdroid.CompatibilityChecker import org.fdroid.index.v2.IndexV2DiffStreamReceiver internal class DbV2DiffStreamReceiver( - private val db: FDroidDatabaseInt, - private val repoId: Long, - private val compatibilityChecker: CompatibilityChecker, + private val db: FDroidDatabaseInt, + private val repoId: Long, + private val compatibilityChecker: CompatibilityChecker, ) : IndexV2DiffStreamReceiver { - private val locales: LocaleListCompat = LocaleListCompat.getDefault() + private val locales: LocaleListCompat = LocaleListCompat.getDefault() - override fun receiveRepoDiff(version: Long, repoJsonObject: JsonObject) { - db.getRepositoryDao().updateRepository(repoId, version, repoJsonObject) - } - - override fun receivePackageMetadataDiff(packageName: String, packageJsonObject: JsonObject?) { - db.getAppDao().updateApp(repoId, packageName, packageJsonObject, locales) - } - - override fun receiveVersionsDiff( - packageName: String, - versionsDiffMap: Map?, - ) { - db.getVersionDao().update(repoId, packageName, versionsDiffMap) { - compatibilityChecker.isCompatible(it) - } - } - - @Synchronized - override fun onStreamEnded() { - db.afterUpdatingRepo(repoId) + override fun receiveRepoDiff(version: Long, repoJsonObject: JsonObject) { + db.getRepositoryDao().updateRepository(repoId, version, repoJsonObject) + } + + override fun receivePackageMetadataDiff(packageName: String, packageJsonObject: JsonObject?) { + db.getAppDao().updateApp(repoId, packageName, packageJsonObject, locales) + } + + override fun receiveVersionsDiff( + packageName: String, + versionsDiffMap: Map?, + ) { + db.getVersionDao().update(repoId, packageName, versionsDiffMap) { + compatibilityChecker.isCompatible(it) } + } + @Synchronized + override fun onStreamEnded() { + db.afterUpdatingRepo(repoId) + } } diff --git a/libs/database/src/main/java/org/fdroid/database/DbV2StreamReceiver.kt b/libs/database/src/main/java/org/fdroid/database/DbV2StreamReceiver.kt index 5a4fe7e15..36926798e 100644 --- a/libs/database/src/main/java/org/fdroid/database/DbV2StreamReceiver.kt +++ b/libs/database/src/main/java/org/fdroid/database/DbV2StreamReceiver.kt @@ -12,59 +12,57 @@ import org.fdroid.index.v2.RepoV2 /** * Receives a stream of IndexV2 data and stores it in the DB. * - * Note: This should only be used once. - * If you want to process a second stream, create a new instance. + * Note: This should only be used once. If you want to process a second stream, create a new + * instance. */ internal class DbV2StreamReceiver( - private val db: FDroidDatabaseInt, - private val repoId: Long, - private val compatibilityChecker: CompatibilityChecker, + private val db: FDroidDatabaseInt, + private val repoId: Long, + private val compatibilityChecker: CompatibilityChecker, ) : IndexV2StreamReceiver { - private val locales: LocaleListCompat = LocaleListCompat.getDefault() - private var clearedRepoData = false - private val nonNullFileV2: (FileV2?) -> Unit = { fileV2 -> - if (fileV2 != null) { - if (fileV2.sha256 == null) throw SerializationException("${fileV2.name} has no sha256") - if (fileV2.size == null) throw SerializationException("${fileV2.name} has no size") - if (!fileV2.name.startsWith('/')) { - throw SerializationException("${fileV2.name} does not start with /") - } - } + private val locales: LocaleListCompat = LocaleListCompat.getDefault() + private var clearedRepoData = false + private val nonNullFileV2: (FileV2?) -> Unit = { fileV2 -> + if (fileV2 != null) { + if (fileV2.sha256 == null) throw SerializationException("${fileV2.name} has no sha256") + if (fileV2.size == null) throw SerializationException("${fileV2.name} has no size") + if (!fileV2.name.startsWith('/')) { + throw SerializationException("${fileV2.name} does not start with /") + } } + } - @Synchronized - override fun receive(repo: RepoV2, version: Long) { - repo.walkFiles(nonNullFileV2) - clearRepoDataIfNeeded() - db.getRepositoryDao().update(repoId, repo, version, TWO) + @Synchronized + override fun receive(repo: RepoV2, version: Long) { + repo.walkFiles(nonNullFileV2) + clearRepoDataIfNeeded() + db.getRepositoryDao().update(repoId, repo, version, TWO) + } + + @Synchronized + override fun receive(packageName: String, p: PackageV2) { + p.walkFiles(nonNullFileV2) + clearRepoDataIfNeeded() + db.getAppDao().insert(repoId, packageName, p.metadata, locales) + db.getVersionDao().insert(repoId, packageName, p.versions) { + compatibilityChecker.isCompatible(it.manifest) } + } - @Synchronized - override fun receive(packageName: String, p: PackageV2) { - p.walkFiles(nonNullFileV2) - clearRepoDataIfNeeded() - db.getAppDao().insert(repoId, packageName, p.metadata, locales) - db.getVersionDao().insert(repoId, packageName, p.versions) { - compatibilityChecker.isCompatible(it.manifest) - } + @Synchronized + override fun onStreamEnded() { + db.afterUpdatingRepo(repoId) + } + + /** + * As it is a valid index to receive packages before the repo, we can not clear all repo data when + * receiving the repo, but need to do it once at the beginning. + */ + private fun clearRepoDataIfNeeded() { + if (!clearedRepoData) { + db.getRepositoryDao().clear(repoId) + clearedRepoData = true } - - @Synchronized - override fun onStreamEnded() { - db.afterUpdatingRepo(repoId) - } - - /** - * As it is a valid index to receive packages before the repo, - * we can not clear all repo data when receiving the repo, - * but need to do it once at the beginning. - */ - private fun clearRepoDataIfNeeded() { - if (!clearedRepoData) { - db.getRepositoryDao().clear(repoId) - clearedRepoData = true - } - } - + } } diff --git a/libs/database/src/main/java/org/fdroid/database/FDroidDatabase.kt b/libs/database/src/main/java/org/fdroid/database/FDroidDatabase.kt index 0298cd5e8..8601dbd80 100644 --- a/libs/database/src/main/java/org/fdroid/database/FDroidDatabase.kt +++ b/libs/database/src/main/java/org/fdroid/database/FDroidDatabase.kt @@ -7,141 +7,141 @@ import androidx.room.AutoMigration import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters -import org.fdroid.LocaleChooser.getBestLocale import java.io.Closeable import java.util.Locale import java.util.concurrent.Callable +import org.fdroid.LocaleChooser.getBestLocale @Database( - // When bumping this version, please make sure to add one (or more) migration(s) below! - // Consider also providing tests for that migration. - // Don't forget to commit the new schema to the git repo as well. - version = 10, - entities = [ - // repo - CoreRepository::class, - Mirror::class, - AntiFeature::class, - Category::class, - ReleaseChannel::class, - RepositoryPreferences::class, - // packages - AppMetadata::class, - AppMetadataFts::class, - LocalizedFile::class, - LocalizedFileList::class, - // versions - Version::class, - VersionedString::class, - // app user preferences - AppPrefs::class, + // When bumping this version, please make sure to add one (or more) migration(s) below! + // Consider also providing tests for that migration. + // Don't forget to commit the new schema to the git repo as well. + version = 10, + entities = + [ + // repo + CoreRepository::class, + Mirror::class, + AntiFeature::class, + Category::class, + ReleaseChannel::class, + RepositoryPreferences::class, + // packages + AppMetadata::class, + AppMetadataFts::class, + LocalizedFile::class, + LocalizedFileList::class, + // versions + Version::class, + VersionedString::class, + // app user preferences + AppPrefs::class, ], - views = [ - LocalizedIcon::class, - HighestVersion::class, - PreferredRepo::class, - ], - exportSchema = true, - autoMigrations = [ - AutoMigration(1, 2, MultiRepoMigration::class), - // 2 to 3 is a manual migration - AutoMigration(3, 4), - AutoMigration(4, 5), - // 5 to 6 is a manual migration - AutoMigration(6, 7), - AutoMigration(7, 8, CountryCodeMigration::class), - // 8 to 9 is a manual migration - AutoMigration(9, 10), - // add future migrations above! + views = [LocalizedIcon::class, HighestVersion::class, PreferredRepo::class], + exportSchema = true, + autoMigrations = + [ + AutoMigration(1, 2, MultiRepoMigration::class), + // 2 to 3 is a manual migration + AutoMigration(3, 4), + AutoMigration(4, 5), + // 5 to 6 is a manual migration + AutoMigration(6, 7), + AutoMigration(7, 8, CountryCodeMigration::class), + // 8 to 9 is a manual migration + AutoMigration(9, 10), + // add future migrations above! ], ) @TypeConverters(Converters::class) internal abstract class FDroidDatabaseInt : RoomDatabase(), FDroidDatabase, Closeable { - abstract override fun getRepositoryDao(): RepositoryDaoInt - abstract override fun getAppDao(): AppDaoInt - abstract override fun getVersionDao(): VersionDaoInt - abstract override fun getAppPrefsDao(): AppPrefsDaoInt + abstract override fun getRepositoryDao(): RepositoryDaoInt - @Deprecated("Will be removed in future version") - override fun afterLocalesChanged(locales: LocaleListCompat) { - val appDao = getAppDao() - runInTransaction { - appDao.getAppMetadata().forEach { appMetadata -> - appDao.updateAppMetadata( - repoId = appMetadata.repoId, - packageName = appMetadata.packageName, - name = appMetadata.name.getBestLocale(locales), - summary = appMetadata.summary.getBestLocale(locales), - ) - } - } - } + abstract override fun getAppDao(): AppDaoInt - /** - * Call this after updating the data belonging to the given [repoId], - * so the [AppMetadata.isCompatible] can be recalculated in case new versions were added. - */ - fun afterUpdatingRepo(repoId: Long) { - getAppDao().updateCompatibility(repoId) - } + abstract override fun getVersionDao(): VersionDaoInt - override fun clearAllAppData() { - runInTransaction { - getAppDao().clearAll() - getRepositoryDao().resetTimestamps() - getRepositoryDao().resetETags() - } - } + abstract override fun getAppPrefsDao(): AppPrefsDaoInt - // just here to make KSP happy - override fun close() { - super.close() + @Deprecated("Will be removed in future version") + override fun afterLocalesChanged(locales: LocaleListCompat) { + val appDao = getAppDao() + runInTransaction { + appDao.getAppMetadata().forEach { appMetadata -> + appDao.updateAppMetadata( + repoId = appMetadata.repoId, + packageName = appMetadata.packageName, + name = appMetadata.name.getBestLocale(locales), + summary = appMetadata.summary.getBestLocale(locales), + ) + } } + } - // just here to make KSP happy - override fun runInTransaction(body: Runnable) { - super.runInTransaction(body) - } + /** + * Call this after updating the data belonging to the given [repoId], so the + * [AppMetadata.isCompatible] can be recalculated in case new versions were added. + */ + fun afterUpdatingRepo(repoId: Long) { + getAppDao().updateCompatibility(repoId) + } - // just here to make KSP happy - override fun runInTransaction(body: Callable): V { - return super.runInTransaction(body) + override fun clearAllAppData() { + runInTransaction { + getAppDao().clearAll() + getRepositoryDao().resetTimestamps() + getRepositoryDao().resetETags() } + } + + // just here to make KSP happy + override fun close() { + super.close() + } + + // just here to make KSP happy + override fun runInTransaction(body: Runnable) { + super.runInTransaction(body) + } + + // just here to make KSP happy + override fun runInTransaction(body: Callable): V { + return super.runInTransaction(body) + } } -/** - * The F-Droid database offering methods to retrieve the various data access objects. - */ +/** The F-Droid database offering methods to retrieve the various data access objects. */ public interface FDroidDatabase { - public fun getRepositoryDao(): RepositoryDao - public fun getAppDao(): AppDao - public fun getVersionDao(): VersionDao - public fun getAppPrefsDao(): AppPrefsDao + public fun getRepositoryDao(): RepositoryDao - /** - * Call this after the system [Locale]s have changed. - * If this isn't called, the cached localized app metadata (e.g. name, summary) will be wrong. - */ - @Deprecated("Will be removed in future version") - public fun afterLocalesChanged( - locales: LocaleListCompat = getLocales(Resources.getSystem().configuration), - ) + public fun getAppDao(): AppDao - /** - * Call this to run all of the given [body] inside a database transaction. - * Please run as little code as possible to keep the time the database is blocked minimal. - */ - public fun runInTransaction(body: Runnable) + public fun getVersionDao(): VersionDao - /** - * Like [runInTransaction], but can return something. - */ - public fun runInTransaction(body: Callable): V + public fun getAppPrefsDao(): AppPrefsDao - /** - * Removes all apps and associated data (such as versions) from all repositories. - * The repository data and app preferences are kept as-is. - * Only the timestamp of the last repo update gets reset, so we won't try to apply diffs. - */ - public fun clearAllAppData() + /** + * Call this after the system [Locale]s have changed. If this isn't called, the cached localized + * app metadata (e.g. name, summary) will be wrong. + */ + @Deprecated("Will be removed in future version") + public fun afterLocalesChanged( + locales: LocaleListCompat = getLocales(Resources.getSystem().configuration) + ) + + /** + * Call this to run all the given [body] inside a database transaction. Please run as little code + * as possible to keep the time the database is blocked minimal. + */ + public fun runInTransaction(body: Runnable) + + /** Like [runInTransaction], but can return something. */ + public fun runInTransaction(body: Callable): V + + /** + * Removes all apps and associated data (such as versions) from all repositories. The repository + * data and app preferences are kept as-is. Only the timestamp of the last repo update gets reset, + * so we won't try to apply diffs. + */ + public fun clearAllAppData() } diff --git a/libs/database/src/main/java/org/fdroid/database/FDroidDatabaseHolder.kt b/libs/database/src/main/java/org/fdroid/database/FDroidDatabaseHolder.kt index 87d79b369..685b2d57f 100644 --- a/libs/database/src/main/java/org/fdroid/database/FDroidDatabaseHolder.kt +++ b/libs/database/src/main/java/org/fdroid/database/FDroidDatabaseHolder.kt @@ -12,83 +12,77 @@ import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch /** - * A way to pre-populate the database with a fixture. - * This can be supplied to [FDroidDatabaseHolder.getDb] - * and will then be called when a new database is created. + * A way to pre-populate the database with a fixture. This can be supplied to + * [FDroidDatabaseHolder.getDb] and will then be called when a new database is created. */ public fun interface FDroidFixture { - /** - * Called when a new database gets created. - * Multiple DB operations should use [FDroidDatabase.runInTransaction]. - */ - public fun prePopulateDb(db: FDroidDatabase) + /** + * Called when a new database gets created. Multiple DB operations should use + * [FDroidDatabase.runInTransaction]. + */ + public fun prePopulateDb(db: FDroidDatabase) } /** - * A database holder using a singleton pattern to ensure - * that only one database is open at the same time. + * A database holder using a singleton pattern to ensure that only one database is open at the same + * time. */ public object FDroidDatabaseHolder { - // Singleton prevents multiple instances of database opening at the same time. - @Volatile - @GuardedBy("lock") - private var instance: FDroidDatabaseInt? = null - private val lock = Object() + // Singleton prevents multiple instances of database opening at the same time. + @Volatile @GuardedBy("lock") private var instance: FDroidDatabaseInt? = null + private val lock = Any() - internal val TAG = FDroidDatabase::class.simpleName - internal val dispatcher get() = Dispatchers.IO + internal val TAG = FDroidDatabase::class.simpleName + internal val dispatcher + get() = Dispatchers.IO - /** - * Give you an existing instance of [FDroidDatabase] or creates/opens a new one if none exists. - * Note: The given [name] is only used when calling this for the first time. - * Subsequent calls with a different name will return the instance created by the first call. - */ - @JvmStatic - @JvmOverloads - public fun getDb( - context: Context, - name: String = "fdroid_db", - fixture: FDroidFixture? = null, - ): FDroidDatabase { - // if the INSTANCE is not null, then return it, - // if it is, then create the database - return instance ?: synchronized(lock) { - val builder = Room.databaseBuilder( - context.applicationContext, - FDroidDatabaseInt::class.java, - name, - ).apply { - addMigrations(MIGRATION_2_3, MIGRATION_5_6, MIGRATION_8_9) - // We allow destructive migration (if no real migration was provided), - // so we have the option to nuke the DB in production (if that will ever be needed). - fallbackToDestructiveMigration(false) - // Add our [FixtureCallback] if a fixture was provided - if (fixture != null) addCallback(FixtureCallback(fixture)) + /** + * Give you an existing instance of [FDroidDatabase] or creates/opens a new one if none exists. + * Note: The given [name] is only used when calling this for the first time. Subsequent calls with + * a different name will return the instance created by the first call. + */ + @JvmStatic + @JvmOverloads + public fun getDb( + context: Context, + name: String = "fdroid_db", + fixture: FDroidFixture? = null, + ): FDroidDatabase { + // if the INSTANCE is not null, then return it, + // if it is, then create the database + return instance + ?: synchronized(lock) { + val builder = + Room.databaseBuilder(context.applicationContext, FDroidDatabaseInt::class.java, name) + .apply { + addMigrations(MIGRATION_2_3, MIGRATION_5_6, MIGRATION_8_9) + // We allow destructive migration (if no real migration was provided), + // so we have the option to nuke the DB in production (if that will ever be needed). + fallbackToDestructiveMigration(false) + // Add our [FixtureCallback] if a fixture was provided + if (fixture != null) addCallback(FixtureCallback(fixture)) } - val instance = builder.build() - this.instance = instance - // return instance - instance - } + val instance = builder.build() + this.instance = instance + // return instance + instance + } + } + + private class FixtureCallback(private val fixture: FDroidFixture) : RoomDatabase.Callback() { + override fun onCreate(db: SupportSQLiteDatabase) { + super.onCreate(db) + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(dispatcher) { + val database: FDroidDatabase + synchronized(lock) { database = instance ?: error("DB not yet initialized") } + fixture.prePopulateDb(database) + Log.d(TAG, "Loaded fixtures") + } } - private class FixtureCallback(private val fixture: FDroidFixture) : RoomDatabase.Callback() { - override fun onCreate(db: SupportSQLiteDatabase) { - super.onCreate(db) - @OptIn(DelicateCoroutinesApi::class) - GlobalScope.launch(dispatcher) { - val database: FDroidDatabase - synchronized(lock) { - database = instance ?: error("DB not yet initialized") - } - fixture.prePopulateDb(database) - Log.d(TAG, "Loaded fixtures") - } - } - - override fun onDestructiveMigration(db: SupportSQLiteDatabase) { - onCreate(db) - } + override fun onDestructiveMigration(db: SupportSQLiteDatabase) { + onCreate(db) } - + } } diff --git a/libs/database/src/main/java/org/fdroid/database/Migrations.kt b/libs/database/src/main/java/org/fdroid/database/Migrations.kt index d1481b40e..7fbaea3d4 100644 --- a/libs/database/src/main/java/org/fdroid/database/Migrations.kt +++ b/libs/database/src/main/java/org/fdroid/database/Migrations.kt @@ -13,212 +13,223 @@ private const val REPO_WEIGHT = 1_000_000_000 internal class MultiRepoMigration : AutoMigrationSpec { - private val log = KotlinLogging.logger {} + private val log = KotlinLogging.logger {} - override fun onPostMigrate(db: SupportSQLiteDatabase) { - super.onPostMigrate(db) - // do migration in one transaction that can be rolled back if there's issues - db.beginTransaction() - try { - migrateWeights(db) - db.setTransactionSuccessful() - } finally { - db.endTransaction() - } + override fun onPostMigrate(db: SupportSQLiteDatabase) { + super.onPostMigrate(db) + // do migration in one transaction that can be rolled back if there's issues + db.beginTransaction() + try { + migrateWeights(db) + db.setTransactionSuccessful() + } finally { + db.endTransaction() } + } - private fun migrateWeights(db: SupportSQLiteDatabase) { - // get repositories - val repos = ArrayList() - val archiveMap = HashMap() - db.query( - """ + private fun migrateWeights(db: SupportSQLiteDatabase) { + // get repositories + val repos = ArrayList() + val archiveMap = HashMap() + db + .query( + """ SELECT repoId, address, certificate, weight FROM ${CoreRepository.TABLE} JOIN ${RepositoryPreferences.TABLE} USING (repoId) ORDER BY weight DESC""" - ).use { cursor -> - while (cursor.moveToNext()) { - val repo = getRepo(cursor) - log.error { repo.toString() } - if (repo.isArchive() && repo.certificate != null) { - if (archiveMap.containsKey(repo.certificate)) { - log.error { "More than two repos with certificate of ${repo.address}" } - // still migrating this as a normal repo then - repos.add(repo) - } else { - // remember archive repo, so we get position it below main repo - archiveMap[repo.certificate] = repo - } - } else { - repos.add(repo) - } - } - } - - // now go through all repos and adapt their weight, - // so that repos get a higher weight with space for archive repos - var nextWeight = REPO_WEIGHT - repos.forEach { repo -> - val archiveRepo = archiveMap[repo.certificate] - if (archiveRepo == null) { - db.updateRepoWeight(repo, nextWeight) + ) + .use { cursor -> + while (cursor.moveToNext()) { + val repo = getRepo(cursor) + log.error { repo.toString() } + if (repo.isArchive() && repo.certificate != null) { + if (archiveMap.containsKey(repo.certificate)) { + log.error { "More than two repos with certificate of ${repo.address}" } + // still migrating this as a normal repo then + repos.add(repo) } else { - db.updateRepoWeight(repo, nextWeight) - db.updateRepoWeight(archiveRepo, nextWeight - 1) - archiveMap.remove(repo.certificate) + // remember archive repo, so we get position it below main repo + archiveMap[repo.certificate] = repo } - nextWeight -= 2 + } else { + repos.add(repo) + } } - // going through archive repos without main repo as well and put them at the end - // so they don't get stuck with minimum weights - archiveMap.forEach { (_, repo) -> - db.updateRepoWeight(repo, nextWeight) - nextWeight -= 1 - } - } + } - private fun SupportSQLiteDatabase.updateRepoWeight(repo: Repo, newWeight: Int) { - val rowsAffected = update( - table = RepositoryPreferences.TABLE, - conflictAlgorithm = CONFLICT_FAIL, - values = ContentValues(1).apply { - put("weight", newWeight) - }, - whereClause = "repoId = ?", - whereArgs = arrayOf(repo.repoId), - ) - if (rowsAffected > 1) error("repo ${repo.address} had more than one preference") + // now go through all repos and adapt their weight, + // so that repos get a higher weight with space for archive repos + var nextWeight = REPO_WEIGHT + repos.forEach { repo -> + val archiveRepo = archiveMap[repo.certificate] + if (archiveRepo == null) { + db.updateRepoWeight(repo, nextWeight) + } else { + db.updateRepoWeight(repo, nextWeight) + db.updateRepoWeight(archiveRepo, nextWeight - 1) + archiveMap.remove(repo.certificate) + } + nextWeight -= 2 } + // going through archive repos without main repo as well and put them at the end + // so they don't get stuck with minimum weights + archiveMap.forEach { (_, repo) -> + db.updateRepoWeight(repo, nextWeight) + nextWeight -= 1 + } + } - private fun getRepo(c: Cursor) = Repo( - repoId = c.getLong(0), - address = c.getString(1), - certificate = c.getString(2), - weight = c.getInt(3), + private fun SupportSQLiteDatabase.updateRepoWeight(repo: Repo, newWeight: Int) { + val rowsAffected = + update( + table = RepositoryPreferences.TABLE, + conflictAlgorithm = CONFLICT_FAIL, + values = ContentValues(1).apply { put("weight", newWeight) }, + whereClause = "repoId = ?", + whereArgs = arrayOf(repo.repoId), + ) + if (rowsAffected > 1) error("repo ${repo.address} had more than one preference") + } + + private fun getRepo(c: Cursor) = + Repo( + repoId = c.getLong(0), + address = c.getString(1), + certificate = c.getString(2), + weight = c.getInt(3), ) - private data class Repo( - val repoId: Long, - val address: String, - val certificate: String?, - val weight: Int, - ) { - fun isArchive(): Boolean = address.trimEnd('/').endsWith("/archive") - } + private data class Repo( + val repoId: Long, + val address: String, + val certificate: String?, + val weight: Int, + ) { + fun isArchive(): Boolean = address.trimEnd('/').endsWith("/archive") + } } /** - * Removes all repos without a certificate as those are broken anyway - * and force us to handle repos without certs. + * Removes all repos without a certificate as those are broken anyway and force us to handle repos + * without certs. */ -internal val MIGRATION_2_3 = object : Migration(2, 3) { +internal val MIGRATION_2_3 = + object : Migration(2, 3) { override fun migrate(db: SupportSQLiteDatabase) { - db.delete(CoreRepository.TABLE, "certificate IS NULL", null) + db.delete(CoreRepository.TABLE, "certificate IS NULL", null) } -} + } /** - * The tokenizer of the FTS4 table for the app metadata was modified. - * This migration is needed to recreate the FTS table to respect the new tokenizer. + * The tokenizer of the FTS4 table for the app metadata was modified. This migration is needed to + * recreate the FTS table to respect the new tokenizer. */ -internal val MIGRATION_5_6 = object : Migration(5, 6) { +internal val MIGRATION_5_6 = + object : Migration(5, 6) { override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("DROP TABLE `AppMetadataFts`") - // table creation taken from auto-generated code: - // build/generated/source/kapt/debug/org/fdroid/database/FDroidDatabaseInt_Impl.java - // the corresponding triggers are added automatically - db.execSQL("CREATE VIRTUAL TABLE IF NOT EXISTS `AppMetadataFts`" + - "USING FTS4(`repoId` INTEGER NOT NULL, `packageName` TEXT NOT NULL, " + - "`localizedName` TEXT, `localizedSummary` TEXT, " + - "tokenize=unicode61 \"remove_diacritics=0\", content=`AppMetadata`)") - // rebuild the FTS table to populate it with the new tokenizer - db.execSQL("INSERT INTO AppMetadataFts(AppMetadataFts) VALUES('rebuild')") + db.execSQL("DROP TABLE `AppMetadataFts`") + // table creation taken from auto-generated code: + // build/generated/source/kapt/debug/org/fdroid/database/FDroidDatabaseInt_Impl.java + // the corresponding triggers are added automatically + db.execSQL( + "CREATE VIRTUAL TABLE IF NOT EXISTS `AppMetadataFts`" + + "USING FTS4(`repoId` INTEGER NOT NULL, `packageName` TEXT NOT NULL, " + + "`localizedName` TEXT, `localizedSummary` TEXT, " + + "tokenize=unicode61 \"remove_diacritics=0\", content=`AppMetadata`)" + ) + // rebuild the FTS table to populate it with the new tokenizer + db.execSQL("INSERT INTO AppMetadataFts(AppMetadataFts) VALUES('rebuild')") } -} + } /** - * Somebody changed the initial IndexV2 definition of MirrorV2.location to MirrorV2.countryCode - * in fdroidserver and doesn't want to undo this rename. - * So now we need to handle this in the client to be in line with the index format produced. + * Somebody changed the initial IndexV2 definition of MirrorV2.location to MirrorV2.countryCode in + * fdroidserver and doesn't want to undo this rename. So now we need to handle this in the client to + * be in line with the index format produced. */ -@RenameColumn( - tableName = Mirror.TABLE, - fromColumnName = "location", - toColumnName = "countryCode", -) +@RenameColumn(tableName = Mirror.TABLE, fromColumnName = "location", toColumnName = "countryCode") internal class CountryCodeMigration : AutoMigrationSpec { - override fun onPostMigrate(db: SupportSQLiteDatabase) { - // reset timestamps and etags so next repo updates pull full index, refresh all data - db.beginTransaction() - try { - db.execSQL("UPDATE ${CoreRepository.TABLE} SET timestamp = -1") - db.execSQL("UPDATE ${RepositoryPreferences.TABLE} SET lastETag = NULL") - db.setTransactionSuccessful() - } finally { - db.endTransaction() - } + override fun onPostMigrate(db: SupportSQLiteDatabase) { + // reset timestamps and etags so next repo updates pull full index, refresh all data + db.beginTransaction() + try { + db.execSQL("UPDATE ${CoreRepository.TABLE} SET timestamp = -1") + db.execSQL("UPDATE ${RepositoryPreferences.TABLE} SET lastETag = NULL") + db.setTransactionSuccessful() + } finally { + db.endTransaction() } + } } /** - * The tokenizer of the FTS4 table for the app metadata was modified. - * This migration is needed to recreate the FTS table to respect the new tokenizer - * and also re-create the triggers to keep the FTS table in sync with the AppMetadata table. + * The tokenizer of the FTS4 table for the app metadata was modified. This migration is needed to + * recreate the FTS table to respect the new tokenizer and also re-create the triggers to keep the + * FTS table in sync with the AppMetadata table. */ -internal val MIGRATION_8_9 = object : Migration(8, 9) { +internal val MIGRATION_8_9 = + object : Migration(8, 9) { override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("DROP TABLE `AppMetadataFts`") - db.execSQL("DROP TRIGGER IF EXISTS `room_fts_content_sync_AppMetadataFts_BEFORE_UPDATE`") - db.execSQL("DROP TRIGGER IF EXISTS `room_fts_content_sync_AppMetadataFts_BEFORE_DELETE`") - db.execSQL("DROP TRIGGER IF EXISTS `room_fts_content_sync_AppMetadataFts_AFTER_UPDATE`") - db.execSQL("DROP TRIGGER IF EXISTS `room_fts_content_sync_AppMetadataFts_AFTER_INSERT`") - // table creation taken from auto-generated code: - // build/generated/ksp/debug/kotlin/org/fdroid/database/FDroidDatabaseInt_Impl.kt - db.execSQL( - """ - CREATE VIRTUAL TABLE IF NOT EXISTS `AppMetadataFts` - USING FTS4( - `repoId` INTEGER NOT NULL, - `name` TEXT, `summary` TEXT, - `description` TEXT, - `authorName` TEXT, - `packageName` TEXT NOT NULL, - tokenize=unicode61 `remove_diacritics=1` `separators=.` `tokenchars=-`, - content=`AppMetadata`, - notindexed=`repoId` - )""".trimIndent() + db.execSQL("DROP TABLE `AppMetadataFts`") + db.execSQL("DROP TRIGGER IF EXISTS `room_fts_content_sync_AppMetadataFts_BEFORE_UPDATE`") + db.execSQL("DROP TRIGGER IF EXISTS `room_fts_content_sync_AppMetadataFts_BEFORE_DELETE`") + db.execSQL("DROP TRIGGER IF EXISTS `room_fts_content_sync_AppMetadataFts_AFTER_UPDATE`") + db.execSQL("DROP TRIGGER IF EXISTS `room_fts_content_sync_AppMetadataFts_AFTER_INSERT`") + // table creation taken from auto-generated code: + // build/generated/ksp/debug/kotlin/org/fdroid/database/FDroidDatabaseInt_Impl.kt + db.execSQL( + """ + CREATE VIRTUAL TABLE IF NOT EXISTS `AppMetadataFts` + USING FTS4( + `repoId` INTEGER NOT NULL, + `name` TEXT, `summary` TEXT, + `description` TEXT, + `authorName` TEXT, + `packageName` TEXT NOT NULL, + tokenize=unicode61 `remove_diacritics=1` `separators=.` `tokenchars=-`, + content=`AppMetadata`, + notindexed=`repoId` ) - db.execSQL( - """ - CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_AppMetadataFts_BEFORE_UPDATE - BEFORE UPDATE ON `AppMetadata` BEGIN DELETE FROM `AppMetadataFts` - WHERE `docid`=OLD.`rowid`; END - """.trimIndent() - ) - db.execSQL( - """ - CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_AppMetadataFts_BEFORE_DELETE - BEFORE DELETE ON `AppMetadata` BEGIN DELETE FROM `AppMetadataFts` - WHERE `docid`=OLD.`rowid`; END - """.trimIndent() - ) - db.execSQL( - """ - CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_AppMetadataFts_AFTER_UPDATE - AFTER UPDATE ON `AppMetadata` BEGIN INSERT INTO - `AppMetadataFts`(`docid`, `repoId`, `name`, `summary`, `description`, `authorName`, `packageName`) - VALUES (NEW.`rowid`, NEW.`repoId`, NEW.`name`, NEW.`summary`, NEW.`description`, NEW.`authorName`, NEW.`packageName`) - ; END""".trimIndent() - ) - db.execSQL( - """ - CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_AppMetadataFts_AFTER_INSERT - AFTER INSERT ON `AppMetadata` BEGIN INSERT INTO - `AppMetadataFts`(`docid`, `repoId`, `name`, `summary`, `description`, `authorName`, `packageName`) - VALUES (NEW.`rowid`, NEW.`repoId`, NEW.`name`, NEW.`summary`, NEW.`description`, NEW.`authorName`, NEW.`packageName`) - ; END""".trimIndent() - ) - // rebuild the FTS table to populate it with the new tokenizer - db.execSQL("INSERT INTO AppMetadataFts(AppMetadataFts) VALUES('rebuild')") + """ + .trimIndent() + ) + db.execSQL( + """ + CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_AppMetadataFts_BEFORE_UPDATE + BEFORE UPDATE ON `AppMetadata` BEGIN DELETE FROM `AppMetadataFts` + WHERE `docid`=OLD.`rowid`; END + """ + .trimIndent() + ) + db.execSQL( + """ + CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_AppMetadataFts_BEFORE_DELETE + BEFORE DELETE ON `AppMetadata` BEGIN DELETE FROM `AppMetadataFts` + WHERE `docid`=OLD.`rowid`; END + """ + .trimIndent() + ) + db.execSQL( + """ + CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_AppMetadataFts_AFTER_UPDATE + AFTER UPDATE ON `AppMetadata` BEGIN INSERT INTO + `AppMetadataFts`(`docid`, `repoId`, `name`, `summary`, `description`, `authorName`, `packageName`) + VALUES (NEW.`rowid`, NEW.`repoId`, NEW.`name`, NEW.`summary`, NEW.`description`, NEW.`authorName`, NEW.`packageName`) + ; END + """ + .trimIndent() + ) + db.execSQL( + """ + CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_AppMetadataFts_AFTER_INSERT + AFTER INSERT ON `AppMetadata` BEGIN INSERT INTO + `AppMetadataFts`(`docid`, `repoId`, `name`, `summary`, `description`, `authorName`, `packageName`) + VALUES (NEW.`rowid`, NEW.`repoId`, NEW.`name`, NEW.`summary`, NEW.`description`, NEW.`authorName`, NEW.`packageName`) + ; END + """ + .trimIndent() + ) + // rebuild the FTS table to populate it with the new tokenizer + db.execSQL("INSERT INTO AppMetadataFts(AppMetadataFts) VALUES('rebuild')") } -} + } diff --git a/libs/database/src/main/java/org/fdroid/database/Repository.kt b/libs/database/src/main/java/org/fdroid/database/Repository.kt index ca39003ce..55d6f2ba1 100644 --- a/libs/database/src/main/java/org/fdroid/database/Repository.kt +++ b/libs/database/src/main/java/org/fdroid/database/Repository.kt @@ -9,6 +9,7 @@ import androidx.room.ForeignKey import androidx.room.Ignore import androidx.room.PrimaryKey import androidx.room.Relation +import java.util.concurrent.TimeUnit import org.fdroid.LocaleChooser.getBestLocale import org.fdroid.index.IndexFormatVersion import org.fdroid.index.IndexUtils.getFingerprint @@ -20,40 +21,40 @@ import org.fdroid.index.v2.LocalizedTextV2 import org.fdroid.index.v2.MirrorV2 import org.fdroid.index.v2.ReleaseChannelV2 import org.fdroid.index.v2.RepoV2 -import java.util.concurrent.TimeUnit private const val TAG = "Repository" @Entity(tableName = CoreRepository.TABLE) internal data class CoreRepository( - @PrimaryKey(autoGenerate = true) val repoId: Long = 0, - val name: LocalizedTextV2 = emptyMap(), - val icon: LocalizedFileV2?, - val address: String, - val webBaseUrl: String? = null, - val timestamp: Long, - val version: Long?, - val formatVersion: IndexFormatVersion?, - val maxAge: Int?, - val description: LocalizedTextV2 = emptyMap(), - val certificate: String, + @PrimaryKey(autoGenerate = true) val repoId: Long = 0, + val name: LocalizedTextV2 = emptyMap(), + val icon: LocalizedFileV2?, + val address: String, + val webBaseUrl: String? = null, + val timestamp: Long, + val version: Long?, + val formatVersion: IndexFormatVersion?, + val maxAge: Int?, + val description: LocalizedTextV2 = emptyMap(), + val certificate: String, ) { - internal companion object { - const val TABLE = "CoreRepository" - } + internal companion object { + const val TABLE = "CoreRepository" + } - init { - // TODO comment in some time after #2662 had time to resolve itself -// validateCertificate(certificate) - } + init { + // TODO comment in some time after #2662 had time to resolve itself + // validateCertificate(certificate) + } } internal fun RepoV2.toCoreRepository( - repoId: Long = 0, - version: Long, - formatVersion: IndexFormatVersion? = null, - certificate: String, -) = CoreRepository( + repoId: Long = 0, + version: Long, + formatVersion: IndexFormatVersion? = null, + certificate: String, +) = + CoreRepository( repoId = repoId, name = name, icon = icon, @@ -65,182 +66,187 @@ internal fun RepoV2.toCoreRepository( maxAge = null, description = description, certificate = certificate, -) + ) @ConsistentCopyVisibility -public data class Repository internal constructor( - @Embedded internal val repository: CoreRepository, - @Relation( - parentColumn = "repoId", - entityColumn = "repoId", - ) - internal val mirrors: List, - @Relation( - parentColumn = "repoId", - entityColumn = "repoId", - ) - internal val antiFeatures: List, - @Relation( - parentColumn = "repoId", - entityColumn = "repoId", - ) - internal val categories: List, - @Relation( - parentColumn = "repoId", - entityColumn = "repoId", - ) - internal val releaseChannels: List, - @Relation( - parentColumn = "repoId", - entityColumn = "repoId", - ) - internal val preferences: RepositoryPreferences, +public data class Repository +internal constructor( + @Embedded internal val repository: CoreRepository, + @Relation(parentColumn = "repoId", entityColumn = "repoId") internal val mirrors: List, + @Relation(parentColumn = "repoId", entityColumn = "repoId") + internal val antiFeatures: List, + @Relation(parentColumn = "repoId", entityColumn = "repoId") + internal val categories: List, + @Relation(parentColumn = "repoId", entityColumn = "repoId") + internal val releaseChannels: List, + @Relation(parentColumn = "repoId", entityColumn = "repoId") + internal val preferences: RepositoryPreferences, ) { - /** - * Used to create a minimal version of a [Repository]. - */ - @JvmOverloads - public constructor( - repoId: Long, - address: String, - timestamp: Long, - formatVersion: IndexFormatVersion, - certificate: String, - version: Long, - weight: Int, - lastUpdated: Long, - username: String? = null, - password: String? = null, - lastError: String? = null, - ) : this( - repository = CoreRepository( - repoId = repoId, - icon = null, - address = address, - timestamp = timestamp, - formatVersion = formatVersion, - maxAge = 42, - certificate = certificate, - version = version, - ), - mirrors = emptyList(), - antiFeatures = emptyList(), - categories = emptyList(), - releaseChannels = emptyList(), - preferences = RepositoryPreferences( - repoId = repoId, - weight = weight, - lastUpdated = lastUpdated, - username = username, - password = password, - lastError = lastError, - ) - ) + /** Used to create a minimal version of a [Repository]. */ + @JvmOverloads + public constructor( + repoId: Long, + address: String, + timestamp: Long, + formatVersion: IndexFormatVersion, + certificate: String, + version: Long, + weight: Int, + lastUpdated: Long, + username: String? = null, + password: String? = null, + lastError: String? = null, + ) : this( + repository = + CoreRepository( + repoId = repoId, + icon = null, + address = address, + timestamp = timestamp, + formatVersion = formatVersion, + maxAge = 42, + certificate = certificate, + version = version, + ), + mirrors = emptyList(), + antiFeatures = emptyList(), + categories = emptyList(), + releaseChannels = emptyList(), + preferences = + RepositoryPreferences( + repoId = repoId, + weight = weight, + lastUpdated = lastUpdated, + username = username, + password = password, + lastError = lastError, + ), + ) - public val repoId: Long get() = repository.repoId - public val address: String get() = repository.address - public val webBaseUrl: String? get() = repository.webBaseUrl - public val timestamp: Long get() = repository.timestamp - public val version: Long get() = repository.version ?: 0 - public val formatVersion: IndexFormatVersion? get() = repository.formatVersion - public val certificate: String get() = repository.certificate + public val repoId: Long + get() = repository.repoId - /** - * True if this repository is an archive repo. - * It is suggested to not show archive repos in the list of repos in the UI. - */ - public val isArchiveRepo: Boolean - get() = repository.address.trimEnd('/').endsWith("/archive") + public val address: String + get() = repository.address - public fun getName(localeList: LocaleListCompat): String? = - repository.name.getBestLocale(localeList) + public val webBaseUrl: String? + get() = repository.webBaseUrl - public fun getDescription(localeList: LocaleListCompat): String? = - repository.description.getBestLocale(localeList) + public val timestamp: Long + get() = repository.timestamp - public fun getIcon(localeList: LocaleListCompat): FileV2? = - repository.icon.getBestLocale(localeList) + public val version: Long + get() = repository.version ?: 0 - public fun getAntiFeatures(): Map { - return antiFeatures.associateBy { antiFeature -> antiFeature.id } + public val formatVersion: IndexFormatVersion? + get() = repository.formatVersion + + public val certificate: String + get() = repository.certificate + + /** + * True if this repository is an archive repo. It is suggested to not show archive repos in the + * list of repos in the UI. + */ + public val isArchiveRepo: Boolean + get() = repository.address.trimEnd('/').endsWith("/archive") + + public fun getName(localeList: LocaleListCompat): String? = + repository.name.getBestLocale(localeList) + + public fun getDescription(localeList: LocaleListCompat): String? = + repository.description.getBestLocale(localeList) + + public fun getIcon(localeList: LocaleListCompat): FileV2? = + repository.icon.getBestLocale(localeList) + + public fun getAntiFeatures(): Map { + return antiFeatures.associateBy { antiFeature -> antiFeature.id } + } + + public fun getCategories(): Map { + return categories.associateBy { category -> category.id } + } + + public fun getReleaseChannels(): Map { + return releaseChannels.associateBy { releaseChannel -> releaseChannel.id } + } + + public val weight: Int + get() = preferences.weight + + public val enabled: Boolean + get() = preferences.enabled + + public val lastUpdated: Long? + get() = preferences.lastUpdated + + public val userMirrors: List + get() = preferences.userMirrors ?: emptyList() + + public val disabledMirrors: List + get() = preferences.disabledMirrors ?: emptyList() + + public val username: String? + get() = preferences.username + + public val password: String? + get() = preferences.password + + @Suppress("DEPRECATION") + @Deprecated("Only used for v1 index", ReplaceWith("")) + public val lastETag: String? + get() = preferences.lastETag + + /** + * The fingerprint for the [certificate]. This gets calculated on first call and is an expensive + * operation. Subsequent calls re-use the + */ + @delegate:Ignore public val fingerprint: String by lazy { getFingerprint(certificate) } + + /** Returns official and user-added mirrors without the [disabledMirrors]. */ + public fun getMirrors(): List { + return getAllMirrors(true) + .filter { !disabledMirrors.contains(it.baseUrl) } + .ifEmpty { listOf(org.fdroid.download.Mirror(address)) } + } + + public val allUserMirrors: List + get() = userMirrors.map { org.fdroid.download.Mirror(it) } + + public val allOfficialMirrors: List + get() = getAllMirrors(false) + + /** Returns all mirrors, including [disabledMirrors]. */ + @JvmOverloads + public fun getAllMirrors(includeUserMirrors: Boolean = true): List { + val all = + mirrors.map { it.toDownloadMirror() } + + if (includeUserMirrors) userMirrors.map { org.fdroid.download.Mirror(it) } else emptyList() + // whether or not the repo address is part of the mirrors is not yet standardized, + // so we may need to add it to the list ourselves + val hasCanonicalMirror = all.find { it.baseUrl == address } != null + return if (hasCanonicalMirror) all + else all.toMutableList().apply { add(0, org.fdroid.download.Mirror(address)) } + } + + val shareUri: String + @WorkerThread // because fingerprint creation can take time + get() { + return "https://fdroid.link/#$address?fingerprint=$fingerprint" } - public fun getCategories(): Map { - return categories.associateBy { category -> category.id } - } + public val errorCount: Int + get() = preferences.errorCount - public fun getReleaseChannels(): Map { - return releaseChannels.associateBy { releaseChannel -> releaseChannel.id } - } - - public val weight: Int get() = preferences.weight - public val enabled: Boolean get() = preferences.enabled - public val lastUpdated: Long? get() = preferences.lastUpdated - public val userMirrors: List get() = preferences.userMirrors ?: emptyList() - public val disabledMirrors: List get() = preferences.disabledMirrors ?: emptyList() - public val username: String? get() = preferences.username - public val password: String? get() = preferences.password - - @Suppress("DEPRECATION") - @Deprecated("Only used for v1 index", ReplaceWith("")) - public val lastETag: String? - get() = preferences.lastETag - - /** - * The fingerprint for the [certificate]. - * This gets calculated on first call and is an expensive operation. - * Subsequent calls re-use the - */ - @delegate:Ignore - public val fingerprint: String by lazy { - getFingerprint(certificate) - } - - /** - * Returns official and user-added mirrors without the [disabledMirrors]. - */ - public fun getMirrors(): List { - return getAllMirrors(true).filter { - !disabledMirrors.contains(it.baseUrl) - }.ifEmpty { listOf(org.fdroid.download.Mirror(address)) } - } - - public val allUserMirrors: List - get() = userMirrors.map { org.fdroid.download.Mirror(it) } - - public val allOfficialMirrors: List - get() = getAllMirrors(false) - - /** - * Returns all mirrors, including [disabledMirrors]. - */ - @JvmOverloads - public fun getAllMirrors(includeUserMirrors: Boolean = true): List { - val all = mirrors.map { - it.toDownloadMirror() - } + if (includeUserMirrors) userMirrors.map { - org.fdroid.download.Mirror(it) - } else emptyList() - // whether or not the repo address is part of the mirrors is not yet standardized, - // so we may need to add it to the list ourselves - val hasCanonicalMirror = all.find { it.baseUrl == address } != null - return if (hasCanonicalMirror) all else all.toMutableList().apply { - add(0, org.fdroid.download.Mirror(address)) - } - } - - val shareUri: String - @WorkerThread // because fingerprint creation can take time - get() { - return "https://fdroid.link/#$address?fingerprint=$fingerprint" - } - public val errorCount: Int get() = preferences.errorCount - public val lastError: String? get() = preferences.lastError + public val lastError: String? + get() = preferences.lastError } // Dummy repo to use in Compose Previews and in tests @Deprecated("Will be removed in future version") -public val DUMMY_TEST_REPO: Repository = Repository( +public val DUMMY_TEST_REPO: Repository = + Repository( repoId = 1L, address = "https://example.com/fdroid/repo", timestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(2), @@ -249,227 +255,229 @@ public val DUMMY_TEST_REPO: Repository = Repository( version = 1L, weight = 1, lastUpdated = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1), -) + ) -/** - * A database table to store repository mirror information. - */ +/** A database table to store repository mirror information. */ @Entity( - tableName = Mirror.TABLE, - primaryKeys = ["repoId", "url"], - foreignKeys = [ForeignKey( + tableName = Mirror.TABLE, + primaryKeys = ["repoId", "url"], + foreignKeys = + [ + ForeignKey( entity = CoreRepository::class, parentColumns = ["repoId"], childColumns = ["repoId"], onDelete = ForeignKey.CASCADE, - )], + ) + ], ) internal data class Mirror( - val repoId: Long, - val url: String, - val countryCode: String? = null, - @ColumnInfo(defaultValue = "0") - val isPrimary: Boolean = false, + val repoId: Long, + val url: String, + val countryCode: String? = null, + @ColumnInfo(defaultValue = "0") val isPrimary: Boolean = false, ) { - internal companion object { - const val TABLE = "Mirror" - } + internal companion object { + const val TABLE = "Mirror" + } - fun toDownloadMirror(): org.fdroid.download.Mirror = org.fdroid.download.Mirror( - baseUrl = url, - countryCode = countryCode, - // TODO add isPrimary = isPrimary, + fun toDownloadMirror(): org.fdroid.download.Mirror = + org.fdroid.download.Mirror( + baseUrl = url, + countryCode = countryCode, + // TODO add isPrimary = isPrimary, ) } -internal fun MirrorV2.toMirror(repoId: Long) = Mirror( +internal fun MirrorV2.toMirror(repoId: Long) = + Mirror( repoId = repoId, url = url, countryCode = countryCode, // TODO add isPrimary = isPrimary, -) + ) internal fun List.toMirrors(repoId: Long): List { - return this.map { it.toMirror(repoId) } + return this.map { it.toMirror(repoId) } } -/** - * An attribute belonging to a [Repository]. - */ +/** An attribute belonging to a [Repository]. */ public abstract class RepoAttribute { - public abstract val icon: LocalizedFileV2 - internal abstract val name: LocalizedTextV2 - internal abstract val description: LocalizedTextV2 + public abstract val icon: LocalizedFileV2 + internal abstract val name: LocalizedTextV2 + internal abstract val description: LocalizedTextV2 - public fun getIcon(localeList: LocaleListCompat): FileV2? = - icon.getBestLocale(localeList) + public fun getIcon(localeList: LocaleListCompat): FileV2? = icon.getBestLocale(localeList) - public fun getName(localeList: LocaleListCompat): String? = - name.getBestLocale(localeList) + public fun getName(localeList: LocaleListCompat): String? = name.getBestLocale(localeList) - public fun getDescription(localeList: LocaleListCompat): String? = - description.getBestLocale(localeList) + public fun getDescription(localeList: LocaleListCompat): String? = + description.getBestLocale(localeList) } -/** - * An anti-feature belonging to a [Repository]. - */ +/** An anti-feature belonging to a [Repository]. */ @Entity( - tableName = AntiFeature.TABLE, - primaryKeys = ["repoId", "id"], - foreignKeys = [ForeignKey( + tableName = AntiFeature.TABLE, + primaryKeys = ["repoId", "id"], + foreignKeys = + [ + ForeignKey( entity = CoreRepository::class, parentColumns = ["repoId"], childColumns = ["repoId"], onDelete = ForeignKey.CASCADE, - )], + ) + ], ) public data class AntiFeature( - public val repoId: Long, - public val id: String, - override val icon: LocalizedFileV2 = emptyMap(), - override val name: LocalizedTextV2, - override val description: LocalizedTextV2 = emptyMap(), + public val repoId: Long, + public val id: String, + override val icon: LocalizedFileV2 = emptyMap(), + override val name: LocalizedTextV2, + override val description: LocalizedTextV2 = emptyMap(), ) : RepoAttribute() { - internal companion object { - const val TABLE = "AntiFeature" - } + internal companion object { + const val TABLE = "AntiFeature" + } } internal fun Map.toRepoAntiFeatures(repoId: Long) = map { - AntiFeature( - repoId = repoId, - id = it.key, - icon = it.value.icon, - name = it.value.name, - description = it.value.description, - ) + AntiFeature( + repoId = repoId, + id = it.key, + icon = it.value.icon, + name = it.value.name, + description = it.value.description, + ) } -/** - * A category of apps belonging to a [Repository]. - */ +/** A category of apps belonging to a [Repository]. */ @Entity( - tableName = Category.TABLE, - primaryKeys = ["repoId", "id"], - foreignKeys = [ForeignKey( + tableName = Category.TABLE, + primaryKeys = ["repoId", "id"], + foreignKeys = + [ + ForeignKey( entity = CoreRepository::class, parentColumns = ["repoId"], childColumns = ["repoId"], onDelete = ForeignKey.CASCADE, - )], + ) + ], ) public data class Category( - public val repoId: Long, - public val id: String, - override val icon: LocalizedFileV2 = emptyMap(), - override val name: LocalizedTextV2, - override val description: LocalizedTextV2 = emptyMap(), + public val repoId: Long, + public val id: String, + override val icon: LocalizedFileV2 = emptyMap(), + override val name: LocalizedTextV2, + override val description: LocalizedTextV2 = emptyMap(), ) : RepoAttribute() { - internal companion object { - const val TABLE = "Category" - } + internal companion object { + const val TABLE = "Category" + } } internal fun Map.toRepoCategories(repoId: Long) = map { - Category( - repoId = repoId, - id = it.key, - icon = it.value.icon, - name = it.value.name, - description = it.value.description, - ) + Category( + repoId = repoId, + id = it.key, + icon = it.value.icon, + name = it.value.name, + description = it.value.description, + ) } -/** - * A release-channel for apps belonging to a [Repository]. - */ +/** A release-channel for apps belonging to a [Repository]. */ @Entity( - tableName = ReleaseChannel.TABLE, - primaryKeys = ["repoId", "id"], - foreignKeys = [ForeignKey( + tableName = ReleaseChannel.TABLE, + primaryKeys = ["repoId", "id"], + foreignKeys = + [ + ForeignKey( entity = CoreRepository::class, parentColumns = ["repoId"], childColumns = ["repoId"], onDelete = ForeignKey.CASCADE, - )], + ) + ], ) public data class ReleaseChannel( - internal val repoId: Long, - internal val id: String, - override val icon: LocalizedFileV2 = emptyMap(), - override val name: LocalizedTextV2, - override val description: LocalizedTextV2 = emptyMap(), + internal val repoId: Long, + internal val id: String, + override val icon: LocalizedFileV2 = emptyMap(), + override val name: LocalizedTextV2, + override val description: LocalizedTextV2 = emptyMap(), ) : RepoAttribute() { - internal companion object { - const val TABLE = "ReleaseChannel" - } + internal companion object { + const val TABLE = "ReleaseChannel" + } } internal fun Map.toRepoReleaseChannel(repoId: Long) = map { - ReleaseChannel( - repoId = repoId, - id = it.key, - name = it.value.name, - description = it.value.description, - ) + ReleaseChannel( + repoId = repoId, + id = it.key, + name = it.value.name, + description = it.value.description, + ) } @Entity(tableName = RepositoryPreferences.TABLE) internal data class RepositoryPreferences( - @PrimaryKey internal val repoId: Long, - val weight: Int, - val enabled: Boolean = true, - val lastUpdated: Long? = System.currentTimeMillis(), - @Deprecated("Only used for indexV1") val lastETag: String? = null, - val userMirrors: List? = null, - val disabledMirrors: List? = null, - val username: String? = null, - val password: String? = null, - @ColumnInfo(defaultValue = "0") - val errorCount: Int = 0, - val lastError: String? = null, + @PrimaryKey internal val repoId: Long, + val weight: Int, + val enabled: Boolean = true, + val lastUpdated: Long? = System.currentTimeMillis(), + @Deprecated("Only used for indexV1") val lastETag: String? = null, + val userMirrors: List? = null, + val disabledMirrors: List? = null, + val username: String? = null, + val password: String? = null, + @ColumnInfo(defaultValue = "0") val errorCount: Int = 0, + val lastError: String? = null, ) { - internal companion object { - const val TABLE = "RepositoryPreferences" - } + internal companion object { + const val TABLE = "RepositoryPreferences" + } } -/** - * A reduced version of [Repository] used to pre-populate the [FDroidDatabase]. - */ -public data class InitialRepository @JvmOverloads constructor( - val name: String, - val address: String, - val mirrors: List = emptyList(), - val description: String, - val certificate: String, - val version: Long, - val enabled: Boolean, - @Deprecated("This is automatically assigned now and can be safely removed.") - val weight: Int = 0, // still used for testing, could be made internal or tests migrate away +/** A reduced version of [Repository] used to pre-populate the [FDroidDatabase]. */ +public data class InitialRepository +@JvmOverloads +constructor( + val name: String, + val address: String, + val mirrors: List = emptyList(), + val description: String, + val certificate: String, + val version: Long, + val enabled: Boolean, + @Deprecated("This is automatically assigned now and can be safely removed.") + val weight: Int = 0, // still used for testing, could be made internal or tests migrate away ) { - init { - validateCertificate(certificate) - } + init { + validateCertificate(certificate) + } } @Throws(IllegalArgumentException::class) private fun validateCertificate(certificate: String?) { - if (certificate != null) require(certificate.length % 2 == 0 && + if (certificate != null) + require( + certificate.length % 2 == 0 && certificate.chunked(2).find { it.toIntOrNull(16) == null } == null - ) { "Invalid certificate: $certificate" } + ) { + "Invalid certificate: $certificate" + } } -/** - * A reduced version of [Repository] used to add new repositories. - */ +/** A reduced version of [Repository] used to add new repositories. */ public data class NewRepository( - val name: LocalizedTextV2, - val icon: LocalizedFileV2, - val address: String, - val formatVersion: IndexFormatVersion?, - val certificate: String, - val username: String? = null, - val password: String? = null, + val name: LocalizedTextV2, + val icon: LocalizedFileV2, + val address: String, + val formatVersion: IndexFormatVersion?, + val certificate: String, + val username: String? = null, + val password: String? = null, ) diff --git a/libs/database/src/main/java/org/fdroid/database/RepositoryDao.kt b/libs/database/src/main/java/org/fdroid/database/RepositoryDao.kt index 46dd10e5c..336103278 100644 --- a/libs/database/src/main/java/org/fdroid/database/RepositoryDao.kt +++ b/libs/database/src/main/java/org/fdroid/database/RepositoryDao.kt @@ -26,570 +26,514 @@ import org.fdroid.index.v2.ReflectionDiffer.applyDiff import org.fdroid.index.v2.RepoV2 public interface RepositoryDao { - /** - * Inserts a new [InitialRepository] from a fixture. - * - * @return the [Repository.repoId] of the inserted repo. - */ - public fun insert(initialRepo: InitialRepository): Long + /** + * Inserts a new [InitialRepository] from a fixture. + * + * @return the [Repository.repoId] of the inserted repo. + */ + public fun insert(initialRepo: InitialRepository): Long - /** - * Inserts a new repository into the database. - */ - public fun insert(newRepository: NewRepository): Long + /** Inserts a new repository into the database. */ + public fun insert(newRepository: NewRepository): Long - /** - * Returns the repository with the given [repoId] or null, if none was found with that ID. - */ - public fun getRepository(repoId: Long): Repository? + /** Returns the repository with the given [repoId] or null, if none was found with that ID. */ + public fun getRepository(repoId: Long): Repository? - /** - * Returns a list of all [Repository]s in the database. - */ - public fun getRepositories(): List + /** Returns a list of all [Repository]s in the database. */ + public fun getRepositories(): List - /** - * Same as [getRepositories], but does return a [LiveData]. - */ - public fun getLiveRepositories(): LiveData> + /** Same as [getRepositories], but does return a [LiveData]. */ + public fun getLiveRepositories(): LiveData> - /** - * Returns a live data of all categories declared by all [Repository]s. - */ - public fun getLiveCategories(): LiveData> + /** Returns a live data of all categories declared by all [Repository]s. */ + public fun getLiveCategories(): LiveData> - /** - * Returns a live data of all anti-features declared by all [Repository]s. - */ - public fun getAntiFeaturesFlow(): Flow> + /** Returns a live data of all anti-features declared by all [Repository]s. */ + public fun getAntiFeaturesFlow(): Flow> - /** - * Enables or disables the repository with the given [repoId]. - * Data from disabled repositories is ignored in many queries. - */ - public fun setRepositoryEnabled(repoId: Long, enabled: Boolean) + /** + * Enables or disables the repository with the given [repoId]. Data from disabled repositories is + * ignored in many queries. + */ + public fun setRepositoryEnabled(repoId: Long, enabled: Boolean) - /** - * Updates the user-defined mirrors of the repository with the given [repoId]. - * The existing mirrors get overwritten with the given [mirrors]. - */ - public fun updateUserMirrors(repoId: Long, mirrors: List) + /** + * Updates the user-defined mirrors of the repository with the given [repoId]. The existing + * mirrors get overwritten with the given [mirrors]. + */ + public fun updateUserMirrors(repoId: Long, mirrors: List) - /** - * Updates the user name and password (for basic authentication) - * of the repository with the given [repoId]. - * The existing user name and password get overwritten with the given [username] and [password]. - */ - public fun updateUsernameAndPassword(repoId: Long, username: String?, password: String?) + /** + * Updates the username and password (for basic authentication) of the repository with the given + * [repoId]. The existing username and password get overwritten with the given [username] and + * [password]. + */ + public fun updateUsernameAndPassword(repoId: Long, username: String?, password: String?) - /** - * Updates the disabled mirrors of the repository with the given [repoId]. - * The existing disabled mirrors get overwritten with the given [disabledMirrors]. - */ - public fun updateDisabledMirrors(repoId: Long, disabledMirrors: List) + /** + * Updates the disabled mirrors of the repository with the given [repoId]. The existing disabled + * mirrors get overwritten with the given [disabledMirrors]. + */ + public fun updateDisabledMirrors(repoId: Long, disabledMirrors: List) - /** - * Removes a [Repository] with the given [repoId] with all associated data from the database. - */ - public fun deleteRepository(repoId: Long) + /** Removes a [Repository] with the given [repoId] with all associated data from the database. */ + public fun deleteRepository(repoId: Long) - /** - * Removes all repos and their preferences. - */ - public fun clearAll() + /** Removes all repos and their preferences. */ + public fun clearAll() - /** - * Force a checkpoint on the SQLite WAL such that the file size gets reduced. - * Blocks until concurrent reads have finished. - * Useful to call after large inserts (repo update) - * @see https://www.sqlite.org/wal.html#avoiding_excessively_large_wal_files - */ - public fun walCheckpoint() + /** + * Force a checkpoint on the SQLite WAL such that the file size gets reduced. Blocks until + * concurrent reads have finished. Useful to call after large inserts (repo update) + * + * @see https://www.sqlite.org/wal.html#avoiding_excessively_large_wal_files + */ + public fun walCheckpoint() } @Dao internal interface RepositoryDaoInt : RepositoryDao { - @Insert(onConflict = REPLACE) - fun insertOrReplace(repository: CoreRepository): Long + @Insert(onConflict = REPLACE) fun insertOrReplace(repository: CoreRepository): Long - @Update - fun update(repository: CoreRepository) + @Update fun update(repository: CoreRepository) - @Insert(onConflict = REPLACE) - fun insertMirrors(mirrors: List) + @Insert(onConflict = REPLACE) fun insertMirrors(mirrors: List) - @Insert(onConflict = REPLACE) - fun insertAntiFeatures(repoFeature: List) + @Insert(onConflict = REPLACE) fun insertAntiFeatures(repoFeature: List) - @Insert(onConflict = REPLACE) - fun insertCategories(repoFeature: List) + @Insert(onConflict = REPLACE) fun insertCategories(repoFeature: List) - @Insert(onConflict = REPLACE) - fun insertReleaseChannels(repoFeature: List) + @Insert(onConflict = REPLACE) fun insertReleaseChannels(repoFeature: List) - @Insert(onConflict = REPLACE) - fun insert(repositoryPreferences: RepositoryPreferences) + @Insert(onConflict = REPLACE) fun insert(repositoryPreferences: RepositoryPreferences) - @Transaction - override fun insert(initialRepo: InitialRepository): Long { - val repo = CoreRepository( - name = mapOf("en-US" to initialRepo.name), - address = initialRepo.address, - icon = null, - timestamp = -1, - version = initialRepo.version, - formatVersion = null, - maxAge = null, - description = mapOf("en-US" to initialRepo.description), - certificate = initialRepo.certificate, + @Transaction + override fun insert(initialRepo: InitialRepository): Long { + val repo = + CoreRepository( + name = mapOf("en-US" to initialRepo.name), + address = initialRepo.address, + icon = null, + timestamp = -1, + version = initialRepo.version, + formatVersion = null, + maxAge = null, + description = mapOf("en-US" to initialRepo.description), + certificate = initialRepo.certificate, + ) + val repoId = insertOrReplace(repo) + val currentMinWeight = getMinRepositoryWeight() + val repositoryPreferences = + RepositoryPreferences( + repoId = repoId, + weight = currentMinWeight - 2, + lastUpdated = null, + enabled = initialRepo.enabled, + ) + insert(repositoryPreferences) + insertMirrors(initialRepo.mirrors.map { url -> Mirror(repoId, url, null) }) + return repoId + } + + @Transaction + override fun insert(newRepository: NewRepository): Long { + val repo = + CoreRepository( + name = newRepository.name, + icon = newRepository.icon, + address = newRepository.address, + timestamp = -1, + version = null, + formatVersion = newRepository.formatVersion, + maxAge = null, + certificate = newRepository.certificate, + ) + val repoId = insertOrReplace(repo) + val currentMinWeight = getMinRepositoryWeight() + val repositoryPreferences = + RepositoryPreferences( + repoId = repoId, + weight = currentMinWeight - 2, + lastUpdated = null, + username = newRepository.username, + password = newRepository.password, + ) + insert(repositoryPreferences) + return repoId + } + + @Transaction + @VisibleForTesting + @Deprecated("Use insert instead") + fun insertEmptyRepo( + address: String, + username: String? = null, + password: String? = null, + // just used for testing + certificate: String = "6789", + ): Long { + val repo = + CoreRepository( + name = mapOf("en-US" to address), + icon = null, + address = address, + timestamp = -1, + version = null, + formatVersion = null, + maxAge = null, + certificate = certificate, + ) + val repoId = insertOrReplace(repo) + val currentMinWeight = getMinRepositoryWeight() + val repositoryPreferences = + RepositoryPreferences( + repoId = repoId, + weight = currentMinWeight - 2, + lastUpdated = null, + username = username, + password = password, + ) + insert(repositoryPreferences) + return repoId + } + + @Transaction + @VisibleForTesting + fun insertOrReplace(repository: RepoV2, version: Long = 0): Long { + val repoId = + insertOrReplace( + repository.toCoreRepository( + version = version, + certificate = "0123", // just for testing ) - val repoId = insertOrReplace(repo) - val currentMinWeight = getMinRepositoryWeight() - val repositoryPreferences = RepositoryPreferences( - repoId = repoId, - weight = currentMinWeight - 2, - lastUpdated = null, - enabled = initialRepo.enabled, - ) - insert(repositoryPreferences) - insertMirrors(initialRepo.mirrors.map { url -> Mirror(repoId, url, null) }) - return repoId - } + ) + val currentMinWeight = getMinRepositoryWeight() + val repositoryPreferences = RepositoryPreferences(repoId, currentMinWeight - 2) + insert(repositoryPreferences) + insertRepoTables(repoId, repository) + return repoId + } - @Transaction - override fun insert(newRepository: NewRepository): Long { - val repo = CoreRepository( - name = newRepository.name, - icon = newRepository.icon, - address = newRepository.address, - timestamp = -1, - version = null, - formatVersion = newRepository.formatVersion, - maxAge = null, - certificate = newRepository.certificate, - ) - val repoId = insertOrReplace(repo) - val currentMinWeight = getMinRepositoryWeight() - val repositoryPreferences = RepositoryPreferences( - repoId = repoId, - weight = currentMinWeight - 2, - lastUpdated = null, - username = newRepository.username, - password = newRepository.password, - ) - insert(repositoryPreferences) - return repoId - } + @Query("SELECT COALESCE(MIN(weight), ${Int.MAX_VALUE}) FROM ${RepositoryPreferences.TABLE}") + fun getMinRepositoryWeight(): Int - @Transaction - @VisibleForTesting - @Deprecated("Use insert instead") - fun insertEmptyRepo( - address: String, - username: String? = null, - password: String? = null, - // just used for testing - certificate: String = "6789", - ): Long { - val repo = CoreRepository( - name = mapOf("en-US" to address), - icon = null, - address = address, - timestamp = -1, - version = null, - formatVersion = null, - maxAge = null, - certificate = certificate, - ) - val repoId = insertOrReplace(repo) - val currentMinWeight = getMinRepositoryWeight() - val repositoryPreferences = RepositoryPreferences( - repoId = repoId, - weight = currentMinWeight - 2, - lastUpdated = null, - username = username, - password = password, - ) - insert(repositoryPreferences) - return repoId - } + @Transaction + @Query("SELECT * FROM ${CoreRepository.TABLE} WHERE repoId = :repoId") + override fun getRepository(repoId: Long): Repository? - @Transaction - @VisibleForTesting - fun insertOrReplace(repository: RepoV2, version: Long = 0): Long { - val repoId = insertOrReplace( - repository.toCoreRepository( - version = version, - certificate = "0123", // just for testing - ) - ) - val currentMinWeight = getMinRepositoryWeight() - val repositoryPreferences = RepositoryPreferences(repoId, currentMinWeight - 2) - insert(repositoryPreferences) - insertRepoTables(repoId, repository) - return repoId - } - - @Query("SELECT COALESCE(MIN(weight), ${Int.MAX_VALUE}) FROM ${RepositoryPreferences.TABLE}") - fun getMinRepositoryWeight(): Int - - @Transaction - @Query("SELECT * FROM ${CoreRepository.TABLE} WHERE repoId = :repoId") - override fun getRepository(repoId: Long): Repository? - - /** - * Returns a non-archive repository with the given [certificate], if it exists in the DB. - */ - @Transaction - @Query( - """SELECT * FROM ${CoreRepository.TABLE} + /** Returns a non-archive repository with the given [certificate], if it exists in the DB. */ + @Transaction + @Query( + """SELECT * FROM ${CoreRepository.TABLE} WHERE certificate = :certificate AND address NOT LIKE "%/archive" COLLATE NOCASE LIMIT 1""" - ) - fun getRepository(certificate: String): Repository? + ) + fun getRepository(certificate: String): Repository? - @Transaction - @RewriteQueriesToDropUnusedColumns - @Query( - """SELECT * FROM ${CoreRepository.TABLE} + @Transaction + @RewriteQueriesToDropUnusedColumns + @Query( + """SELECT * FROM ${CoreRepository.TABLE} JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) ORDER BY pref.weight DESC""" - ) - override fun getRepositories(): List + ) + override fun getRepositories(): List - @Transaction - @RewriteQueriesToDropUnusedColumns - @Query( - """SELECT * FROM ${CoreRepository.TABLE} + @Transaction + @RewriteQueriesToDropUnusedColumns + @Query( + """SELECT * FROM ${CoreRepository.TABLE} JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) ORDER BY pref.weight DESC""" - ) - override fun getLiveRepositories(): LiveData> + ) + override fun getLiveRepositories(): LiveData> - @Query("SELECT * FROM ${RepositoryPreferences.TABLE} WHERE repoId = :repoId") - fun getRepositoryPreferences(repoId: Long): RepositoryPreferences? + @Query("SELECT * FROM ${RepositoryPreferences.TABLE} WHERE repoId = :repoId") + fun getRepositoryPreferences(repoId: Long): RepositoryPreferences? - @RewriteQueriesToDropUnusedColumns - @Query( - """SELECT * FROM ${Category.TABLE} + @RewriteQueriesToDropUnusedColumns + @Query( + """SELECT * FROM ${Category.TABLE} JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) WHERE pref.enabled = 1 GROUP BY id HAVING MAX(pref.weight)""" - ) - override fun getLiveCategories(): LiveData> + ) + override fun getLiveCategories(): LiveData> - @RewriteQueriesToDropUnusedColumns - @Query( - """SELECT * FROM ${AntiFeature.TABLE} + @RewriteQueriesToDropUnusedColumns + @Query( + """SELECT * FROM ${AntiFeature.TABLE} JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) WHERE pref.enabled = 1 GROUP BY id HAVING MAX(pref.weight)""" + ) + override fun getAntiFeaturesFlow(): Flow> + + /** + * Updates an existing repo with new data from a full index update. Call [clear] first to ensure + * old data was removed. + */ + @Transaction + fun update(repoId: Long, repository: RepoV2, version: Long, formatVersion: IndexFormatVersion) { + val repo = getRepository(repoId) ?: error("Repo with id $repoId did not exist") + update(repository.toCoreRepository(repoId, version, formatVersion, repo.certificate)) + insertRepoTables(repoId, repository) + } + + private fun insertRepoTables(repoId: Long, repository: RepoV2) { + insertMirrors(repository.mirrors.map { it.toMirror(repoId) }) + insertAntiFeatures(repository.antiFeatures.toRepoAntiFeatures(repoId)) + insertCategories(repository.categories.toRepoCategories(repoId)) + insertReleaseChannels(repository.releaseChannels.toRepoReleaseChannel(repoId)) + } + + @Update fun updateRepository(repo: CoreRepository): Int + + @Update fun updateRepositoryPreferences(preferences: RepositoryPreferences) + + /** Used to update an existing repository with a given [jsonObject] JSON diff. */ + @Transaction + fun updateRepository(repoId: Long, version: Long, jsonObject: JsonObject) { + // get existing repo + val repo = getRepository(repoId) ?: error("Repo $repoId does not exist") + // update repo with JSON diff + updateRepository(applyDiff(repo.repository, jsonObject).copy(version = version)) + // replace mirror list (if it is in the diff) + diffAndUpdateListTable( + jsonObject = jsonObject, + jsonObjectKey = "mirrors", + listParser = { mirrorArray -> + json.decodeFromJsonElement>(mirrorArray).map { it.toMirror(repoId) } + }, + deleteList = { deleteMirrors(repoId) }, + insertNewList = { mirrors -> insertMirrors(mirrors) }, ) - override fun getAntiFeaturesFlow(): Flow> + // diff and update the antiFeatures + diffAndUpdateTable( + jsonObject = jsonObject, + jsonObjectKey = "antiFeatures", + itemList = repo.antiFeatures, + itemFinder = { key, item -> item.id == key }, + newItem = { key -> AntiFeature(repoId, key, emptyMap(), emptyMap(), emptyMap()) }, + deleteAll = { deleteAntiFeatures(repoId) }, + deleteOne = { key -> deleteAntiFeature(repoId, key) }, + insertReplace = { list -> insertAntiFeatures(list) }, + ) + // diff and update the categories + diffAndUpdateTable( + jsonObject = jsonObject, + jsonObjectKey = "categories", + itemList = repo.categories, + itemFinder = { key, item -> item.id == key }, + newItem = { key -> Category(repoId, key, emptyMap(), emptyMap(), emptyMap()) }, + deleteAll = { deleteCategories(repoId) }, + deleteOne = { key -> deleteCategory(repoId, key) }, + insertReplace = { list -> insertCategories(list) }, + ) + // diff and update the releaseChannels + diffAndUpdateTable( + jsonObject = jsonObject, + jsonObjectKey = "releaseChannels", + itemList = repo.releaseChannels, + itemFinder = { key, item -> item.id == key }, + newItem = { key -> ReleaseChannel(repoId, key, emptyMap(), emptyMap(), emptyMap()) }, + deleteAll = { deleteReleaseChannels(repoId) }, + deleteOne = { key -> deleteReleaseChannel(repoId, key) }, + insertReplace = { list -> insertReleaseChannels(list) }, + ) + } - /** - * Updates an existing repo with new data from a full index update. - * Call [clear] first to ensure old data was removed. - */ - @Transaction - fun update( - repoId: Long, - repository: RepoV2, - version: Long, - formatVersion: IndexFormatVersion, - ) { - val repo = getRepository(repoId) ?: error("Repo with id $repoId did not exist") - update(repository.toCoreRepository(repoId, version, formatVersion, repo.certificate)) - insertRepoTables(repoId, repository) - } + @Transaction + override fun setRepositoryEnabled(repoId: Long, enabled: Boolean) { + // When disabling a repository, we need to remove it as preferred repo for all apps, + // otherwise our queries that ignore disabled repos will not return anything anymore. + if (!enabled) resetPreferredRepoInAppPrefs(repoId) + setRepositoryEnabledInternal(repoId, enabled) + } - private fun insertRepoTables(repoId: Long, repository: RepoV2) { - insertMirrors(repository.mirrors.map { it.toMirror(repoId) }) - insertAntiFeatures(repository.antiFeatures.toRepoAntiFeatures(repoId)) - insertCategories(repository.categories.toRepoCategories(repoId)) - insertReleaseChannels(repository.releaseChannels.toRepoReleaseChannel(repoId)) - } + @Query("UPDATE ${RepositoryPreferences.TABLE} SET enabled = :enabled WHERE repoId = :repoId") + fun setRepositoryEnabledInternal(repoId: Long, enabled: Boolean) - @Update - fun updateRepository(repo: CoreRepository): Int + @Query("UPDATE ${AppPrefs.TABLE} SET preferredRepoId = NULL WHERE preferredRepoId = :repoId") + fun resetPreferredRepoInAppPrefs(repoId: Long) - @Update - fun updateRepositoryPreferences(preferences: RepositoryPreferences) - - /** - * Used to update an existing repository with a given [jsonObject] JSON diff. - */ - @Transaction - fun updateRepository(repoId: Long, version: Long, jsonObject: JsonObject) { - // get existing repo - val repo = getRepository(repoId) ?: error("Repo $repoId does not exist") - // update repo with JSON diff - updateRepository(applyDiff(repo.repository, jsonObject).copy(version = version)) - // replace mirror list (if it is in the diff) - diffAndUpdateListTable( - jsonObject = jsonObject, - jsonObjectKey = "mirrors", - listParser = { mirrorArray -> - json.decodeFromJsonElement>(mirrorArray).map { - it.toMirror(repoId) - } - }, - deleteList = { deleteMirrors(repoId) }, - insertNewList = { mirrors -> insertMirrors(mirrors) }, - ) - // diff and update the antiFeatures - diffAndUpdateTable( - jsonObject = jsonObject, - jsonObjectKey = "antiFeatures", - itemList = repo.antiFeatures, - itemFinder = { key, item -> item.id == key }, - newItem = { key -> AntiFeature(repoId, key, emptyMap(), emptyMap(), emptyMap()) }, - deleteAll = { deleteAntiFeatures(repoId) }, - deleteOne = { key -> deleteAntiFeature(repoId, key) }, - insertReplace = { list -> insertAntiFeatures(list) }, - ) - // diff and update the categories - diffAndUpdateTable( - jsonObject = jsonObject, - jsonObjectKey = "categories", - itemList = repo.categories, - itemFinder = { key, item -> item.id == key }, - newItem = { key -> Category(repoId, key, emptyMap(), emptyMap(), emptyMap()) }, - deleteAll = { deleteCategories(repoId) }, - deleteOne = { key -> deleteCategory(repoId, key) }, - insertReplace = { list -> insertCategories(list) }, - ) - // diff and update the releaseChannels - diffAndUpdateTable( - jsonObject = jsonObject, - jsonObjectKey = "releaseChannels", - itemList = repo.releaseChannels, - itemFinder = { key, item -> item.id == key }, - newItem = { key -> ReleaseChannel(repoId, key, emptyMap(), emptyMap(), emptyMap()) }, - deleteAll = { deleteReleaseChannels(repoId) }, - deleteOne = { key -> deleteReleaseChannel(repoId, key) }, - insertReplace = { list -> insertReleaseChannels(list) }, - ) - } - - @Transaction - override fun setRepositoryEnabled(repoId: Long, enabled: Boolean) { - // When disabling a repository, we need to remove it as preferred repo for all apps, - // otherwise our queries that ignore disabled repos will not return anything anymore. - if (!enabled) resetPreferredRepoInAppPrefs(repoId) - setRepositoryEnabledInternal(repoId, enabled) - } - - @Query("UPDATE ${RepositoryPreferences.TABLE} SET enabled = :enabled WHERE repoId = :repoId") - fun setRepositoryEnabledInternal(repoId: Long, enabled: Boolean) - - @Query("UPDATE ${AppPrefs.TABLE} SET preferredRepoId = NULL WHERE preferredRepoId = :repoId") - fun resetPreferredRepoInAppPrefs(repoId: Long) - - @Query( - """UPDATE ${RepositoryPreferences.TABLE} SET userMirrors = :mirrors + @Query( + """UPDATE ${RepositoryPreferences.TABLE} SET userMirrors = :mirrors WHERE repoId = :repoId""" - ) - override fun updateUserMirrors(repoId: Long, mirrors: List) + ) + override fun updateUserMirrors(repoId: Long, mirrors: List) - @Query( - """UPDATE ${RepositoryPreferences.TABLE} SET username = :username, password = :password + @Query( + """UPDATE ${RepositoryPreferences.TABLE} SET username = :username, password = :password WHERE repoId = :repoId""" - ) - override fun updateUsernameAndPassword(repoId: Long, username: String?, password: String?) + ) + override fun updateUsernameAndPassword(repoId: Long, username: String?, password: String?) - @Query( - """UPDATE ${RepositoryPreferences.TABLE} SET disabledMirrors = :disabledMirrors + @Query( + """UPDATE ${RepositoryPreferences.TABLE} SET disabledMirrors = :disabledMirrors WHERE repoId = :repoId""" - ) - override fun updateDisabledMirrors(repoId: Long, disabledMirrors: List) + ) + override fun updateDisabledMirrors(repoId: Long, disabledMirrors: List) - /** - * Changes repository weights/priorities that determine list order and preferred repositories. - * The lower a repository is in the list, the lower is its priority. - * If an app is in more than one repo, by default, the repo higher in the list wins. - * - * @param repoToReorder this repository will change its position in the list. - * @param repoTarget the repository in which place the [repoToReorder] shall be moved. - * If our list is [ A B C D ] and we call reorderRepositories(B, D), - * then the new list will be [ A C D B ]. - * - * @throws IllegalArgumentException if one of the repos is an archive repo. - * Those are expected to be tied to their main repo one down the list - * and are moved automatically when their main repo moves. - */ - @Transaction - fun reorderRepositories(repoToReorder: Repository, repoTarget: Repository) { - require(!repoToReorder.isArchiveRepo && !repoTarget.isArchiveRepo) { - "Re-ordering of archive repos is not supported" - } - if (repoToReorder.weight > repoTarget.weight) { - // repoToReorder is higher, - // so move repos below repoToReorder (and its archive below) two weights up - shiftRepoWeights(repoTarget.weight, repoToReorder.weight - 2, 2) - } else if (repoToReorder.weight < repoTarget.weight) { - // repoToReorder is lower, so move repos above repoToReorder two weights down - shiftRepoWeights(repoToReorder.weight + 1, repoTarget.weight, -2) - } else { - return // both repos have same weight, not re-ordering anything - } - // move repoToReorder in place of repoTarget - setWeight(repoToReorder.repoId, repoTarget.weight) - // also adjust weight of archive repo, if it exists - val archiveRepoId = getArchiveRepoId(repoToReorder.certificate) - if (archiveRepoId != null) { - setWeight(archiveRepoId, repoTarget.weight - 1) - } + /** + * Changes repository weights/priorities that determine list order and preferred repositories. The + * lower a repository is in the list, the lower is its priority. If an app is in more than one + * repo, by default, the repo higher in the list wins. + * + * @param repoToReorder this repository will change its position in the list. + * @param repoTarget the repository in which place the [repoToReorder] shall be moved. If our list + * is [ A B C D ] and we call reorderRepositories(B, D), then the new list will be [ A C D B ]. + * @throws IllegalArgumentException if one of the repos is an archive repo. Those are expected to + * be tied to their main repo one down the list and are moved automatically when their main repo + * moves. + */ + @Transaction + fun reorderRepositories(repoToReorder: Repository, repoTarget: Repository) { + require(!repoToReorder.isArchiveRepo && !repoTarget.isArchiveRepo) { + "Re-ordering of archive repos is not supported" } + if (repoToReorder.weight > repoTarget.weight) { + // repoToReorder is higher, + // so move repos below repoToReorder (and its archive below) two weights up + shiftRepoWeights(repoTarget.weight, repoToReorder.weight - 2, 2) + } else if (repoToReorder.weight < repoTarget.weight) { + // repoToReorder is lower, so move repos above repoToReorder two weights down + shiftRepoWeights(repoToReorder.weight + 1, repoTarget.weight, -2) + } else { + return // both repos have same weight, not re-ordering anything + } + // move repoToReorder in place of repoTarget + setWeight(repoToReorder.repoId, repoTarget.weight) + // also adjust weight of archive repo, if it exists + val archiveRepoId = getArchiveRepoId(repoToReorder.certificate) + if (archiveRepoId != null) { + setWeight(archiveRepoId, repoTarget.weight - 1) + } + } - @Query("""UPDATE ${RepositoryPreferences.TABLE} SET weight = :weight WHERE repoId = :repoId""") - fun setWeight(repoId: Long, weight: Int) + @Query("""UPDATE ${RepositoryPreferences.TABLE} SET weight = :weight WHERE repoId = :repoId""") + fun setWeight(repoId: Long, weight: Int) - @Query( - """UPDATE ${RepositoryPreferences.TABLE} SET weight = weight + :offset + @Query( + """UPDATE ${RepositoryPreferences.TABLE} SET weight = weight + :offset WHERE weight >= :weightFrom AND weight <= :weightTo""" - ) - fun shiftRepoWeights(weightFrom: Int, weightTo: Int, offset: Int) + ) + fun shiftRepoWeights(weightFrom: Int, weightTo: Int, offset: Int) - @Query( - """SELECT repoId FROM ${CoreRepository.TABLE} + @Query( + """SELECT repoId FROM ${CoreRepository.TABLE} WHERE certificate = :cert AND address LIKE '%/archive' COLLATE NOCASE""" - ) - fun getArchiveRepoId(cert: String): Long? + ) + fun getArchiveRepoId(cert: String): Long? - @Query( - """UPDATE ${RepositoryPreferences.TABLE} + @Query( + """UPDATE ${RepositoryPreferences.TABLE} SET errorCount = errorCount + 1, lastError = :errorMsg WHERE repoId = :repoId """ - ) - fun trackRepoUpdateError(repoId: Long, errorMsg: String) + ) + fun trackRepoUpdateError(repoId: Long, errorMsg: String) - @Query( - """UPDATE ${RepositoryPreferences.TABLE} + @Query( + """UPDATE ${RepositoryPreferences.TABLE} SET errorCount = 0, lastError = NULL WHERE repoId = :repoId """ - ) - fun resetRepoUpdateError(repoId: Long) + ) + fun resetRepoUpdateError(repoId: Long) - @Transaction - override fun deleteRepository(repoId: Long) { - deleteCoreRepository(repoId) - // we don't use cascading delete for preferences, - // so we can replace index data on full updates - deleteRepositoryPreferences(repoId) - // When deleting a repository, we need to remove it as preferred repo for all apps, - // otherwise our queries will not return anything anymore. - resetPreferredRepoInAppPrefs(repoId) - } + @Transaction + override fun deleteRepository(repoId: Long) { + deleteCoreRepository(repoId) + // we don't use cascading delete for preferences, + // so we can replace index data on full updates + deleteRepositoryPreferences(repoId) + // When deleting a repository, we need to remove it as preferred repo for all apps, + // otherwise our queries will not return anything anymore. + resetPreferredRepoInAppPrefs(repoId) + } - @Query("DELETE FROM ${CoreRepository.TABLE} WHERE repoId = :repoId") - fun deleteCoreRepository(repoId: Long) + @Query("DELETE FROM ${CoreRepository.TABLE} WHERE repoId = :repoId") + fun deleteCoreRepository(repoId: Long) - @Query("DELETE FROM ${RepositoryPreferences.TABLE} WHERE repoId = :repoId") - fun deleteRepositoryPreferences(repoId: Long) + @Query("DELETE FROM ${RepositoryPreferences.TABLE} WHERE repoId = :repoId") + fun deleteRepositoryPreferences(repoId: Long) - @Query("DELETE FROM ${CoreRepository.TABLE}") - fun deleteAllCoreRepositories() + @Query("DELETE FROM ${CoreRepository.TABLE}") fun deleteAllCoreRepositories() - @Query("DELETE FROM ${RepositoryPreferences.TABLE}") - fun deleteAllRepositoryPreferences() + @Query("DELETE FROM ${RepositoryPreferences.TABLE}") fun deleteAllRepositoryPreferences() - /** - * Used for diffing. - */ - @Query("DELETE FROM ${Mirror.TABLE} WHERE repoId = :repoId") - fun deleteMirrors(repoId: Long) + /** Used for diffing. */ + @Query("DELETE FROM ${Mirror.TABLE} WHERE repoId = :repoId") fun deleteMirrors(repoId: Long) - /** - * Used for diffing. - */ - @Query("DELETE FROM ${AntiFeature.TABLE} WHERE repoId = :repoId") - fun deleteAntiFeatures(repoId: Long) + /** Used for diffing. */ + @Query("DELETE FROM ${AntiFeature.TABLE} WHERE repoId = :repoId") + fun deleteAntiFeatures(repoId: Long) - /** - * Used for diffing. - */ - @Query("DELETE FROM ${AntiFeature.TABLE} WHERE repoId = :repoId AND id = :id") - fun deleteAntiFeature(repoId: Long, id: String) + /** Used for diffing. */ + @Query("DELETE FROM ${AntiFeature.TABLE} WHERE repoId = :repoId AND id = :id") + fun deleteAntiFeature(repoId: Long, id: String) - /** - * Used for diffing. - */ - @Query("DELETE FROM ${Category.TABLE} WHERE repoId = :repoId") - fun deleteCategories(repoId: Long) + /** Used for diffing. */ + @Query("DELETE FROM ${Category.TABLE} WHERE repoId = :repoId") fun deleteCategories(repoId: Long) - /** - * Used for diffing. - */ - @Query("DELETE FROM ${Category.TABLE} WHERE repoId = :repoId AND id = :id") - fun deleteCategory(repoId: Long, id: String) + /** Used for diffing. */ + @Query("DELETE FROM ${Category.TABLE} WHERE repoId = :repoId AND id = :id") + fun deleteCategory(repoId: Long, id: String) - /** - * Used for diffing. - */ - @Query("DELETE FROM ${ReleaseChannel.TABLE} WHERE repoId = :repoId") - fun deleteReleaseChannels(repoId: Long) + /** Used for diffing. */ + @Query("DELETE FROM ${ReleaseChannel.TABLE} WHERE repoId = :repoId") + fun deleteReleaseChannels(repoId: Long) - /** - * Used for diffing. - */ - @Query("DELETE FROM ${ReleaseChannel.TABLE} WHERE repoId = :repoId AND id = :id") - fun deleteReleaseChannel(repoId: Long, id: String) + /** Used for diffing. */ + @Query("DELETE FROM ${ReleaseChannel.TABLE} WHERE repoId = :repoId AND id = :id") + fun deleteReleaseChannel(repoId: Long, id: String) - /** - * Resets timestamps for *all* repos in the database. - * This will use a full index instead of diffs - * when updating the repository via [IndexV2Updater]. - */ - @Query("UPDATE ${CoreRepository.TABLE} SET timestamp = -1") - fun resetTimestamps() + /** + * Resets timestamps for *all* repos in the database. This will use a full index instead of diffs + * when updating the repository via [IndexV2Updater]. + */ + @Query("UPDATE ${CoreRepository.TABLE} SET timestamp = -1") fun resetTimestamps() - /** - * Resets ETags for *all* repos in the database. - * This will use cause a full index update when updating the repository via [IndexV1Updater]. - */ - @Query("UPDATE ${RepositoryPreferences.TABLE} SET lastETag = NULL") - fun resetETags() + /** + * Resets ETags for *all* repos in the database. This will use cause a full index update when + * updating the repository via [IndexV1Updater]. + */ + @Query("UPDATE ${RepositoryPreferences.TABLE} SET lastETag = NULL") fun resetETags() - /** - * Use when replacing an existing repo with a full index. - * This removes all existing index data associated with this repo from the database, - * but does not touch repository preferences. - * @throws IllegalStateException if no repo with the given [repoId] exists. - */ - @Transaction - fun clear(repoId: Long) { - val repo = getRepository(repoId) ?: error("repo with id $repoId does not exist") - // this clears all foreign key associated data since the repo gets replaced - insertOrReplace(repo.repository) - } + /** + * Use when replacing an existing repo with a full index. This removes all existing index data + * associated with this repo from the database, but does not touch repository preferences. + * + * @throws IllegalStateException if no repo with the given [repoId] exists. + */ + @Transaction + fun clear(repoId: Long) { + val repo = getRepository(repoId) ?: error("repo with id $repoId does not exist") + // this clears all foreign key associated data since the repo gets replaced + insertOrReplace(repo.repository) + } - @Transaction - override fun clearAll() { - deleteAllCoreRepositories() - deleteAllRepositoryPreferences() - } + @Transaction + override fun clearAll() { + deleteAllCoreRepositories() + deleteAllRepositoryPreferences() + } - @VisibleForTesting - @Query("SELECT COUNT(*) FROM ${Mirror.TABLE}") - fun countMirrors(): Int + @VisibleForTesting @Query("SELECT COUNT(*) FROM ${Mirror.TABLE}") fun countMirrors(): Int - @VisibleForTesting - @Query("SELECT COUNT(*) FROM ${AntiFeature.TABLE}") - fun countAntiFeatures(): Int + @VisibleForTesting + @Query("SELECT COUNT(*) FROM ${AntiFeature.TABLE}") + fun countAntiFeatures(): Int - @VisibleForTesting - @Query("SELECT COUNT(*) FROM ${Category.TABLE}") - fun countCategories(): Int + @VisibleForTesting @Query("SELECT COUNT(*) FROM ${Category.TABLE}") fun countCategories(): Int - @VisibleForTesting - @Query("SELECT COUNT(*) FROM ${ReleaseChannel.TABLE}") - fun countReleaseChannels(): Int + @VisibleForTesting + @Query("SELECT COUNT(*) FROM ${ReleaseChannel.TABLE}") + fun countReleaseChannels(): Int - override fun walCheckpoint() { - rawCheckpoint(SimpleSQLiteQuery("pragma wal_checkpoint(truncate)")) - } + override fun walCheckpoint() { + rawCheckpoint(SimpleSQLiteQuery("pragma wal_checkpoint(truncate)")) + } - @RawQuery - fun rawCheckpoint(supportSQLiteQuery: SupportSQLiteQuery): Int + @RawQuery fun rawCheckpoint(supportSQLiteQuery: SupportSQLiteQuery): Int } diff --git a/libs/database/src/main/java/org/fdroid/database/Version.kt b/libs/database/src/main/java/org/fdroid/database/Version.kt index f3021b672..d75b6657b 100644 --- a/libs/database/src/main/java/org/fdroid/database/Version.kt +++ b/libs/database/src/main/java/org/fdroid/database/Version.kt @@ -23,58 +23,69 @@ import org.fdroid.index.v2.SignerV2 import org.fdroid.index.v2.UsesSdkV2 /** - * A database table entity representing the version of an [App] - * identified by its [versionCode] and [signer]. - * This holds the data of [PackageVersionV2]. + * A database table entity representing the version of an [App] identified by its [versionCode] and + * [signer]. This holds the data of [PackageVersionV2]. */ @Entity( - tableName = Version.TABLE, - primaryKeys = ["repoId", "packageName", "versionId"], - foreignKeys = [ForeignKey( + tableName = Version.TABLE, + primaryKeys = ["repoId", "packageName", "versionId"], + foreignKeys = + [ + ForeignKey( entity = AppMetadata::class, parentColumns = ["repoId", "packageName"], childColumns = ["repoId", "packageName"], onDelete = ForeignKey.CASCADE, - )], + ) + ], ) internal data class Version( - val repoId: Long, - val packageName: String, - val versionId: String, - override val added: Long, - @Embedded(prefix = "file_") val file: FileV1, - @Embedded(prefix = "src_") val src: FileV2? = null, - @Embedded(prefix = "manifest_") val manifest: AppManifest, - override val releaseChannels: List? = emptyList(), - val antiFeatures: Map? = null, - val whatsNew: LocalizedTextV2? = null, - val appLabel: LocalizedTextV2? = null, - val isCompatible: Boolean, + val repoId: Long, + val packageName: String, + val versionId: String, + override val added: Long, + @Embedded(prefix = "file_") val file: FileV1, + @Embedded(prefix = "src_") val src: FileV2? = null, + @Embedded(prefix = "manifest_") val manifest: AppManifest, + override val releaseChannels: List? = emptyList(), + val antiFeatures: Map? = null, + val whatsNew: LocalizedTextV2? = null, + val appLabel: LocalizedTextV2? = null, + val isCompatible: Boolean, ) : PackageVersion { - internal companion object { - const val TABLE = "Version" - } + internal companion object { + const val TABLE = "Version" + } - override val versionCode: Long get() = manifest.versionCode - override val versionName: String get() = manifest.versionName - override val size: Long? get() = file.size - override val signer: SignerV2? get() = manifest.signer - override val packageManifest: PackageManifest get() = manifest - override val hasKnownVulnerability: Boolean - get() = antiFeatures?.contains(ANTI_FEATURE_KNOWN_VULNERABILITY) == true + override val versionCode: Long + get() = manifest.versionCode - internal fun toAppVersion(versionedStrings: List): AppVersion = AppVersion( - version = this, - versionedStrings = versionedStrings, - ) + override val versionName: String + get() = manifest.versionName + + override val size: Long? + get() = file.size + + override val signer: SignerV2? + get() = manifest.signer + + override val packageManifest: PackageManifest + get() = manifest + + override val hasKnownVulnerability: Boolean + get() = antiFeatures?.contains(ANTI_FEATURE_KNOWN_VULNERABILITY) == true + + internal fun toAppVersion(versionedStrings: List): AppVersion = + AppVersion(version = this, versionedStrings = versionedStrings) } internal fun PackageVersionV2.toVersion( - repoId: Long, - packageName: String, - versionId: String, - isCompatible: Boolean, -) = Version( + repoId: Long, + packageName: String, + versionId: String, + isCompatible: Boolean, +) = + Version( repoId = repoId, packageName = packageName, versionId = versionId, @@ -86,79 +97,103 @@ internal fun PackageVersionV2.toVersion( antiFeatures = antiFeatures, whatsNew = whatsNew, isCompatible = isCompatible, -) + ) -/** - * A version of an [App] identified by [AppManifest.versionCode] and [AppManifest.signer]. - */ +/** A version of an [App] identified by [AppManifest.versionCode] and [AppManifest.signer]. */ @ConsistentCopyVisibility -public data class AppVersion internal constructor( - @Embedded internal val version: Version, - @Relation( - parentColumn = "versionId", - entityColumn = "versionId", - ) - internal val versionedStrings: List?, +public data class AppVersion +internal constructor( + @Embedded internal val version: Version, + @Relation(parentColumn = "versionId", entityColumn = "versionId") + internal val versionedStrings: List?, ) : PackageVersion { - public val repoId: Long get() = version.repoId - public val packageName: String get() = version.packageName - public override val added: Long get() = version.added - override val size: Long? get() = version.file.size - public val isCompatible: Boolean get() = version.isCompatible - public val manifest: AppManifest get() = version.manifest - public val file: FileV1 get() = version.file - public val src: FileV2? get() = version.src - public val usesPermission: List - get() = versionedStrings?.getPermissions(version) ?: emptyList() - public val usesPermissionSdk23: List - get() = versionedStrings?.getPermissionsSdk23(version) ?: emptyList() - public val featureNames: List get() = version.manifest.features ?: emptyList() - public val nativeCode: List get() = version.manifest.nativecode ?: emptyList() - public override val releaseChannels: List get() = version.releaseChannels ?: emptyList() + public val repoId: Long + get() = version.repoId - @get:Ignore - public override val versionCode: Long get() = version.manifest.versionCode + public val packageName: String + get() = version.packageName - @get:Ignore - override val versionName: String get() = version.manifest.versionName + public override val added: Long + get() = version.added - @get:Ignore - public override val signer: SignerV2? get() = version.manifest.signer + override val size: Long? + get() = version.file.size - @Ignore - public override val packageManifest: PackageManifest = version.manifest + public val isCompatible: Boolean + get() = version.isCompatible - @Ignore - public override val hasKnownVulnerability: Boolean = version.hasKnownVulnerability - public val antiFeatureKeys: List - get() = version.antiFeatures?.map { it.key } ?: emptyList() + public val manifest: AppManifest + get() = version.manifest - public fun getWhatsNew(localeList: LocaleListCompat): String? = - version.whatsNew.getBestLocale(localeList) + public val file: FileV1 + get() = version.file - public fun getAntiFeatureReason(antiFeatureKey: String, localeList: LocaleListCompat): String? { - return version.antiFeatures?.get(antiFeatureKey)?.getBestLocale(localeList) - } + public val src: FileV2? + get() = version.src + + public val usesPermission: List + get() = versionedStrings?.getPermissions(version) ?: emptyList() + + public val usesPermissionSdk23: List + get() = versionedStrings?.getPermissionsSdk23(version) ?: emptyList() + + public val featureNames: List + get() = version.manifest.features ?: emptyList() + + public val nativeCode: List + get() = version.manifest.nativecode ?: emptyList() + + public override val releaseChannels: List + get() = version.releaseChannels ?: emptyList() + + @get:Ignore + public override val versionCode: Long + get() = version.manifest.versionCode + + @get:Ignore + override val versionName: String + get() = version.manifest.versionName + + @get:Ignore + public override val signer: SignerV2? + get() = version.manifest.signer + + @Ignore public override val packageManifest: PackageManifest = version.manifest + + @Ignore public override val hasKnownVulnerability: Boolean = version.hasKnownVulnerability + public val antiFeatureKeys: List + get() = version.antiFeatures?.map { it.key } ?: emptyList() + + public fun getWhatsNew(localeList: LocaleListCompat): String? = + version.whatsNew.getBestLocale(localeList) + + public fun getAntiFeatureReason(antiFeatureKey: String, localeList: LocaleListCompat): String? { + return version.antiFeatures?.get(antiFeatureKey)?.getBestLocale(localeList) + } } -/** - * The manifest information of an [AppVersion]. - */ +/** The manifest information of an [AppVersion]. */ public data class AppManifest( - public val versionName: String, - public val versionCode: Long, - @Embedded(prefix = "usesSdk_") public val usesSdk: UsesSdkV2? = null, - public override val maxSdkVersion: Int? = null, - @Embedded(prefix = "signer_") public val signer: SignerV2? = null, - public override val nativecode: List? = emptyList(), - public val features: List? = emptyList(), + public val versionName: String, + public val versionCode: Long, + @Embedded(prefix = "usesSdk_") public val usesSdk: UsesSdkV2? = null, + public override val maxSdkVersion: Int? = null, + @Embedded(prefix = "signer_") public val signer: SignerV2? = null, + public override val nativecode: List? = emptyList(), + public val features: List? = emptyList(), ) : PackageManifest { - public override val minSdkVersion: Int? get() = usesSdk?.minSdkVersion - public override val featureNames: List? get() = features - public override val targetSdkVersion: Int? get() = usesSdk?.targetSdkVersion + public override val minSdkVersion: Int? + get() = usesSdk?.minSdkVersion + + public override val featureNames: List? + get() = features + + public override val targetSdkVersion: Int? + get() = usesSdk?.targetSdkVersion } -internal fun ManifestV2.toManifest() = AppManifest( +internal fun ManifestV2.toManifest() = + AppManifest( versionName = versionName, versionCode = versionCode, usesSdk = usesSdk, @@ -166,97 +201,91 @@ internal fun ManifestV2.toManifest() = AppManifest( signer = signer, nativecode = nativecode, features = features.map { it.name }, -) + ) @DatabaseView( - viewName = HighestVersion.TABLE, - value = """SELECT repoId, packageName, antiFeatures FROM ${Version.TABLE} + viewName = HighestVersion.TABLE, + value = + """SELECT repoId, packageName, antiFeatures FROM ${Version.TABLE} GROUP BY repoId, packageName HAVING MAX(manifest_versionCode)""", ) internal class HighestVersion( - val repoId: Long, - val packageName: String, - val antiFeatures: Map? = null, + val repoId: Long, + val packageName: String, + val antiFeatures: Map? = null, ) { - internal companion object { - const val TABLE = "HighestVersion" - } + internal companion object { + const val TABLE = "HighestVersion" + } } internal enum class VersionedStringType { - PERMISSION, - PERMISSION_SDK_23, + PERMISSION, + PERMISSION_SDK_23, } @Entity( - tableName = VersionedString.TABLE, - primaryKeys = ["repoId", "packageName", "versionId", "type", "name"], - foreignKeys = [ForeignKey( + tableName = VersionedString.TABLE, + primaryKeys = ["repoId", "packageName", "versionId", "type", "name"], + foreignKeys = + [ + ForeignKey( entity = Version::class, parentColumns = ["repoId", "packageName", "versionId"], childColumns = ["repoId", "packageName", "versionId"], onDelete = ForeignKey.CASCADE, - )], + ) + ], ) internal data class VersionedString( - val repoId: Long, - val packageName: String, - val versionId: String, - val type: VersionedStringType, - val name: String, - val version: Int? = null, + val repoId: Long, + val packageName: String, + val versionId: String, + val type: VersionedStringType, + val name: String, + val version: Int? = null, ) { - internal companion object { - const val TABLE = "VersionedString" - } + internal companion object { + const val TABLE = "VersionedString" + } } -internal fun List.toVersionedString( - version: Version, - type: VersionedStringType, -) = map { permission -> +internal fun List.toVersionedString(version: Version, type: VersionedStringType) = + map { permission -> VersionedString( - repoId = version.repoId, - packageName = version.packageName, - versionId = version.versionId, - type = type, - name = permission.name, - version = permission.maxSdkVersion, + repoId = version.repoId, + packageName = version.packageName, + versionId = version.versionId, + type = type, + name = permission.name, + version = permission.maxSdkVersion, ) -} + } internal fun ManifestV2.getVersionedStrings(version: Version): List { - return usesPermission.toVersionedString(version, PERMISSION) + - usesPermissionSdk23.toVersionedString(version, PERMISSION_SDK_23) + return usesPermission.toVersionedString(version, PERMISSION) + + usesPermissionSdk23.toVersionedString(version, PERMISSION_SDK_23) } internal fun List.getPermissions(version: Version) = mapNotNull { v -> - v.map(version, PERMISSION) { - PermissionV2( - name = v.name, - maxSdkVersion = v.version, - ) - } + v.map(version, PERMISSION) { PermissionV2(name = v.name, maxSdkVersion = v.version) } } internal fun List.getPermissionsSdk23(version: Version) = mapNotNull { v -> - v.map(version, PERMISSION_SDK_23) { - PermissionV2( - name = v.name, - maxSdkVersion = v.version, - ) - } + v.map(version, PERMISSION_SDK_23) { PermissionV2(name = v.name, maxSdkVersion = v.version) } } private fun VersionedString.map( - v: Version, - wantedType: VersionedStringType, - factory: () -> T, + v: Version, + wantedType: VersionedStringType, + factory: () -> T, ): T? { - return if (repoId != v.repoId || - packageName != v.packageName || - versionId != v.versionId || - type != wantedType - ) null - else factory() + return if ( + repoId != v.repoId || + packageName != v.packageName || + versionId != v.versionId || + type != wantedType + ) + null + else factory() } diff --git a/libs/database/src/main/java/org/fdroid/database/VersionDao.kt b/libs/database/src/main/java/org/fdroid/database/VersionDao.kt index 71be99dc3..0f1e6709a 100644 --- a/libs/database/src/main/java/org/fdroid/database/VersionDao.kt +++ b/libs/database/src/main/java/org/fdroid/database/VersionDao.kt @@ -22,232 +22,240 @@ import org.fdroid.index.v2.PermissionV2 import org.fdroid.index.v2.ReflectionDiffer public interface VersionDao { - /** - * Inserts new versions for a given [packageName] from a full index. - */ - public fun insert( - repoId: Long, - packageName: String, - packageVersions: Map, - checkIfCompatible: (PackageVersionV2) -> Boolean, - ) + /** Inserts new versions for a given [packageName] from a full index. */ + public fun insert( + repoId: Long, + packageName: String, + packageVersions: Map, + checkIfCompatible: (PackageVersionV2) -> Boolean, + ) - /** - * Returns a list of versions for the given [packageName] sorting by highest version code first. - */ - public fun getAppVersions(packageName: String): LiveData> + /** + * Returns a list of versions for the given [packageName] sorting by highest version code first. + */ + public fun getAppVersions(packageName: String): LiveData> - /** - * Returns a list of versions from the repo identified by the given [repoId] - * for the given [packageName] sorting by highest version code first. - */ - public fun getAppVersions(repoId: Long, packageName: String): LiveData> + /** + * Returns a list of versions from the repo identified by the given [repoId] for the given + * [packageName] sorting by highest version code first. + */ + public fun getAppVersions(repoId: Long, packageName: String): LiveData> } /** * A list of unknown fields in [PackageVersionV2] that we don't allow for [Version]. * - * We are applying reflection diffs against internal database classes - * and need to prevent the untrusted external JSON input to modify internal fields in those classes. - * This list must always hold the names of all those internal FIELDS for [Version]. + * We are applying reflection diffs against internal database classes and need to prevent the + * untrusted external JSON input to modify internal fields in those classes. This list must always + * hold the names of all those internal FIELDS for [Version]. */ private val DENY_LIST = listOf("packageName", "repoId", "versionId") @Dao internal interface VersionDaoInt : VersionDao { - @Transaction - override fun insert( - repoId: Long, - packageName: String, - packageVersions: Map, - checkIfCompatible: (PackageVersionV2) -> Boolean, - ) { - packageVersions.entries.iterator().forEach { (versionId, packageVersion) -> - val isCompatible = checkIfCompatible(packageVersion) - insert(repoId, packageName, versionId, packageVersion, isCompatible) + @Transaction + override fun insert( + repoId: Long, + packageName: String, + packageVersions: Map, + checkIfCompatible: (PackageVersionV2) -> Boolean, + ) { + packageVersions.entries.iterator().forEach { (versionId, packageVersion) -> + val isCompatible = checkIfCompatible(packageVersion) + insert(repoId, packageName, versionId, packageVersion, isCompatible) + } + } + + @Transaction + fun insert( + repoId: Long, + packageName: String, + versionId: String, + packageVersion: PackageVersionV2, + isCompatible: Boolean, + ) { + val version = packageVersion.toVersion(repoId, packageName, versionId, isCompatible) + insert(version) + insert(packageVersion.manifest.getVersionedStrings(version)) + } + + @Insert(onConflict = REPLACE) fun insert(version: Version) + + @Insert(onConflict = REPLACE) fun insert(versionedString: List) + + @Update fun update(version: Version) + + fun update( + repoId: Long, + packageName: String, + versionsDiffMap: Map?, + checkIfCompatible: (PackageManifest) -> Boolean, + ) { + if (versionsDiffMap == null) { // no more versions, delete all + deleteAppVersion(repoId, packageName) + } else + versionsDiffMap.forEach { (versionId, jsonObject) -> + if (jsonObject == null) { // delete individual version + deleteAppVersion(repoId, packageName, versionId) + } else { + val version = getVersion(repoId, packageName, versionId) + if (version == null) { // new version, parse normally + val packageVersionV2: PackageVersionV2 = json.decodeFromJsonElement(jsonObject) + val isCompatible = checkIfCompatible(packageVersionV2.packageManifest) + insert(repoId, packageName, versionId, packageVersionV2, isCompatible) + } else { // diff against existing version + diffVersion(version, jsonObject, checkIfCompatible) + } } + } // end forEach + } + + private fun diffVersion( + version: Version, + jsonObject: JsonObject, + checkIfCompatible: (PackageManifest) -> Boolean, + ) { + // ensure that diff does not include internal keys + DENY_LIST.forEach { forbiddenKey -> + if (jsonObject.containsKey(forbiddenKey)) { + throw SerializationException(forbiddenKey) + } } - - @Transaction - fun insert( - repoId: Long, - packageName: String, - versionId: String, - packageVersion: PackageVersionV2, - isCompatible: Boolean, - ) { - val version = packageVersion.toVersion(repoId, packageName, versionId, isCompatible) - insert(version) - insert(packageVersion.manifest.getVersionedStrings(version)) + // diff version + val diffedVersion = ReflectionDiffer.applyDiff(version, jsonObject) + val isCompatible = checkIfCompatible(diffedVersion.packageManifest) + update(diffedVersion.copy(isCompatible = isCompatible)) + // diff versioned strings + val manifest = jsonObject["manifest"] + if (manifest is JsonNull) { // no more manifest, delete all versionedStrings + deleteVersionedStrings(version.repoId, version.packageName, version.versionId) + } else if (manifest is JsonObject) { + diffVersionedStrings(version, manifest, "usesPermission", PERMISSION) + diffVersionedStrings(version, manifest, "usesPermissionSdk23", PERMISSION_SDK_23) } + } - @Insert(onConflict = REPLACE) - fun insert(version: Version) - - @Insert(onConflict = REPLACE) - fun insert(versionedString: List) - - @Update - fun update(version: Version) - - fun update( - repoId: Long, - packageName: String, - versionsDiffMap: Map?, - checkIfCompatible: (PackageManifest) -> Boolean, - ) { - if (versionsDiffMap == null) { // no more versions, delete all - deleteAppVersion(repoId, packageName) - } else versionsDiffMap.forEach { (versionId, jsonObject) -> - if (jsonObject == null) { // delete individual version - deleteAppVersion(repoId, packageName, versionId) - } else { - val version = getVersion(repoId, packageName, versionId) - if (version == null) { // new version, parse normally - val packageVersionV2: PackageVersionV2 = - json.decodeFromJsonElement(jsonObject) - val isCompatible = checkIfCompatible(packageVersionV2.packageManifest) - insert(repoId, packageName, versionId, packageVersionV2, isCompatible) - } else { // diff against existing version - diffVersion(version, jsonObject, checkIfCompatible) - } - } - } // end forEach - } - - private fun diffVersion( - version: Version, - jsonObject: JsonObject, - checkIfCompatible: (PackageManifest) -> Boolean, - ) { - // ensure that diff does not include internal keys - DENY_LIST.forEach { forbiddenKey -> - if (jsonObject.containsKey(forbiddenKey)) { - throw SerializationException(forbiddenKey) - } - } - // diff version - val diffedVersion = ReflectionDiffer.applyDiff(version, jsonObject) - val isCompatible = checkIfCompatible(diffedVersion.packageManifest) - update(diffedVersion.copy(isCompatible = isCompatible)) - // diff versioned strings - val manifest = jsonObject["manifest"] - if (manifest is JsonNull) { // no more manifest, delete all versionedStrings - deleteVersionedStrings(version.repoId, version.packageName, version.versionId) - } else if (manifest is JsonObject) { - diffVersionedStrings(version, manifest, "usesPermission", PERMISSION) - diffVersionedStrings(version, manifest, "usesPermissionSdk23", - PERMISSION_SDK_23) - } - } - - private fun diffVersionedStrings( - version: Version, - jsonObject: JsonObject, - key: String, - type: VersionedStringType, - ) = DbDiffUtils.diffAndUpdateListTable( - jsonObject = jsonObject, - jsonObjectKey = key, - listParser = { permissionArray -> - val list: List = json.decodeFromJsonElement(permissionArray) - list.toVersionedString(version, type) - }, - deleteList = { - deleteVersionedStrings(version.repoId, version.packageName, version.versionId, type) - }, - insertNewList = { versionedStrings -> insert(versionedStrings) }, + private fun diffVersionedStrings( + version: Version, + jsonObject: JsonObject, + key: String, + type: VersionedStringType, + ) = + DbDiffUtils.diffAndUpdateListTable( + jsonObject = jsonObject, + jsonObjectKey = key, + listParser = { permissionArray -> + val list: List = json.decodeFromJsonElement(permissionArray) + list.toVersionedString(version, type) + }, + deleteList = { + deleteVersionedStrings(version.repoId, version.packageName, version.versionId, type) + }, + insertNewList = { versionedStrings -> insert(versionedStrings) }, ) - /** - * The `ASC` sort is to handle the rare corner case when a - * compatible version with the right signer is available with the - * same version code from the same repo. For example, if there are - * APKs with different ABIs, but same Version Code. Both Google - * and F-Droid recommend using different Version Codes for each ABI. - * `ASC` isn't quite right, but works fine for this rare case that - * happens when app devs do strange things. The 100% correct ABI - * sort order would be: `arm64-v8a`, `armeabi-v7a`, `x86_64`, `x86`. - * - * For more info, see: - * https://gitlab.com/fdroid/fdroidclient/-/merge_requests/1394#note_1896148332 - */ - @Transaction - @RewriteQueriesToDropUnusedColumns - @Query("""SELECT * FROM ${Version.TABLE} + /** + * The `ASC` sort is to handle the rare corner case when a compatible version with the right + * signer is available with the same version code from the same repo. For example, if there are + * APKs with different ABIs, but same Version Code. Both Google and F-Droid recommend using + * different Version Codes for each ABI. `ASC` isn't quite right, but works fine for this rare + * case that happens when app devs do strange things. The 100% correct ABI sort order would be: + * `arm64-v8a`, `armeabi-v7a`, `x86_64`, `x86`. + * + * For more info, see: + * https://gitlab.com/fdroid/fdroidclient/-/merge_requests/1394#note_1896148332 + */ + @Transaction + @RewriteQueriesToDropUnusedColumns + @Query( + """SELECT * FROM ${Version.TABLE} JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) WHERE pref.enabled = 1 AND packageName = :packageName - ORDER BY manifest_versionCode DESC, pref.weight DESC, manifest_nativecode ASC""") - override fun getAppVersions(packageName: String): LiveData> + ORDER BY manifest_versionCode DESC, pref.weight DESC, manifest_nativecode ASC""" + ) + override fun getAppVersions(packageName: String): LiveData> - @Transaction - @RewriteQueriesToDropUnusedColumns - @Query("""SELECT * FROM ${Version.TABLE} + @Transaction + @RewriteQueriesToDropUnusedColumns + @Query( + """SELECT * FROM ${Version.TABLE} WHERE repoId = :repoId AND packageName = :packageName - ORDER BY manifest_versionCode DESC, manifest_nativecode ASC""") - override fun getAppVersions(repoId: Long, packageName: String): LiveData> + ORDER BY manifest_versionCode DESC, manifest_nativecode ASC""" + ) + override fun getAppVersions(repoId: Long, packageName: String): LiveData> - @Query("""SELECT * FROM ${Version.TABLE} - WHERE repoId = :repoId AND packageName = :packageName AND versionId = :versionId""") - fun getVersion(repoId: Long, packageName: String, versionId: String): Version? + @Query( + """SELECT * FROM ${Version.TABLE} + WHERE repoId = :repoId AND packageName = :packageName AND versionId = :versionId""" + ) + fun getVersion(repoId: Long, packageName: String, versionId: String): Version? - /** - * Used for finding versions that are an update, - * so takes [AppPrefs.ignoreVersionCodeUpdate] into account. - */ - fun getVersions(packageNames: List): List { - // since sqlite 3.32.0 (in SDK 31 the max variables number was increased to 32766 - return if (packageNames.size <= 999 || SDK_INT >= 31) getVersionsInternal(packageNames) - else packageNames.chunked(999).flatMap { getVersionsInternal(it) } - } + /** + * Used for finding versions that are an update, so takes [AppPrefs.ignoreVersionCodeUpdate] into + * account. + */ + fun getVersions(packageNames: List): List { + // since sqlite 3.32.0 (in SDK 31 the max variables number was increased to 32766 + return if (packageNames.size <= 999 || SDK_INT >= 31) getVersionsInternal(packageNames) + else packageNames.chunked(999).flatMap { getVersionsInternal(it) } + } - @RewriteQueriesToDropUnusedColumns - @Query("""SELECT * FROM ${Version.TABLE} + @RewriteQueriesToDropUnusedColumns + @Query( + """SELECT * FROM ${Version.TABLE} JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) LEFT JOIN ${AppPrefs.TABLE} AS appPrefs USING (packageName) WHERE pref.enabled = 1 AND manifest_versionCode > COALESCE(appPrefs.ignoreVersionCodeUpdate, 0) AND packageName IN (:packageNames) - ORDER BY manifest_versionCode DESC, pref.weight DESC""") - fun getVersionsInternal(packageNames: List): List + ORDER BY manifest_versionCode DESC, pref.weight DESC""" + ) + fun getVersionsInternal(packageNames: List): List - @Query("""SELECT * FROM ${VersionedString.TABLE} - WHERE repoId = :repoId AND packageName = :packageName""") - fun getVersionedStrings(repoId: Long, packageName: String): List + @Query( + """SELECT * FROM ${VersionedString.TABLE} + WHERE repoId = :repoId AND packageName = :packageName""" + ) + fun getVersionedStrings(repoId: Long, packageName: String): List - @Query("""SELECT * FROM ${VersionedString.TABLE} - WHERE repoId = :repoId AND packageName = :packageName AND versionId = :versionId""") - fun getVersionedStrings( - repoId: Long, - packageName: String, - versionId: String, - ): List + @Query( + """SELECT * FROM ${VersionedString.TABLE} + WHERE repoId = :repoId AND packageName = :packageName AND versionId = :versionId""" + ) + fun getVersionedStrings( + repoId: Long, + packageName: String, + versionId: String, + ): List - @Query("""DELETE FROM ${Version.TABLE} WHERE repoId = :repoId AND packageName = :packageName""") - fun deleteAppVersion(repoId: Long, packageName: String) + @Query("""DELETE FROM ${Version.TABLE} WHERE repoId = :repoId AND packageName = :packageName""") + fun deleteAppVersion(repoId: Long, packageName: String) - @Query("""DELETE FROM ${Version.TABLE} - WHERE repoId = :repoId AND packageName = :packageName AND versionId = :versionId""") - fun deleteAppVersion(repoId: Long, packageName: String, versionId: String) + @Query( + """DELETE FROM ${Version.TABLE} + WHERE repoId = :repoId AND packageName = :packageName AND versionId = :versionId""" + ) + fun deleteAppVersion(repoId: Long, packageName: String, versionId: String) - @Query("""DELETE FROM ${VersionedString.TABLE} - WHERE repoId = :repoId AND packageName = :packageName AND versionId = :versionId""") - fun deleteVersionedStrings(repoId: Long, packageName: String, versionId: String) + @Query( + """DELETE FROM ${VersionedString.TABLE} + WHERE repoId = :repoId AND packageName = :packageName AND versionId = :versionId""" + ) + fun deleteVersionedStrings(repoId: Long, packageName: String, versionId: String) - @Query("""DELETE FROM ${VersionedString.TABLE} WHERE repoId = :repoId - AND packageName = :packageName AND versionId = :versionId AND type = :type""") - fun deleteVersionedStrings( - repoId: Long, - packageName: String, - versionId: String, - type: VersionedStringType, - ) + @Query( + """DELETE FROM ${VersionedString.TABLE} WHERE repoId = :repoId + AND packageName = :packageName AND versionId = :versionId AND type = :type""" + ) + fun deleteVersionedStrings( + repoId: Long, + packageName: String, + versionId: String, + type: VersionedStringType, + ) - @Query("SELECT COUNT(*) FROM ${Version.TABLE}") - fun countAppVersions(): Int - - @Query("SELECT COUNT(*) FROM ${VersionedString.TABLE}") - fun countVersionedStrings(): Int + @Query("SELECT COUNT(*) FROM ${Version.TABLE}") fun countAppVersions(): Int + @Query("SELECT COUNT(*) FROM ${VersionedString.TABLE}") fun countVersionedStrings(): Int } diff --git a/libs/database/src/main/java/org/fdroid/download/DownloaderFactory.kt b/libs/database/src/main/java/org/fdroid/download/DownloaderFactory.kt index a913e439d..9ca351d1b 100644 --- a/libs/database/src/main/java/org/fdroid/download/DownloaderFactory.kt +++ b/libs/database/src/main/java/org/fdroid/download/DownloaderFactory.kt @@ -2,54 +2,49 @@ package org.fdroid.download import android.net.Uri import android.util.Log -import org.fdroid.IndexFile -import org.fdroid.database.Repository import java.io.File import java.io.IOException +import org.fdroid.IndexFile +import org.fdroid.database.Repository -/** - * This is in the database library, because only that knows about the [Repository] class. - */ +/** This is in the database library, because only that knows about the [Repository] class. */ public abstract class DownloaderFactory { - /** - * Same as [create], but trying canonical address first. - * - * See https://gitlab.com/fdroid/fdroidclient/-/issues/1708 for why this is still needed. - */ - @Throws(IOException::class) - public fun createWithTryFirstMirror( - repo: Repository, - uri: Uri, - indexFile: IndexFile, - destFile: File, - ): Downloader { - val tryFirst = repo.getMirrors().find { mirror -> - mirror.baseUrl == repo.address - } - if (tryFirst == null) { - Log.w("DownloaderFactory", "Try-first mirror not found, disabled by user?") - } - val mirrors: List = repo.getMirrors() - return create(repo, mirrors, uri, indexFile, destFile, tryFirst) + /** + * Same as [create], but trying canonical address first. + * + * See https://gitlab.com/fdroid/fdroidclient/-/issues/1708 for why this is still needed. + */ + @Throws(IOException::class) + public fun createWithTryFirstMirror( + repo: Repository, + uri: Uri, + indexFile: IndexFile, + destFile: File, + ): Downloader { + val tryFirst = repo.getMirrors().find { mirror -> mirror.baseUrl == repo.address } + if (tryFirst == null) { + Log.w("DownloaderFactory", "Try-first mirror not found, disabled by user?") } + val mirrors: List = repo.getMirrors() + return create(repo, mirrors, uri, indexFile, destFile, tryFirst) + } - @Throws(IOException::class) - public abstract fun create( - repo: Repository, - uri: Uri, - indexFile: IndexFile, - destFile: File, - ): Downloader - - @Throws(IOException::class) - protected abstract fun create( - repo: Repository, - mirrors: List, - uri: Uri, - indexFile: IndexFile, - destFile: File, - tryFirst: Mirror?, - ): Downloader + @Throws(IOException::class) + public abstract fun create( + repo: Repository, + uri: Uri, + indexFile: IndexFile, + destFile: File, + ): Downloader + @Throws(IOException::class) + protected abstract fun create( + repo: Repository, + mirrors: List, + uri: Uri, + indexFile: IndexFile, + destFile: File, + tryFirst: Mirror?, + ): Downloader } diff --git a/libs/database/src/main/java/org/fdroid/index/IndexUpdater.kt b/libs/database/src/main/java/org/fdroid/index/IndexUpdater.kt index 8cdce3494..8294e66c0 100644 --- a/libs/database/src/main/java/org/fdroid/index/IndexUpdater.kt +++ b/libs/database/src/main/java/org/fdroid/index/IndexUpdater.kt @@ -3,117 +3,109 @@ package org.fdroid.index import android.net.Uri import androidx.annotation.VisibleForTesting import androidx.annotation.WorkerThread +import androidx.core.net.toUri +import java.io.File +import java.io.IOException import org.fdroid.database.Repository import org.fdroid.database.RepositoryDaoInt import org.fdroid.download.Downloader import org.fdroid.download.NotFoundException -import java.io.File -import java.io.IOException -/** - * The currently known (and supported) format versions of the F-Droid index. - */ -public enum class IndexFormatVersion { ONE, TWO } +/** The currently known (and supported) format versions of the F-Droid index. */ +public enum class IndexFormatVersion { + ONE, + TWO, +} public sealed class IndexUpdateResult { - public object Unchanged : IndexUpdateResult() - public object Processed : IndexUpdateResult() - public object NotFound : IndexUpdateResult() - public data class Error(public val e: Exception) : IndexUpdateResult() + public object Unchanged : IndexUpdateResult() + + public object Processed : IndexUpdateResult() + + public object NotFound : IndexUpdateResult() + + public data class Error(public val e: Exception) : IndexUpdateResult() } public interface IndexUpdateListener { - /** - * If [totalBytes] is 0 or less, it is unknown and indeterminate progress should be shown. - */ - public fun onDownloadProgress(repo: Repository, bytesRead: Long, totalBytes: Long) - public fun onUpdateProgress(repo: Repository, appsProcessed: Int, totalApps: Int) + /** If [totalBytes] is 0 or less, it is unknown and indeterminate progress should be shown. */ + public fun onDownloadProgress(repo: Repository, bytesRead: Long, totalBytes: Long) + + public fun onUpdateProgress(repo: Repository, appsProcessed: Int, totalApps: Int) } public fun interface RepoUriBuilder { - /** - * Returns an [Uri] for downloading a file from the [Repository]. - * Allowing different implementations for this is useful for exotic repository locations - * that do not allow for simple concatenation. - */ - public fun getUri(repo: Repository, vararg pathElements: String): Uri + /** + * Returns an [Uri] for downloading a file from the [Repository]. Allowing different + * implementations for this is useful for exotic repository locations that do not allow for simple + * concatenation. + */ + public fun getUri(repo: Repository, vararg pathElements: String): Uri } internal val defaultRepoUriBuilder = RepoUriBuilder { repo, pathElements -> - val builder = Uri.parse(repo.address).buildUpon() - pathElements.forEach { builder.appendEncodedPath(it) } - builder.build() + val builder = repo.address.toUri().buildUpon() + pathElements.forEach { builder.appendEncodedPath(it) } + builder.build() } public fun interface TempFileProvider { - @Throws(IOException::class) - public fun createTempFile(sha256: String?): File + @Throws(IOException::class) public fun createTempFile(sha256: String?): File } -/** - * A class to update information of a [Repository] in the database with a new downloaded index. - */ +/** A class to update information of a [Repository] in the database with a new downloaded index. */ public abstract class IndexUpdater { - @VisibleForTesting - internal abstract val repoDao: RepositoryDaoInt + @VisibleForTesting internal abstract val repoDao: RepositoryDaoInt - /** - * The [IndexFormatVersion] used by this updater. - * One updater usually handles exactly one format version. - * If you need a higher level of abstraction, check [RepoUpdater]. - */ - public abstract val formatVersion: IndexFormatVersion + /** + * The [IndexFormatVersion] used by this updater. One updater usually handles exactly one format + * version. If you need a higher level of abstraction, check [RepoUpdater]. + */ + public abstract val formatVersion: IndexFormatVersion - /** - * Updates an existing [repo] with a known [Repository.certificate]. - */ - @WorkerThread - public fun update(repo: Repository): IndexUpdateResult = catchExceptions(repo) { - updateRepo(repo).also { result -> - // reset repo errors if repo updated fine again, but is still unchanged - if (repo.errorCount > 0 && result is IndexUpdateResult.Unchanged) { - repoDao.resetRepoUpdateError(repo.repoId) - } + /** Updates an existing [repo] with a known [Repository.certificate]. */ + @WorkerThread + public fun update(repo: Repository): IndexUpdateResult = + catchExceptions(repo) { + updateRepo(repo).also { result -> + // reset repo errors if repo updated fine again, but is still unchanged + if (repo.errorCount > 0 && result is IndexUpdateResult.Unchanged) { + repoDao.resetRepoUpdateError(repo.repoId) } + } } - @WorkerThread - protected abstract fun updateRepo(repo: Repository): IndexUpdateResult + @WorkerThread protected abstract fun updateRepo(repo: Repository): IndexUpdateResult - @WorkerThread - private fun catchExceptions( - repo: Repository, - block: () -> IndexUpdateResult, - ): IndexUpdateResult { - return try { - block() - } catch (e: NotFoundException) { - onError(repo.repoId, e) - IndexUpdateResult.NotFound - } catch (e: Exception) { - onError(repo.repoId, e) - IndexUpdateResult.Error(e) - } + @WorkerThread + private fun catchExceptions(repo: Repository, block: () -> IndexUpdateResult): IndexUpdateResult { + return try { + block() + } catch (e: NotFoundException) { + onError(repo.repoId, e) + IndexUpdateResult.NotFound + } catch (e: Exception) { + onError(repo.repoId, e) + IndexUpdateResult.Error(e) } + } - @WorkerThread - private fun onError(repoId: Long, e: Exception) { - val msg = buildString { - append(e.localizedMessage ?: e.message ?: e.javaClass.simpleName) - e.cause?.let { cause -> - append("\n") - append(cause.localizedMessage ?: cause.message ?: cause.javaClass.simpleName) - } - } - repoDao.trackRepoUpdateError(repoId, msg) + @WorkerThread + private fun onError(repoId: Long, e: Exception) { + val msg = buildString { + append(e.localizedMessage ?: e.message ?: e.javaClass.simpleName) + e.cause?.let { cause -> + append("\n") + append(cause.localizedMessage ?: cause.message ?: cause.javaClass.simpleName) + } } + repoDao.trackRepoUpdateError(repoId, msg) + } } -internal fun Downloader.setIndexUpdateListener( - listener: IndexUpdateListener?, - repo: Repository, -) { - if (listener != null) setListener { bytesRead, totalBytes -> - listener.onDownloadProgress(repo, bytesRead, totalBytes) +internal fun Downloader.setIndexUpdateListener(listener: IndexUpdateListener?, repo: Repository) { + if (listener != null) + setListener { bytesRead, totalBytes -> + listener.onDownloadProgress(repo, bytesRead, totalBytes) } } diff --git a/libs/database/src/main/java/org/fdroid/index/RepoManager.kt b/libs/database/src/main/java/org/fdroid/index/RepoManager.kt index 5a8cdf2da..72bf11bde 100644 --- a/libs/database/src/main/java/org/fdroid/index/RepoManager.kt +++ b/libs/database/src/main/java/org/fdroid/index/RepoManager.kt @@ -7,6 +7,10 @@ import androidx.annotation.UiThread import androidx.annotation.WorkerThread import androidx.lifecycle.LiveData import androidx.lifecycle.asLiveData +import java.io.File +import java.net.Proxy +import java.util.concurrent.CountDownLatch +import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -30,296 +34,282 @@ import org.fdroid.download.Mirror import org.fdroid.repo.AddRepoState import org.fdroid.repo.RepoAdder import org.fdroid.repo.RepoUriGetter -import java.io.File -import java.net.Proxy -import java.util.concurrent.CountDownLatch -import kotlin.coroutines.CoroutineContext @OptIn(DelicateCoroutinesApi::class) -public class RepoManager @JvmOverloads constructor( - context: Context, - private val db: FDroidDatabase, - downloaderFactory: DownloaderFactory, - httpManager: HttpManager, - repoUriBuilder: RepoUriBuilder = defaultRepoUriBuilder, - compatibilityChecker: CompatibilityChecker = CompatibilityCheckerImpl( - packageManager = context.packageManager, - forceTouchApps = false, - ), - private val coroutineContext: CoroutineContext = Dispatchers.IO, +public class RepoManager +@JvmOverloads +constructor( + context: Context, + private val db: FDroidDatabase, + downloaderFactory: DownloaderFactory, + httpManager: HttpManager, + repoUriBuilder: RepoUriBuilder = defaultRepoUriBuilder, + compatibilityChecker: CompatibilityChecker = + CompatibilityCheckerImpl(packageManager = context.packageManager, forceTouchApps = false), + private val coroutineContext: CoroutineContext = Dispatchers.IO, ) { - private val repositoryDao = db.getRepositoryDao() as RepositoryDaoInt - private val appPrefsDao = db.getAppPrefsDao() as AppPrefsDaoInt - private val tempFileProvider = TempFileProvider { sha256 -> - if (sha256 == null) { - File.createTempFile("dl-", "", context.cacheDir) - } else { - File(context.cacheDir, sha256).apply { createNewFile() } - } + private val repositoryDao = db.getRepositoryDao() as RepositoryDaoInt + private val appPrefsDao = db.getAppPrefsDao() as AppPrefsDaoInt + private val tempFileProvider = TempFileProvider { sha256 -> + if (sha256 == null) { + File.createTempFile("dl-", "", context.cacheDir) + } else { + File(context.cacheDir, sha256).apply { createNewFile() } } - private val repoAdder = RepoAdder( - context = context, - db = db, - tempFileProvider = tempFileProvider, - downloaderFactory = downloaderFactory, - httpManager = httpManager, - compatibilityChecker = compatibilityChecker, - repoUriBuilder = repoUriBuilder, - coroutineContext = coroutineContext, + } + private val repoAdder = + RepoAdder( + context = context, + db = db, + tempFileProvider = tempFileProvider, + downloaderFactory = downloaderFactory, + httpManager = httpManager, + compatibilityChecker = compatibilityChecker, + repoUriBuilder = repoUriBuilder, + coroutineContext = coroutineContext, ) - private val _repositoriesState: MutableStateFlow> = - MutableStateFlow(emptyList()) - public val repositoriesState: StateFlow> = _repositoriesState.asStateFlow() - public val liveRepositories: LiveData> = _repositoriesState.asLiveData() + private val _repositoriesState: MutableStateFlow> = MutableStateFlow(emptyList()) + public val repositoriesState: StateFlow> = _repositoriesState.asStateFlow() + public val liveRepositories: LiveData> = _repositoriesState.asLiveData() - public val addRepoState: StateFlow = repoAdder.addRepoState.asStateFlow() - public val liveAddRepoState: LiveData = repoAdder.addRepoState.asLiveData() + public val addRepoState: StateFlow = repoAdder.addRepoState.asStateFlow() + public val liveAddRepoState: LiveData = repoAdder.addRepoState.asLiveData() - /** - * Used internally as a mechanism to wait until repositories are loaded from the DB. - * This happens quite fast and the load is triggered at construction time. - * However, in some cases like when the app got killed and restarted by the OS, - * code could try to access the [repositoriesState] before they've loaded. - */ - private val repoCountDownLatch = CountDownLatch(1) + /** + * Used internally as a mechanism to wait until repositories are loaded from the DB. This happens + * quite fast and the load is triggered at construction time. However, in some cases like when the + * app got killed and restarted by the OS, code could try to access the [repositoriesState] before + * they've loaded. + */ + private val repoCountDownLatch = CountDownLatch(1) - init { - // we need to load the repositories first off the UiThread, so it doesn't deadlock - GlobalScope.launch(coroutineContext) { - _repositoriesState.update { repositoryDao.getRepositories() } - repoCountDownLatch.countDown() - withContext(Dispatchers.Main) { - // keep observing the repos from the DB - // and update internal cache when changes happen - db.getRepositoryDao().getLiveRepositories().observeForever { repositories -> - _repositoriesState.update { repositories } - } - } + init { + // we need to load the repositories first off the UiThread, so it doesn't deadlock + GlobalScope.launch(coroutineContext) { + _repositoriesState.update { repositoryDao.getRepositories() } + repoCountDownLatch.countDown() + withContext(Dispatchers.Main) { + // keep observing the repos from the DB + // and update internal cache when changes happen + db.getRepositoryDao().getLiveRepositories().observeForever { repositories -> + _repositoriesState.update { repositories } + } + } + } + } + + /** + * This method will block the current thread in the rare case that the repositories have not been + * loaded from the DB. + */ + public fun getRepository(repoId: Long): Repository? { + repoCountDownLatch.await() + return repositoriesState.value.firstOrNull { repo -> repo.repoId == repoId } + } + + /** + * This method will block the current thread in the rare case that the repositories have not been + * loaded from the DB. + */ + public fun getRepositories(): List { + repoCountDownLatch.await() + return repositoriesState.value + } + + /** + * Enables or disables the repository with the given [repoId] and also the corresponding archive + * repo if existing. Data from disabled repositories is ignored in many queries. + */ + @WorkerThread + public fun setRepositoryEnabled(repoId: Long, enabled: Boolean) { + if (enabled) { + repositoryDao.setRepositoryEnabled(repoId, true) + } else { + db.runInTransaction { + // find and also disable archive repo if existing + val repository = repositoryDao.getRepository(repoId) ?: return@runInTransaction + val archiveRepoId = repositoryDao.getArchiveRepoId(repository.certificate) + if (archiveRepoId != null) { + repositoryDao.setRepositoryEnabled(archiveRepoId, false) + } + // disable main repo + repositoryDao.setRepositoryEnabled(repoId, false) + } + } + } + + /** + * Removes a Repository (and also the corresponding archive repo if existing) with the given + * repoId with all associated data from the database. + */ + @WorkerThread + public fun deleteRepository(repoId: Long) { + // while this will get updated automatically, getting the update may be slow, + // so to speed up the UI, we emit the state change right away (deletion is unlikely to fail) + _repositoriesState.update { + it.filter { repo -> + // keep only repos that are not the deleted one + repo.repoId != repoId + } + } + db.runInTransaction { + // find and remove archive repo if existing + val repository = repositoryDao.getRepository(repoId) ?: return@runInTransaction + val archiveRepoId = repositoryDao.getArchiveRepoId(repository.certificate) + if (archiveRepoId != null) repositoryDao.deleteRepository(archiveRepoId) + // delete main repo + repositoryDao.deleteRepository(repoId) + } + } + + /** + * Fetches a preview of the repository at the given [url] with the intention of possibly adding it + * to the database. Progress can be observed via [addRepoState] or [liveAddRepoState]. + */ + @AnyThread + @JvmOverloads + public fun fetchRepositoryPreview(url: String, proxy: Proxy? = null) { + repoAdder.fetchRepository(url, proxy) + } + + /** + * When [addRepoState] is in [org.fdroid.repo.Fetching.done], you can call this to actually add + * the repo to the DB. + * + * @throws IllegalStateException if [addRepoState] is currently in any other state. + */ + @AnyThread + public fun addFetchedRepository() { + GlobalScope.launch(coroutineContext) { + val addedRepo = repoAdder.addFetchedRepository() + // if repo was added, update state right away, so it becomes available asap + if (addedRepo != null) + withContext(Dispatchers.Main) { + _repositoriesState.update { it.toMutableList().apply { add(addedRepo) } } } } + } - /** - * This method will block the current thread in the rare case - * that the repositories have not been loaded from the DB. - */ - public fun getRepository(repoId: Long): Repository? { - repoCountDownLatch.await() - return repositoriesState.value.firstOrNull { repo -> repo.repoId == repoId } + /** + * Aborts the process of fetching a [Repository] preview, e.g. when the user leaves the UI flow or + * wants to cancel the preview process. Note that this won't work after [addFetchedRepository] has + * already been called. + */ + @UiThread + public fun abortAddingRepository() { + repoAdder.abortAddingRepo() + } + + @AnyThread + public fun setPreferredRepoId(packageName: String, repoId: Long): Job { + return GlobalScope.launch(coroutineContext) { + db.runInTransaction { + val appPrefs = appPrefsDao.getAppPrefsOrNull(packageName) ?: AppPrefs(packageName) + appPrefsDao.update(appPrefs.copy(preferredRepoId = repoId)) + } } + } - /** - * This method will block the current thread in the rare case - * that the repositories have not been loaded from the DB. - */ - public fun getRepositories(): List { - repoCountDownLatch.await() - return repositoriesState.value + /** + * Changes repository priorities that determine the order they are returned from [getRepositories] + * and the preferred repositories. The lower a repository is in the list, the lower is its + * priority. If an app is in more than one repository, by default, the repo higher in the list + * will provide metadata and updates. Only setting [AppPrefs.preferredRepoId] overrides this. + * + * @param repoToReorder this repository will change its position in the list. + * @param repoTarget the repository in which place the [repoToReorder] shall be moved. If our list + * is [ A B C D ] and we call reorderRepositories(B, D), then the new list will be [ A C D B ]. + * @throws IllegalArgumentException if one of the repos is an archive repo. Those are expected to + * be tied to their main repo one down the list and are moved automatically when their main repo + * moves. + */ + @AnyThread + public fun reorderRepositories(repoToReorder: Repository, repoTarget: Repository) { + GlobalScope.launch(coroutineContext) { + repositoryDao.reorderRepositories(repoToReorder, repoTarget) } + } - /** - * Enables or disables the repository with the given [repoId] - * and also the corresponding archive repo if existing. - * Data from disabled repositories is ignored in many queries. - */ - @WorkerThread - public fun setRepositoryEnabled(repoId: Long, enabled: Boolean) { - if (enabled) { - repositoryDao.setRepositoryEnabled(repoId, true) - } else { - db.runInTransaction { - // find and also disable archive repo if existing - val repository = repositoryDao.getRepository(repoId) ?: return@runInTransaction - val archiveRepoId = repositoryDao.getArchiveRepoId(repository.certificate) - if (archiveRepoId != null) { - repositoryDao.setRepositoryEnabled(archiveRepoId, false) - } - // disable main repo - repositoryDao.setRepositoryEnabled(repoId, false) - } + /** + * Enables or disabled the archive repo for the given [repository]. + * + * Note that this can throw all kinds of exceptions, especially when the given [repository] does + * not have a (working) archive repository. You should catch those and update your UI accordingly. + * + * @return The ID of the archive repository, if one exists, null otherwise. + */ + @WorkerThread + public suspend fun setArchiveRepoEnabled( + repository: Repository, + enabled: Boolean, + proxy: Proxy? = null, + ): Long? { + val cert = repository.certificate + var archiveRepoId = repositoryDao.getArchiveRepoId(cert) + if (enabled) { + if (archiveRepoId == null) { + archiveRepoId = repoAdder.addArchiveRepo(repository, proxy) + } else { + repositoryDao.setRepositoryEnabled(archiveRepoId, true) + } + } else if (archiveRepoId != null) { + repositoryDao.setRepositoryEnabled(archiveRepoId, false) + } + return archiveRepoId + } + + /** Returns true if the given [uri] belongs to a swap repo. */ + @UiThread + public fun isSwapUri(uri: Uri?): Boolean { + return uri != null && RepoUriGetter.isSwapUri(uri) + } + + @WorkerThread + public fun updateUsernameAndPassword(repoId: Long, username: String?, password: String?) { + repositoryDao.updateUsernameAndPassword(repoId, username, password) + } + + @WorkerThread + public fun setMirrorEnabled(repoId: Long, mirror: Mirror, enabled: Boolean) { + val repo = repositoryDao.getRepository(repoId) ?: return + + // Run as transaction to avoid race conditions between getting the mirrors and setting them + db.runInTransaction { + val disabled = repo.disabledMirrors.toMutableList() + + if (enabled) { + if (disabled.contains(mirror.baseUrl)) { + disabled.remove(mirror.baseUrl) + repositoryDao.updateDisabledMirrors(repoId, disabled) } - } + } else { + if (!disabled.contains(mirror.baseUrl)) { + disabled.add(mirror.baseUrl) - /** - * Removes a Repository (and also the corresponding archive repo if existing) - * with the given repoId with all associated data from the database. - */ - @WorkerThread - public fun deleteRepository(repoId: Long) { - // while this will get updated automatically, getting the update may be slow, - // so to speed up the UI, we emit the state change right away (deletion is unlikely to fail) - _repositoriesState.update { - it.filter { repo -> - // keep only repos that are not the deleted one - repo.repoId != repoId - } - } - db.runInTransaction { - // find and remove archive repo if existing - val repository = repositoryDao.getRepository(repoId) ?: return@runInTransaction - val archiveRepoId = repositoryDao.getArchiveRepoId(repository.certificate) - if (archiveRepoId != null) repositoryDao.deleteRepository(archiveRepoId) - // delete main repo - repositoryDao.deleteRepository(repoId) + if (disabled.size == repo.getAllMirrors().size) { + // if all mirrors are disabled, re-enable canonical repo as mirror + disabled.remove(repo.address) + } + + repositoryDao.updateDisabledMirrors(repoId, disabled) } + } } + } - /** - * Fetches a preview of the repository at the given [url] - * with the intention of possibly adding it to the database. - * Progress can be observed via [addRepoState] or [liveAddRepoState]. - */ - @AnyThread - @JvmOverloads - public fun fetchRepositoryPreview(url: String, proxy: Proxy? = null) { - repoAdder.fetchRepository(url, proxy) - } - - /** - * When [addRepoState] is in [org.fdroid.repo.Fetching.done], - * you can call this to actually add the repo to the DB. - * @throws IllegalStateException if [addRepoState] is currently in any other state. - */ - @AnyThread - public fun addFetchedRepository() { - GlobalScope.launch(coroutineContext) { - val addedRepo = repoAdder.addFetchedRepository() - // if repo was added, update state right away, so it becomes available asap - if (addedRepo != null) withContext(Dispatchers.Main) { - _repositoriesState.update { - it.toMutableList().apply { - add(addedRepo) - } - } - } - } - } - - /** - * Aborts the process of fetching a [Repository] preview, - * e.g. when the user leaves the UI flow or wants to cancel the preview process. - * Note that this won't work after [addFetchedRepository] has already been called. - */ - @UiThread - public fun abortAddingRepository() { - repoAdder.abortAddingRepo() - } - - @AnyThread - public fun setPreferredRepoId(packageName: String, repoId: Long): Job { - return GlobalScope.launch(coroutineContext) { - db.runInTransaction { - val appPrefs = appPrefsDao.getAppPrefsOrNull(packageName) ?: AppPrefs(packageName) - appPrefsDao.update(appPrefs.copy(preferredRepoId = repoId)) - } - } - } - - /** - * Changes repository priorities that determine the order - * they are returned from [getRepositories] and the preferred repositories. - * The lower a repository is in the list, the lower is its priority. - * If an app is in more than one repository, by default, - * the repo higher in the list will provide metadata and updates. - * Only setting [AppPrefs.preferredRepoId] overrides this. - * - * @param repoToReorder this repository will change its position in the list. - * @param repoTarget the repository in which place the [repoToReorder] shall be moved. - * If our list is [ A B C D ] and we call reorderRepositories(B, D), - * then the new list will be [ A C D B ]. - * @throws IllegalArgumentException if one of the repos is an archive repo. - * Those are expected to be tied to their main repo one down the list - * and are moved automatically when their main repo moves. - */ - @AnyThread - public fun reorderRepositories(repoToReorder: Repository, repoTarget: Repository) { - GlobalScope.launch(coroutineContext) { - repositoryDao.reorderRepositories(repoToReorder, repoTarget) - } - } - - /** - * Enables or disabled the archive repo for the given [repository]. - * - * Note that this can throw all kinds of exceptions, - * especially when the given [repository] does not have a (working) archive repository. - * You should catch those and update your UI accordingly. - * - * @return The ID of the archive repository, if one exists, null otherwise. - */ - @WorkerThread - public suspend fun setArchiveRepoEnabled( - repository: Repository, - enabled: Boolean, - proxy: Proxy? = null, - ): Long? { - val cert = repository.certificate - var archiveRepoId = repositoryDao.getArchiveRepoId(cert) - if (enabled) { - if (archiveRepoId == null) { - archiveRepoId = repoAdder.addArchiveRepo(repository, proxy) - } else { - repositoryDao.setRepositoryEnabled(archiveRepoId, true) - } - } else if (archiveRepoId != null) { - repositoryDao.setRepositoryEnabled(archiveRepoId, false) - } - return archiveRepoId - } - - /** - * Returns true if the given [uri] belongs to a swap repo. - */ - @UiThread - public fun isSwapUri(uri: Uri?): Boolean { - return uri != null && RepoUriGetter.isSwapUri(uri) - } - - @WorkerThread - public fun updateUsernameAndPassword(repoId: Long, username: String?, password: String?) { - repositoryDao.updateUsernameAndPassword(repoId, username, password) - } - - @WorkerThread - public fun setMirrorEnabled(repoId: Long, mirror: Mirror, enabled: Boolean) { - val repo = repositoryDao.getRepository(repoId) ?: return - - // Run as transaction to avoid race conditions between getting the mirrors and setting them - db.runInTransaction { - val disabled = repo.disabledMirrors.toMutableList() - - if (enabled) { - if (disabled.contains(mirror.baseUrl)) { - disabled.remove(mirror.baseUrl) - repositoryDao.updateDisabledMirrors(repoId, disabled) - } - } else { - if (!disabled.contains(mirror.baseUrl)) { - disabled.add(mirror.baseUrl) - - if (disabled.size == repo.getAllMirrors().size) { - // if all mirrors are disabled, re-enable canonical repo as mirror - disabled.remove(repo.address) - } - - repositoryDao.updateDisabledMirrors(repoId, disabled) - } - } - } - } - - @WorkerThread - public fun deleteUserMirror(repoId: Long, mirror: Mirror) { - val repo = repositoryDao.getRepository(repoId) ?: return - - // Run as transaction to avoid race conditions between getting the mirrors and setting them - db.runInTransaction { - val user = repo.userMirrors.toMutableList() - - if (user.contains(mirror.baseUrl)) { - user.remove(mirror.baseUrl) - repositoryDao.updateUserMirrors(repoId, user) - } - } + @WorkerThread + public fun deleteUserMirror(repoId: Long, mirror: Mirror) { + val repo = repositoryDao.getRepository(repoId) ?: return + + // Run as transaction to avoid race conditions between getting the mirrors and setting them + db.runInTransaction { + val user = repo.userMirrors.toMutableList() + + if (user.contains(mirror.baseUrl)) { + user.remove(mirror.baseUrl) + repositoryDao.updateUserMirrors(repoId, user) + } } + } } diff --git a/libs/database/src/main/java/org/fdroid/index/RepoUpdater.kt b/libs/database/src/main/java/org/fdroid/index/RepoUpdater.kt index 858f4493d..49b4ee06f 100644 --- a/libs/database/src/main/java/org/fdroid/index/RepoUpdater.kt +++ b/libs/database/src/main/java/org/fdroid/index/RepoUpdater.kt @@ -1,5 +1,7 @@ package org.fdroid.index +import java.io.File +import java.io.FileNotFoundException import mu.KotlinLogging import org.fdroid.CompatibilityChecker import org.fdroid.database.FDroidDatabase @@ -7,75 +9,68 @@ import org.fdroid.database.Repository import org.fdroid.download.DownloaderFactory import org.fdroid.index.v1.IndexV1Updater import org.fdroid.index.v2.IndexV2Updater -import java.io.File -import java.io.FileNotFoundException /** * Updates a [Repository] with a downloaded index, detects changes and chooses the right * [IndexUpdater] automatically. */ public class RepoUpdater( - tempDir: File, - db: FDroidDatabase, - downloaderFactory: DownloaderFactory, - repoUriBuilder: RepoUriBuilder = defaultRepoUriBuilder, - compatibilityChecker: CompatibilityChecker, - listener: IndexUpdateListener? = null, + tempDir: File, + db: FDroidDatabase, + downloaderFactory: DownloaderFactory, + repoUriBuilder: RepoUriBuilder = defaultRepoUriBuilder, + compatibilityChecker: CompatibilityChecker, + listener: IndexUpdateListener? = null, ) { - private val log = KotlinLogging.logger {} - private val tempFileProvider = TempFileProvider { sha256 -> - if (sha256 == null) { - File.createTempFile("dl-", "", tempDir) - } else { - File(tempDir, sha256).apply { createNewFile() } - } + private val log = KotlinLogging.logger {} + private val tempFileProvider = TempFileProvider { sha256 -> + if (sha256 == null) { + File.createTempFile("dl-", "", tempDir) + } else { + File(tempDir, sha256).apply { createNewFile() } } + } - /** - * A list of [IndexUpdater]s to try, sorted by newest first. - */ - private val indexUpdater = listOf( - IndexV2Updater( - database = db, - tempFileProvider = tempFileProvider, - downloaderFactory = downloaderFactory, - repoUriBuilder = repoUriBuilder, - compatibilityChecker = compatibilityChecker, - listener = listener, - ), - IndexV1Updater( - database = db, - tempFileProvider = tempFileProvider, - downloaderFactory = downloaderFactory, - repoUriBuilder = repoUriBuilder, - compatibilityChecker = compatibilityChecker, - listener = listener, - ), + /** A list of [IndexUpdater]s to try, sorted by newest first. */ + private val indexUpdater = + listOf( + IndexV2Updater( + database = db, + tempFileProvider = tempFileProvider, + downloaderFactory = downloaderFactory, + repoUriBuilder = repoUriBuilder, + compatibilityChecker = compatibilityChecker, + listener = listener, + ), + IndexV1Updater( + database = db, + tempFileProvider = tempFileProvider, + downloaderFactory = downloaderFactory, + repoUriBuilder = repoUriBuilder, + compatibilityChecker = compatibilityChecker, + listener = listener, + ), ) - /** - * Updates the given [repo]. - */ - public fun update(repo: Repository): IndexUpdateResult = update(repo) { updater -> - updater.update(repo) - } + /** Updates the given [repo]. */ + public fun update(repo: Repository): IndexUpdateResult = + update(repo) { updater -> updater.update(repo) } - private fun update( - repo: Repository, - doUpdate: (IndexUpdater) -> IndexUpdateResult, - ): IndexUpdateResult { - indexUpdater.forEach { updater -> - // don't downgrade to older updaters if repo used new format already - val repoFormatVersion = repo.formatVersion - if (repoFormatVersion != null && repoFormatVersion > updater.formatVersion) { - val updaterVersion = updater.formatVersion.name - log.warn { "Not using updater $updaterVersion for repo ${repo.address}" } - return@forEach - } - val result = doUpdate(updater) - if (result != IndexUpdateResult.NotFound) return result - } - return IndexUpdateResult.Error(FileNotFoundException("No files found for ${repo.address}")) + private fun update( + repo: Repository, + doUpdate: (IndexUpdater) -> IndexUpdateResult, + ): IndexUpdateResult { + indexUpdater.forEach { updater -> + // don't downgrade to older updaters if repo used new format already + val repoFormatVersion = repo.formatVersion + if (repoFormatVersion != null && repoFormatVersion > updater.formatVersion) { + val updaterVersion = updater.formatVersion.name + log.warn { "Not using updater $updaterVersion for repo ${repo.address}" } + return@forEach + } + val result = doUpdate(updater) + if (result != IndexUpdateResult.NotFound) return result } - + return IndexUpdateResult.Error(FileNotFoundException("No files found for ${repo.address}")) + } } diff --git a/libs/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt b/libs/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt index a57dc0c36..327be836e 100644 --- a/libs/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt +++ b/libs/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt @@ -24,64 +24,67 @@ import org.fdroid.index.v2.FileV2 public const val SIGNED_FILE_NAME: String = "index-v1.jar" public class IndexV1Updater( - database: FDroidDatabase, - private val tempFileProvider: TempFileProvider, - private val downloaderFactory: DownloaderFactory, - private val repoUriBuilder: RepoUriBuilder = defaultRepoUriBuilder, - private val compatibilityChecker: CompatibilityChecker, - private val listener: IndexUpdateListener? = null, + database: FDroidDatabase, + private val tempFileProvider: TempFileProvider, + private val downloaderFactory: DownloaderFactory, + private val repoUriBuilder: RepoUriBuilder = defaultRepoUriBuilder, + private val compatibilityChecker: CompatibilityChecker, + private val listener: IndexUpdateListener? = null, ) : IndexUpdater() { - private val log = KotlinLogging.logger {} - public override val formatVersion: IndexFormatVersion = ONE - private val db: FDroidDatabaseInt = database as FDroidDatabaseInt - override val repoDao: RepositoryDaoInt = db.getRepositoryDao() + private val log = KotlinLogging.logger {} + public override val formatVersion: IndexFormatVersion = ONE + private val db: FDroidDatabaseInt = database as FDroidDatabaseInt + override val repoDao: RepositoryDaoInt = db.getRepositoryDao() - override fun updateRepo(repo: Repository): IndexUpdateResult { - // Normally, we shouldn't allow repository downgrades and assert the condition below. - // However, F-Droid is concerned that late v2 bugs will require users to downgrade to v1, - // as it happened already with the migration from v0 to v1. - if (repo.formatVersion != null && repo.formatVersion != ONE) { - log.error { "Format downgrade for ${repo.address}" } - } - val file = tempFileProvider.createTempFile(null) - val downloader = downloaderFactory.createWithTryFirstMirror( - repo = repo, - uri = repoUriBuilder.getUri(repo, SIGNED_FILE_NAME), - indexFile = FileV2.fromPath("/$SIGNED_FILE_NAME"), - destFile = file, - ).apply { - cacheTag = repo.lastETag - setIndexUpdateListener(listener, repo) - } - try { - downloader.download() - if (!downloader.hasChanged()) return IndexUpdateResult.Unchanged - val eTag = downloader.cacheTag - - val verifier = IndexV1Verifier(file, repo.certificate, null) - db.runInTransaction { - verifier.getStreamAndVerify { inputStream -> - listener?.onUpdateProgress(repo, 0, 0) - val streamReceiver = DbV1StreamReceiver(db, repo.repoId, compatibilityChecker) - val streamProcessor = IndexV1StreamProcessor(streamReceiver, repo.timestamp) - streamProcessor.process(inputStream) - } - // update RepositoryPreferences with timestamp and ETag (for v1) - val updatedPrefs = repo.preferences.copy( - lastUpdated = System.currentTimeMillis(), - lastETag = eTag, - errorCount = 0, - lastError = null, - ) - repoDao.updateRepositoryPreferences(updatedPrefs) - } - } catch (e: OldIndexException) { - if (e.isSameTimestamp) return IndexUpdateResult.Unchanged - else throw e - } finally { - file.delete() - } - return IndexUpdateResult.Processed + override fun updateRepo(repo: Repository): IndexUpdateResult { + // Normally, we shouldn't allow repository downgrades and assert the condition below. + // However, F-Droid is concerned that late v2 bugs will require users to downgrade to v1, + // as it happened already with the migration from v0 to v1. + if (repo.formatVersion != null && repo.formatVersion != ONE) { + log.error { "Format downgrade for ${repo.address}" } } + val file = tempFileProvider.createTempFile(null) + val downloader = + downloaderFactory + .createWithTryFirstMirror( + repo = repo, + uri = repoUriBuilder.getUri(repo, SIGNED_FILE_NAME), + indexFile = FileV2.fromPath("/$SIGNED_FILE_NAME"), + destFile = file, + ) + .apply { + cacheTag = repo.lastETag + setIndexUpdateListener(listener, repo) + } + try { + downloader.download() + if (!downloader.hasChanged()) return IndexUpdateResult.Unchanged + val eTag = downloader.cacheTag + + val verifier = IndexV1Verifier(file, repo.certificate, null) + db.runInTransaction { + verifier.getStreamAndVerify { inputStream -> + listener?.onUpdateProgress(repo, 0, 0) + val streamReceiver = DbV1StreamReceiver(db, repo.repoId, compatibilityChecker) + val streamProcessor = IndexV1StreamProcessor(streamReceiver, repo.timestamp) + streamProcessor.process(inputStream) + } + // update RepositoryPreferences with timestamp and ETag (for v1) + val updatedPrefs = + repo.preferences.copy( + lastUpdated = System.currentTimeMillis(), + lastETag = eTag, + errorCount = 0, + lastError = null, + ) + repoDao.updateRepositoryPreferences(updatedPrefs) + } + } catch (e: OldIndexException) { + if (e.isSameTimestamp) return IndexUpdateResult.Unchanged else throw e + } finally { + file.delete() + } + return IndexUpdateResult.Processed + } } diff --git a/libs/database/src/main/java/org/fdroid/index/v2/IndexV2Updater.kt b/libs/database/src/main/java/org/fdroid/index/v2/IndexV2Updater.kt index af8b28056..5cd5bcd13 100644 --- a/libs/database/src/main/java/org/fdroid/index/v2/IndexV2Updater.kt +++ b/libs/database/src/main/java/org/fdroid/index/v2/IndexV2Updater.kt @@ -24,105 +24,111 @@ import org.fdroid.index.setIndexUpdateListener public const val SIGNED_FILE_NAME: String = "entry.jar" public class IndexV2Updater( - database: FDroidDatabase, - private val tempFileProvider: TempFileProvider, - private val downloaderFactory: DownloaderFactory, - private val repoUriBuilder: RepoUriBuilder = defaultRepoUriBuilder, - private val compatibilityChecker: CompatibilityChecker, - private val listener: IndexUpdateListener? = null, + database: FDroidDatabase, + private val tempFileProvider: TempFileProvider, + private val downloaderFactory: DownloaderFactory, + private val repoUriBuilder: RepoUriBuilder = defaultRepoUriBuilder, + private val compatibilityChecker: CompatibilityChecker, + private val listener: IndexUpdateListener? = null, ) : IndexUpdater() { - public override val formatVersion: IndexFormatVersion = TWO - private val db: FDroidDatabaseInt = database as FDroidDatabaseInt - override val repoDao: RepositoryDaoInt = db.getRepositoryDao() + public override val formatVersion: IndexFormatVersion = TWO + private val db: FDroidDatabaseInt = database as FDroidDatabaseInt + override val repoDao: RepositoryDaoInt = db.getRepositoryDao() - override fun updateRepo(repo: Repository): IndexUpdateResult { - val (_, entry) = getCertAndEntry(repo, repo.certificate) - // don't process repos that we already did process in the past - if (entry.timestamp <= repo.timestamp) return IndexUpdateResult.Unchanged - // get diff, if available - val diff = entry.getDiff(repo.timestamp) - return if (diff == null || repo.formatVersion == ONE) { - // no diff found (or this is upgrade from v1 repo), so do full index update - val streamReceiver = DbV2StreamReceiver(db, repo.repoId, compatibilityChecker) - val streamProcessor = IndexV2FullStreamProcessor(streamReceiver) - processStream(repo, entry.index, entry.version, streamProcessor) - } else { - // use available diff - val streamReceiver = DbV2DiffStreamReceiver(db, repo.repoId, compatibilityChecker) - val streamProcessor = IndexV2DiffStreamProcessor(streamReceiver) - processStream(repo, diff, entry.version, streamProcessor) - } + override fun updateRepo(repo: Repository): IndexUpdateResult { + val (_, entry) = getCertAndEntry(repo, repo.certificate) + // don't process repos that we already did process in the past + if (entry.timestamp <= repo.timestamp) return IndexUpdateResult.Unchanged + // get diff, if available + val diff = entry.getDiff(repo.timestamp) + return if (diff == null || repo.formatVersion == ONE) { + // no diff found (or this is upgrade from v1 repo), so do full index update + val streamReceiver = DbV2StreamReceiver(db, repo.repoId, compatibilityChecker) + val streamProcessor = IndexV2FullStreamProcessor(streamReceiver) + processStream(repo, entry.index, entry.version, streamProcessor) + } else { + // use available diff + val streamReceiver = DbV2DiffStreamReceiver(db, repo.repoId, compatibilityChecker) + val streamProcessor = IndexV2DiffStreamProcessor(streamReceiver) + processStream(repo, diff, entry.version, streamProcessor) } + } - private fun getCertAndEntry(repo: Repository, certificate: String): Pair { - val file = tempFileProvider.createTempFile(null) - val downloader = downloaderFactory.createWithTryFirstMirror( - repo = repo, - uri = repoUriBuilder.getUri(repo, SIGNED_FILE_NAME), - indexFile = FileV2.fromPath("/$SIGNED_FILE_NAME"), - destFile = file, - ).apply { - if (listener != null) setListener { bytesRead, _ -> - // don't report a total for entry.jar, - // because we'll download another file afterwards - // and progress reporting would jump to 100% two times. - listener.onDownloadProgress(repo, bytesRead, -1) + private fun getCertAndEntry(repo: Repository, certificate: String): Pair { + val file = tempFileProvider.createTempFile(null) + val downloader = + downloaderFactory + .createWithTryFirstMirror( + repo = repo, + uri = repoUriBuilder.getUri(repo, SIGNED_FILE_NAME), + indexFile = FileV2.fromPath("/$SIGNED_FILE_NAME"), + destFile = file, + ) + .apply { + if (listener != null) + setListener { bytesRead, _ -> + // don't report a total for entry.jar, + // because we'll download another file afterwards + // and progress reporting would jump to 100% two times. + listener.onDownloadProgress(repo, bytesRead, -1) } } - try { - downloader.download() - val verifier = EntryVerifier(file, certificate, null) - return verifier.getStreamAndVerify { inputStream -> - IndexParser.parseEntry(inputStream) - } - } finally { - file.delete() - } + try { + downloader.download() + val verifier = EntryVerifier(file, certificate, null) + return verifier.getStreamAndVerify { inputStream -> IndexParser.parseEntry(inputStream) } + } finally { + file.delete() } + } - private fun processStream( - repo: Repository, - entryFile: EntryFileV2, - repoVersion: Long, - streamProcessor: IndexV2StreamProcessor, - ): IndexUpdateResult { - val file = tempFileProvider.createTempFile(entryFile.sha256) - val downloader = downloaderFactory.createWithTryFirstMirror( - repo = repo, - uri = repoUriBuilder.getUri(repo, entryFile.name.trimStart('/')), - indexFile = entryFile, - destFile = file, - ).apply { - setIndexUpdateListener(listener, repo) + private fun processStream( + repo: Repository, + entryFile: EntryFileV2, + repoVersion: Long, + streamProcessor: IndexV2StreamProcessor, + ): IndexUpdateResult { + val file = tempFileProvider.createTempFile(entryFile.sha256) + val downloader = + downloaderFactory + .createWithTryFirstMirror( + repo = repo, + uri = repoUriBuilder.getUri(repo, entryFile.name.trimStart('/')), + indexFile = entryFile, + destFile = file, + ) + .apply { setIndexUpdateListener(listener, repo) } + try { + downloader.download() + file.inputStream().use { inputStream -> + db.runInTransaction { + // ensure somebody else hasn't updated the repo in the meantime + val currentTimestamp = repoDao.getRepository(repo.repoId)?.timestamp + if (currentTimestamp != repo.timestamp) + throw ConcurrentModificationException( + "Repo timestamp expected ${repo.timestamp}, but was $currentTimestamp" + ) + // still the expected timestamp, so go on processing... + streamProcessor.process(repoVersion, inputStream) { i -> + listener?.onUpdateProgress(repo, i, entryFile.numPackages) + } + // update RepositoryPreferences with timestamp + val repoPrefs = + repoDao.getRepositoryPreferences(repo.repoId) + ?: error("No repo prefs for ${repo.repoId}") + val updatedPrefs = + repoPrefs.copy( + lastUpdated = System.currentTimeMillis(), + errorCount = 0, + lastError = null, + ) + repoDao.updateRepositoryPreferences(updatedPrefs) } - try { - downloader.download() - file.inputStream().use { inputStream -> - db.runInTransaction { - // ensure somebody else hasn't updated the repo in the meantime - val currentTimestamp = repoDao.getRepository(repo.repoId)?.timestamp - if (currentTimestamp != repo.timestamp) throw ConcurrentModificationException( - "Repo timestamp expected ${repo.timestamp}, but was $currentTimestamp" - ) - // still the expected timestamp, so go on processing... - streamProcessor.process(repoVersion, inputStream) { i -> - listener?.onUpdateProgress(repo, i, entryFile.numPackages) - } - // update RepositoryPreferences with timestamp - val repoPrefs = repoDao.getRepositoryPreferences(repo.repoId) - ?: error("No repo prefs for ${repo.repoId}") - val updatedPrefs = repoPrefs.copy( - lastUpdated = System.currentTimeMillis(), - errorCount = 0, - lastError = null, - ) - repoDao.updateRepositoryPreferences(updatedPrefs) - } - } - } finally { - file.delete() - } - return IndexUpdateResult.Processed + } + } finally { + file.delete() } + return IndexUpdateResult.Processed + } } diff --git a/libs/database/src/main/java/org/fdroid/repo/KnownRepos.kt b/libs/database/src/main/java/org/fdroid/repo/KnownRepos.kt index 34c2fe7bf..d002d83a0 100644 --- a/libs/database/src/main/java/org/fdroid/repo/KnownRepos.kt +++ b/libs/database/src/main/java/org/fdroid/repo/KnownRepos.kt @@ -1,18 +1,19 @@ package org.fdroid.repo /** - * A map from canonical repo URL to lower-case fingerprint of this repo. - * When adding new repos here, please test that adding the repo still works. + * A map from canonical repo URL to lower-case fingerprint of this repo. When adding new repos here, + * please test that adding the repo still works. */ -internal val knownRepos = mapOf( +internal val knownRepos = + mapOf( "https://apt.izzysoft.de/fdroid/repo" to - "3bf0d6abfeae2f401707b6d966be743bf0eee49c2561b9ba39073711f628937a", + "3bf0d6abfeae2f401707b6d966be743bf0eee49c2561b9ba39073711f628937a", "https://archive.newpipe.net/fdroid/repo" to - "e2402c78f9b97c6c89e97db914a2751fda1d02fe2039cc0897a462bdb57e7501", + "e2402c78f9b97c6c89e97db914a2751fda1d02fe2039cc0897a462bdb57e7501", "https://briarproject.org/fdroid/repo" to - "1fb874bee7276d28ecb2c9b06e8a122ec4bcb4008161436ce474c257cbf49bd6", + "1fb874bee7276d28ecb2c9b06e8a122ec4bcb4008161436ce474c257cbf49bd6", "https://guardianproject.info/fdroid/repo" to - "b7c2eefd8dac7806af67dfcd92eb18126bc08312a7f2d6f3862e46013c7a6135", + "b7c2eefd8dac7806af67dfcd92eb18126bc08312a7f2d6f3862e46013c7a6135", "https://microg.org/fdroid/repo" to - "9bd06727e62796c0130eb6dab39b73157451582cbd138e86c468acc395d14165", -) + "9bd06727e62796c0130eb6dab39b73157451582cbd138e86c468acc395d14165", + ) diff --git a/libs/database/src/main/java/org/fdroid/repo/RepoAdder.kt b/libs/database/src/main/java/org/fdroid/repo/RepoAdder.kt index 3f9c601c6..0f4b2c1cd 100644 --- a/libs/database/src/main/java/org/fdroid/repo/RepoAdder.kt +++ b/libs/database/src/main/java/org/fdroid/repo/RepoAdder.kt @@ -11,6 +11,10 @@ import androidx.annotation.VisibleForTesting import androidx.annotation.WorkerThread import androidx.core.content.ContextCompat.getSystemService import androidx.core.net.toUri +import java.io.File +import java.io.IOException +import java.net.Proxy +import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -46,10 +50,6 @@ import org.fdroid.repo.AddRepoError.ErrorType.INVALID_INDEX import org.fdroid.repo.AddRepoError.ErrorType.IO_ERROR import org.fdroid.repo.AddRepoError.ErrorType.IS_ARCHIVE_REPO import org.fdroid.repo.AddRepoError.ErrorType.UNKNOWN_SOURCES_DISALLOWED -import java.io.File -import java.io.IOException -import java.net.Proxy -import kotlin.coroutines.CoroutineContext internal const val REPO_ID = 0L @@ -58,385 +58,389 @@ public sealed class AddRepoState public object None : AddRepoState() public class Fetching( - public val fetchUrl: String, - public val receivedRepo: Repository?, - public val apps: List, - public val fetchResult: FetchResult?, - public val indexFile: File? = null, + public val fetchUrl: String, + public val receivedRepo: Repository?, + public val apps: List, + public val fetchResult: FetchResult?, + public val indexFile: File? = null, ) : AddRepoState() { - /** - * true if fetching is complete. - */ - public val done: Boolean = indexFile != null - override fun toString(): String { - return "Fetching(fetchUrl=$fetchUrl, repo=${receivedRepo?.address}, apps=${apps.size}, " + - "fetchResult=$fetchResult, done=$done)" - } + /** true if fetching is complete. */ + public val done: Boolean = indexFile != null + + override fun toString(): String { + return "Fetching(fetchUrl=$fetchUrl, repo=${receivedRepo?.address}, apps=${apps.size}, " + + "fetchResult=$fetchResult, done=$done)" + } } public object Adding : AddRepoState() -public class Added( - public val repo: Repository, - public val updateResult: IndexUpdateResult?, -) : AddRepoState() +public class Added(public val repo: Repository, public val updateResult: IndexUpdateResult?) : + AddRepoState() public data class AddRepoError( - public val errorType: ErrorType, - public val exception: Exception? = null, + public val errorType: ErrorType, + public val exception: Exception? = null, ) : AddRepoState() { - public enum class ErrorType { - UNKNOWN_SOURCES_DISALLOWED, - INVALID_FINGERPRINT, - IS_ARCHIVE_REPO, - INVALID_INDEX, - IO_ERROR, - } + public enum class ErrorType { + UNKNOWN_SOURCES_DISALLOWED, + INVALID_FINGERPRINT, + IS_ARCHIVE_REPO, + INVALID_INDEX, + IO_ERROR, + } } public sealed class FetchResult { - public data object IsNewRepository : FetchResult() - public data object IsNewRepoAndNewMirror : FetchResult() - public data class IsNewMirror(internal val existingRepoId: Long) : FetchResult() + public data object IsNewRepository : FetchResult() - public data class IsExistingRepository(val existingRepoId: Long) : FetchResult() - public data class IsExistingMirror(val existingRepoId: Long) : FetchResult() + public data object IsNewRepoAndNewMirror : FetchResult() + + public data class IsNewMirror(internal val existingRepoId: Long) : FetchResult() + + public data class IsExistingRepository(val existingRepoId: Long) : FetchResult() + + public data class IsExistingMirror(val existingRepoId: Long) : FetchResult() } @OptIn(DelicateCoroutinesApi::class) internal class RepoAdder( - private val context: Context, - private val db: FDroidDatabase, - private val tempFileProvider: TempFileProvider, - private val downloaderFactory: DownloaderFactory, - private val httpManager: HttpManager, - private val compatibilityChecker: CompatibilityChecker, - private val repoUriGetter: RepoUriGetter = RepoUriGetter, - private val repoUriBuilder: RepoUriBuilder = defaultRepoUriBuilder, - private val coroutineContext: CoroutineContext = Dispatchers.IO, + private val context: Context, + private val db: FDroidDatabase, + private val tempFileProvider: TempFileProvider, + private val downloaderFactory: DownloaderFactory, + private val httpManager: HttpManager, + private val compatibilityChecker: CompatibilityChecker, + private val repoUriGetter: RepoUriGetter = RepoUriGetter, + private val repoUriBuilder: RepoUriBuilder = defaultRepoUriBuilder, + private val coroutineContext: CoroutineContext = Dispatchers.IO, ) { - private val log = KotlinLogging.logger {} - private val repositoryDao = db.getRepositoryDao() as RepositoryDaoInt + private val log = KotlinLogging.logger {} + private val repositoryDao = db.getRepositoryDao() as RepositoryDaoInt - internal val addRepoState: MutableStateFlow = MutableStateFlow(None) + internal val addRepoState: MutableStateFlow = MutableStateFlow(None) - private var fetchJob: Job? = null + private var fetchJob: Job? = null - internal fun fetchRepository(url: String, proxy: Proxy?) { - fetchJob = GlobalScope.launch(coroutineContext) { - fetchRepositoryInt(url, proxy) + internal fun fetchRepository(url: String, proxy: Proxy?) { + fetchJob = GlobalScope.launch(coroutineContext) { fetchRepositoryInt(url, proxy) } + } + + @WorkerThread + @VisibleForTesting + internal suspend fun fetchRepositoryInt(url: String, proxy: Proxy? = null) { + if (hasDisallowInstallUnknownSources(context)) { + addRepoState.value = AddRepoError(UNKNOWN_SOURCES_DISALLOWED) + return + } + // get repo url and fingerprint + val nUri = repoUriGetter.getUri(url) + log.info("Parsed URI: $nUri") + if (nUri.uri.scheme !in listOf("content", "file") && isInvalidHttpUrl(nUri.uri.toString())) { + val e = IllegalArgumentException("Unsupported URI: ${nUri.uri}") + addRepoState.value = AddRepoError(INVALID_INDEX, e) + return + } + if (nUri.uri.lastPathSegment == "archive") { + addRepoState.value = AddRepoError(IS_ARCHIVE_REPO) + return + } + val fetchUrl = nUri.uri.toString().trimEnd('/') + + // some plumping to receive the repo preview + var receivedRepo: Repository? = null + val apps = ArrayList() + var fetchResult: FetchResult? = null + + val receiver = + object : RepoPreviewReceiver { + override fun onRepoReceived(repo: Repository) { + receivedRepo = repo + if (repo.address in knownRepos) { + val knownFingerprint = knownRepos[repo.address] + if (knownFingerprint != repo.fingerprint) + throw SigningException( + "Known fingerprint different from given one: ${repo.fingerprint}" + ) + } + fetchResult = getFetchResult(fetchUrl, repo) + coroutineContext.ensureActive() // ensure active before updating state + addRepoState.value = Fetching(fetchUrl, receivedRepo, apps.toList(), fetchResult) } + + override fun onAppReceived(app: AppOverviewItem) { + apps.add(app) + coroutineContext.ensureActive() // ensure active before updating state + addRepoState.value = Fetching(fetchUrl, receivedRepo, apps.toList(), fetchResult) + } + } + // set a state early, so the ui can show progress animation + coroutineContext.ensureActive() // ensure active before updating state + addRepoState.value = Fetching(fetchUrl, receivedRepo, apps, fetchResult) + + // try fetching repo with v2 format first and fallback to v1 + val indexFile = + try { + fetchRepo(nUri.uri, nUri.fingerprint, proxy, nUri.username, nUri.password, receiver) + } catch (e: SigningException) { + log.error(e) { "Error verifying repo with given fingerprint." } + onError(AddRepoError(INVALID_FINGERPRINT, e)) + return + } catch (e: IOException) { + log.error(e) { "Error fetching repo." } + onError(AddRepoError(IO_ERROR, e)) + return + } catch (e: SerializationException) { + log.error(e) { "Error fetching repo." } + onError(AddRepoError(INVALID_INDEX, e)) + return + } catch (e: NotFoundException) { // v1 repos can also have 404 + log.error(e) { "Error fetching repo." } + onError(AddRepoError(INVALID_INDEX, e)) + return + } + // set final result + val finalRepo = receivedRepo + coroutineContext.ensureActive() // ensure active before updating state + if (finalRepo == null) { + onError(AddRepoError(INVALID_INDEX)) + } else { + addRepoState.value = Fetching(fetchUrl, finalRepo, apps, fetchResult, indexFile) + } + } + + private suspend fun onError(state: AddRepoError) { + if (currentCoroutineContext().isActive) { + addRepoState.value = state + } + } + + /** + * Fetches the repo from the given [uri] and posts updates to [receiver]. + * + * @return the temporary file the repo was written to. + */ + private suspend fun fetchRepo( + uri: Uri, + fingerprint: String?, + proxy: Proxy?, + username: String?, + password: String?, + receiver: RepoPreviewReceiver, + ): File { + return try { + val repo = getTempRepo(uri, IndexFormatVersion.TWO, username, password) + val repoFetcher = + RepoV2Fetcher(tempFileProvider, downloaderFactory, httpManager, repoUriBuilder, proxy) + repoFetcher.fetchRepo(uri, repo, receiver, fingerprint) + } catch (e: NotFoundException) { + log.warn(e) { "Did not find v2 repo, trying v1 now." } + // try to fetch v1 repo + val repo = getTempRepo(uri, IndexFormatVersion.ONE, username, password) + val repoFetcher = RepoV1Fetcher(tempFileProvider, downloaderFactory, repoUriBuilder) + repoFetcher.fetchRepo(uri, repo, receiver, fingerprint) + } + } + + private fun getFetchResult(fetchUrlIn: String, fetchedRepo: Repository): FetchResult { + // Note the delicate difference between fetchedRepo (from the network) and + // existingRepo (from the database) in this function! + val cert = fetchedRepo.certificate + val existingRepo = repositoryDao.getRepository(cert) + val fetchUrl = fetchUrlIn.trimEnd('/') + + // is completely new + if (existingRepo == null) { + val isFetchedRepoAddress = fetchUrl == fetchedRepo.address.trimEnd('/') + val isFetchedRepoDefinedMirror = + fetchedRepo.mirrors.find { fetchUrl == it.url.trimEnd('/') } != null + + val isUserMirror = !isFetchedRepoAddress && !isFetchedRepoDefinedMirror + return if (isUserMirror) { + FetchResult.IsNewRepoAndNewMirror + } else { + FetchResult.IsNewRepository + } } - @WorkerThread - @VisibleForTesting - internal suspend fun fetchRepositoryInt( - url: String, - proxy: Proxy? = null, - ) { - if (hasDisallowInstallUnknownSources(context)) { - addRepoState.value = AddRepoError(UNKNOWN_SOURCES_DISALLOWED) - return - } - // get repo url and fingerprint - val nUri = repoUriGetter.getUri(url) - log.info("Parsed URI: $nUri") - if (nUri.uri.scheme !in listOf("content", "file") && - isInvalidHttpUrl(nUri.uri.toString()) - ) { - val e = IllegalArgumentException("Unsupported URI: ${nUri.uri}") - addRepoState.value = AddRepoError(INVALID_INDEX, e) - return - } - if (nUri.uri.lastPathSegment == "archive") { - addRepoState.value = AddRepoError(IS_ARCHIVE_REPO) - return - } - val fetchUrl = nUri.uri.toString().trimEnd('/') - - // some plumping to receive the repo preview - var receivedRepo: Repository? = null - val apps = ArrayList() - var fetchResult: FetchResult? = null - - val receiver = object : RepoPreviewReceiver { - override fun onRepoReceived(repo: Repository) { - receivedRepo = repo - if (repo.address in knownRepos) { - val knownFingerprint = knownRepos[repo.address] - if (knownFingerprint != repo.fingerprint) throw SigningException( - "Known fingerprint different from given one: ${repo.fingerprint}" - ) - } - fetchResult = getFetchResult(fetchUrl, repo) - coroutineContext.ensureActive() // ensure active before updating state - addRepoState.value = Fetching(fetchUrl, receivedRepo, apps.toList(), fetchResult) - } - - override fun onAppReceived(app: AppOverviewItem) { - apps.add(app) - coroutineContext.ensureActive() // ensure active before updating state - addRepoState.value = Fetching(fetchUrl, receivedRepo, apps.toList(), fetchResult) - } - } - // set a state early, so the ui can show progress animation - coroutineContext.ensureActive() // ensure active before updating state - addRepoState.value = Fetching(fetchUrl, receivedRepo, apps, fetchResult) - - // try fetching repo with v2 format first and fallback to v1 - val indexFile = try { - fetchRepo(nUri.uri, nUri.fingerprint, proxy, nUri.username, nUri.password, receiver) - } catch (e: SigningException) { - log.error(e) { "Error verifying repo with given fingerprint." } - onError(AddRepoError(INVALID_FINGERPRINT, e)) - return - } catch (e: IOException) { - log.error(e) { "Error fetching repo." } - onError(AddRepoError(IO_ERROR, e)) - return - } catch (e: SerializationException) { - log.error(e) { "Error fetching repo." } - onError(AddRepoError(INVALID_INDEX, e)) - return - } catch (e: NotFoundException) { // v1 repos can also have 404 - log.error(e) { "Error fetching repo." } - onError(AddRepoError(INVALID_INDEX, e)) - return - } - // set final result - val finalRepo = receivedRepo - coroutineContext.ensureActive() // ensure active before updating state - if (finalRepo == null) { - onError(AddRepoError(INVALID_INDEX)) - } else { - addRepoState.value = Fetching(fetchUrl, finalRepo, apps, fetchResult, indexFile) - } + // is existing repo, is canonical address + val isExistingRepoAddress = fetchUrl == existingRepo.address.trimEnd('/') + if (isExistingRepoAddress) { + return FetchResult.IsExistingRepository(existingRepo.repoId) } - private suspend fun onError(state: AddRepoError) { - if (currentCoroutineContext().isActive) { - addRepoState.value = state - } + // is existing repo, is mirror + val isNewMirror = + existingRepo.getAllMirrors().find { fetchUrl == it.url.toString().trimEnd('/') } == null + return if (isNewMirror) { + FetchResult.IsNewMirror(existingRepo.repoId) + } else { + FetchResult.IsExistingMirror(existingRepo.repoId) } + } - /** - * Fetches the repo from the given [uri] and posts updates to [receiver]. - * @return the temporary file the repo was written to. - */ - private suspend fun fetchRepo( - uri: Uri, - fingerprint: String?, - proxy: Proxy?, - username: String?, - password: String?, - receiver: RepoPreviewReceiver, - ): File { - return try { - val repo = getTempRepo(uri, IndexFormatVersion.TWO, username, password) - val repoFetcher = RepoV2Fetcher( - tempFileProvider, downloaderFactory, httpManager, repoUriBuilder, proxy + @WorkerThread + internal fun addFetchedRepository(): Repository? { + // first cancel fetch preview job, so it stops emitting new states, + // screwing up the atomicity of getAndUpdate() below. + fetchJob?.cancel() + + // get current state before changing it + // prevent double calls (e.g. caused by double tapping a UI button) + val state = + addRepoState.getAndUpdate { + log.info { "Previous state was $it" } + Adding + } as? Fetching ?: error("Unexpected previous state") + log.info { "Moved to state ${addRepoState.value}, cancelling preview job..." } + + val repo = state.receivedRepo ?: throw IllegalStateException("No repo: ${addRepoState.value}") + val fetchResult = + state.fetchResult ?: throw IllegalStateException("No fetchResult: ${addRepoState.value}") + + var indexUpdateResult: IndexUpdateResult? = null + val modifiedRepo: Repository = + when (fetchResult) { + is FetchResult.IsExistingRepository -> error("Repo exists: $fetchResult") + is FetchResult.IsExistingMirror -> error("Mirror exists: $fetchResult") + is FetchResult.IsNewRepository, + is FetchResult.IsNewRepoAndNewMirror -> { + // reset the timestamp of the actual repo, + // so a following repo update will pick this up + val newRepo = + NewRepository( + name = repo.repository.name, + icon = repo.repository.icon ?: emptyMap(), + address = repo.address, + formatVersion = repo.formatVersion, + certificate = repo.certificate, + username = repo.username, + password = repo.password, ) - repoFetcher.fetchRepo(uri, repo, receiver, fingerprint) - } catch (e: NotFoundException) { - log.warn(e) { "Did not find v2 repo, trying v1 now." } - // try to fetch v1 repo - val repo = getTempRepo(uri, IndexFormatVersion.ONE, username, password) - val repoFetcher = RepoV1Fetcher(tempFileProvider, downloaderFactory, repoUriBuilder) - repoFetcher.fetchRepo(uri, repo, receiver, fingerprint) - } - } + db + .runInTransaction { + // add the repo + val repoId = repositoryDao.insert(newRepo) - private fun getFetchResult(fetchUrlIn: String, fetchedRepo: Repository): FetchResult { - // Note the delicate difference between fetchedRepo (from the network) and - // existingRepo (from the database) in this function! - val cert = fetchedRepo.certificate - val existingRepo = repositoryDao.getRepository(cert) - val fetchUrl = fetchUrlIn.trimEnd('/') - - // is completely new - if (existingRepo == null) { - val isFetchedRepoAddress = fetchUrl == fetchedRepo.address.trimEnd('/') - val isFetchedRepoDefinedMirror = - fetchedRepo.mirrors.find { fetchUrl == it.url.trimEnd('/') } != null - - val isUserMirror = !isFetchedRepoAddress && !isFetchedRepoDefinedMirror - return if (isUserMirror) { - FetchResult.IsNewRepoAndNewMirror - } else { - FetchResult.IsNewRepository + // add user mirror + // this can happen if the user was adding a mirror URL, and they originally had + // neither the repo nor the mirror added + if (fetchResult is FetchResult.IsNewRepoAndNewMirror) { + val userMirrors = listOf(state.fetchUrl) + repositoryDao.updateUserMirrors(repoId, userMirrors) + } + repositoryDao.getRepository(repoId) ?: error("New repository not found in DB") + } + .also { repo -> + // Update the repo before returning, so we already have its content. + // This should pick up [indexFile] automatically without re-downloading, + // because we use the sha256 hash as the file name. + indexUpdateResult = + RepoUpdater( + tempDir = context.cacheDir, + db = db, + downloaderFactory = downloaderFactory, + compatibilityChecker = compatibilityChecker, + ) + .update(repo) + log.info { "Updated repo: $indexUpdateResult" } } } - // is existing repo, is canonical address - val isExistingRepoAddress = fetchUrl == existingRepo.address.trimEnd('/') - if (isExistingRepoAddress) { - return FetchResult.IsExistingRepository(existingRepo.repoId) + is FetchResult.IsNewMirror -> { + val repoId = fetchResult.existingRepoId + db.runInTransaction { + val existingRepo = repositoryDao.getRepository(repoId) ?: error("No repo with $repoId") + val userMirrors = existingRepo.userMirrors.toMutableList().apply { add(state.fetchUrl) } + repositoryDao.updateUserMirrors(repoId, userMirrors) + existingRepo + } } + } + log.info { "Added repository" } + state.indexFile?.delete() + addRepoState.value = Added(modifiedRepo, indexUpdateResult) + return modifiedRepo + } - // is existing repo, is mirror - val isNewMirror = existingRepo.getAllMirrors() - .find { fetchUrl == it.url.toString().trimEnd('/') } == null - return if (isNewMirror) { - FetchResult.IsNewMirror(existingRepo.repoId) - } else { - FetchResult.IsExistingMirror(existingRepo.repoId) - } - } + internal fun abortAddingRepo() { + (addRepoState.value as? Fetching)?.indexFile?.delete() + addRepoState.value = None + fetchJob?.cancel() + } - @WorkerThread - internal fun addFetchedRepository(): Repository? { - // first cancel fetch preview job, so it stops emitting new states, - // screwing up the atomicity of getAndUpdate() below. - fetchJob?.cancel() + @AnyThread + internal suspend fun addArchiveRepo(repo: Repository, proxy: Proxy? = null): Long? = + withContext(coroutineContext) { + if (repo.isArchiveRepo) error("Repo ${repo.address} is already an archive repo.") - // get current state before changing it - // prevent double calls (e.g. caused by double tapping a UI button) - val state = addRepoState.getAndUpdate { - log.info { "Previous state was $it" } - Adding - } as? Fetching ?: error("Unexpected previous state") - log.info { "Moved to state ${addRepoState.value}, cancelling preview job..." } + var archiveRepoId: Long? = null + val address = repo.address.replace(Regex("repo/?$"), "archive") - val repo = state.receivedRepo - ?: throw IllegalStateException("No repo: ${addRepoState.value}") - val fetchResult = state.fetchResult - ?: throw IllegalStateException("No fetchResult: ${addRepoState.value}") - - var indexUpdateResult: IndexUpdateResult? = null - val modifiedRepo: Repository = when (fetchResult) { - is FetchResult.IsExistingRepository -> error("Repo exists: $fetchResult") - is FetchResult.IsExistingMirror -> error("Mirror exists: $fetchResult") - is FetchResult.IsNewRepository, is FetchResult.IsNewRepoAndNewMirror -> { - // reset the timestamp of the actual repo, - // so a following repo update will pick this up - val newRepo = NewRepository( - name = repo.repository.name, - icon = repo.repository.icon ?: emptyMap(), - address = repo.address, - formatVersion = repo.formatVersion, - certificate = repo.certificate, - username = repo.username, - password = repo.password, - ) - db.runInTransaction { - // add the repo - val repoId = repositoryDao.insert(newRepo) - - // add user mirror - // this can happen if the user was adding a mirror URL, and they originally had - // neither the repo nor the mirror added - if (fetchResult is FetchResult.IsNewRepoAndNewMirror) { - val userMirrors = listOf(state.fetchUrl) - repositoryDao.updateUserMirrors(repoId, userMirrors) - } - repositoryDao.getRepository(repoId) ?: error("New repository not found in DB") - }.also { repo -> - // Update the repo before returning, so we already have its content. - // This should pick up [indexFile] automatically without re-downloading, - // because we use the sha256 hash as the file name. - indexUpdateResult = RepoUpdater( - tempDir = context.cacheDir, - db = db, - downloaderFactory = downloaderFactory, - compatibilityChecker = compatibilityChecker, - ).update(repo) - log.info { "Updated repo: $indexUpdateResult" } - } + @Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE") + val receiver = + object : RepoPreviewReceiver { + override fun onRepoReceived(archiveRepo: Repository) { + // reset the timestamp of the actual repo, + // so a following repo update will pick this up + val newRepo = + NewRepository( + name = archiveRepo.repository.name, + icon = archiveRepo.repository.icon ?: emptyMap(), + address = archiveRepo.address, + formatVersion = archiveRepo.formatVersion, + certificate = archiveRepo.certificate, + username = archiveRepo.username, + password = archiveRepo.password, + ) + db.runInTransaction { + val repoId = repositoryDao.insert(newRepo) + repositoryDao.setWeight(repoId, repo.weight - 1) + archiveRepoId = repoId } + } - is FetchResult.IsNewMirror -> { - val repoId = fetchResult.existingRepoId - db.runInTransaction { - val existingRepo = repositoryDao.getRepository(repoId) - ?: error("No repo with $repoId") - val userMirrors = existingRepo.userMirrors.toMutableList().apply { - add(state.fetchUrl) - } - repositoryDao.updateUserMirrors(repoId, userMirrors) - existingRepo - } - } + override fun onAppReceived(app: AppOverviewItem) { + // no-op + } } - log.info { "Added repository" } - state.indexFile?.delete() - addRepoState.value = Added(modifiedRepo, indexUpdateResult) - return modifiedRepo + val uri = address.toUri() + fetchRepo(uri, repo.fingerprint, proxy, repo.username, repo.password, receiver) + return@withContext archiveRepoId } - internal fun abortAddingRepo() { - (addRepoState.value as? Fetching)?.indexFile?.delete() - addRepoState.value = None - fetchJob?.cancel() + private fun hasDisallowInstallUnknownSources(context: Context): Boolean { + val userManager = + getSystemService(context, UserManager::class.java) ?: error("No UserManager available.") + return if (SDK_INT < 29) userManager.hasUserRestriction(DISALLOW_INSTALL_UNKNOWN_SOURCES) + else { + userManager.hasUserRestriction(DISALLOW_INSTALL_UNKNOWN_SOURCES) || + userManager.hasUserRestriction(DISALLOW_INSTALL_UNKNOWN_SOURCES_GLOBALLY) } + } - @AnyThread - internal suspend fun addArchiveRepo(repo: Repository, proxy: Proxy? = null): Long? = - withContext(coroutineContext) { - if (repo.isArchiveRepo) error("Repo ${repo.address} is already an archive repo.") - - var archiveRepoId: Long? = null - val address = repo.address.replace(Regex("repo/?$"), "archive") - - @Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE") - val receiver = object : RepoPreviewReceiver { - override fun onRepoReceived(archiveRepo: Repository) { - // reset the timestamp of the actual repo, - // so a following repo update will pick this up - val newRepo = NewRepository( - name = archiveRepo.repository.name, - icon = archiveRepo.repository.icon ?: emptyMap(), - address = archiveRepo.address, - formatVersion = archiveRepo.formatVersion, - certificate = archiveRepo.certificate, - username = archiveRepo.username, - password = archiveRepo.password, - ) - db.runInTransaction { - val repoId = repositoryDao.insert(newRepo) - repositoryDao.setWeight(repoId, repo.weight - 1) - archiveRepoId = repoId - } - } - - override fun onAppReceived(app: AppOverviewItem) { - // no-op - } - } - val uri = address.toUri() - fetchRepo(uri, repo.fingerprint, proxy, repo.username, repo.password, receiver) - return@withContext archiveRepoId - } - - private fun hasDisallowInstallUnknownSources(context: Context): Boolean { - val userManager = getSystemService(context, UserManager::class.java) - ?: error("No UserManager available.") - return if (SDK_INT < 29) userManager.hasUserRestriction(DISALLOW_INSTALL_UNKNOWN_SOURCES) - else userManager.hasUserRestriction(DISALLOW_INSTALL_UNKNOWN_SOURCES) || - userManager.hasUserRestriction(DISALLOW_INSTALL_UNKNOWN_SOURCES_GLOBALLY) - } - - private fun getTempRepo( - uri: Uri, - indexFormatVersion: IndexFormatVersion, - username: String?, - password: String?, - ) = Repository( - repoId = REPO_ID, - address = uri.toString(), - timestamp = -1L, - formatVersion = indexFormatVersion, - certificate = "This is fake and will be replaced by real cert before saving in DB.", - version = 0L, - weight = 0, - lastUpdated = -1L, - username = username, - password = password, + private fun getTempRepo( + uri: Uri, + indexFormatVersion: IndexFormatVersion, + username: String?, + password: String?, + ) = + Repository( + repoId = REPO_ID, + address = uri.toString(), + timestamp = -1L, + formatVersion = indexFormatVersion, + certificate = "This is fake and will be replaced by real cert before saving in DB.", + version = 0L, + weight = 0, + lastUpdated = -1L, + username = username, + password = password, ) - } internal val defaultRepoUriBuilder = RepoUriBuilder { repo, pathElements -> - val builder = repo.address.toUri().buildUpon() - pathElements.forEach { builder.appendEncodedPath(it) } - builder.build() + val builder = repo.address.toUri().buildUpon() + pathElements.forEach { builder.appendEncodedPath(it) } + builder.build() } diff --git a/libs/database/src/main/java/org/fdroid/repo/RepoFetcher.kt b/libs/database/src/main/java/org/fdroid/repo/RepoFetcher.kt index eb47ae5e6..2ca4d5fa0 100644 --- a/libs/database/src/main/java/org/fdroid/repo/RepoFetcher.kt +++ b/libs/database/src/main/java/org/fdroid/repo/RepoFetcher.kt @@ -1,35 +1,37 @@ package org.fdroid.repo import android.net.Uri +import java.io.File +import java.io.IOException import kotlinx.serialization.SerializationException import org.fdroid.database.AppOverviewItem import org.fdroid.database.Repository import org.fdroid.download.NotFoundException import org.fdroid.index.SigningException -import java.io.File -import java.io.IOException internal fun interface RepoFetcher { - /** - * Fetches the repo from the given [uri] and posts updates to [receiver]. - * @return the temporary file the repo was written to. - * Note that the OS may delete this at any time. - */ - @Throws( - IOException::class, - SigningException::class, - NotFoundException::class, - SerializationException::class, - ) - suspend fun fetchRepo( - uri: Uri, - repo: Repository, - receiver: RepoPreviewReceiver, - fingerprint: String?, - ): File + /** + * Fetches the repo from the given [uri] and posts updates to [receiver]. + * + * @return the temporary file the repo was written to. Note that the OS may delete this at any + * time. + */ + @Throws( + IOException::class, + SigningException::class, + NotFoundException::class, + SerializationException::class, + ) + suspend fun fetchRepo( + uri: Uri, + repo: Repository, + receiver: RepoPreviewReceiver, + fingerprint: String?, + ): File } internal interface RepoPreviewReceiver { - fun onRepoReceived(repo: Repository) - fun onAppReceived(app: AppOverviewItem) + fun onRepoReceived(repo: Repository) + + fun onAppReceived(app: AppOverviewItem) } diff --git a/libs/database/src/main/java/org/fdroid/repo/RepoUriGetter.kt b/libs/database/src/main/java/org/fdroid/repo/RepoUriGetter.kt index 7fc8e3255..45658aea3 100644 --- a/libs/database/src/main/java/org/fdroid/repo/RepoUriGetter.kt +++ b/libs/database/src/main/java/org/fdroid/repo/RepoUriGetter.kt @@ -1,108 +1,116 @@ package org.fdroid.repo import android.net.Uri +import androidx.core.net.toUri import org.fdroid.database.Repository internal object RepoUriGetter { - fun getUri(url: String): NormalizedUri { - val uri = Uri.parse(url).let { - when { - it.scheme.equals("fdroidrepos", ignoreCase = true) -> { - it.buildUpon().scheme("https").build() - } + fun getUri(url: String): NormalizedUri { + val uri = + url.toUri().let { + when { + it.scheme.equals("fdroidrepos", ignoreCase = true) -> { + it.buildUpon().scheme("https").build() + } - it.scheme.equals("fdroidrepo", ignoreCase = true) -> { - it.buildUpon().scheme("http").build() - } + it.scheme.equals("fdroidrepo", ignoreCase = true) -> { + it.buildUpon().scheme("http").build() + } - it.host == "fdroid.link" && it.encodedFragment != null -> getFdroidLinkUri(it) + it.host == "fdroid.link" && it.encodedFragment != null -> getFdroidLinkUri(it) - it.scheme.isNullOrBlank() -> { - // assume https:// when no scheme given - it.buildUpon().scheme("https").path("//${it.path}").build() - } + it.scheme.isNullOrBlank() -> { + // assume https:// when no scheme given + it.buildUpon().scheme("https").path("//${it.path}").build() + } - else -> it - } + else -> it } - val fingerprint = uri.getQueryParameterOrNull("fingerprint")?.lowercase()?.trimEnd() - ?: uri.getQueryParameterOrNull("FINGERPRINT")?.lowercase()?.trimEnd() + } + val fingerprint = + uri.getQueryParameterOrNull("fingerprint")?.lowercase()?.trimEnd() + ?: uri.getQueryParameterOrNull("FINGERPRINT")?.lowercase()?.trimEnd() - val pathSegments = uri.pathSegments - var username: String? = null - var password: String? = null - val normalizedUri = uri.buildUpon().apply { - // extract and remove userInfo, if available - val userInfo = uri.userInfo - val authority = uri.authority - if (userInfo != null && authority != null) { - val host = authority.split('@')[1] - val usernamePassword = userInfo.split(':') - if (usernamePassword.isNotEmpty()) username = usernamePassword[0] - if (usernamePassword.size > 1) password = usernamePassword[1] - authority(host) // remove userInfo from URI - } - clearQuery() // removes fingerprint and other query params - fragment("") // remove # hash fragment - if (uri.scheme != "content" && uri.scheme != "file") { - // do some path auto-adding, if it is missing - if (pathSegments.size >= 2 && - pathSegments[pathSegments.lastIndex - 1] == "fdroid" && - (pathSegments.last() == "repo" || pathSegments.last() == "archive") - ) { - // path already is /fdroid/repo, use as is - } else if (pathSegments.lastOrNull() == "repo" || - pathSegments.lastOrNull() == "archive" - ) { - // path already ends in /repo, use as is - } else if (pathSegments.size >= 1 && pathSegments.last() == "fdroid") { - // path is /fdroid with missing /repo, so add that - appendPath("repo") - } else { - // path is missing /fdroid/repo, so add it - appendPath("fdroid") - appendPath("repo") - } - } - }.build().let { newUri -> - // hacky way to remove trailing slash - val path = newUri.path - if (path != null && path.endsWith('/')) { - newUri.buildUpon().path(path.trimEnd('/')).build() + val pathSegments = uri.pathSegments + var username: String? = null + var password: String? = null + val normalizedUri = + uri + .buildUpon() + .apply { + // extract and remove userInfo, if available + val userInfo = uri.userInfo + val authority = uri.authority + if (userInfo != null && authority != null) { + val host = authority.split('@')[1] + val usernamePassword = userInfo.split(':') + if (usernamePassword.isNotEmpty()) username = usernamePassword[0] + if (usernamePassword.size > 1) password = usernamePassword[1] + authority(host) // remove userInfo from URI + } + clearQuery() // removes fingerprint and other query params + fragment("") // remove # hash fragment + if (uri.scheme != "content" && uri.scheme != "file") { + // do some path auto-adding, if it is missing + if ( + pathSegments.size >= 2 && + pathSegments[pathSegments.lastIndex - 1] == "fdroid" && + (pathSegments.last() == "repo" || pathSegments.last() == "archive") + ) { + // path already is /fdroid/repo, use as is + } else if ( + pathSegments.lastOrNull() == "repo" || pathSegments.lastOrNull() == "archive" + ) { + // path already ends in /repo, use as is + } else if (pathSegments.size >= 1 && pathSegments.last() == "fdroid") { + // path is /fdroid with missing /repo, so add that + appendPath("repo") } else { - newUri + // path is missing /fdroid/repo, so add it + appendPath("fdroid") + appendPath("repo") } + } } - return NormalizedUri(normalizedUri, fingerprint, username, password) - } - - fun isSwapUri(uri: Uri): Boolean { - val swap = uri.getQueryParameterOrNull("swap") ?: uri.getQueryParameterOrNull("SWAP") - return swap != null && uri.scheme?.lowercase() == "http" - } - - private fun getFdroidLinkUri(uri: Uri): Uri { - return Uri.parse(uri.encodedFragment) - } - - private fun Uri.getQueryParameterOrNull(key: String): String? { - return try { - getQueryParameter(key) - } catch (e: Exception) { - return null + .build() + .let { newUri -> + // hacky way to remove trailing slash + val path = newUri.path + if (path != null && path.endsWith('/')) { + newUri.buildUpon().path(path.trimEnd('/')).build() + } else { + newUri + } } + return NormalizedUri(normalizedUri, fingerprint, username, password) + } + + fun isSwapUri(uri: Uri): Boolean { + val swap = uri.getQueryParameterOrNull("swap") ?: uri.getQueryParameterOrNull("SWAP") + return swap != null && uri.scheme?.lowercase() == "http" + } + + private fun getFdroidLinkUri(uri: Uri): Uri { + return Uri.parse(uri.encodedFragment) + } + + private fun Uri.getQueryParameterOrNull(key: String): String? { + return try { + getQueryParameter(key) + } catch (e: Exception) { + return null } + } - /** - * A class for normalizing the [Repository] URI and holding an optional fingerprint - * as well as username/password for basic authentication. - */ - data class NormalizedUri( - val uri: Uri, - val fingerprint: String?, - val username: String? = null, - val password: String? = null, - ) - + /** + * A class for normalizing the [Repository] URI and holding an optional fingerprint as well as + * username/password for basic authentication. + */ + data class NormalizedUri( + val uri: Uri, + val fingerprint: String?, + val username: String? = null, + val password: String? = null, + ) } diff --git a/libs/database/src/main/java/org/fdroid/repo/RepoV1Fetcher.kt b/libs/database/src/main/java/org/fdroid/repo/RepoV1Fetcher.kt index 7b5cf50b9..f003a43ac 100644 --- a/libs/database/src/main/java/org/fdroid/repo/RepoV1Fetcher.kt +++ b/libs/database/src/main/java/org/fdroid/repo/RepoV1Fetcher.kt @@ -2,6 +2,7 @@ package org.fdroid.repo import android.net.Uri import androidx.core.os.LocaleListCompat +import java.io.File import kotlinx.serialization.SerializationException import org.fdroid.database.Repository import org.fdroid.download.DownloaderFactory @@ -15,51 +16,51 @@ import org.fdroid.index.parseV1 import org.fdroid.index.v1.IndexV1Verifier import org.fdroid.index.v1.SIGNED_FILE_NAME import org.fdroid.index.v2.FileV2 -import java.io.File internal class RepoV1Fetcher( - private val tempFileProvider: TempFileProvider, - private val downloaderFactory: DownloaderFactory, - private val repoUriBuilder: RepoUriBuilder, + private val tempFileProvider: TempFileProvider, + private val downloaderFactory: DownloaderFactory, + private val repoUriBuilder: RepoUriBuilder, ) : RepoFetcher { - private val locales: LocaleListCompat = LocaleListCompat.getDefault() + private val locales: LocaleListCompat = LocaleListCompat.getDefault() - @Throws(SigningException::class, SerializationException::class) - override suspend fun fetchRepo( - uri: Uri, - repo: Repository, - receiver: RepoPreviewReceiver, - fingerprint: String?, - ): File { - // download and verify index-v1.jar - val indexFile = tempFileProvider.createTempFile(null) - val entryDownloader = downloaderFactory.create( - repo = repo, - uri = repoUriBuilder.getUri(repo, SIGNED_FILE_NAME), - indexFile = FileV2.fromPath("/$SIGNED_FILE_NAME"), - destFile = indexFile, - ) - entryDownloader.download() - val verifier = IndexV1Verifier(indexFile, null, fingerprint) - val (cert, indexV1) = verifier.getStreamAndVerify { inputStream -> - IndexParser.parseV1(inputStream) - } - val version = indexV1.repo.version - val indexV2 = IndexConverter().toIndexV2(indexV1) - val receivedRepo = RepoV2StreamReceiver.getRepository( - repo = indexV2.repo, - version = version.toLong(), - formatVersion = IndexFormatVersion.ONE, - certificate = cert, - username = repo.username, - password = repo.password, - ) - receiver.onRepoReceived(receivedRepo) - indexV2.packages.forEach { (packageName, packageV2) -> - val app = RepoV2StreamReceiver.getAppOverViewItem(packageName, packageV2, locales) - receiver.onAppReceived(app) - } - return indexFile + @Throws(SigningException::class, SerializationException::class) + override suspend fun fetchRepo( + uri: Uri, + repo: Repository, + receiver: RepoPreviewReceiver, + fingerprint: String?, + ): File { + // download and verify index-v1.jar + val indexFile = tempFileProvider.createTempFile(null) + val entryDownloader = + downloaderFactory.create( + repo = repo, + uri = repoUriBuilder.getUri(repo, SIGNED_FILE_NAME), + indexFile = FileV2.fromPath("/$SIGNED_FILE_NAME"), + destFile = indexFile, + ) + entryDownloader.download() + val verifier = IndexV1Verifier(indexFile, null, fingerprint) + val (cert, indexV1) = + verifier.getStreamAndVerify { inputStream -> IndexParser.parseV1(inputStream) } + val version = indexV1.repo.version + val indexV2 = IndexConverter().toIndexV2(indexV1) + val receivedRepo = + RepoV2StreamReceiver.getRepository( + repo = indexV2.repo, + version = version.toLong(), + formatVersion = IndexFormatVersion.ONE, + certificate = cert, + username = repo.username, + password = repo.password, + ) + receiver.onRepoReceived(receivedRepo) + indexV2.packages.forEach { (packageName, packageV2) -> + val app = RepoV2StreamReceiver.getAppOverViewItem(packageName, packageV2, locales) + receiver.onAppReceived(app) } + return indexFile + } } diff --git a/libs/database/src/main/java/org/fdroid/repo/RepoV2Fetcher.kt b/libs/database/src/main/java/org/fdroid/repo/RepoV2Fetcher.kt index eaf129e72..5dfb2dc72 100644 --- a/libs/database/src/main/java/org/fdroid/repo/RepoV2Fetcher.kt +++ b/libs/database/src/main/java/org/fdroid/repo/RepoV2Fetcher.kt @@ -1,6 +1,10 @@ package org.fdroid.repo import android.net.Uri +import java.io.File +import java.net.Proxy +import java.security.DigestInputStream +import java.security.MessageDigest import mu.KotlinLogging import org.fdroid.database.Repository import org.fdroid.download.DownloadRequest @@ -17,85 +21,83 @@ import org.fdroid.index.v2.EntryVerifier import org.fdroid.index.v2.FileV2 import org.fdroid.index.v2.IndexV2FullStreamProcessor import org.fdroid.index.v2.SIGNED_FILE_NAME -import java.io.File -import java.net.Proxy -import java.security.DigestInputStream -import java.security.MessageDigest internal class RepoV2Fetcher( - private val tempFileProvider: TempFileProvider, - private val downloaderFactory: DownloaderFactory, - private val httpManager: HttpManager, - private val repoUriBuilder: RepoUriBuilder, - private val proxy: Proxy? = null, + private val tempFileProvider: TempFileProvider, + private val downloaderFactory: DownloaderFactory, + private val httpManager: HttpManager, + private val repoUriBuilder: RepoUriBuilder, + private val proxy: Proxy? = null, ) : RepoFetcher { - private val log = KotlinLogging.logger {} + private val log = KotlinLogging.logger {} - @Throws(SigningException::class) - override suspend fun fetchRepo( - uri: Uri, - repo: Repository, - receiver: RepoPreviewReceiver, - fingerprint: String?, - ): File { - // download and verify entry - val entryFile = tempFileProvider.createTempFile(null) - val entryDownloader = downloaderFactory.create( + @Throws(SigningException::class) + override suspend fun fetchRepo( + uri: Uri, + repo: Repository, + receiver: RepoPreviewReceiver, + fingerprint: String?, + ): File { + // download and verify entry + val entryFile = tempFileProvider.createTempFile(null) + val entryDownloader = + downloaderFactory.create( + repo = repo, + uri = repoUriBuilder.getUri(repo, SIGNED_FILE_NAME), + indexFile = FileV2.fromPath("/$SIGNED_FILE_NAME"), + destFile = entryFile, + ) + val (cert, entry) = + try { + entryDownloader.download() + val verifier = EntryVerifier(entryFile, null, fingerprint) + verifier.getStreamAndVerify { inputStream -> IndexParser.parseEntry(inputStream) } + } finally { + entryFile.delete() + } + + log.info { "Downloaded entry, now streaming index..." } + + val streamReceiver = RepoV2StreamReceiver(receiver, cert, repo.username, repo.password) + val streamProcessor = IndexV2FullStreamProcessor(streamReceiver) + val indexFile = tempFileProvider.createTempFile(entry.index.sha256) + val inputStream = + if (uri.scheme?.startsWith("http") == true) { + // stream index for http(s) downloads + val indexRequest = + DownloadRequest( + indexFile = entry.index, + mirrors = repo.getMirrors(), + proxy = proxy, + username = repo.username, + password = repo.password, + ) + val digestInputStream = httpManager.getDigestInputStream(indexRequest) + // wrap stream to exfiltrate index file for later usage + SavingInputStream(digestInputStream, indexFile) + } else { + // no streaming supported, download file first + val indexDownloader = + downloaderFactory.create( repo = repo, - uri = repoUriBuilder.getUri(repo, SIGNED_FILE_NAME), - indexFile = FileV2.fromPath("/$SIGNED_FILE_NAME"), - destFile = entryFile, - ) - val (cert, entry) = try { - entryDownloader.download() - val verifier = EntryVerifier(entryFile, null, fingerprint) - verifier.getStreamAndVerify { inputStream -> - IndexParser.parseEntry(inputStream) - } - } finally { - entryFile.delete() - } - - log.info { "Downloaded entry, now streaming index..." } - - val streamReceiver = RepoV2StreamReceiver(receiver, cert, repo.username, repo.password) - val streamProcessor = IndexV2FullStreamProcessor(streamReceiver) - val indexFile = tempFileProvider.createTempFile(entry.index.sha256) - val inputStream = if (uri.scheme?.startsWith("http") == true) { - // stream index for http(s) downloads - val indexRequest = DownloadRequest( - indexFile = entry.index, - mirrors = repo.getMirrors(), - proxy = proxy, - username = repo.username, - password = repo.password, - ) - val digestInputStream = httpManager.getDigestInputStream(indexRequest) - // wrap stream to exfiltrate index file for later usage - SavingInputStream(digestInputStream, indexFile) - } else { - // no streaming supported, download file first - val indexDownloader = downloaderFactory.create( - repo = repo, - uri = repoUriBuilder.getUri(repo, entry.index.name.trimStart('/')), - indexFile = entry.index, - destFile = indexFile, - ) - indexDownloader.download() - val digest = MessageDigest.getInstance("SHA-256") - DigestInputStream(indexFile.inputStream(), digest) - } - inputStream.use { inputStream -> - streamProcessor.process(entry.version, inputStream) { } - } - val hexDigest = when (inputStream) { - is DigestInputStream -> inputStream.getDigestHex() - is SavingInputStream -> inputStream.inputStream.getDigestHex() - else -> error("Unknown InputStream ${inputStream::class.java}") - } - if (!hexDigest.equals(entry.index.sha256, ignoreCase = true)) { - throw SigningException("Invalid ${entry.index.name} hash: $hexDigest") - } - return indexFile + uri = repoUriBuilder.getUri(repo, entry.index.name.trimStart('/')), + indexFile = entry.index, + destFile = indexFile, + ) + indexDownloader.download() + val digest = MessageDigest.getInstance("SHA-256") + DigestInputStream(indexFile.inputStream(), digest) + } + inputStream.use { inputStream -> streamProcessor.process(entry.version, inputStream) {} } + val hexDigest = + when (inputStream) { + is DigestInputStream -> inputStream.getDigestHex() + is SavingInputStream -> inputStream.inputStream.getDigestHex() + else -> error("Unknown InputStream ${inputStream::class.java}") + } + if (!hexDigest.equals(entry.index.sha256, ignoreCase = true)) { + throw SigningException("Invalid ${entry.index.name} hash: $hexDigest") } + return indexFile + } } diff --git a/libs/database/src/main/java/org/fdroid/repo/RepoV2StreamReceiver.kt b/libs/database/src/main/java/org/fdroid/repo/RepoV2StreamReceiver.kt index 7003b223d..702c99c74 100644 --- a/libs/database/src/main/java/org/fdroid/repo/RepoV2StreamReceiver.kt +++ b/libs/database/src/main/java/org/fdroid/repo/RepoV2StreamReceiver.kt @@ -17,89 +17,88 @@ import org.fdroid.index.v2.PackageV2 import org.fdroid.index.v2.RepoV2 internal open class RepoV2StreamReceiver( - private val receiver: RepoPreviewReceiver, - private val certificate: String, - private val username: String?, - private val password: String?, + private val receiver: RepoPreviewReceiver, + private val certificate: String, + private val username: String?, + private val password: String?, ) : IndexV2StreamReceiver { - companion object { - fun getRepository( - repo: RepoV2, - version: Long, - formatVersion: IndexFormatVersion, - certificate: String, - username: String?, - password: String?, - ) = Repository( - repository = repo.toCoreRepository( - version = version, - formatVersion = formatVersion, - certificate = certificate - ), - mirrors = repo.mirrors.toMirrors(REPO_ID), - antiFeatures = repo.antiFeatures.toRepoAntiFeatures(REPO_ID), - categories = repo.categories.toRepoCategories(REPO_ID), - releaseChannels = repo.releaseChannels.toRepoReleaseChannel(REPO_ID), - preferences = RepositoryPreferences( - repoId = REPO_ID, - weight = 0, - enabled = true, - username = username, - password = password, - ), - ) - - fun getAppOverViewItem( - packageName: String, - p: PackageV2, - locales: LocaleListCompat, - ) = AppOverviewItem( + companion object { + fun getRepository( + repo: RepoV2, + version: Long, + formatVersion: IndexFormatVersion, + certificate: String, + username: String?, + password: String?, + ) = + Repository( + repository = + repo.toCoreRepository( + version = version, + formatVersion = formatVersion, + certificate = certificate, + ), + mirrors = repo.mirrors.toMirrors(REPO_ID), + antiFeatures = repo.antiFeatures.toRepoAntiFeatures(REPO_ID), + categories = repo.categories.toRepoCategories(REPO_ID), + releaseChannels = repo.releaseChannels.toRepoReleaseChannel(REPO_ID), + preferences = + RepositoryPreferences( repoId = REPO_ID, - packageName = packageName, - added = p.metadata.added, - lastUpdated = p.metadata.lastUpdated, - name = p.metadata.name.getBestLocale(locales), - summary = p.metadata.summary.getBestLocale(locales), - internalName = p.metadata.name, - internalSummary = p.metadata.summary, - antiFeatures = p.versions.values.lastOrNull()?.antiFeatures, - localizedIcon = p.metadata.icon?.map { (locale, file) -> - LocalizedIcon( - repoId = 0L, - packageName = packageName, - type = "icon", - locale = locale, - name = file.name, - sha256 = file.sha256, - size = file.size, - ipfsCidV1 = file.ipfsCidV1, - ) - }, - isCompatible = true, // not concerned with compatibility at this point - ) - } + weight = 0, + enabled = true, + username = username, + password = password, + ), + ) - private val locales: LocaleListCompat = LocaleListCompat.getDefault() - - override fun receive(repo: RepoV2, version: Long) { - receiver.onRepoReceived( - getRepository( - repo = repo, - version = version, - formatVersion = IndexFormatVersion.TWO, - certificate = certificate, - username = username, - password = password, + fun getAppOverViewItem(packageName: String, p: PackageV2, locales: LocaleListCompat) = + AppOverviewItem( + repoId = REPO_ID, + packageName = packageName, + added = p.metadata.added, + lastUpdated = p.metadata.lastUpdated, + name = p.metadata.name.getBestLocale(locales), + summary = p.metadata.summary.getBestLocale(locales), + internalName = p.metadata.name, + internalSummary = p.metadata.summary, + antiFeatures = p.versions.values.lastOrNull()?.antiFeatures, + localizedIcon = + p.metadata.icon?.map { (locale, file) -> + LocalizedIcon( + repoId = 0L, + packageName = packageName, + type = "icon", + locale = locale, + name = file.name, + sha256 = file.sha256, + size = file.size, + ipfsCidV1 = file.ipfsCidV1, ) - ) - } + }, + isCompatible = true, // not concerned with compatibility at this point + ) + } - override fun receive(packageName: String, p: PackageV2) { - receiver.onAppReceived(getAppOverViewItem(packageName, p, locales)) - } + private val locales: LocaleListCompat = LocaleListCompat.getDefault() - override fun onStreamEnded() { - } + override fun receive(repo: RepoV2, version: Long) { + receiver.onRepoReceived( + getRepository( + repo = repo, + version = version, + formatVersion = IndexFormatVersion.TWO, + certificate = certificate, + username = username, + password = password, + ) + ) + } + override fun receive(packageName: String, p: PackageV2) { + receiver.onAppReceived(getAppOverViewItem(packageName, p, locales)) + } + + override fun onStreamEnded() {} } diff --git a/libs/database/src/main/java/org/fdroid/repo/SavingInputStream.kt b/libs/database/src/main/java/org/fdroid/repo/SavingInputStream.kt index d3d77038c..372e42bcf 100644 --- a/libs/database/src/main/java/org/fdroid/repo/SavingInputStream.kt +++ b/libs/database/src/main/java/org/fdroid/repo/SavingInputStream.kt @@ -4,42 +4,36 @@ import java.io.File import java.io.InputStream import java.security.DigestInputStream -internal class SavingInputStream( - val inputStream: DigestInputStream, - outputFile: File, -) : InputStream() { +internal class SavingInputStream(val inputStream: DigestInputStream, outputFile: File) : + InputStream() { - private val outputStream = outputFile.outputStream() + private val outputStream = outputFile.outputStream() - override fun read(): Int { - val byte = inputStream.read() - if (byte != -1) { - outputStream.write(byte) - } - return byte + override fun read(): Int { + val byte = inputStream.read() + if (byte != -1) { + outputStream.write(byte) } + return byte + } - override fun read(b: ByteArray): Int { - val bytesRead = inputStream.read(b) - if (bytesRead != -1) { - outputStream.write(b, 0, bytesRead) - } - return bytesRead + override fun read(b: ByteArray): Int { + val bytesRead = inputStream.read(b) + if (bytesRead != -1) { + outputStream.write(b, 0, bytesRead) } + return bytesRead + } - override fun read(b: ByteArray, off: Int, len: Int): Int { - val bytesRead = inputStream.read(b, off, len) - if (bytesRead != -1) { - outputStream.write(b, off, bytesRead) - } - return bytesRead + override fun read(b: ByteArray, off: Int, len: Int): Int { + val bytesRead = inputStream.read(b, off, len) + if (bytesRead != -1) { + outputStream.write(b, off, bytesRead) } + return bytesRead + } - override fun close() { - try { - inputStream.close() - } finally { - outputStream.close() - } - } + override fun close() { + outputStream.use { inputStream.close() } + } } diff --git a/libs/database/src/test/java/org/fdroid/database/AppPrefsTest.kt b/libs/database/src/test/java/org/fdroid/database/AppPrefsTest.kt index 1e2944774..bed92ff6c 100644 --- a/libs/database/src/test/java/org/fdroid/database/AppPrefsTest.kt +++ b/libs/database/src/test/java/org/fdroid/database/AppPrefsTest.kt @@ -1,72 +1,72 @@ package org.fdroid.database -import org.fdroid.test.TestUtils.getRandomString -import org.junit.Test import kotlin.random.Random import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue +import org.fdroid.test.TestUtils.getRandomString +import org.junit.Test internal class AppPrefsTest { - @Test - fun testDefaults() { - val prefs = AppPrefs(getRandomString()) - assertFalse(prefs.ignoreAllUpdates) - for (i in 1..1337L) assertFalse(prefs.shouldIgnoreUpdate(i)) - assertEquals(emptyList(), prefs.releaseChannels) - } + @Test + fun testDefaults() { + val prefs = AppPrefs(getRandomString()) + assertFalse(prefs.ignoreAllUpdates) + for (i in 1..1337L) assertFalse(prefs.shouldIgnoreUpdate(i)) + assertEquals(emptyList(), prefs.releaseChannels) + } - @Test - fun testIgnoreVersionCodeUpdate() { - val ignoredCode = Random.nextLong(1, Long.MAX_VALUE - 1) - val prefs = AppPrefs(getRandomString(), ignoredCode) - assertFalse(prefs.ignoreAllUpdates) - assertTrue(prefs.shouldIgnoreUpdate(ignoredCode - 1)) - assertTrue(prefs.shouldIgnoreUpdate(ignoredCode)) - assertFalse(prefs.shouldIgnoreUpdate(ignoredCode + 1)) + @Test + fun testIgnoreVersionCodeUpdate() { + val ignoredCode = Random.nextLong(1, Long.MAX_VALUE - 1) + val prefs = AppPrefs(getRandomString(), ignoredCode) + assertFalse(prefs.ignoreAllUpdates) + assertTrue(prefs.shouldIgnoreUpdate(ignoredCode - 1)) + assertTrue(prefs.shouldIgnoreUpdate(ignoredCode)) + assertFalse(prefs.shouldIgnoreUpdate(ignoredCode + 1)) - // after toggling, it is not ignored anymore - assertFalse(prefs.toggleIgnoreVersionCodeUpdate(ignoredCode) - .shouldIgnoreUpdate(ignoredCode)) - } + // after toggling, it is not ignored anymore + assertFalse(prefs.toggleIgnoreVersionCodeUpdate(ignoredCode).shouldIgnoreUpdate(ignoredCode)) + } - @Test - fun testIgnoreAllUpdates() { - val prefs = AppPrefs(getRandomString()).toggleIgnoreAllUpdates() - assertTrue(prefs.ignoreAllUpdates) - assertTrue(prefs.shouldIgnoreUpdate(Random.nextLong())) - assertTrue(prefs.shouldIgnoreUpdate(Random.nextLong())) - assertTrue(prefs.shouldIgnoreUpdate(Random.nextLong())) + @Test + fun testIgnoreAllUpdates() { + val prefs = AppPrefs(getRandomString()).toggleIgnoreAllUpdates() + assertTrue(prefs.ignoreAllUpdates) + assertTrue(prefs.shouldIgnoreUpdate(Random.nextLong())) + assertTrue(prefs.shouldIgnoreUpdate(Random.nextLong())) + assertTrue(prefs.shouldIgnoreUpdate(Random.nextLong())) - // after toggling, all are not ignored anymore - val toggled = prefs.toggleIgnoreAllUpdates() - assertFalse(toggled.ignoreAllUpdates) - assertFalse(toggled.shouldIgnoreUpdate(Random.nextLong(1, Long.MAX_VALUE - 1))) - assertFalse(toggled.shouldIgnoreUpdate(Random.nextLong(1, Long.MAX_VALUE - 1))) - assertFalse(toggled.shouldIgnoreUpdate(Random.nextLong(1, Long.MAX_VALUE - 1))) - } + // after toggling, all are not ignored anymore + val toggled = prefs.toggleIgnoreAllUpdates() + assertFalse(toggled.ignoreAllUpdates) + assertFalse(toggled.shouldIgnoreUpdate(Random.nextLong(1, Long.MAX_VALUE - 1))) + assertFalse(toggled.shouldIgnoreUpdate(Random.nextLong(1, Long.MAX_VALUE - 1))) + assertFalse(toggled.shouldIgnoreUpdate(Random.nextLong(1, Long.MAX_VALUE - 1))) + } - @Test - fun testReleaseChannels() { - // no release channels initially - val prefs = AppPrefs(getRandomString()) - assertEquals(emptyList(), prefs.releaseChannels) + @Test + fun testReleaseChannels() { + // no release channels initially + val prefs = AppPrefs(getRandomString()) + assertEquals(emptyList(), prefs.releaseChannels) - // A gets toggled and is then in channels - val a = prefs.toggleReleaseChannel("A") - assertEquals(listOf("A"), a.releaseChannels) + // A gets toggled and is then in channels + val a = prefs.toggleReleaseChannel("A") + assertEquals(listOf("A"), a.releaseChannels) - // toggling it off returns empty list again - assertEquals(emptyList(), a.toggleReleaseChannel("A").releaseChannels) + // toggling it off returns empty list again + assertEquals(emptyList(), a.toggleReleaseChannel("A").releaseChannels) - // toggling A and B returns both - val ab = prefs.toggleReleaseChannel("A").toggleReleaseChannel("B") - assertEquals(setOf("A", "B"), ab.releaseChannels.toSet()) - - // toggling both off returns empty list again - assertEquals(emptyList(), - ab.toggleReleaseChannel("A").toggleReleaseChannel("B").releaseChannels) - } + // toggling A and B returns both + val ab = prefs.toggleReleaseChannel("A").toggleReleaseChannel("B") + assertEquals(setOf("A", "B"), ab.releaseChannels.toSet()) + // toggling both off returns empty list again + assertEquals( + emptyList(), + ab.toggleReleaseChannel("A").toggleReleaseChannel("B").releaseChannels, + ) + } } diff --git a/libs/database/src/test/java/org/fdroid/database/ConvertersTest.kt b/libs/database/src/test/java/org/fdroid/database/ConvertersTest.kt index 37e46bfb1..c1cc7abf6 100644 --- a/libs/database/src/test/java/org/fdroid/database/ConvertersTest.kt +++ b/libs/database/src/test/java/org/fdroid/database/ConvertersTest.kt @@ -1,41 +1,40 @@ package org.fdroid.database -import org.fdroid.test.TestRepoUtils.getRandomLocalizedFileV2 -import org.fdroid.test.TestUtils.getRandomList -import org.fdroid.test.TestUtils.getRandomString import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull +import org.fdroid.test.TestRepoUtils.getRandomLocalizedFileV2 +import org.fdroid.test.TestUtils.getRandomList +import org.fdroid.test.TestUtils.getRandomString internal class ConvertersTest { - @Test - fun testListConversion() { - val list = getRandomList { getRandomString() } + @Test + fun testListConversion() { + val list = getRandomList { getRandomString() } - val str = Converters.listStringToString(list) - val convertedList = Converters.fromStringToListString(str) - assertEquals(list, convertedList) - } + val str = Converters.listStringToString(list) + val convertedList = Converters.fromStringToListString(str) + assertEquals(list, convertedList) + } - @Test - fun testEmptyListConversion() { - val list = emptyList() + @Test + fun testEmptyListConversion() { + val list = emptyList() - val str = Converters.listStringToString(list) - assertNull(str) - assertNull(Converters.listStringToString(null)) - val convertedList = Converters.fromStringToListString(str) - assertEquals(list, convertedList) - } + val str = Converters.listStringToString(list) + assertNull(str) + assertNull(Converters.listStringToString(null)) + val convertedList = Converters.fromStringToListString(str) + assertEquals(list, convertedList) + } - @Test - fun testFileV2Conversion() { - val file = getRandomLocalizedFileV2() - - val str = Converters.localizedFileV2toString(file) - val convertedFile = Converters.fromStringToLocalizedFileV2(str) - assertEquals(file, convertedFile) - } + @Test + fun testFileV2Conversion() { + val file = getRandomLocalizedFileV2() + val str = Converters.localizedFileV2toString(file) + val convertedFile = Converters.fromStringToLocalizedFileV2(str) + assertEquals(file, convertedFile) + } } diff --git a/libs/database/src/test/java/org/fdroid/database/DbV2StreamReceiverTest.kt b/libs/database/src/test/java/org/fdroid/database/DbV2StreamReceiverTest.kt index 6dc797671..0531c2fcc 100644 --- a/libs/database/src/test/java/org/fdroid/database/DbV2StreamReceiverTest.kt +++ b/libs/database/src/test/java/org/fdroid/database/DbV2StreamReceiverTest.kt @@ -3,49 +3,44 @@ package org.fdroid.database import androidx.test.ext.junit.runners.AndroidJUnit4 import io.mockk.every import io.mockk.mockk +import kotlin.test.assertFailsWith import kotlinx.serialization.SerializationException import org.fdroid.CompatibilityChecker import org.fdroid.index.v2.FileV2 import org.fdroid.index.v2.RepoV2 import org.junit.Test import org.junit.runner.RunWith -import kotlin.test.assertFailsWith @RunWith(AndroidJUnit4::class) internal class DbV2StreamReceiverTest { - private val db: FDroidDatabaseInt = mockk() - private val compatChecker: CompatibilityChecker = mockk() - private val dbV2StreamReceiver = DbV2StreamReceiver(db, 42L, compatChecker) + private val db: FDroidDatabaseInt = mockk() + private val compatChecker: CompatibilityChecker = mockk() + private val dbV2StreamReceiver = DbV2StreamReceiver(db, 42L, compatChecker) - @Test - fun testFileV2Verified() { - // proper icon file passes - val repoV2 = RepoV2( - icon = mapOf("en" to FileV2(name = "/foo", sha256 = "bar", size = 23L)), - address = "http://example.org", - timestamp = 42L, - ) - every { db.getRepositoryDao() } returns mockk(relaxed = true) - dbV2StreamReceiver.receive(repoV2, 42L) + @Test + fun testFileV2Verified() { + // proper icon file passes + val repoV2 = + RepoV2( + icon = mapOf("en" to FileV2(name = "/foo", sha256 = "bar", size = 23L)), + address = "http://example.org", + timestamp = 42L, + ) + every { db.getRepositoryDao() } returns mockk(relaxed = true) + dbV2StreamReceiver.receive(repoV2, 42L) - // icon file without leading / does not pass - val repoV2NoSlash = - repoV2.copy(icon = mapOf("en" to FileV2(name = "foo", sha256 = "bar", size = 23L))) - assertFailsWith { - dbV2StreamReceiver.receive(repoV2NoSlash, 42L) - } + // icon file without leading / does not pass + val repoV2NoSlash = + repoV2.copy(icon = mapOf("en" to FileV2(name = "foo", sha256 = "bar", size = 23L))) + assertFailsWith { dbV2StreamReceiver.receive(repoV2NoSlash, 42L) } - // icon file without sha256 hash fails - val repoNoSha256 = repoV2.copy(icon = mapOf("en" to FileV2(name = "/foo", size = 23L))) - assertFailsWith { - dbV2StreamReceiver.receive(repoNoSha256, 42L) - } + // icon file without sha256 hash fails + val repoNoSha256 = repoV2.copy(icon = mapOf("en" to FileV2(name = "/foo", size = 23L))) + assertFailsWith { dbV2StreamReceiver.receive(repoNoSha256, 42L) } - // icon file without size fails - val repoNoSize = repoV2.copy(icon = mapOf("en" to FileV2(name = "/foo", sha256 = "bar"))) - assertFailsWith { - dbV2StreamReceiver.receive(repoNoSize, 42L) - } - } + // icon file without size fails + val repoNoSize = repoV2.copy(icon = mapOf("en" to FileV2(name = "/foo", sha256 = "bar"))) + assertFailsWith { dbV2StreamReceiver.receive(repoNoSize, 42L) } + } } diff --git a/libs/database/src/test/java/org/fdroid/download/TestDownloadFactory.kt b/libs/database/src/test/java/org/fdroid/download/TestDownloadFactory.kt index ee328fe5d..ab3794ffa 100644 --- a/libs/database/src/test/java/org/fdroid/download/TestDownloadFactory.kt +++ b/libs/database/src/test/java/org/fdroid/download/TestDownloadFactory.kt @@ -1,32 +1,34 @@ package org.fdroid.download import android.net.Uri +import java.io.File import org.fdroid.IndexFile import org.fdroid.database.Repository -import java.io.File internal class TestDownloadFactory(private val httpManager: HttpManager) : DownloaderFactory() { - override fun create( - repo: Repository, - uri: Uri, - indexFile: IndexFile, - destFile: File, - ): Downloader = HttpDownloaderV2( - httpManager = httpManager, - request = DownloadRequest(indexFile, repo.getMirrors()), - destFile = destFile + override fun create( + repo: Repository, + uri: Uri, + indexFile: IndexFile, + destFile: File, + ): Downloader = + HttpDownloaderV2( + httpManager = httpManager, + request = DownloadRequest(indexFile, repo.getMirrors()), + destFile = destFile, ) - override fun create( - repo: Repository, - mirrors: List, - uri: Uri, - indexFile: IndexFile, - destFile: File, - tryFirst: Mirror?, - ): Downloader = HttpDownloaderV2( - httpManager = httpManager, - request = DownloadRequest(indexFile, repo.getMirrors()), - destFile = destFile + override fun create( + repo: Repository, + mirrors: List, + uri: Uri, + indexFile: IndexFile, + destFile: File, + tryFirst: Mirror?, + ): Downloader = + HttpDownloaderV2( + httpManager = httpManager, + request = DownloadRequest(indexFile, repo.getMirrors()), + destFile = destFile, ) } diff --git a/libs/database/src/test/java/org/fdroid/repo/RepoAdderIntegrationTest.kt b/libs/database/src/test/java/org/fdroid/repo/RepoAdderIntegrationTest.kt index 06bb96b70..5c49893fe 100644 --- a/libs/database/src/test/java/org/fdroid/repo/RepoAdderIntegrationTest.kt +++ b/libs/database/src/test/java/org/fdroid/repo/RepoAdderIntegrationTest.kt @@ -8,6 +8,12 @@ import io.mockk.MockKException import io.mockk.every import io.mockk.mockk import io.mockk.slot +import java.util.concurrent.Callable +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.fdroid.CompatibilityChecker @@ -27,133 +33,122 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith -import java.util.concurrent.Callable -import kotlin.test.assertEquals -import kotlin.test.assertIs -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) internal class RepoAdderIntegrationTest { - @get:Rule - var folder: TemporaryFolder = TemporaryFolder() + @get:Rule var folder: TemporaryFolder = TemporaryFolder() - private val context = InstrumentationRegistry.getInstrumentation().targetContext - private val db = mockk() - private val repoDao = mockk() - private val tempFileProvider = TempFileProvider { folder.newFile() } - private val httpManager = HttpManager("test") - private val downloaderFactory = TestDownloadFactory(httpManager) - private val compatibilityChecker = mockk() + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val db = mockk() + private val repoDao = mockk() + private val tempFileProvider = TempFileProvider { folder.newFile() } + private val httpManager = HttpManager("test") + private val downloaderFactory = TestDownloadFactory(httpManager) + private val compatibilityChecker = mockk() - private val repoAdder: RepoAdder + private val repoAdder: RepoAdder - init { - every { db.getRepositoryDao() } returns repoDao - repoAdder = RepoAdder( - context = context, - db = db, - tempFileProvider = tempFileProvider, - downloaderFactory = downloaderFactory, - httpManager = httpManager, - compatibilityChecker = compatibilityChecker, - ) + init { + every { db.getRepositoryDao() } returns repoDao + repoAdder = + RepoAdder( + context = context, + db = db, + tempFileProvider = tempFileProvider, + downloaderFactory = downloaderFactory, + httpManager = httpManager, + compatibilityChecker = compatibilityChecker, + ) + } + + @Before + fun optIn() { + assumeTrue(false) // don't run integration tests with real repos all the time + } + + @Test + fun testFedilabV1() = runTest { + // repo not in DB + every { repoDao.getRepository(any()) } returns null + + repoAdder.fetchRepository( + url = + "https://fdroid.fedilab.app/repo/" + + "?fingerprint=11F0A69910A4280E2CD3CCC3146337D006BE539B18E1A9FEACE15FF757A94FEB", + proxy = null, + ) + repoAdder.addRepoState.test { + assertEquals(None, awaitItem()) + val firstFetching = awaitItem() + assertTrue(firstFetching is Fetching) + assertNull(firstFetching.receivedRepo) + assertTrue(firstFetching.apps.isEmpty()) + + val secondFetching = awaitItem() + assertTrue(secondFetching is Fetching, "$secondFetching") + val repo = secondFetching.receivedRepo + assertNotNull(repo) + assertEquals("https://fdroid.fedilab.app/repo", repo.address) + println(repo.getName(LocaleListCompat.getDefault()) ?: "null") + println(repo.certificate) + + assertEquals(1, (awaitItem() as Fetching).apps.size) + assertEquals(2, (awaitItem() as Fetching).apps.size) + assertEquals(3, (awaitItem() as Fetching).apps.size) + assertEquals(4, (awaitItem() as Fetching).apps.size) + assertEquals(5, (awaitItem() as Fetching).apps.size) + assertTrue((awaitItem() as Fetching).done) } - @Before - fun optIn() { - assumeTrue(false) // don't run integration tests with real repos all the time + val state = repoAdder.addRepoState.value + assertTrue(state is Fetching, state.toString()) + assertTrue(state.apps.isNotEmpty()) + state.apps.forEach { app -> println(" ${app.packageName} ${app.summary}") } + + val runSlot = slot>() + every { db.runInTransaction(capture(runSlot)) } answers { runSlot.captured.call() } + val newRepo: Repository = mockk() + every { newRepo.formatVersion } returns IndexFormatVersion.TWO + every { repoDao.insert(any()) } returns 42L + every { repoDao.getRepository(42L) } returns newRepo + + repoAdder.addFetchedRepository() + repoAdder.addRepoState.test { + val addedState = awaitItem() + assertTrue(addedState is Added, addedState.toString()) + assertEquals(newRepo, addedState.repo) + // we are not mocking all the actual repo adding, + // so just assert that this fails due to mocking + assertIs(addedState.updateResult) + assertIs(addedState.updateResult.e) } + } - @Test - fun testFedilabV1() = runTest { - // repo not in DB - every { repoDao.getRepository(any()) } returns null + @Test + fun testIzzy() = runBlocking { + // repo not in DB + every { repoDao.getRepository(any()) } returns null - repoAdder.fetchRepository( - url = "https://fdroid.fedilab.app/repo/" + - "?fingerprint=11F0A69910A4280E2CD3CCC3146337D006BE539B18E1A9FEACE15FF757A94FEB", - proxy = null - ) - repoAdder.addRepoState.test { - assertEquals(None, awaitItem()) - val firstFetching = awaitItem() - assertTrue(firstFetching is Fetching) - assertNull(firstFetching.receivedRepo) - assertTrue(firstFetching.apps.isEmpty()) + repoAdder.fetchRepositoryInt( + url = + "https://apt.izzysoft.de/fdroid/repo" + + "?fingerprint=3BF0D6ABFEAE2F401707B6D966BE743BF0EEE49C2561B9BA39073711F628937A" + ) + val state = repoAdder.addRepoState.value + assertTrue(state is Fetching, state.toString()) + assertTrue(state.apps.isNotEmpty()) - val secondFetching = awaitItem() - assertTrue(secondFetching is Fetching, "$secondFetching") - val repo = secondFetching.receivedRepo - assertNotNull(repo) - assertEquals("https://fdroid.fedilab.app/repo", repo.address) - println(repo.getName(LocaleListCompat.getDefault()) ?: "null") - println(repo.certificate) - - assertEquals(1, (awaitItem() as Fetching).apps.size) - assertEquals(2, (awaitItem() as Fetching).apps.size) - assertEquals(3, (awaitItem() as Fetching).apps.size) - assertEquals(4, (awaitItem() as Fetching).apps.size) - assertEquals(5, (awaitItem() as Fetching).apps.size) - assertTrue((awaitItem() as Fetching).done) - } - - val state = repoAdder.addRepoState.value - assertTrue(state is Fetching, state.toString()) - assertTrue(state.apps.isNotEmpty()) - state.apps.forEach { app -> - println(" ${app.packageName} ${app.summary}") - } - - val runSlot = slot>() - every { db.runInTransaction(capture(runSlot)) } answers { - runSlot.captured.call() - } - val newRepo: Repository = mockk() - every { newRepo.formatVersion } returns IndexFormatVersion.TWO - every { repoDao.insert(any()) } returns 42L - every { repoDao.getRepository(42L) } returns newRepo - - repoAdder.addFetchedRepository() - repoAdder.addRepoState.test { - val addedState = awaitItem() - assertTrue(addedState is Added, addedState.toString()) - assertEquals(newRepo, addedState.repo) - // we are not mocking all the actual repo adding, - // so just assert that this fails due to mocking - assertIs(addedState.updateResult) - assertIs(addedState.updateResult.e) - } - } - - @Test - fun testIzzy() = runBlocking { - // repo not in DB - every { repoDao.getRepository(any()) } returns null - - repoAdder.fetchRepositoryInt( - url = "https://apt.izzysoft.de/fdroid/repo" + - "?fingerprint=3BF0D6ABFEAE2F401707B6D966BE743BF0EEE49C2561B9BA39073711F628937A" - ) - val state = repoAdder.addRepoState.value - assertTrue(state is Fetching, state.toString()) - assertTrue(state.apps.isNotEmpty()) - - println(state.receivedRepo?.getName(LocaleListCompat.getDefault()) ?: "null") - println(state.receivedRepo?.certificate) - state.apps.forEach { app -> - println(" ${app.packageName} ${app.summary}") - } - } - - @Test - fun testIzzyWrongFingerprint() = runBlocking { - repoAdder.fetchRepositoryInt("https://apt.izzysoft.de/fdroid/repo?fingerprint=fooBar") - val state = repoAdder.addRepoState.value - assertTrue(state is AddRepoError, state.toString()) - assertEquals(state.errorType, INVALID_FINGERPRINT, state.errorType.name) - } + println(state.receivedRepo?.getName(LocaleListCompat.getDefault()) ?: "null") + println(state.receivedRepo?.certificate) + state.apps.forEach { app -> println(" ${app.packageName} ${app.summary}") } + } + @Test + fun testIzzyWrongFingerprint() = runBlocking { + repoAdder.fetchRepositoryInt("https://apt.izzysoft.de/fdroid/repo?fingerprint=fooBar") + val state = repoAdder.addRepoState.value + assertTrue(state is AddRepoError, state.toString()) + assertEquals(state.errorType, INVALID_FINGERPRINT, state.errorType.name) + } } diff --git a/libs/database/src/test/java/org/fdroid/repo/RepoAdderTest.kt b/libs/database/src/test/java/org/fdroid/repo/RepoAdderTest.kt index 86c5e617d..b04dc3c78 100644 --- a/libs/database/src/test/java/org/fdroid/repo/RepoAdderTest.kt +++ b/libs/database/src/test/java/org/fdroid/repo/RepoAdderTest.kt @@ -19,6 +19,18 @@ import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.slot import io.mockk.verify +import java.io.ByteArrayInputStream +import java.io.IOException +import java.security.DigestInputStream +import java.security.MessageDigest +import java.util.concurrent.Callable +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.test.fail import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -61,1003 +73,974 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith -import java.io.ByteArrayInputStream -import java.io.IOException -import java.security.DigestInputStream -import java.security.MessageDigest -import java.util.concurrent.Callable -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertFalse -import kotlin.test.assertIs -import kotlin.test.assertNull -import kotlin.test.assertTrue -import kotlin.test.fail @RunWith(AndroidJUnit4::class) internal class RepoAdderTest { - @get:Rule - var folder: TemporaryFolder = TemporaryFolder() + @get:Rule var folder: TemporaryFolder = TemporaryFolder() - private val context = InstrumentationRegistry.getInstrumentation().targetContext - private val db = mockk() - private val repoDao = mockk() - private val tempFileProvider = mockk() - private val httpManager = mockk() - private val compatibilityChecker = mockk() - private val downloaderFactory = mockk() - private val downloader = mockk() - private val digest = mockk() + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val db = mockk() + private val repoDao = mockk() + private val tempFileProvider = mockk() + private val httpManager = mockk() + private val compatibilityChecker = mockk() + private val downloaderFactory = mockk() + private val downloader = mockk() + private val digest = mockk() - private val repoAdder: RepoAdder - private val assets: AssetManager = context.resources.assets - private val localeList = LocaleListCompat.getDefault() + private val repoAdder: RepoAdder + private val assets: AssetManager = context.resources.assets + private val localeList = LocaleListCompat.getDefault() - init { - every { db.getRepositoryDao() } returns repoDao - every { digest.update(any(), any(), any()) } just Runs - every { digest.update(any()) } just Runs + init { + every { db.getRepositoryDao() } returns repoDao + every { digest.update(any(), any(), any()) } just Runs + every { digest.update(any()) } just Runs - mockkStatic("org.fdroid.download.HttpManagerKt") + mockkStatic("org.fdroid.download.HttpManagerKt") - repoAdder = RepoAdder( - context = context, - db = db, - tempFileProvider = tempFileProvider, - downloaderFactory = downloaderFactory, - httpManager = httpManager, - compatibilityChecker = compatibilityChecker, - ) + repoAdder = + RepoAdder( + context = context, + db = db, + tempFileProvider = tempFileProvider, + downloaderFactory = downloaderFactory, + httpManager = httpManager, + compatibilityChecker = compatibilityChecker, + ) + } + + @Test + fun testDisallowInstallUnknownSources() = runTest { + val context = mockk() + val userManager = mockk() + val repoAdder = + RepoAdder(context, db, tempFileProvider, downloaderFactory, httpManager, compatibilityChecker) + + every { context.getSystemService(UserManager::class.java) } returns userManager + every { userManager.hasUserRestriction(DISALLOW_INSTALL_UNKNOWN_SOURCES) } returns true + + repoAdder.fetchRepositoryInt("https://example.org/repo/") + repoAdder.addRepoState.test { + val state1 = awaitItem() + assertTrue(state1 is AddRepoError) + assertEquals(UNKNOWN_SOURCES_DISALLOWED, state1.errorType) + } + } + + @Test + fun testInvalidUri() = runTest { + repoAdder.fetchRepositoryInt("irc://example.org/repo/") // invalid scheme + + repoAdder.addRepoState.test { + val state1 = awaitItem() + assertIs(state1) + assertEquals(INVALID_INDEX, state1.errorType) } - @Test - fun testDisallowInstallUnknownSources() = runTest { - val context = mockk() - val userManager = mockk() - val repoAdder = RepoAdder( - context, - db, - tempFileProvider, - downloaderFactory, - httpManager, - compatibilityChecker, - ) + repoAdder.abortAddingRepo() + repoAdder.fetchRepositoryInt("https://%-") // invalid hostname - every { context.getSystemService(UserManager::class.java) } returns userManager - every { - userManager.hasUserRestriction(DISALLOW_INSTALL_UNKNOWN_SOURCES) - } returns true + repoAdder.addRepoState.test { + val state1 = awaitItem() + assertIs(state1) + assertEquals(INVALID_INDEX, state1.errorType) + } + } - repoAdder.fetchRepositoryInt("https://example.org/repo/") - repoAdder.addRepoState.test { - val state1 = awaitItem() - assertTrue(state1 is AddRepoError) - assertEquals(UNKNOWN_SOURCES_DISALLOWED, state1.errorType) + @Test + fun testAddingMinRepo() = runTest { + val url = TestDataMinV2.repo.address + testAddingMinRepoInt(url, IsNewRepository) + } + + @Test + fun testAddingMinRepoByUserMirror() = runTest { + val url = "https://user-mirror-of-min-v1.org/repo" + testAddingMinRepoInt(url, IsNewRepoAndNewMirror) + } + + private suspend fun testAddingMinRepoInt(url: String, expectedResult: FetchResult) { + val repoName = TestDataMinV2.repo.name.getBestLocale(localeList) + + mockMinRepoDownload(url) + + // repo not in DB + every { repoDao.getRepository(any()) } returns null + + expectMinRepoPreview(repoName, url, expectedResult) + + val newRepo: Repository = mockk() + mockNewRepoDbInsertion(repoName, TestDataMinV2.repo.address, newRepo, url) + + repoAdder.addRepoState.test { + val fetching: Fetching = awaitItem() as Fetching // still Fetching from last call + assertEquals(expectedResult, fetching.fetchResult) + + every { newRepo.formatVersion } returns IndexFormatVersion.TWO + every { newRepo.repoId } returns 1 + every { repoDao.trackRepoUpdateError(1, any()) } just Runs + + repoAdder.addFetchedRepository() + + assertIs(awaitItem()) // now moved to Adding + + val addedState = awaitItem() + assertIs(addedState) + assertEquals(newRepo, addedState.repo) + // we are not mocking all the actual repo adding, + // so just assert that this fails due to mocking + assertIs(addedState.updateResult) + assertIs(addedState.updateResult.e) + } + } + + @Test + fun testAddingMidRepoByOfficialMirror() = runTest { + val url = "https://mid-v1.com/repo" // official mirror + val repoName = TestDataMidV2.repo.name.getBestLocale(localeList) + + mockMidRepoDownload(url) + + // repo not in DB + every { repoDao.getRepository(any()) } returns null + + expectMidRepoPreview(repoName, url, IsNewRepository) + + val newRepo: Repository = mockk() + mockNewRepoDbInsertion(repoName, TestDataMidV2.repo.address, newRepo) + + repoAdder.addRepoState.test { + val fetching: Fetching = awaitItem() as Fetching // still Fetching from last call + assertIs(fetching.fetchResult) + + every { newRepo.formatVersion } returns IndexFormatVersion.TWO + every { newRepo.repoId } returns 1 + every { repoDao.trackRepoUpdateError(1, any()) } just Runs + + repoAdder.addFetchedRepository() + + assertIs(awaitItem()) // now moved to Adding + + val addedState = awaitItem() + assertIs(addedState) + assertEquals(newRepo, addedState.repo) + // we are not mocking all the actual repo adding, + // so just assert that this fails due to mocking + assertIs(addedState.updateResult) + assertIs(addedState.updateResult.e) + } + + verify(exactly = 0) { repoDao.updateUserMirrors(42L, listOf(url)) } + } + + @Test + fun testAddingUserMirrorForExistingMinRepo() = runTest { + val url = "https://user-mirror-of-min-v1.org/repo" + val repoName = TestDataMinV2.repo.name.getBestLocale(localeList) + + mockMinRepoDownload(url) + + // repo is already in the DB + val existingRepo = + Repository( + repository = + TestDataMinV2.repo.toCoreRepository( + repoId = 42L, + version = 1337L, + formatVersion = IndexFormatVersion.TWO, + certificate = "cert", + ), + mirrors = emptyList(), + antiFeatures = emptyList(), + categories = emptyList(), + releaseChannels = emptyList(), + preferences = RepositoryPreferences(42L, 23), + ) + every { repoDao.getRepository(any()) } returns existingRepo + + expectMinRepoPreview(repoName, url, IsNewMirror(42L)) + + val transactionSlot = slot>() + every { db.runInTransaction(capture(transactionSlot)) } answers + { + transactionSlot.captured.call() + } + every { repoDao.getRepository(42L) } returns existingRepo + every { repoDao.updateUserMirrors(42L, listOf(url.trimEnd('/'))) } just Runs + + repoAdder.addRepoState.test { + val fetching: Fetching = awaitItem() as Fetching // still Fetching from last call + assertIs(fetching.fetchResult) + assertEquals(existingRepo.repoId, fetching.fetchResult.existingRepoId) + + repoAdder.addFetchedRepository() + + assertIs(awaitItem()) // now moved to Adding + + val addedState = awaitItem() + assertTrue(addedState is Added, addedState.toString()) + assertEquals(existingRepo, addedState.repo) + } + + verify(exactly = 1) { repoDao.updateUserMirrors(42L, listOf(url.trimEnd('/'))) } + } + + @Test + fun testRepoAlreadyExists() = runTest { + val url = "https://min-v1.org/repo" + + // repo is already in the DB + val existingRepo = + Repository( + repository = + TestDataMinV2.repo.toCoreRepository( + repoId = REPO_ID, + version = 1337L, + formatVersion = IndexFormatVersion.TWO, + certificate = "cert", + ), + mirrors = emptyList(), + antiFeatures = emptyList(), + categories = emptyList(), + releaseChannels = emptyList(), + preferences = RepositoryPreferences(REPO_ID, 23), + ) + testRepoAlreadyExists(url, existingRepo) + } + + @Test + fun testRepoAlreadyExistsWithOfficialMirror() = runTest { + val url = "https://min-v1.org.org/repo" + val mirrorUrl = "https://official-mirror-of-min-v1.org.org/repo" + + // repo is already in the DB + val existingRepo = + Repository( + repository = + TestDataMinV2.repo.toCoreRepository( + repoId = REPO_ID, + version = 1337L, + formatVersion = IndexFormatVersion.TWO, + certificate = "cert", + ), + mirrors = listOf(Mirror(REPO_ID, url), Mirror(REPO_ID, mirrorUrl)), + antiFeatures = emptyList(), + categories = emptyList(), + releaseChannels = emptyList(), + preferences = RepositoryPreferences(REPO_ID, 23), + ) + + testRepoAlreadyExists(mirrorUrl, existingRepo) + } + + @Test + fun testRepoAlreadyExistsUserMirror() = runTest { + val url = "https://min-v1.org.org/repo" + val mirrorUrl = "https://user-mirror-of-min-v1.org.org/repo" + + // repo is already in the DB + val existingRepo = + Repository( + repository = + TestDataMinV2.repo.toCoreRepository( + repoId = REPO_ID, + version = 1337L, + formatVersion = IndexFormatVersion.TWO, + certificate = "cert", + ), + mirrors = listOf(Mirror(REPO_ID, url)), + antiFeatures = emptyList(), + categories = emptyList(), + releaseChannels = emptyList(), + preferences = + RepositoryPreferences(repoId = REPO_ID, weight = 23, userMirrors = listOf(url, mirrorUrl)), + ) + testRepoAlreadyExists(mirrorUrl, existingRepo) + } + + @Test + fun testRepoAlreadyExistsWithFingerprint() = runTest { + val url = "https://min-v1.org/repo?fingerprint=${VerifierConstants.FINGERPRINT}" + val downloadUrl = "https://min-v1.org/repo" + + // repo is already in the DB + val existingRepo = + Repository( + repository = + TestDataMinV2.repo.toCoreRepository( + repoId = REPO_ID, + version = 1337L, + formatVersion = IndexFormatVersion.TWO, + certificate = VerifierConstants.CERTIFICATE, + ), + mirrors = listOf(Mirror(REPO_ID, downloadUrl)), + antiFeatures = emptyList(), + categories = emptyList(), + releaseChannels = emptyList(), + preferences = RepositoryPreferences(REPO_ID, 23), + ) + testRepoAlreadyExists(url, existingRepo, downloadUrl) + } + + @Test + fun testRepoAlreadyExistsWithFingerprintTrailingSlash() = runTest { + val url = "https://min-v1.org/repo/?fingerprint=${VerifierConstants.FINGERPRINT}" + val downloadUrl = "https://min-v1.org/repo" + + // repo is already in the DB + val existingRepo = + Repository( + repository = + TestDataMinV2.repo.toCoreRepository( + repoId = REPO_ID, + version = 1337L, + formatVersion = IndexFormatVersion.TWO, + certificate = VerifierConstants.CERTIFICATE, + ), + mirrors = listOf(Mirror(REPO_ID, downloadUrl)), + antiFeatures = emptyList(), + categories = emptyList(), + releaseChannels = emptyList(), + preferences = RepositoryPreferences(REPO_ID, 23), + ) + testRepoAlreadyExists(url, existingRepo, downloadUrl) + } + + private suspend fun testRepoAlreadyExists( + // The URL that the user "entered" and that is passed to repoAdder.fetchRepository() + url: String, + existingRepo: Repository, + // The "normalized" URL that the HTTP stack will end up requesting (i.e., without + // username/password/fingerprint) + downloadUrl: String = url, + ) { + val repoName = TestDataMinV2.repo.name.getBestLocale(localeList) + + mockMinRepoDownload(downloadUrl) + + // repo is already in the DB + every { repoDao.getRepository(any()) } returns existingRepo + + val isRepo = existingRepo.address == downloadUrl + val expectedFetchResult = + if (isRepo) FetchResult.IsExistingRepository(existingRepo.repoId) + else FetchResult.IsExistingMirror(existingRepo.repoId) + + expectMinRepoPreview(repoName, url, expectedFetchResult) + + assertFailsWith { repoAdder.addFetchedRepository() } + } + + @Test + fun testDownloadEntryThrowsIoException() = runTest { + val url = "https://example.org/repo" + val jarFile = folder.newFile() + + every { tempFileProvider.createTempFile(any()) } returns jarFile + every { + downloaderFactory.create( + repo = match { it.address == url && it.formatVersion == IndexFormatVersion.TWO }, + uri = Uri.parse("$url/entry.jar"), + indexFile = any(), + destFile = jarFile, + ) + } returns downloader + every { downloader.download() } throws IOException() + + repoAdder.addRepoState.test { + assertIs(awaitItem()) + + repoAdder.fetchRepository(url = url, proxy = null) + + val state1 = awaitItem() + assertIs(state1) + assertNull(state1.receivedRepo) + assertTrue(state1.apps.isEmpty()) + assertNull(state1.fetchResult) + + val state2 = awaitItem() + assertTrue(state2 is AddRepoError, "$state2") + assertEquals(IO_ERROR, state2.errorType) + } + } + + @Test + fun testParsingThrowsSerializationException() = runTest { + val url = "https://example.org/repo" + val urlTrimmed = url.trimEnd('/') + val jarFile = folder.newFile() + val index = "{ invalid JSON foo bar,".toByteArray() + val indexStream = DigestInputStream(ByteArrayInputStream(index), digest) + + every { tempFileProvider.createTempFile(any()) } returns jarFile + every { + downloaderFactory.create( + repo = match { it.address == urlTrimmed && it.formatVersion == IndexFormatVersion.TWO }, + uri = Uri.parse("$urlTrimmed/entry.jar"), + indexFile = any(), + destFile = jarFile, + ) + } returns downloader + every { downloader.download() } answers + { + jarFile.outputStream().use { outputStream -> + assets.open("diff-empty-min/entry.jar").use { inputStream -> + inputStream.copyTo(outputStream) + } } - } - - @Test - fun testInvalidUri() = runTest { - repoAdder.fetchRepositoryInt("irc://example.org/repo/") // invalid scheme - - repoAdder.addRepoState.test { - val state1 = awaitItem() - assertIs(state1) - assertEquals(INVALID_INDEX, state1.errorType) + } + coEvery { + httpManager.getDigestInputStream( + match { + it.indexFile.name == "../index-min-v2.json" && + it.mirrors.size == 1 && + it.mirrors[0].baseUrl == urlTrimmed } + ) + } returns indexStream + every { + digest.digest() // sha256 from entry.json + } returns "851ecda085ed53adab25f761a9dbf4c09d59e5bff9c9d5530814d56445ae30f2".decodeHex() - repoAdder.abortAddingRepo() - repoAdder.fetchRepositoryInt("https://%-") // invalid hostname + repoAdder.addRepoState.test { + assertIs(awaitItem()) - repoAdder.addRepoState.test { - val state1 = awaitItem() - assertIs(state1) - assertEquals(INVALID_INDEX, state1.errorType) + repoAdder.fetchRepository(url = url, proxy = null) + + val state1 = awaitItem() + assertIs(state1) + assertNull(state1.receivedRepo) + assertTrue(state1.apps.isEmpty()) + assertNull(state1.fetchResult) + + val state2 = awaitItem() + assertTrue(state2 is AddRepoError, "$state2") + assertEquals(INVALID_INDEX, state2.errorType) + } + } + + @Test + fun testWrongFingerprint() = runTest { + val url = "https://example.org/repo/" + val jarFile = folder.newFile() + + every { tempFileProvider.createTempFile(any()) } returns jarFile + every { + downloaderFactory.create( + repo = + match { it.address == url.trimEnd('/') && it.formatVersion == IndexFormatVersion.TWO }, + uri = Uri.parse(url + "entry.jar"), + indexFile = any(), + destFile = jarFile, + ) + } returns downloader + // not actually thrown by the downloader, but mocking verifier is harder + every { downloader.download() } throws SigningException("boom!") + + repoAdder.addRepoState.test { + assertIs(awaitItem()) + + repoAdder.fetchRepository(url = url, proxy = null) + + val state1 = awaitItem() + assertIs(state1) + assertNull(state1.receivedRepo) + assertTrue(state1.apps.isEmpty()) + assertNull(state1.fetchResult) + + val state2 = awaitItem() + assertTrue(state2 is AddRepoError, "$state2") + assertEquals(INVALID_FINGERPRINT, state2.errorType) + } + } + + @Test + fun testWrongKnownFingerprint() = runTest { + val url = "https://example.org/repo" + testMinRepoPreview(url) { state2 -> + assertTrue(state2 is AddRepoError, "$state2") + assertEquals(INVALID_FINGERPRINT, state2.errorType) + val e = assertIs(state2.exception) + assertTrue(e.message!!.contains("Known fingerprint different")) + } + } + + @Test + fun testWrongKnownFingerprintWithGivenFingerprint() = runTest { + val url = "https://example.org/repo?fingerprint=${VerifierConstants.FINGERPRINT}" + testMinRepoPreview("https://example.org/repo", url) { state2 -> + assertTrue(state2 is AddRepoError, "$state2") + assertEquals(INVALID_FINGERPRINT, state2.errorType) + val e = assertIs(state2.exception) + assertTrue(e.message!!.contains("Known fingerprint different")) + } + } + + private suspend fun testMinRepoPreview( + repoAddress: String, + url: String = repoAddress, + onSecondState: (AddRepoState) -> Unit, + ) { + val jarFile = folder.newFile() + val repoV2 = RepoV2(address = "https://briarproject.org/fdroid/repo", timestamp = 42L) + val indexV2 = IndexV2(repo = repoV2) + val index = json.encodeToString(IndexV2.serializer(), indexV2).toByteArray() + val indexStream = DigestInputStream(ByteArrayInputStream(index), digest) + + every { tempFileProvider.createTempFile(any()) } returns jarFile + every { + downloaderFactory.create( + repo = match { it.address == repoAddress && it.formatVersion == IndexFormatVersion.TWO }, + uri = Uri.parse("$repoAddress/entry.jar"), + indexFile = any(), + destFile = jarFile, + ) + } returns downloader + every { downloader.download() } answers + { + jarFile.outputStream().use { outputStream -> + assets.open("diff-empty-min/entry.jar").use { inputStream -> + inputStream.copyTo(outputStream) + } } - } - - @Test - fun testAddingMinRepo() = runTest { - val url = TestDataMinV2.repo.address - testAddingMinRepoInt(url, IsNewRepository) - } - - @Test - fun testAddingMinRepoByUserMirror() = runTest { - val url = "https://user-mirror-of-min-v1.org/repo" - testAddingMinRepoInt(url, IsNewRepoAndNewMirror) - } - - private suspend fun testAddingMinRepoInt( - url: String, - expectedResult: FetchResult, - ) { - val repoName = TestDataMinV2.repo.name.getBestLocale(localeList) - - mockMinRepoDownload(url) - - // repo not in DB - every { repoDao.getRepository(any()) } returns null - - expectMinRepoPreview(repoName, url, expectedResult) - - val newRepo: Repository = mockk() - mockNewRepoDbInsertion(repoName, TestDataMinV2.repo.address, newRepo, url) - - repoAdder.addRepoState.test { - val fetching: Fetching = awaitItem() as Fetching // still Fetching from last call - assertEquals(expectedResult, fetching.fetchResult) - - every { newRepo.formatVersion } returns IndexFormatVersion.TWO - every { newRepo.repoId } returns 1 - every { repoDao.trackRepoUpdateError(1, any()) } just Runs - - repoAdder.addFetchedRepository() - - assertIs(awaitItem()) // now moved to Adding - - val addedState = awaitItem() - assertIs(addedState) - assertEquals(newRepo, addedState.repo) - // we are not mocking all the actual repo adding, - // so just assert that this fails due to mocking - assertIs(addedState.updateResult) - assertIs(addedState.updateResult.e) + } + coEvery { + httpManager.getDigestInputStream( + match { + it.indexFile.name == "../index-min-v2.json" && + it.mirrors.size == 1 && + it.mirrors[0].baseUrl == repoAddress } + ) + } returns indexStream + every { + digest.digest() // sha256 from entry.json + } returns "851ecda085ed53adab25f761a9dbf4c09d59e5bff9c9d5530814d56445ae30f2".decodeHex() + + repoAdder.addRepoState.test { + assertIs(awaitItem()) + + repoAdder.fetchRepository(url = url, proxy = null) + + val state1 = awaitItem() + assertIs(state1) + assertNull(state1.receivedRepo) + assertTrue(state1.apps.isEmpty()) + assertNull(state1.fetchResult) + + val state2 = awaitItem() + onSecondState(state2) } + } - @Test - fun testAddingMidRepoByOfficialMirror() = runTest { - val url = "https://mid-v1.com/repo" // official mirror - val repoName = TestDataMidV2.repo.name.getBestLocale(localeList) + @Test + fun testKnownFingerprintIsAccepted() = runTest { + val repoAddress = "https://guardianproject.info/fdroid/repo" + val fingerprint = knownRepos[repoAddress] + val url = "https://example.org/repo?fingerprint=$fingerprint" - mockMidRepoDownload(url) + val jarFile = folder.newFile() + val repoV2 = RepoV2(address = repoAddress, timestamp = 42L) + val indexV2 = IndexV2(repo = repoV2) + val index = json.encodeToString(IndexV2.serializer(), indexV2).toByteArray() + val indexStream = DigestInputStream(ByteArrayInputStream(index), digest) - // repo not in DB - every { repoDao.getRepository(any()) } returns null - - expectMidRepoPreview(repoName, url, IsNewRepository) - - val newRepo: Repository = mockk() - mockNewRepoDbInsertion(repoName, TestDataMidV2.repo.address, newRepo) - - repoAdder.addRepoState.test { - val fetching: Fetching = awaitItem() as Fetching // still Fetching from last call - assertIs(fetching.fetchResult) - - every { newRepo.formatVersion } returns IndexFormatVersion.TWO - every { newRepo.repoId } returns 1 - every { repoDao.trackRepoUpdateError(1, any()) } just Runs - - repoAdder.addFetchedRepository() - - assertIs(awaitItem()) // now moved to Adding - - val addedState = awaitItem() - assertIs(addedState) - assertEquals(newRepo, addedState.repo) - // we are not mocking all the actual repo adding, - // so just assert that this fails due to mocking - assertIs(addedState.updateResult) - assertIs(addedState.updateResult.e) + every { tempFileProvider.createTempFile(any()) } returns jarFile + every { + downloaderFactory.create( + repo = + match { + it.address == "https://example.org/repo" && it.formatVersion == IndexFormatVersion.TWO + }, + uri = Uri.parse("https://example.org/repo/entry.jar"), + indexFile = any(), + destFile = jarFile, + ) + } returns downloader + every { downloader.download() } answers + { + jarFile.outputStream().use { outputStream -> + assets.open("guardianproject_entry.jar").use { inputStream -> + inputStream.copyTo(outputStream) + } } - - verify(exactly = 0) { - repoDao.updateUserMirrors(42L, listOf(url)) + } + coEvery { + httpManager.getDigestInputStream( + match { + it.indexFile.name == "/index-v2.json" && + it.mirrors.size == 1 && + it.mirrors[0].baseUrl == "https://example.org/repo" } + ) + } returns indexStream + every { + digest.digest() // sha256 from entry.json + } returns "cd925cdc31c88e8509bd64e62f7680d8dbffe2643990f62404acfda71e538906".decodeHex() + + // repo not in DB + every { repoDao.getRepository(any()) } returns null + + repoAdder.addRepoState.test { + assertIs(awaitItem()) + + repoAdder.fetchRepository(url = url, proxy = null) + + val state1 = awaitItem() + assertIs(state1) + assertNull(state1.receivedRepo) + assertTrue(state1.apps.isEmpty()) + assertNull(state1.fetchResult) + + val state2 = awaitItem() + assertIs(state2) + assertEquals(repoAddress, state2.receivedRepo?.address) + assertIs(state2.fetchResult) + assertFalse(state2.done) + + val state3 = awaitItem() + assertIs(state3) + assertIs(state3.fetchResult) + assertTrue(state3.done) } + } - @Test - fun testAddingUserMirrorForExistingMinRepo() = runTest { - val url = "https://user-mirror-of-min-v1.org/repo" - val repoName = TestDataMinV2.repo.name.getBestLocale(localeList) + @Test + fun testFallbackToV1() = runTest { + val url = "http://testy.at.or.at/fdroid/repo/" + val urlTrimmed = "http://testy.at.or.at/fdroid/repo" - mockMinRepoDownload(url) + val jarFile = folder.newFile() + every { tempFileProvider.createTempFile(any()) } returns jarFile - // repo is already in the DB - val existingRepo = Repository( - repository = TestDataMinV2.repo.toCoreRepository( - repoId = 42L, - version = 1337L, - formatVersion = IndexFormatVersion.TWO, - certificate = "cert", - ), - mirrors = emptyList(), - antiFeatures = emptyList(), - categories = emptyList(), - releaseChannels = emptyList(), - preferences = RepositoryPreferences(42L, 23), - ) - every { repoDao.getRepository(any()) } returns existingRepo + every { + downloaderFactory.create( + repo = match { it.address == urlTrimmed && it.formatVersion == IndexFormatVersion.TWO }, + uri = Uri.parse("$urlTrimmed/entry.jar"), + indexFile = any(), + destFile = jarFile, + ) + } returns downloader + every { downloader.download() } throws NotFoundException() - expectMinRepoPreview(repoName, url, IsNewMirror(42L)) - - val transactionSlot = slot>() - every { - db.runInTransaction(capture(transactionSlot)) - } answers { transactionSlot.captured.call() } - every { repoDao.getRepository(42L) } returns existingRepo - every { repoDao.updateUserMirrors(42L, listOf(url.trimEnd('/'))) } just Runs - - repoAdder.addRepoState.test { - val fetching: Fetching = awaitItem() as Fetching // still Fetching from last call - assertIs(fetching.fetchResult) - assertEquals(existingRepo.repoId, fetching.fetchResult.existingRepoId) - - repoAdder.addFetchedRepository() - - assertIs(awaitItem()) // now moved to Adding - - val addedState = awaitItem() - assertTrue(addedState is Added, addedState.toString()) - assertEquals(existingRepo, addedState.repo) + val downloaderV1 = mockk() + every { + downloaderFactory.create( + repo = match { it.address == urlTrimmed && it.formatVersion == IndexFormatVersion.ONE }, + uri = Uri.parse("$urlTrimmed/index-v1.jar"), + indexFile = any(), + destFile = jarFile, + ) + } returns downloaderV1 + every { downloaderV1.download() } answers + { + jarFile.outputStream().use { outputStream -> + assets.open("testy.at.or.at_index-v1.jar").use { inputStream -> + inputStream.copyTo(outputStream) + } } + } + every { repoDao.getRepository(any()) } returns null - verify(exactly = 1) { - repoDao.updateUserMirrors(42L, listOf(url.trimEnd('/'))) + repoAdder.addRepoState.test { + assertIs(awaitItem()) + + repoAdder.fetchRepository(url = url, proxy = null) + + val state1 = awaitItem() + assertIs(state1) + assertNull(state1.receivedRepo) + assertTrue(state1.apps.isEmpty()) + + for (i in 0..64) assertIs(awaitItem()) + } + val addRepoState = repoAdder.addRepoState.value + assertIs(addRepoState) + assertIs(addRepoState.fetchResult) + assertEquals(63, addRepoState.apps.size) + } + + @Test + fun testDownloadV1ThrowsNotFoundException() = runTest { + val url = "https://example.org/repo" + val jarFile = folder.newFile() + + every { tempFileProvider.createTempFile(any()) } returns jarFile + every { + downloaderFactory.create( + repo = match { it.address == url && it.formatVersion == IndexFormatVersion.TWO }, + uri = Uri.parse("$url/entry.jar"), + indexFile = any(), + destFile = jarFile, + ) + } returns downloader + every { downloader.download() } throws NotFoundException() + + every { + downloaderFactory.create( + repo = match { it.address == url && it.formatVersion == IndexFormatVersion.ONE }, + uri = Uri.parse("$url/index-v1.jar"), + indexFile = any(), + destFile = jarFile, + ) + } returns downloader + every { downloader.download() } throws NotFoundException() + + repoAdder.addRepoState.test { + assertIs(awaitItem()) + + repoAdder.fetchRepository(url = url, proxy = null) + + val state1 = awaitItem() + assertIs(state1) + assertNull(state1.receivedRepo) + assertTrue(state1.apps.isEmpty()) + + val state2 = awaitItem() + assertTrue(state2 is AddRepoError, "$state2") + assertEquals(INVALID_INDEX, state2.errorType) + } + } + + @Test + fun testAddingMinRepoWithBasicAuth() = runTest { + val username = getRandomString() + val password = getRandomString() + val url = "https://$username:$password@min-v1.org/repo/" + val urlTrimmed = TestDataMinV2.repo.address + val repoName = TestDataMinV2.repo.name.getBestLocale(localeList) + + // The URL to be downloaded does not contain the username+password, + // they are passed via headers by the HttpManager. + mockMinRepoDownload() + + // repo not in DB + every { repoDao.getRepository(any()) } returns null + + expectMinRepoPreview(repoName, url, IsNewRepository) + + val newRepo: Repository = mockk() + val txnSlot = slot>() + every { db.runInTransaction(capture(txnSlot)) } answers + { + assertTrue(txnSlot.isCaptured) + txnSlot.captured.call() + } + every { + repoDao.insert( + match { + // Note that we are not using the url the user used to add the repo, + // but what the repo tells us to use + it.address == TestDataMinV2.repo.address && + it.formatVersion == IndexFormatVersion.TWO && + it.name.getBestLocale(localeList) == repoName && + it.username == username && + it.password == password // this is the important bit } + ) + } returns 42L + every { repoDao.updateUserMirrors(42L, listOf(urlTrimmed)) } just Runs + every { repoDao.getRepository(42L) } returns newRepo + + repoAdder.addRepoState.test { + assertIs(awaitItem()) // still Fetching from last call + + every { newRepo.formatVersion } returns IndexFormatVersion.TWO + every { newRepo.repoId } returns 1 + every { repoDao.trackRepoUpdateError(1, any()) } just Runs + repoAdder.addFetchedRepository() + + assertIs(awaitItem()) // now moved to Adding + + val addedState = awaitItem() + assertIs(addedState) + assertEquals(newRepo, addedState.repo) + // we are not mocking all the actual repo adding, + // so just assert that this fails due to mocking + assertIs(addedState.updateResult) + assertIs(addedState.updateResult.e) } + } - @Test - fun testRepoAlreadyExists() = runTest { - val url = "https://min-v1.org/repo" + private fun mockMinRepoDownload( + // Override the URL to download, e.g., when adding via a user mirror + downloadUrl: String = TestDataMinV2.repo.address + ) { + mockRepoDownload( + downloadUrl, + "index-min-v2.json", + "diff-empty-min/entry.jar", + "851ecda085ed53adab25f761a9dbf4c09d59e5bff9c9d5530814d56445ae30f2", + ) + } - // repo is already in the DB - val existingRepo = Repository( - repository = TestDataMinV2.repo.toCoreRepository( - repoId = REPO_ID, - version = 1337L, - formatVersion = IndexFormatVersion.TWO, - certificate = "cert", - ), - mirrors = emptyList(), - antiFeatures = emptyList(), - categories = emptyList(), - releaseChannels = emptyList(), - preferences = RepositoryPreferences(REPO_ID, 23), - ) - testRepoAlreadyExists(url, existingRepo) - } + private fun mockMidRepoDownload( + // Override the URL to download, e.g., when adding via a user/official mirror + downloadUrl: String = TestDataMidV2.repo.address + ) { + mockRepoDownload( + downloadUrl, + "index-mid-v2.json", + "diff-empty-mid/entry.jar", + "561630a90ec9bcc29bc133cbd14b2d14d94124bb043c8d48effbad9d18d482fb", + ) + } - @Test - fun testRepoAlreadyExistsWithOfficialMirror() = runTest { - val url = "https://min-v1.org.org/repo" - val mirrorUrl = "https://official-mirror-of-min-v1.org.org/repo" + private fun mockRepoDownload( + downloadUrlTrimmed: String, + indexFile: String, + entryJar: String, + digestHex: String, // sha256 of index-v2.json from entry.json + ) { + assert(!downloadUrlTrimmed.endsWith("/")) // otherwise you are using this helper wrong - // repo is already in the DB - val existingRepo = Repository( - repository = TestDataMinV2.repo.toCoreRepository( - repoId = REPO_ID, - version = 1337L, - formatVersion = IndexFormatVersion.TWO, - certificate = "cert", - ), - mirrors = listOf(Mirror(REPO_ID, url), Mirror(REPO_ID, mirrorUrl)), - antiFeatures = emptyList(), - categories = emptyList(), - releaseChannels = emptyList(), - preferences = RepositoryPreferences(REPO_ID, 23), - ) + val jarFile = folder.newFile() - testRepoAlreadyExists(mirrorUrl, existingRepo) - } + val indexInputStream = assets.open(indexFile) + val indexDigestStream = DigestInputStream(indexInputStream, digest) - @Test - fun testRepoAlreadyExistsUserMirror() = runTest { - val url = "https://min-v1.org.org/repo" - val mirrorUrl = "https://user-mirror-of-min-v1.org.org/repo" - - // repo is already in the DB - val existingRepo = Repository( - repository = TestDataMinV2.repo.toCoreRepository( - repoId = REPO_ID, - version = 1337L, - formatVersion = IndexFormatVersion.TWO, - certificate = "cert", - ), - mirrors = listOf(Mirror(REPO_ID, url)), - antiFeatures = emptyList(), - categories = emptyList(), - releaseChannels = emptyList(), - preferences = RepositoryPreferences( - repoId = REPO_ID, - weight = 23, - userMirrors = listOf(url, mirrorUrl), - ), - ) - testRepoAlreadyExists(mirrorUrl, existingRepo) - } - - @Test - fun testRepoAlreadyExistsWithFingerprint() = runTest { - val url = "https://min-v1.org/repo?fingerprint=${VerifierConstants.FINGERPRINT}" - val downloadUrl = "https://min-v1.org/repo" - - // repo is already in the DB - val existingRepo = Repository( - repository = TestDataMinV2.repo.toCoreRepository( - repoId = REPO_ID, - version = 1337L, - formatVersion = IndexFormatVersion.TWO, - certificate = VerifierConstants.CERTIFICATE, - ), - mirrors = listOf(Mirror(REPO_ID, downloadUrl)), - antiFeatures = emptyList(), - categories = emptyList(), - releaseChannels = emptyList(), - preferences = RepositoryPreferences(REPO_ID, 23), - ) - testRepoAlreadyExists(url, existingRepo, downloadUrl) - } - - @Test - fun testRepoAlreadyExistsWithFingerprintTrailingSlash() = runTest { - val url = "https://min-v1.org/repo/?fingerprint=${VerifierConstants.FINGERPRINT}" - val downloadUrl = "https://min-v1.org/repo" - - // repo is already in the DB - val existingRepo = Repository( - repository = TestDataMinV2.repo.toCoreRepository( - repoId = REPO_ID, - version = 1337L, - formatVersion = IndexFormatVersion.TWO, - certificate = VerifierConstants.CERTIFICATE, - ), - mirrors = listOf(Mirror(REPO_ID, downloadUrl)), - antiFeatures = emptyList(), - categories = emptyList(), - releaseChannels = emptyList(), - preferences = RepositoryPreferences(REPO_ID, 23), - ) - testRepoAlreadyExists(url, existingRepo, downloadUrl) - } - - private suspend fun testRepoAlreadyExists( - // The URL that the user "entered" and that is passed to repoAdder.fetchRepository() - url: String, - existingRepo: Repository, - // The "normalized" URL that the HTTP stack will end up requesting (i.e., without username/password/fingerprint) - downloadUrl: String = url, - ) { - val repoName = TestDataMinV2.repo.name.getBestLocale(localeList) - - mockMinRepoDownload(downloadUrl) - - // repo is already in the DB - every { repoDao.getRepository(any()) } returns existingRepo - - val isRepo = existingRepo.address == downloadUrl - val expectedFetchResult = - if (isRepo) FetchResult.IsExistingRepository(existingRepo.repoId) - else FetchResult.IsExistingMirror(existingRepo.repoId) - - expectMinRepoPreview(repoName, url, expectedFetchResult) - - assertFailsWith { - repoAdder.addFetchedRepository() + every { tempFileProvider.createTempFile(any()) } returns jarFile + every { + downloaderFactory.create( + repo = + match { it.address == downloadUrlTrimmed && it.formatVersion == IndexFormatVersion.TWO }, + uri = Uri.parse("$downloadUrlTrimmed/entry.jar"), + indexFile = any(), + destFile = jarFile, + ) + } returns downloader + every { downloader.download() } answers + { + jarFile.outputStream().use { outputStream -> + assets.open(entryJar).use { inputStream -> inputStream.copyTo(outputStream) } } + } + coEvery { + httpManager.getDigestInputStream( + match { + it.indexFile.name == "../$indexFile" && + it.mirrors.size == 1 && + it.mirrors[0].baseUrl == downloadUrlTrimmed + } + ) + } returns indexDigestStream + every { digest.digest() } returns digestHex.decodeHex() + } + + private fun mockNewRepoDbInsertion( + repoName: String?, + repoAddress: String, + newRepo: Repository, + userMirrorUrl: String? = null, + ) { + val txnSlot = slot>() + every { db.runInTransaction(capture(txnSlot)) } answers + { + assertTrue(txnSlot.isCaptured) + txnSlot.captured.call() + } + + every { + repoDao.insert( + match { + // Note that we are not using the url the user used to add the repo, + // but what the repo tells us to use + it.address == repoAddress && + it.formatVersion == IndexFormatVersion.TWO && + it.name.getBestLocale(localeList) == repoName + } + ) + } returns 42L + every { repoDao.getRepository(42L) } returns newRepo + + if (userMirrorUrl != null && userMirrorUrl != repoAddress) { + every { repoDao.updateUserMirrors(42L, listOf(userMirrorUrl)) } just Runs } + } - @Test - fun testDownloadEntryThrowsIoException() = runTest { - val url = "https://example.org/repo" - val jarFile = folder.newFile() - - every { tempFileProvider.createTempFile(any()) } returns jarFile - every { - downloaderFactory.create( - repo = match { it.address == url && it.formatVersion == IndexFormatVersion.TWO }, - uri = Uri.parse("$url/entry.jar"), - indexFile = any(), - destFile = jarFile, - ) - } returns downloader - every { downloader.download() } throws IOException() - - repoAdder.addRepoState.test { - assertIs(awaitItem()) - - repoAdder.fetchRepository(url = url, proxy = null) - - val state1 = awaitItem() - assertIs(state1) - assertNull(state1.receivedRepo) - assertTrue(state1.apps.isEmpty()) - assertNull(state1.fetchResult) - - val state2 = awaitItem() - assertTrue(state2 is AddRepoError, "$state2") - assertEquals(IO_ERROR, state2.errorType) - } + private suspend fun expectMinRepoPreview( + repoName: String?, + url: String, + expectedFetchResult: FetchResult, + ) { + expectRepoPreview(repoName, url, expectedFetchResult, TestDataMinV2.repo) { + val state = awaitItem() + assertIs(state) + assertEquals(TestDataMinV2.packages.size, state.apps.size) + assertEquals(TestDataMinV2.PACKAGE_NAME, state.apps[0].packageName) + assertFalse(state.done) } + } - @Test - fun testParsingThrowsSerializationException() = runTest { - val url = "https://example.org/repo" - val urlTrimmed = url.trimEnd('/') - val jarFile = folder.newFile() - val index = "{ invalid JSON foo bar,".toByteArray() - val indexStream = DigestInputStream(ByteArrayInputStream(index), digest) + private suspend fun expectMidRepoPreview( + repoName: String?, + url: String, + expectedFetchResult: FetchResult, + ) { + expectRepoPreview(repoName, url, expectedFetchResult, TestDataMidV2.repo) { + val state = awaitItem() + assertIs(state) + assertEquals(1, state.apps.size) + assertEquals(TestDataMidV2.PACKAGE_NAME_1, state.apps[0].packageName) + assertFalse(state.done) - every { tempFileProvider.createTempFile(any()) } returns jarFile - every { - downloaderFactory.create( - repo = match { - it.address == urlTrimmed && it.formatVersion == IndexFormatVersion.TWO - }, - uri = Uri.parse("$urlTrimmed/entry.jar"), - indexFile = any(), - destFile = jarFile, - ) - } returns downloader - every { downloader.download() } answers { - jarFile.outputStream().use { outputStream -> - assets.open("diff-empty-min/entry.jar").use { inputStream -> - inputStream.copyTo(outputStream) - } - } - } - coEvery { - httpManager.getDigestInputStream(match { - it.indexFile.name == "../index-min-v2.json" && - it.mirrors.size == 1 && - it.mirrors[0].baseUrl == urlTrimmed - }) - } returns indexStream - every { - digest.digest() // sha256 from entry.json - } returns "851ecda085ed53adab25f761a9dbf4c09d59e5bff9c9d5530814d56445ae30f2".decodeHex() - - repoAdder.addRepoState.test { - assertIs(awaitItem()) - - repoAdder.fetchRepository(url = url, proxy = null) - - val state1 = awaitItem() - assertIs(state1) - assertNull(state1.receivedRepo) - assertTrue(state1.apps.isEmpty()) - assertNull(state1.fetchResult) - - val state2 = awaitItem() - assertTrue(state2 is AddRepoError, "$state2") - assertEquals(INVALID_INDEX, state2.errorType) - } + // onAppReceived (second app) + val stateNext = awaitItem() + assertIs(stateNext) + assertEquals(TestDataMidV2.packages.size, stateNext.apps.size) + assertEquals(TestDataMidV2.PACKAGE_NAME_1, stateNext.apps[0].packageName) + assertEquals(TestDataMidV2.PACKAGE_NAME_2, stateNext.apps[1].packageName) + assertFalse(stateNext.done) } + } - @Test - fun testWrongFingerprint() = runTest { - val url = "https://example.org/repo/" - val jarFile = folder.newFile() + private suspend fun expectRepoPreview( + repoName: String?, + url: String, + expectedFetchResult: FetchResult, + expectedRepo: RepoV2, + onAppsReceived: suspend TurbineTestContext.() -> Unit, + ) { + repoAdder.addRepoState.test { + assertIs(awaitItem()) - every { tempFileProvider.createTempFile(any()) } returns jarFile - every { - downloaderFactory.create( - repo = match { - it.address == url.trimEnd('/') && it.formatVersion == IndexFormatVersion.TWO - }, - uri = Uri.parse(url + "entry.jar"), - indexFile = any(), - destFile = jarFile, - ) - } returns downloader - // not actually thrown by the downloader, but mocking verifier is harder - every { downloader.download() } throws SigningException("boom!") + launch(Dispatchers.IO) { + // FIXME executing this block may emit items too fast, so we might miss one + // causing flaky tests. A short delay may fix it, let's see. + delay(750) + repoAdder.fetchRepository(url = url, proxy = null) + } - repoAdder.addRepoState.test { - assertIs(awaitItem()) + // early empty state + val state1 = awaitItem() + assertIs(state1) + assertNull(state1.receivedRepo) + assertEquals(emptyList(), state1.apps) + assertFalse(state1.done) - repoAdder.fetchRepository(url = url, proxy = null) + // onRepoReceived + val state2 = awaitItem() + assertIs(state2) + val repo = state2.receivedRepo ?: fail() + assertEquals(expectedRepo.address, repo.address) + assertEquals(repoName, repo.getName(localeList)) + assertEquals(expectedRepo.mirrors.toMirrors(0L), repo.mirrors) + val result = state2.fetchResult ?: fail() + assertEquals(expectedFetchResult, result) + assertTrue(state2.apps.isEmpty()) + assertFalse(state2.done) - val state1 = awaitItem() - assertIs(state1) - assertNull(state1.receivedRepo) - assertTrue(state1.apps.isEmpty()) - assertNull(state1.fetchResult) + // onAppReceived (state3) + onAppsReceived(this) - val state2 = awaitItem() - assertTrue(state2 is AddRepoError, "$state2") - assertEquals(INVALID_FINGERPRINT, state2.errorType) - } - } - - @Test - fun testWrongKnownFingerprint() = runTest { - val url = "https://example.org/repo" - testMinRepoPreview(url) { state2 -> - assertTrue(state2 is AddRepoError, "$state2") - assertEquals(INVALID_FINGERPRINT, state2.errorType) - val e = assertIs(state2.exception) - assertTrue(e.message!!.contains("Known fingerprint different")) - } - } - - @Test - fun testWrongKnownFingerprintWithGivenFingerprint() = runTest { - val url = "https://example.org/repo?fingerprint=${VerifierConstants.FINGERPRINT}" - testMinRepoPreview("https://example.org/repo", url) { state2 -> - assertTrue(state2 is AddRepoError, "$state2") - assertEquals(INVALID_FINGERPRINT, state2.errorType) - val e = assertIs(state2.exception) - assertTrue(e.message!!.contains("Known fingerprint different")) - } - } - - private suspend fun testMinRepoPreview( - repoAddress: String, - url: String = repoAddress, - onSecondState: (AddRepoState) -> Unit, - ) { - val jarFile = folder.newFile() - val repoV2 = RepoV2( - address = "https://briarproject.org/fdroid/repo", - timestamp = 42L, - ) - val indexV2 = IndexV2(repo = repoV2) - val index = json.encodeToString(IndexV2.serializer(), indexV2).toByteArray() - val indexStream = DigestInputStream(ByteArrayInputStream(index), digest) - - every { tempFileProvider.createTempFile(any()) } returns jarFile - every { - downloaderFactory.create( - repo = match { - it.address == repoAddress && it.formatVersion == IndexFormatVersion.TWO - }, - uri = Uri.parse("$repoAddress/entry.jar"), - indexFile = any(), - destFile = jarFile, - ) - } returns downloader - every { downloader.download() } answers { - jarFile.outputStream().use { outputStream -> - assets.open("diff-empty-min/entry.jar").use { inputStream -> - inputStream.copyTo(outputStream) - } - } - } - coEvery { - httpManager.getDigestInputStream(match { - it.indexFile.name == "../index-min-v2.json" && - it.mirrors.size == 1 && - it.mirrors[0].baseUrl == repoAddress - }) - } returns indexStream - every { - digest.digest() // sha256 from entry.json - } returns "851ecda085ed53adab25f761a9dbf4c09d59e5bff9c9d5530814d56445ae30f2".decodeHex() - - repoAdder.addRepoState.test { - assertIs(awaitItem()) - - repoAdder.fetchRepository(url = url, proxy = null) - - val state1 = awaitItem() - assertIs(state1) - assertNull(state1.receivedRepo) - assertTrue(state1.apps.isEmpty()) - assertNull(state1.fetchResult) - - val state2 = awaitItem() - onSecondState(state2) - } - } - - @Test - fun testKnownFingerprintIsAccepted() = runTest { - val repoAddress = "https://guardianproject.info/fdroid/repo" - val fingerprint = knownRepos[repoAddress] - val url = "https://example.org/repo?fingerprint=$fingerprint" - - val jarFile = folder.newFile() - val repoV2 = RepoV2( - address = repoAddress, - timestamp = 42L, - ) - val indexV2 = IndexV2(repo = repoV2) - val index = json.encodeToString(IndexV2.serializer(), indexV2).toByteArray() - val indexStream = DigestInputStream(ByteArrayInputStream(index), digest) - - every { tempFileProvider.createTempFile(any()) } returns jarFile - every { - downloaderFactory.create( - repo = match { - it.address == "https://example.org/repo" && - it.formatVersion == IndexFormatVersion.TWO - }, - uri = Uri.parse("https://example.org/repo/entry.jar"), - indexFile = any(), - destFile = jarFile, - ) - } returns downloader - every { downloader.download() } answers { - jarFile.outputStream().use { outputStream -> - assets.open("guardianproject_entry.jar").use { inputStream -> - inputStream.copyTo(outputStream) - } - } - } - coEvery { - httpManager.getDigestInputStream(match { - it.indexFile.name == "/index-v2.json" && - it.mirrors.size == 1 && - it.mirrors[0].baseUrl == "https://example.org/repo" - }) - } returns indexStream - every { - digest.digest() // sha256 from entry.json - } returns "cd925cdc31c88e8509bd64e62f7680d8dbffe2643990f62404acfda71e538906".decodeHex() - - // repo not in DB - every { repoDao.getRepository(any()) } returns null - - repoAdder.addRepoState.test { - assertIs(awaitItem()) - - repoAdder.fetchRepository(url = url, proxy = null) - - val state1 = awaitItem() - assertIs(state1) - assertNull(state1.receivedRepo) - assertTrue(state1.apps.isEmpty()) - assertNull(state1.fetchResult) - - val state2 = awaitItem() - assertIs(state2) - assertEquals(repoAddress, state2.receivedRepo?.address) - assertIs(state2.fetchResult) - assertFalse(state2.done) - - val state3 = awaitItem() - assertIs(state3) - assertIs(state3.fetchResult) - assertTrue(state3.done) - } - } - - @Test - fun testFallbackToV1() = runTest { - val url = "http://testy.at.or.at/fdroid/repo/" - val urlTrimmed = "http://testy.at.or.at/fdroid/repo" - - val jarFile = folder.newFile() - every { tempFileProvider.createTempFile(any()) } returns jarFile - - every { - downloaderFactory.create( - repo = match { - it.address == urlTrimmed && it.formatVersion == IndexFormatVersion.TWO - }, - uri = Uri.parse("$urlTrimmed/entry.jar"), - indexFile = any(), - destFile = jarFile, - ) - } returns downloader - every { downloader.download() } throws NotFoundException() - - val downloaderV1 = mockk() - every { - downloaderFactory.create( - repo = match { - it.address == urlTrimmed && it.formatVersion == IndexFormatVersion.ONE - }, - uri = Uri.parse("$urlTrimmed/index-v1.jar"), - indexFile = any(), - destFile = jarFile, - ) - } returns downloaderV1 - every { downloaderV1.download() } answers { - jarFile.outputStream().use { outputStream -> - assets.open("testy.at.or.at_index-v1.jar").use { inputStream -> - inputStream.copyTo(outputStream) - } - } - } - every { repoDao.getRepository(any()) } returns null - - repoAdder.addRepoState.test { - assertIs(awaitItem()) - - repoAdder.fetchRepository(url = url, proxy = null) - - val state1 = awaitItem() - assertIs(state1) - assertNull(state1.receivedRepo) - assertTrue(state1.apps.isEmpty()) - - for (i in 0..64) assertIs(awaitItem()) - } - val addRepoState = repoAdder.addRepoState.value - assertIs(addRepoState) - assertIs(addRepoState.fetchResult) - assertEquals(63, addRepoState.apps.size) - } - - @Test - fun testDownloadV1ThrowsNotFoundException() = runTest { - val url = "https://example.org/repo" - val jarFile = folder.newFile() - - every { tempFileProvider.createTempFile(any()) } returns jarFile - every { - downloaderFactory.create( - repo = match { it.address == url && it.formatVersion == IndexFormatVersion.TWO }, - uri = Uri.parse("$url/entry.jar"), - indexFile = any(), - destFile = jarFile, - ) - } returns downloader - every { downloader.download() } throws NotFoundException() - - every { - downloaderFactory.create( - repo = match { it.address == url && it.formatVersion == IndexFormatVersion.ONE }, - uri = Uri.parse("$url/index-v1.jar"), - indexFile = any(), - destFile = jarFile, - ) - } returns downloader - every { downloader.download() } throws NotFoundException() - - repoAdder.addRepoState.test { - assertIs(awaitItem()) - - repoAdder.fetchRepository(url = url, proxy = null) - - val state1 = awaitItem() - assertIs(state1) - assertNull(state1.receivedRepo) - assertTrue(state1.apps.isEmpty()) - - val state2 = awaitItem() - assertTrue(state2 is AddRepoError, "$state2") - assertEquals(INVALID_INDEX, state2.errorType) - } - } - - @Test - fun testAddingMinRepoWithBasicAuth() = runTest { - val username = getRandomString() - val password = getRandomString() - val url = "https://$username:$password@min-v1.org/repo/" - val urlTrimmed = TestDataMinV2.repo.address - val repoName = TestDataMinV2.repo.name.getBestLocale(localeList) - - // The URL to be downloaded does not contain the username+password, - // they are passed via headers by the HttpManager. - mockMinRepoDownload() - - // repo not in DB - every { repoDao.getRepository(any()) } returns null - - expectMinRepoPreview(repoName, url, IsNewRepository) - - val newRepo: Repository = mockk() - val txnSlot = slot>() - every { db.runInTransaction(capture(txnSlot)) } answers { - assertTrue(txnSlot.isCaptured) - txnSlot.captured.call() - } - every { - repoDao.insert(match { - // Note that we are not using the url the user used to add the repo, - // but what the repo tells us to use - it.address == TestDataMinV2.repo.address && - it.formatVersion == IndexFormatVersion.TWO && - it.name.getBestLocale(localeList) == repoName && - it.username == username && - it.password == password // this is the important bit - }) - } returns 42L - every { repoDao.updateUserMirrors(42L, listOf(urlTrimmed)) } just Runs - every { repoDao.getRepository(42L) } returns newRepo - - repoAdder.addRepoState.test { - assertIs(awaitItem()) // still Fetching from last call - - every { newRepo.formatVersion } returns IndexFormatVersion.TWO - every { newRepo.repoId } returns 1 - every { repoDao.trackRepoUpdateError(1, any()) } just Runs - repoAdder.addFetchedRepository() - - assertIs(awaitItem()) // now moved to Adding - - val addedState = awaitItem() - assertIs(addedState) - assertEquals(newRepo, addedState.repo) - // we are not mocking all the actual repo adding, - // so just assert that this fails due to mocking - assertIs(addedState.updateResult) - assertIs(addedState.updateResult.e) - } - } - - private fun mockMinRepoDownload( - // Override the URL to download, e.g., when adding via a user mirror - downloadUrl: String = TestDataMinV2.repo.address, - ) { - mockRepoDownload( - downloadUrl, - "index-min-v2.json", - "diff-empty-min/entry.jar", - "851ecda085ed53adab25f761a9dbf4c09d59e5bff9c9d5530814d56445ae30f2", - ) - } - - private fun mockMidRepoDownload( - // Override the URL to download, e.g., when adding via a user/official mirror - downloadUrl: String = TestDataMidV2.repo.address, - ) { - mockRepoDownload( - downloadUrl, - "index-mid-v2.json", - "diff-empty-mid/entry.jar", - "561630a90ec9bcc29bc133cbd14b2d14d94124bb043c8d48effbad9d18d482fb", - ) - } - - private fun mockRepoDownload( - downloadUrlTrimmed: String, - indexFile: String, - entryJar: String, - digestHex: String, // sha256 of index-v2.json from entry.json - ) { - assert(!downloadUrlTrimmed.endsWith("/")) // otherwise you are using this helper wrong - - val jarFile = folder.newFile() - - val indexInputStream = assets.open(indexFile) - val indexDigestStream = DigestInputStream(indexInputStream, digest) - - every { tempFileProvider.createTempFile(any()) } returns jarFile - every { - downloaderFactory.create( - repo = match { - it.address == downloadUrlTrimmed && it.formatVersion == IndexFormatVersion.TWO - }, - uri = Uri.parse("$downloadUrlTrimmed/entry.jar"), - indexFile = any(), - destFile = jarFile, - ) - } returns downloader - every { downloader.download() } answers { - jarFile.outputStream().use { outputStream -> - assets.open(entryJar).use { inputStream -> - inputStream.copyTo(outputStream) - } - } - } - coEvery { - httpManager.getDigestInputStream(match { - it.indexFile.name == "../$indexFile" && - it.mirrors.size == 1 && - it.mirrors[0].baseUrl == downloadUrlTrimmed - }) - } returns indexDigestStream - every { - digest.digest() - } returns digestHex.decodeHex() - } - - private fun mockNewRepoDbInsertion( - repoName: String?, - repoAddress: String, - newRepo: Repository, - userMirrorUrl: String? = null, - ) { - val txnSlot = slot>() - every { db.runInTransaction(capture(txnSlot)) } answers { - assertTrue(txnSlot.isCaptured) - txnSlot.captured.call() - } - - every { - repoDao.insert(match { - // Note that we are not using the url the user used to add the repo, - // but what the repo tells us to use - it.address == repoAddress && - it.formatVersion == IndexFormatVersion.TWO && - it.name.getBestLocale(localeList) == repoName - }) - } returns 42L - every { repoDao.getRepository(42L) } returns newRepo - - if (userMirrorUrl != null && userMirrorUrl != repoAddress) { - every { repoDao.updateUserMirrors(42L, listOf(userMirrorUrl)) } just Runs - } - } - - private suspend fun expectMinRepoPreview( - repoName: String?, - url: String, - expectedFetchResult: FetchResult, - ) { - expectRepoPreview( - repoName, - url, - expectedFetchResult, - TestDataMinV2.repo, - ) { - val state = awaitItem() - assertIs(state) - assertEquals(TestDataMinV2.packages.size, state.apps.size) - assertEquals(TestDataMinV2.PACKAGE_NAME, state.apps[0].packageName) - assertFalse(state.done) - } - } - - private suspend fun expectMidRepoPreview( - repoName: String?, - url: String, - expectedFetchResult: FetchResult, - ) { - expectRepoPreview( - repoName, - url, - expectedFetchResult, - TestDataMidV2.repo, - ) { - val state = awaitItem() - assertIs(state) - assertEquals(1, state.apps.size) - assertEquals(TestDataMidV2.PACKAGE_NAME_1, state.apps[0].packageName) - assertFalse(state.done) - - // onAppReceived (second app) - val stateNext = awaitItem() - assertIs(stateNext) - assertEquals(TestDataMidV2.packages.size, stateNext.apps.size) - assertEquals(TestDataMidV2.PACKAGE_NAME_1, stateNext.apps[0].packageName) - assertEquals(TestDataMidV2.PACKAGE_NAME_2, stateNext.apps[1].packageName) - assertFalse(stateNext.done) - } - } - - private suspend fun expectRepoPreview( - repoName: String?, - url: String, - expectedFetchResult: FetchResult, - expectedRepo: RepoV2, - onAppsReceived: suspend TurbineTestContext.() -> Unit, - ) { - repoAdder.addRepoState.test { - assertIs(awaitItem()) - - launch(Dispatchers.IO) { - // FIXME executing this block may emit items too fast, so we might miss one - // causing flaky tests. A short delay may fix it, let's see. - delay(750) - repoAdder.fetchRepository(url = url, proxy = null) - } - - // early empty state - val state1 = awaitItem() - assertIs(state1) - assertNull(state1.receivedRepo) - assertEquals(emptyList(), state1.apps) - assertFalse(state1.done) - - // onRepoReceived - val state2 = awaitItem() - assertIs(state2) - val repo = state2.receivedRepo ?: fail() - assertEquals(expectedRepo.address, repo.address) - assertEquals(repoName, repo.getName(localeList)) - assertEquals(expectedRepo.mirrors.toMirrors(0L), repo.mirrors) - val result = state2.fetchResult ?: fail() - assertEquals(expectedFetchResult, result) - assertTrue(state2.apps.isEmpty()) - assertFalse(state2.done) - - // onAppReceived (state3) - onAppsReceived(this) - - // final result - val state4 = awaitItem() - assertIs(state4) - assertTrue(state4.done) - - expectNoEvents() - } + // final result + val state4 = awaitItem() + assertIs(state4) + assertTrue(state4.done) + + expectNoEvents() } + } } diff --git a/libs/database/src/test/java/org/fdroid/repo/RepoUriGetterTest.kt b/libs/database/src/test/java/org/fdroid/repo/RepoUriGetterTest.kt index 00b7a947e..94c564bbe 100644 --- a/libs/database/src/test/java/org/fdroid/repo/RepoUriGetterTest.kt +++ b/libs/database/src/test/java/org/fdroid/repo/RepoUriGetterTest.kt @@ -2,227 +2,228 @@ package org.fdroid.repo import android.net.Uri import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.junit.Test -import org.junit.runner.RunWith import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue +import org.junit.Test +import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) internal class RepoUriGetterTest { - @Test - fun testTrailingSlash() { - val uri = RepoUriGetter.getUri("http://example.org/fdroid/repo/") - assertEquals("http://example.org/fdroid/repo", uri.uri.toString()) - assertNull(uri.fingerprint) - } + @Test + fun testTrailingSlash() { + val uri = RepoUriGetter.getUri("http://example.org/fdroid/repo/") + assertEquals("http://example.org/fdroid/repo", uri.uri.toString()) + assertNull(uri.fingerprint) + } - @Test - fun testWithoutTrailingSlash() { - val uri = RepoUriGetter.getUri("http://example.org/fdroid/repo") - assertEquals("http://example.org/fdroid/repo", uri.uri.toString()) - assertNull(uri.fingerprint) - } + @Test + fun testWithoutTrailingSlash() { + val uri = RepoUriGetter.getUri("http://example.org/fdroid/repo") + assertEquals("http://example.org/fdroid/repo", uri.uri.toString()) + assertNull(uri.fingerprint) + } - @Test - fun testFingerprint() { - val uri = RepoUriGetter.getUri("https://example.org/repo?fingerprint=foobar&test=42") - assertEquals("https://example.org/repo", uri.uri.toString()) - assertEquals("foobar", uri.fingerprint) - } + @Test + fun testFingerprint() { + val uri = RepoUriGetter.getUri("https://example.org/repo?fingerprint=foobar&test=42") + assertEquals("https://example.org/repo", uri.uri.toString()) + assertEquals("foobar", uri.fingerprint) + } - @Test - fun testFingerprintWithTrailingWhitespace() { - val uri1 = RepoUriGetter.getUri("https://example.org/repo?fingerprint=foobar ") - assertEquals("https://example.org/repo", uri1.uri.toString()) - assertEquals("foobar", uri1.fingerprint) + @Test + fun testFingerprintWithTrailingWhitespace() { + val uri1 = RepoUriGetter.getUri("https://example.org/repo?fingerprint=foobar ") + assertEquals("https://example.org/repo", uri1.uri.toString()) + assertEquals("foobar", uri1.fingerprint) - val uri2 = RepoUriGetter.getUri("https://example.org/repo?fingerprint=foobar ") - assertEquals("https://example.org/repo", uri2.uri.toString()) - assertEquals("foobar", uri2.fingerprint) - } + val uri2 = RepoUriGetter.getUri("https://example.org/repo?fingerprint=foobar ") + assertEquals("https://example.org/repo", uri2.uri.toString()) + assertEquals("foobar", uri2.fingerprint) + } - @Test - fun testHash() { - val uri = RepoUriGetter.getUri("https://example.org/repo?fingerprint=foobar&test=42#hash") - assertEquals("https://example.org/repo", uri.uri.toString()) - assertEquals("foobar", uri.fingerprint) - } + @Test + fun testHash() { + val uri = RepoUriGetter.getUri("https://example.org/repo?fingerprint=foobar&test=42#hash") + assertEquals("https://example.org/repo", uri.uri.toString()) + assertEquals("foobar", uri.fingerprint) + } - @Test - fun testAddFdroidSlashRepo() { - val uri = RepoUriGetter.getUri("https://example.org") - assertEquals("https://example.org/fdroid/repo", uri.uri.toString()) - assertNull(uri.fingerprint) - } + @Test + fun testAddFdroidSlashRepo() { + val uri = RepoUriGetter.getUri("https://example.org") + assertEquals("https://example.org/fdroid/repo", uri.uri.toString()) + assertNull(uri.fingerprint) + } - @Test - fun testLeaveSingleRepo() { - val uri = RepoUriGetter.getUri("https://example.org/repo") - assertEquals("https://example.org/repo", uri.uri.toString()) - assertNull(uri.fingerprint) - } + @Test + fun testLeaveSingleRepo() { + val uri = RepoUriGetter.getUri("https://example.org/repo") + assertEquals("https://example.org/repo", uri.uri.toString()) + assertNull(uri.fingerprint) + } - @Test - fun testAddsMissingRepo() { - val uri = RepoUriGetter.getUri("https://example.org/fdroid/") - assertEquals("https://example.org/fdroid/repo", uri.uri.toString()) - assertNull(uri.fingerprint) - } + @Test + fun testAddsMissingRepo() { + val uri = RepoUriGetter.getUri("https://example.org/fdroid/") + assertEquals("https://example.org/fdroid/repo", uri.uri.toString()) + assertNull(uri.fingerprint) + } - @Test - fun testFDroidLink() { - val uri1 = RepoUriGetter.getUri( - "https://fdroid.link/index.html#https://f-droid.org/repo?" + - "fingerprint=43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab" - ) - assertEquals("https://f-droid.org/repo", uri1.uri.toString()) - assertEquals( - "43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab", - uri1.fingerprint - ) + @Test + fun testFDroidLink() { + val uri1 = + RepoUriGetter.getUri( + "https://fdroid.link/index.html#https://f-droid.org/repo?" + + "fingerprint=43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab" + ) + assertEquals("https://f-droid.org/repo", uri1.uri.toString()) + assertEquals( + "43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab", + uri1.fingerprint, + ) - val uri2 = RepoUriGetter.getUri("https://fdroid.link#https://f-droid.org/repo") - assertEquals("https://f-droid.org/repo", uri2.uri.toString()) - assertNull(uri2.fingerprint) + val uri2 = RepoUriGetter.getUri("https://fdroid.link#https://f-droid.org/repo") + assertEquals("https://f-droid.org/repo", uri2.uri.toString()) + assertNull(uri2.fingerprint) - val uri3 = RepoUriGetter.getUri("https://fdroid.link/#http://f-droid.org/repo") - assertEquals("http://f-droid.org/repo", uri3.uri.toString()) - assertNull(uri3.fingerprint) + val uri3 = RepoUriGetter.getUri("https://fdroid.link/#http://f-droid.org/repo") + assertEquals("http://f-droid.org/repo", uri3.uri.toString()) + assertNull(uri3.fingerprint) - val uri4 = RepoUriGetter.getUri("https://fdroid.link/") - // we don't care what it is as long as it doesn't crash - assertNull(uri4.fingerprint) + val uri4 = RepoUriGetter.getUri("https://fdroid.link/") + // we don't care what it is as long as it doesn't crash + assertNull(uri4.fingerprint) - val uri5 = RepoUriGetter.getUri("https://fdroid.link/#foo") - // we don't care what it is as long as it doesn't crash - assertNull(uri5.fingerprint) + val uri5 = RepoUriGetter.getUri("https://fdroid.link/#foo") + // we don't care what it is as long as it doesn't crash + assertNull(uri5.fingerprint) - val uri6 = RepoUriGetter.getUri("https://fdroid.link/#invalid://foo.bar") - // we don't care what it is as long as it doesn't crash - assertNull(uri6.fingerprint) - } + val uri6 = RepoUriGetter.getUri("https://fdroid.link/#invalid://foo.bar") + // we don't care what it is as long as it doesn't crash + assertNull(uri6.fingerprint) + } - @Test - fun testAddScheme() { - val uri1 = RepoUriGetter.getUri("example.com/repo") - assertEquals("https://example.com/repo", uri1.uri.toString()) - assertNull(uri1.fingerprint) + @Test + fun testAddScheme() { + val uri1 = RepoUriGetter.getUri("example.com/repo") + assertEquals("https://example.com/repo", uri1.uri.toString()) + assertNull(uri1.fingerprint) - val uri2 = RepoUriGetter.getUri( - "example.com/repo?" + - "fingerprint=43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab" - ) - assertEquals("https://example.com/repo", uri2.uri.toString()) - assertEquals( - "43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab", - uri2.fingerprint - ) - } + val uri2 = + RepoUriGetter.getUri( + "example.com/repo?" + + "fingerprint=43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab" + ) + assertEquals("https://example.com/repo", uri2.uri.toString()) + assertEquals( + "43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab", + uri2.fingerprint, + ) + } - @Test - fun testFDroidRepoUriScheme() { - val uri1 = RepoUriGetter.getUri( - "fdroidrepos://grobox.de/fdroid/repo?fingerprint=" + - "28e14fb3b280bce8ff1e0f8e82726ff46923662cecff2a0689108ce19e8b347c" - ) - assertEquals("https://grobox.de/fdroid/repo", uri1.uri.toString()) - assertEquals( - "28e14fb3b280bce8ff1e0f8e82726ff46923662cecff2a0689108ce19e8b347c", - uri1.fingerprint, - ) + @Test + fun testFDroidRepoUriScheme() { + val uri1 = + RepoUriGetter.getUri( + "fdroidrepos://grobox.de/fdroid/repo?fingerprint=" + + "28e14fb3b280bce8ff1e0f8e82726ff46923662cecff2a0689108ce19e8b347c" + ) + assertEquals("https://grobox.de/fdroid/repo", uri1.uri.toString()) + assertEquals( + "28e14fb3b280bce8ff1e0f8e82726ff46923662cecff2a0689108ce19e8b347c", + uri1.fingerprint, + ) - val uri2 = RepoUriGetter.getUri("fdroidrepo://grobox.de/fdroid/repo") - assertEquals("http://grobox.de/fdroid/repo", uri2.uri.toString()) - assertNull(uri2.fingerprint) + val uri2 = RepoUriGetter.getUri("fdroidrepo://grobox.de/fdroid/repo") + assertEquals("http://grobox.de/fdroid/repo", uri2.uri.toString()) + assertNull(uri2.fingerprint) - val uri3 = RepoUriGetter.getUri( - "FDROIDREPOS://grobox.de/fdroid/repo?fingerprint=" + - "28e14fb3b280bce8ff1e0f8e82726ff46923662cecff2a0689108ce19e8b347c" - ) - assertEquals("https://grobox.de/fdroid/repo", uri3.uri.toString()) - assertEquals( - "28e14fb3b280bce8ff1e0f8e82726ff46923662cecff2a0689108ce19e8b347c", - uri3.fingerprint, - ) + val uri3 = + RepoUriGetter.getUri( + "FDROIDREPOS://grobox.de/fdroid/repo?fingerprint=" + + "28e14fb3b280bce8ff1e0f8e82726ff46923662cecff2a0689108ce19e8b347c" + ) + assertEquals("https://grobox.de/fdroid/repo", uri3.uri.toString()) + assertEquals( + "28e14fb3b280bce8ff1e0f8e82726ff46923662cecff2a0689108ce19e8b347c", + uri3.fingerprint, + ) - val uri4 = RepoUriGetter.getUri("fdroidREPO://grobox.de/fdroid/repo") - assertEquals("http://grobox.de/fdroid/repo", uri4.uri.toString()) - assertNull(uri4.fingerprint) - } + val uri4 = RepoUriGetter.getUri("fdroidREPO://grobox.de/fdroid/repo") + assertEquals("http://grobox.de/fdroid/repo", uri4.uri.toString()) + assertNull(uri4.fingerprint) + } - @Test - fun testUsernamePassword() { - val uri1 = RepoUriGetter.getUri( - "https://username:password@example.org/repo?fingerprint=foobar&test=42" - ) - assertEquals("https://example.org/repo", uri1.uri.toString()) - assertEquals("foobar", uri1.fingerprint) - assertEquals("username", uri1.username) - assertEquals("password", uri1.password) + @Test + fun testUsernamePassword() { + val uri1 = + RepoUriGetter.getUri("https://username:password@example.org/repo?fingerprint=foobar&test=42") + assertEquals("https://example.org/repo", uri1.uri.toString()) + assertEquals("foobar", uri1.fingerprint) + assertEquals("username", uri1.username) + assertEquals("password", uri1.password) - // no password - val uri2 = - RepoUriGetter.getUri("https://username@example.org/repo?fingerprint=foobar&test=42") - assertEquals("https://example.org/repo", uri2.uri.toString()) - assertEquals("foobar", uri2.fingerprint) - assertEquals("username", uri2.username) - assertNull(uri2.password) + // no password + val uri2 = RepoUriGetter.getUri("https://username@example.org/repo?fingerprint=foobar&test=42") + assertEquals("https://example.org/repo", uri2.uri.toString()) + assertEquals("foobar", uri2.fingerprint) + assertEquals("username", uri2.username) + assertNull(uri2.password) - // empty host - val uri3 = RepoUriGetter.getUri("https://foo:bar@/repo?fingerprint=foobar&test=42") - assertEquals("https:///repo", uri3.uri.toString()) - assertEquals("foobar", uri3.fingerprint) - assertEquals("foo", uri3.username) - assertEquals("bar", uri3.password) + // empty host + val uri3 = RepoUriGetter.getUri("https://foo:bar@/repo?fingerprint=foobar&test=42") + assertEquals("https:///repo", uri3.uri.toString()) + assertEquals("foobar", uri3.fingerprint) + assertEquals("foo", uri3.username) + assertEquals("bar", uri3.password) - // empty everything doesn't crash - RepoUriGetter.getUri(":@/") - RepoUriGetter.getUri(":@") - RepoUriGetter.getUri("@") - RepoUriGetter.getUri("") - } + // empty everything doesn't crash + RepoUriGetter.getUri(":@/") + RepoUriGetter.getUri(":@") + RepoUriGetter.getUri("@") + RepoUriGetter.getUri("") + } - @Test - fun testNonHierarchicalUri() { - RepoUriGetter.getUri("mailto:nobody@google.com") // should not crash - } + @Test + fun testNonHierarchicalUri() { + RepoUriGetter.getUri("mailto:nobody@google.com") // should not crash + } - @Test - fun testSwapUri() { - val uri = - RepoUriGetter.getUri( - "http://192.168.3.159:8888/fdroid/repo?FINGERPRINT=" + - "BA29D02E303B2604D00C91189600E868B26FA0B248DC39D75C5C0F4349CA5FA9" + - "&SWAP=1&BSSID=44:FE:3B:7F:7F:EE" - ) - assertEquals("http://192.168.3.159:8888/fdroid/repo", uri.uri.toString()) - assertEquals( - "ba29d02e303b2604d00c91189600e868b26fa0b248dc39d75c5c0f4349ca5fa9", - uri.fingerprint, - ) - } + @Test + fun testSwapUri() { + val uri = + RepoUriGetter.getUri( + "http://192.168.3.159:8888/fdroid/repo?FINGERPRINT=" + + "BA29D02E303B2604D00C91189600E868B26FA0B248DC39D75C5C0F4349CA5FA9" + + "&SWAP=1&BSSID=44:FE:3B:7F:7F:EE" + ) + assertEquals("http://192.168.3.159:8888/fdroid/repo", uri.uri.toString()) + assertEquals( + "ba29d02e303b2604d00c91189600e868b26fa0b248dc39d75c5c0f4349ca5fa9", + uri.fingerprint, + ) + } - @Test - fun testIsSwapUri() { - val uri1 = Uri.parse( - "http://192.168.3.159:8888/fdroid/repo?FINGERPRINT=" + - "BA29D02E303B2604D00C91189600E868B26FA0B248DC39D75C5C0F4349CA5FA9" + - "&SWAP=1&BSSID=44:FE:3B:7F:7F:EE" - ) - assertTrue(RepoUriGetter.isSwapUri(uri1)) + @Test + fun testIsSwapUri() { + val uri1 = + Uri.parse( + "http://192.168.3.159:8888/fdroid/repo?FINGERPRINT=" + + "BA29D02E303B2604D00C91189600E868B26FA0B248DC39D75C5C0F4349CA5FA9" + + "&SWAP=1&BSSID=44:FE:3B:7F:7F:EE" + ) + assertTrue(RepoUriGetter.isSwapUri(uri1)) - val uri2 = Uri.parse( - "http://192.168.3.159:8888/fdroid/repo?" + - "swap=1&BSSID=44:FE:3B:7F:7F:EE" - ) - assertTrue(RepoUriGetter.isSwapUri(uri2)) + val uri2 = + Uri.parse("http://192.168.3.159:8888/fdroid/repo?" + "swap=1&BSSID=44:FE:3B:7F:7F:EE") + assertTrue(RepoUriGetter.isSwapUri(uri2)) - val uri3 = Uri.parse("http://192.168.3.159:8888/fdroid/repo?BSSID=44:FE:3B:7F:7F:EE") - assertFalse(RepoUriGetter.isSwapUri(uri3)) + val uri3 = Uri.parse("http://192.168.3.159:8888/fdroid/repo?BSSID=44:FE:3B:7F:7F:EE") + assertFalse(RepoUriGetter.isSwapUri(uri3)) - val uri4 = Uri.parse("mailto:nobody@google.com") - assertFalse(RepoUriGetter.isSwapUri(uri4)) - } + val uri4 = Uri.parse("mailto:nobody@google.com") + assertFalse(RepoUriGetter.isSwapUri(uri4)) + } } diff --git a/libs/download/build.gradle.kts b/libs/download/build.gradle.kts index 706e6d1a9..e15b4a274 100644 --- a/libs/download/build.gradle.kts +++ b/libs/download/build.gradle.kts @@ -1,124 +1,108 @@ plugins { - alias(libs.plugins.jetbrains.kotlin.multiplatform) - alias(libs.plugins.android.library) - alias(libs.plugins.jetbrains.dokka) - alias(libs.plugins.vanniktech.maven.publish) + alias(libs.plugins.jetbrains.kotlin.multiplatform) + alias(libs.plugins.android.library) + alias(libs.plugins.jetbrains.dokka) + alias(libs.plugins.vanniktech.maven.publish) + alias(libs.plugins.ktfmt) } kotlin { - androidTarget { - compilerOptions { - jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 - } - publishLibraryVariants("release") + androidTarget { + compilerOptions { jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 } + publishLibraryVariants("release") + } + compilerOptions { optIn.add("kotlin.RequiresOptIn") } + explicitApi() + @OptIn(org.jetbrains.kotlin.gradle.dsl.abi.ExperimentalAbiValidation::class) + abiValidation { enabled = true } + sourceSets { + commonMain { + dependencies { + api(project(":libs:core")) + api(libs.ktor.client.core) + implementation(libs.microutils.kotlin.logging) + } } - compilerOptions { - optIn.add("kotlin.RequiresOptIn") + commonTest { + dependencies { + implementation(kotlin("test")) + implementation(libs.ktor.client.mock) + implementation(libs.mockk) + } } - explicitApi() - @OptIn(org.jetbrains.kotlin.gradle.dsl.abi.ExperimentalAbiValidation::class) - abiValidation { - enabled = true + // JVM is disabled for now, because Android app is including it instead of Android library + jvmMain { dependencies { implementation(libs.ktor.client.cio) } } + jvmTest { dependencies { implementation(libs.junit) } } + androidMain { + dependencies { + implementation(libs.ktor.client.okhttp) + //noinspection UseTomlInstead + implementation("com.github.bumptech.glide:glide:4.16.0") { + isTransitive = false // we don't need all that it pulls in, just the basics + } + implementation(libs.glide.annotations) + implementation("io.coil-kt.coil3:coil-core:3.3.0") { + isTransitive = false // we don't need all that it pulls in, just the basics + } + implementation("javax.inject:javax.inject:1") + } } - sourceSets { - commonMain { - dependencies { - api(project(":libs:core")) - api(libs.ktor.client.core) - implementation(libs.microutils.kotlin.logging) - } - } - commonTest { - dependencies { - implementation(kotlin("test")) - implementation(libs.ktor.client.mock) - implementation(libs.mockk) - } - } - // JVM is disabled for now, because Android app is including it instead of Android library - jvmMain { - dependencies { - implementation(libs.ktor.client.cio) - } - } - jvmTest { - dependencies { - implementation(libs.junit) - } - } - androidMain { - dependencies { - implementation(libs.ktor.client.okhttp) - //noinspection UseTomlInstead - implementation("com.github.bumptech.glide:glide:4.16.0") { - isTransitive = false // we don't need all that it pulls in, just the basics - } - implementation(libs.glide.annotations) - implementation("io.coil-kt.coil3:coil-core:3.3.0") { - isTransitive = false // we don't need all that it pulls in, just the basics - } - implementation("javax.inject:javax.inject:1") - } - } - androidUnitTest { - dependencies { - implementation(kotlin("test")) - implementation(libs.json) - implementation(libs.junit) - implementation(libs.logback.classic) - } - } - val commonTest by getting - androidInstrumentedTest { - dependsOn(commonTest) - dependencies { - implementation(project(":libs:sharedTest")) - implementation(libs.androidx.test.runner) - implementation(libs.androidx.test.ext.junit) - implementation(libs.mockk.android) - } - } + androidUnitTest { + dependencies { + implementation(kotlin("test")) + implementation(libs.json) + implementation(libs.junit) + implementation(libs.logback.classic) + } } + val commonTest by getting + androidInstrumentedTest { + dependsOn(commonTest) + dependencies { + implementation(project(":libs:sharedTest")) + implementation(libs.androidx.test.runner) + implementation(libs.androidx.test.ext.junit) + implementation(libs.mockk.android) + } + } + } } android { - namespace = "org.fdroid.download" - @Suppress("ktlint:standard:chain-method-continuation") - compileSdk = libs.versions.compileSdk.get().toInt() - defaultConfig { - minSdk = 21 - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - testInstrumentationRunnerArguments["disableAnalytics"] = "true" - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - lint { - checkReleaseBuilds = false - abortOnError = true + namespace = "org.fdroid.download" + compileSdk = libs.versions.compileSdk.get().toInt() + defaultConfig { + minSdk = 21 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunnerArguments["disableAnalytics"] = "true" + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + lint { + checkReleaseBuilds = false + abortOnError = true - htmlReport = true - xmlReport = false - textReport = true + htmlReport = true + xmlReport = false + textReport = true - lintConfig = file("lint.xml") - } - testOptions { - targetSdk = 34 // needed for instrumentation tests - packaging { - resources.excludes.add("META-INF/*") - } - } + lintConfig = file("lint.xml") + } + testOptions { + targetSdk = 34 // needed for instrumentation tests + packaging { resources.excludes.add("META-INF/*") } + } } -signing { - useGpgCmd() -} +ktfmt { googleStyle() } + +signing { useGpgCmd() } dokka { - pluginsConfiguration.html { - customAssets.from("${file("${rootProject.rootDir}/logo-icon.svg")}") - footerMessage.set("© 2010-2025 F-Droid Limited and Contributors") - } + pluginsConfiguration.html { + customAssets.from("${file("${rootProject.rootDir}/logo-icon.svg")}") + footerMessage.set("© 2010-2025 F-Droid Limited and Contributors") + } } diff --git a/libs/download/src/androidInstrumentedTest/kotlin/org/fdroid/download/HttpManagerInstrumentationTest.kt b/libs/download/src/androidInstrumentedTest/kotlin/org/fdroid/download/HttpManagerInstrumentationTest.kt index 233979620..f5ca13d81 100644 --- a/libs/download/src/androidInstrumentedTest/kotlin/org/fdroid/download/HttpManagerInstrumentationTest.kt +++ b/libs/download/src/androidInstrumentedTest/kotlin/org/fdroid/download/HttpManagerInstrumentationTest.kt @@ -8,6 +8,11 @@ import io.ktor.client.engine.HttpClientEngine import io.ktor.client.engine.HttpClientEngineFactory import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.engine.okhttp.OkHttpConfig +import javax.net.ssl.SSLHandshakeException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.fail import kotlinx.coroutines.delay import okhttp3.ConnectionSpec import okhttp3.ConnectionSpec.Companion.MODERN_TLS @@ -20,143 +25,130 @@ import org.fdroid.runSuspend import org.json.JSONObject import org.junit.Assume.assumeTrue import org.junit.runner.RunWith -import javax.net.ssl.SSLHandshakeException -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.fail @FlakyTest @RunWith(AndroidJUnit4::class) @Suppress("BlockingMethodInNonBlockingContext") internal class HttpManagerInstrumentationTest { - private val userAgent = getRandomString() + private val userAgent = getRandomString() - @Test - fun testCleartext() = runSuspend { - suspend fun noSsl(url: String) { - val httpManager = HttpManager(userAgent, null) - val mirror = Mirror(url) - val downloadRequest = DownloadRequest("/", listOf(mirror)) + @Test + fun testCleartext() = runSuspend { + suspend fun noSsl(url: String) { + val httpManager = HttpManager(userAgent, null) + val mirror = Mirror(url) + val downloadRequest = DownloadRequest("/", listOf(mirror)) - httpManager.getBytes(downloadRequest) - } - // try different services in case one is down - listOf( - "http://http.badssl.com/", - "http://neverssl.com", - "http://httpforever.com/", - ).forEach { url -> - Log.i("HttpManagerInstrumentationTest", "Testing $url") - try { - noSsl(url) - Log.i("HttpManagerInstrumentationTest", "Success $url") - return@runSuspend // success - } catch (e: Exception) { - Log.e("HttpManagerInstrumentationTest", "Error $url ", e) + httpManager.getBytes(downloadRequest) + } + // try different services in case one is down + listOf("http://http.badssl.com/", "http://neverssl.com", "http://httpforever.com/").forEach { + url -> + Log.i("HttpManagerInstrumentationTest", "Testing $url") + try { + noSsl(url) + Log.i("HttpManagerInstrumentationTest", "Success $url") + return@runSuspend // success + } catch (e: Exception) { + Log.e("HttpManagerInstrumentationTest", "Error $url ", e) + } + } + fail("All no-SSL domains failed.") + } + + @Test(expected = SSLHandshakeException::class) + fun testNoTls10() = runSuspend { + val httpManager = HttpManager(userAgent, null) + val mirror = Mirror("https://tls-v1-0.badssl.com:1010") + val downloadRequest = DownloadRequest("/", listOf(mirror)) + + httpManager.getBytes(downloadRequest) + } + + @Test(expected = SSLHandshakeException::class) + fun testNoTls11() = runSuspend { + val httpManager = HttpManager(userAgent, null) + val mirror = Mirror("https://tls-v1-1.badssl.com:1010") + val downloadRequest = DownloadRequest("/", listOf(mirror)) + + httpManager.getBytes(downloadRequest) + } + + @Test + fun checkTlsSupport() = runSuspend { + assumeTrue( + "howsmyssl.com uses Let's Encrypt, which does not work on Android 7 and older", + SDK_INT >= 26, + ) + val httpManager = HttpManager(userAgent, null) + val mirror = Mirror("https://www.howsmyssl.com") + val indexFile: IndexFile = getIndexFile("/a/check") + val downloadRequest = DownloadRequest(indexFile, listOf(mirror)) + + val json = JSONObject(httpManager.getBytes(downloadRequest).decodeToString()) + if (SDK_INT >= 29) { + assertEquals("TLS 1.3", json.getString("tls_version")) + } else { + assertEquals("TLS 1.2", json.getString("tls_version")) + } + assertEquals(0, json.getJSONObject("insecure_cipher_suites").length()) + } + + @Test + fun checkTls12Support() = runSuspend { + assumeTrue( + "howsmyssl.com uses Let's Encrypt, which does not work on Android 7 and older", + SDK_INT >= 26, + ) + val clientFactory = + object : HttpClientEngineFactory { + override fun create(block: OkHttpConfig.() -> Unit): HttpClientEngine = + OkHttp.create { + block() + config { + val restricted12 = ConnectionSpec.Builder(RESTRICTED_TLS).tlsVersions(TLS_1_2).build() + val modern12 = ConnectionSpec.Builder(MODERN_TLS).tlsVersions(TLS_1_2).build() + connectionSpecs(listOf(restricted12, modern12)) } - } - fail("All no-SSL domains failed.") + } + } + val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = clientFactory) + val mirror = Mirror("https://www.howsmyssl.com") + val indexFile: IndexFile = getIndexFile("/a/check") + val downloadRequest = DownloadRequest(indexFile, listOf(mirror)) + + val json = JSONObject(httpManager.getBytes(downloadRequest).decodeToString()) + assertEquals("TLS 1.2", json.getString("tls_version")) + assertEquals(0, json.getJSONObject("insecure_cipher_suites").length()) + } + + @Test + fun checkSessionResumeShort() = runSuspend { + assumeTrue( + "tlsprivacy.nervuri.net uses Let's Encrypt, which does not work on old Androids", + SDK_INT >= 26, + ) + val httpManager = HttpManager(userAgent, null) + val mirror = Mirror("https://tlsprivacy.nervuri.net") + val indexFile: IndexFile = getIndexFile("/json/v1") + val downloadRequest = DownloadRequest(indexFile, listOf(mirror)) + + // first request had no session to resume + JSONObject(httpManager.getBytes(downloadRequest).decodeToString()).let { json -> + val connectionInfo = json.getJSONObject("connection_info") + assertFalse(connectionInfo.getBoolean("session_resumed")) } - - @Test(expected = SSLHandshakeException::class) - fun testNoTls10() = runSuspend { - val httpManager = HttpManager(userAgent, null) - val mirror = Mirror("https://tls-v1-0.badssl.com:1010") - val downloadRequest = DownloadRequest("/", listOf(mirror)) - - httpManager.getBytes(downloadRequest) + // second request right after resumed session + JSONObject(httpManager.getBytes(downloadRequest).decodeToString()).let { json -> + val connectionInfo = json.getJSONObject("connection_info") + assumeTrue("Session was not resumed at all", connectionInfo.getBoolean("session_resumed")) } - - @Test(expected = SSLHandshakeException::class) - fun testNoTls11() = runSuspend { - val httpManager = HttpManager(userAgent, null) - val mirror = Mirror("https://tls-v1-1.badssl.com:1010") - val downloadRequest = DownloadRequest("/", listOf(mirror)) - - httpManager.getBytes(downloadRequest) - } - - @Test - fun checkTlsSupport() = runSuspend { - assumeTrue( - "howsmyssl.com uses Let's Encrypt, which does not work on Android 7 and older", - SDK_INT >= 26, - ) - val httpManager = HttpManager(userAgent, null) - val mirror = Mirror("https://www.howsmyssl.com") - val indexFile: IndexFile = getIndexFile("/a/check") - val downloadRequest = DownloadRequest(indexFile, listOf(mirror)) - - val json = JSONObject(httpManager.getBytes(downloadRequest).decodeToString()) - if (SDK_INT >= 29) { - assertEquals("TLS 1.3", json.getString("tls_version")) - } else { - assertEquals("TLS 1.2", json.getString("tls_version")) - } - assertEquals(0, json.getJSONObject("insecure_cipher_suites").length()) - } - - @Test - fun checkTls12Support() = runSuspend { - assumeTrue( - "howsmyssl.com uses Let's Encrypt, which does not work on Android 7 and older", - SDK_INT >= 26, - ) - val clientFactory = object : HttpClientEngineFactory { - override fun create(block: OkHttpConfig.() -> Unit): HttpClientEngine = OkHttp.create { - block() - config { - val restricted12 = ConnectionSpec.Builder(RESTRICTED_TLS) - .tlsVersions(TLS_1_2) - .build() - val modern12 = ConnectionSpec.Builder(MODERN_TLS) - .tlsVersions(TLS_1_2) - .build() - connectionSpecs(listOf(restricted12, modern12)) - } - } - } - val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = clientFactory) - val mirror = Mirror("https://www.howsmyssl.com") - val indexFile: IndexFile = getIndexFile("/a/check") - val downloadRequest = DownloadRequest(indexFile, listOf(mirror)) - - val json = JSONObject(httpManager.getBytes(downloadRequest).decodeToString()) - assertEquals("TLS 1.2", json.getString("tls_version")) - assertEquals(0, json.getJSONObject("insecure_cipher_suites").length()) - } - - @Test - fun checkSessionResumeShort() = runSuspend { - assumeTrue( - "tlsprivacy.nervuri.net uses Let's Encrypt, which does not work on old Androids", - SDK_INT >= 26, - ) - val httpManager = HttpManager(userAgent, null) - val mirror = Mirror("https://tlsprivacy.nervuri.net") - val indexFile: IndexFile = getIndexFile("/json/v1") - val downloadRequest = DownloadRequest(indexFile, listOf(mirror)) - - // first request had no session to resume - JSONObject(httpManager.getBytes(downloadRequest).decodeToString()).let { json -> - val connectionInfo = json.getJSONObject("connection_info") - assertFalse(connectionInfo.getBoolean("session_resumed")) - } - // second request right after resumed session - JSONObject(httpManager.getBytes(downloadRequest).decodeToString()).let { json -> - val connectionInfo = json.getJSONObject("connection_info") - assumeTrue( - "Session was not resumed at all", - connectionInfo.getBoolean("session_resumed") - ) - } - delay(10_100) - // third request after 10s did not resume session - JSONObject(httpManager.getBytes(downloadRequest).decodeToString()).let { json -> - val connectionInfo = json.getJSONObject("connection_info") - assertFalse(connectionInfo.getBoolean("session_resumed")) - } + delay(10_100) + // third request after 10s did not resume session + JSONObject(httpManager.getBytes(downloadRequest).decodeToString()).let { json -> + val connectionInfo = json.getJSONObject("connection_info") + assertFalse(connectionInfo.getBoolean("session_resumed")) } + } } diff --git a/libs/download/src/androidMain/kotlin/org/fdroid/download/Downloader.kt b/libs/download/src/androidMain/kotlin/org/fdroid/download/Downloader.kt index 9244acad2..2874abf12 100644 --- a/libs/download/src/androidMain/kotlin/org/fdroid/download/Downloader.kt +++ b/libs/download/src/androidMain/kotlin/org/fdroid/download/Downloader.kt @@ -1,9 +1,5 @@ package org.fdroid.download -import mu.KotlinLogging -import org.fdroid.IndexFile -import org.fdroid.fdroid.ProgressListener -import org.fdroid.fdroid.isMatching import java.io.File import java.io.FileInputStream import java.io.FileOutputStream @@ -11,225 +7,223 @@ import java.io.IOException import java.io.InputStream import java.io.OutputStream import java.security.MessageDigest +import mu.KotlinLogging +import org.fdroid.IndexFile +import org.fdroid.fdroid.ProgressListener +import org.fdroid.fdroid.isMatching public abstract class Downloader( - protected val indexFile: IndexFile, - @JvmField - protected val outputFile: File, + protected val indexFile: IndexFile, + @JvmField protected val outputFile: File, ) { - public companion object { - private val log = KotlinLogging.logger {} - } + public companion object { + private val log = KotlinLogging.logger {} + } - /** - * If you ask for the cacheTag before calling download(), you will get the - * same one you passed in (if any). If you call it after download(), you - * will get the new cacheTag from the server, or null if there was none. - * - * If this cacheTag matches that returned by the server, then no download will - * take place, and a status code of 304 will be returned by download(). - */ - @Deprecated("Used only for v1 repos") - public var cacheTag: String? = null + /** + * If you ask for the cacheTag before calling download(), you will get the same one you passed in + * (if any). If you call it after download(), you will get the new cacheTag from the server, or + * null if there was none. + * + * If this cacheTag matches that returned by the server, then no download will take place, and a + * status code of 304 will be returned by download(). + */ + @Deprecated("Used only for v1 repos") public var cacheTag: String? = null - @Volatile - private var cancelled = false + @Volatile private var cancelled = false - @Volatile - private var progressListener: ProgressListener? = null + @Volatile private var progressListener: ProgressListener? = null - /** - * Call this to start the download. - * Never call this more than once. Create a new [Downloader], if you need to download again! - */ - @Throws(IOException::class, InterruptedException::class, NotFoundException::class) - public abstract fun download() + /** + * Call this to start the download. Never call this more than once. Create a new [Downloader], if + * you need to download again! + */ + @Throws(IOException::class, InterruptedException::class, NotFoundException::class) + public abstract fun download() - @Throws(IOException::class, NotFoundException::class) - protected abstract fun getInputStream(resumable: Boolean): InputStream - protected open suspend fun getBytes(resumable: Boolean, receiver: BytesReceiver) { - throw NotImplementedError() - } + @Throws(IOException::class, NotFoundException::class) + protected abstract fun getInputStream(resumable: Boolean): InputStream - /** - * Returns the size of the file to be downloaded in bytes. - * Note this is -1 when the size is unknown. - * Used only for progress reporting. - */ - protected abstract fun totalDownloadSize(): Long + protected open suspend fun getBytes(resumable: Boolean, receiver: BytesReceiver) { + throw NotImplementedError() + } - /** - * After calling [download], this returns true if a new file was downloaded and - * false if the file on the server has not changed and thus was not downloaded. - */ - @Deprecated("Only for v1 repos") - public abstract fun hasChanged(): Boolean - public abstract fun close() + /** + * Returns the size of the file to be downloaded in bytes. Note this is -1 when the size is + * unknown. Used only for progress reporting. + */ + protected abstract fun totalDownloadSize(): Long - public fun setListener(listener: ProgressListener) { - progressListener = listener - } + /** + * After calling [download], this returns true if a new file was downloaded and false if the file + * on the server has not changed and thus was not downloaded. + */ + @Deprecated("Only for v1 repos") public abstract fun hasChanged(): Boolean - @Throws(IOException::class, InterruptedException::class) - protected fun downloadFromStream(isResume: Boolean) { - log.debug { "Downloading from stream" } - try { - FileOutputStream(outputFile, isResume).use { outputStream -> - getInputStream(isResume).use { input -> - // Getting the input stream is slow(ish) for HTTP downloads, so we'll check if - // we were interrupted before proceeding to the download. - throwExceptionIfInterrupted() - copyInputToOutputStream(input, outputStream) - } - } - // Even if we have completely downloaded the file, we should probably respect - // the wishes of the user who wanted to cancel us. - throwExceptionIfInterrupted() - } finally { - close() + public abstract fun close() + + public fun setListener(listener: ProgressListener) { + progressListener = listener + } + + @Throws(IOException::class, InterruptedException::class) + protected fun downloadFromStream(isResume: Boolean) { + log.debug { "Downloading from stream" } + try { + FileOutputStream(outputFile, isResume).use { outputStream -> + getInputStream(isResume).use { input -> + // Getting the input stream is slow(ish) for HTTP downloads, so we'll check if + // we were interrupted before proceeding to the download. + throwExceptionIfInterrupted() + copyInputToOutputStream(input, outputStream) } + } + // Even if we have completely downloaded the file, we should probably respect + // the wishes of the user who wanted to cancel us. + throwExceptionIfInterrupted() + } finally { + close() } + } - @Suppress("BlockingMethodInNonBlockingContext") - @Throws( - InterruptedException::class, - IOException::class, - NoResumeException::class, - NotFoundException::class, - ) - protected suspend fun downloadFromBytesReceiver(isResume: Boolean) { - try { - val messageDigest: MessageDigest? = if (indexFile.sha256 == null) null else { - MessageDigest.getInstance("SHA-256") - } - var bytesCopied = outputFile.length() - // read pre-downloaded bytes (if any) for hash to match - if (bytesCopied > 0 && messageDigest != null) outputFile.initDigest(messageDigest) - FileOutputStream(outputFile, isResume).use { outputStream -> - var lastTimeReported = 0L - val bytesTotal = totalDownloadSize() - getBytes(isResume) { bytes, numTotalBytes -> - // Getting the input stream is slow(ish) for HTTP downloads, so we'll check if - // we were interrupted before proceeding to the download. - throwExceptionIfInterrupted() - outputStream.write(bytes) - messageDigest?.update(bytes) - bytesCopied += bytes.size - val total = if (bytesTotal == -1L) numTotalBytes ?: -1L else bytesTotal - lastTimeReported = reportProgress(lastTimeReported, bytesCopied, total) - } - // check if expected sha256 hash matches - indexFile.sha256?.let { expectedHash -> - if (!messageDigest.isMatching(expectedHash)) { - throw IOException("Hash not matching") - } - } - // force progress reporting at the end - reportProgress(0L, bytesCopied, bytesTotal) - } - // Even if we have completely downloaded the file, we should probably respect - // the wishes of the user who wanted to cancel us. - throwExceptionIfInterrupted() - } finally { - close() + @Suppress("BlockingMethodInNonBlockingContext") + @Throws( + InterruptedException::class, + IOException::class, + NoResumeException::class, + NotFoundException::class, + ) + protected suspend fun downloadFromBytesReceiver(isResume: Boolean) { + try { + val messageDigest: MessageDigest? = + if (indexFile.sha256 == null) null + else { + MessageDigest.getInstance("SHA-256") } - } - - /** - * This copies the downloaded data from the [InputStream] to the [OutputStream], - * keeping track of the number of bytes that have flown through for the [progressListener]. - * - * Attention: The caller is responsible for closing the streams. - */ - @Throws(IOException::class, InterruptedException::class) - private fun copyInputToOutputStream(input: InputStream, output: OutputStream) { - val messageDigest: MessageDigest? = if (indexFile.sha256 == null) null else { - MessageDigest.getInstance("SHA-256") + var bytesCopied = outputFile.length() + // read pre-downloaded bytes (if any) for hash to match + if (bytesCopied > 0 && messageDigest != null) outputFile.initDigest(messageDigest) + FileOutputStream(outputFile, isResume).use { outputStream -> + var lastTimeReported = 0L + val bytesTotal = totalDownloadSize() + getBytes(isResume) { bytes, numTotalBytes -> + // Getting the input stream is slow(ish) for HTTP downloads, so we'll check if + // we were interrupted before proceeding to the download. + throwExceptionIfInterrupted() + outputStream.write(bytes) + messageDigest?.update(bytes) + bytesCopied += bytes.size + val total = if (bytesTotal == -1L) numTotalBytes ?: -1L else bytesTotal + lastTimeReported = reportProgress(lastTimeReported, bytesCopied, total) } - try { - var bytesCopied = outputFile.length() - // read pre-downloaded bytes (if any) for hash to match - if (bytesCopied > 0 && messageDigest != null) outputFile.initDigest(messageDigest) - - var lastTimeReported = 0L - val bytesTotal = totalDownloadSize() - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - var numBytes = input.read(buffer) - while (numBytes >= 0) { - throwExceptionIfInterrupted() - output.write(buffer, 0, numBytes) - messageDigest?.update(buffer, 0, numBytes) - bytesCopied += numBytes - lastTimeReported = reportProgress(lastTimeReported, bytesCopied, bytesTotal) - numBytes = input.read(buffer) - } - // check if expected sha256 hash matches - indexFile.sha256?.let { expectedHash -> - if (!messageDigest.isMatching(expectedHash)) { - throw IOException("Hash not matching") - } - } - // force progress reporting at the end - reportProgress(0L, bytesCopied, bytesTotal) - } finally { - output.flush() - progressListener = null + // check if expected sha256 hash matches + indexFile.sha256?.let { expectedHash -> + if (!messageDigest.isMatching(expectedHash)) { + throw IOException("Hash not matching") + } } + // force progress reporting at the end + reportProgress(0L, bytesCopied, bytesTotal) + } + // Even if we have completely downloaded the file, we should probably respect + // the wishes of the user who wanted to cancel us. + throwExceptionIfInterrupted() + } finally { + close() } + } - private fun reportProgress(lastTimeReported: Long, bytesRead: Long, bytesTotal: Long): Long { - val now = System.currentTimeMillis() - return if (now - lastTimeReported > 1000) { - log.debug { "onProgress: $bytesRead/$bytesTotal" } - progressListener?.onProgress(bytesRead, bytesTotal) - now - } else { - lastTimeReported + /** + * This copies the downloaded data from the [InputStream] to the [OutputStream], keeping track of + * the number of bytes that have flown through for the [progressListener]. + * + * Attention: The caller is responsible for closing the streams. + */ + @Throws(IOException::class, InterruptedException::class) + private fun copyInputToOutputStream(input: InputStream, output: OutputStream) { + val messageDigest: MessageDigest? = + if (indexFile.sha256 == null) null + else { + MessageDigest.getInstance("SHA-256") + } + try { + var bytesCopied = outputFile.length() + // read pre-downloaded bytes (if any) for hash to match + if (bytesCopied > 0 && messageDigest != null) outputFile.initDigest(messageDigest) + + var lastTimeReported = 0L + val bytesTotal = totalDownloadSize() + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var numBytes = input.read(buffer) + while (numBytes >= 0) { + throwExceptionIfInterrupted() + output.write(buffer, 0, numBytes) + messageDigest?.update(buffer, 0, numBytes) + bytesCopied += numBytes + lastTimeReported = reportProgress(lastTimeReported, bytesCopied, bytesTotal) + numBytes = input.read(buffer) + } + // check if expected sha256 hash matches + indexFile.sha256?.let { expectedHash -> + if (!messageDigest.isMatching(expectedHash)) { + throw IOException("Hash not matching") } + } + // force progress reporting at the end + reportProgress(0L, bytesCopied, bytesTotal) + } finally { + output.flush() + progressListener = null } + } - /** - * Cancel a running download, triggering an [InterruptedException] - */ - public fun cancelDownload() { - cancelled = true + private fun reportProgress(lastTimeReported: Long, bytesRead: Long, bytesTotal: Long): Long { + val now = System.currentTimeMillis() + return if (now - lastTimeReported > 1000) { + log.debug { "onProgress: $bytesRead/$bytesTotal" } + progressListener?.onProgress(bytesRead, bytesTotal) + now + } else { + lastTimeReported } + } - /** - * Check if the download was cancelled. - */ - public fun wasCancelled(): Boolean { - return cancelled + /** Cancel a running download, triggering an [InterruptedException] */ + public fun cancelDownload() { + cancelled = true + } + + /** Check if the download was cancelled. */ + public fun wasCancelled(): Boolean { + return cancelled + } + + /** + * After every network operation that could take a while, we will check if an interrupt occurred + * during that blocking operation. The goal is to ensure we don't move onto another slow, network + * operation if we have cancelled the download. + * + * @throws InterruptedException + */ + @Throws(InterruptedException::class) + private fun throwExceptionIfInterrupted() { + if (cancelled) { + log.info { "Received interrupt, cancelling download" } + Thread.currentThread().interrupt() + throw InterruptedException() } + } - /** - * After every network operation that could take a while, we will check if an - * interrupt occurred during that blocking operation. The goal is to ensure we - * don't move onto another slow, network operation if we have cancelled the - * download. - * - * @throws InterruptedException - */ - @Throws(InterruptedException::class) - private fun throwExceptionIfInterrupted() { - if (cancelled) { - log.info { "Received interrupt, cancelling download" } - Thread.currentThread().interrupt() - throw InterruptedException() - } + @Throws(IOException::class) + private fun File.initDigest(messageDigest: MessageDigest) { + FileInputStream(this).use { inputStream -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var bytes = inputStream.read(buffer) + while (bytes >= 0) { + messageDigest.update(buffer, 0, bytes) + bytes = inputStream.read(buffer) + } } - - @Throws(IOException::class) - private fun File.initDigest(messageDigest: MessageDigest) { - FileInputStream(this).use { inputStream -> - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - var bytes = inputStream.read(buffer) - while (bytes >= 0) { - messageDigest.update(buffer, 0, bytes) - bytes = inputStream.read(buffer) - } - } - } - + } } diff --git a/libs/download/src/androidMain/kotlin/org/fdroid/download/HttpDownloader.kt b/libs/download/src/androidMain/kotlin/org/fdroid/download/HttpDownloader.kt index 11a7df28e..fcfb73e71 100644 --- a/libs/download/src/androidMain/kotlin/org/fdroid/download/HttpDownloader.kt +++ b/libs/download/src/androidMain/kotlin/org/fdroid/download/HttpDownloader.kt @@ -23,155 +23,140 @@ package org.fdroid.download import io.ktor.client.plugins.ResponseException import io.ktor.http.HttpStatusCode.Companion.NotFound -import kotlinx.coroutines.runBlocking -import mu.KotlinLogging import java.io.File import java.io.IOException import java.io.InputStream import java.util.Date +import kotlinx.coroutines.runBlocking +import mu.KotlinLogging -/** - * Download files over HTTP, with support for proxies, `.onion` addresses, HTTP Basic Auth, etc. - */ +/** Download files over HTTP, with support for proxies, `.onion` addresses, HTTP Basic Auth, etc. */ @Deprecated("Only for v1 repos") -public class HttpDownloader constructor( - private val httpManager: HttpManager, - private val request: DownloadRequest, - destFile: File, +public class HttpDownloader( + private val httpManager: HttpManager, + private val request: DownloadRequest, + destFile: File, ) : Downloader(request.indexFile, destFile) { - private companion object { - val log = KotlinLogging.logger {} + private companion object { + val log = KotlinLogging.logger {} + } + + private var hasChanged = false + private var fileSize: Long? = request.indexFile.size + + override fun getInputStream(resumable: Boolean): InputStream { + throw NotImplementedError("Use getInputStreamSuspend instead.") + } + + @Throws(IOException::class, NoResumeException::class, NotFoundException::class) + protected override suspend fun getBytes(resumable: Boolean, receiver: BytesReceiver) { + val skipBytes = if (resumable) outputFile.length() else null + return try { + httpManager.get(request, skipBytes, receiver) + } catch (e: ResponseException) { + if (e.response.status == NotFound) throw NotFoundException(e) else throw IOException(e) + } + } + + /** + * Get a remote file, checking the HTTP response code, if it has changed since the last time a + * download was tried. + * + * If the `ETag` does not match, it could be caused by the previous download of the same file + * coming from a mirror running on a different webserver, e.g. Apache vs Nginx. `Content-Length` + * and `Last-Modified` are used to check whether the file has changed since those are more + * standardized than `ETag`. Plus, Nginx and Apache 2.4 defaults use only those two values to + * generate the `ETag` anyway. Unfortunately, other webservers and CDNs have totally different + * methods for generating the `ETag`. And mirrors that are syncing using a method other than + * `rsync` could easily have different `Last-Modified` times on the exact same file. On top of + * that, some services like GitHub's raw file support `raw.githubusercontent.com` and GitLab's raw + * file support do not set the `Last-Modified` header at all. So ultimately, then `ETag` needs to + * be used first and foremost, then this calculated `ETag` can serve as a common fallback. + * + * In order to prevent the `ETag` from being used as a form of tracking cookie, this code never + * sends the `ETag` to the server. Instead, it uses a `HEAD` request to get the `ETag` from the + * server, then only issues a `GET` if the `ETag` has changed. + * + * This uses a integer value for `Last-Modified` to avoid enabling the use of that value as some + * kind of "cookieless cookie". One second time resolution should be plenty since these files + * change more on the time space of minutes or hours. + * + * @see + * [update index from any available mirror](https://gitlab.com/fdroid/fdroidclient/issues/1708) + * @see [Cookieless cookies](http://lucb1e.com/rp/cookielesscookies) + */ + @Suppress("DEPRECATION") + @Deprecated("Use only for v1 repos") + @Throws(IOException::class, InterruptedException::class) + public override fun download() { + val headInfo = runBlocking { httpManager.head(request, cacheTag) ?: throw IOException() } + val expectedETag = cacheTag + cacheTag = headInfo.eTag + fileSize = headInfo.contentLength ?: request.indexFile.size ?: -1 + + // If the ETag does not match, it could be because the file is on a mirror + // running a different webserver, e.g. Apache vs Nginx. + // Content-Length and Last-Modified could be used as well. + // Nginx and Apache 2.4 defaults use only those two values to generate the ETag. + // Unfortunately, other webservers and CDNs have totally different methods. + // And mirrors that are syncing using a method other than rsync + // could easily have different Last-Modified times on the exact same file. + // On top of that, some services like GitHub's and GitLab's raw file support + // do not set the header at all. + val lastModified = + try { + // this method is not available multi-platform, so for now only done in JVM + @Suppress("Deprecation") + Date.parse(headInfo.lastModified) / 1000 + } catch (_: Exception) { + 0L + } + val calculatedEtag: String = String.format("%x-%x", lastModified, headInfo.contentLength) + + // !headInfo.eTagChanged: expectedETag == headInfo.eTag (the expected ETag was in server + // response) + // calculatedEtag == expectedETag (ETag calculated from server response matches expected ETag) + if (!headInfo.eTagChanged || calculatedEtag == expectedETag) { + // ETag has not changed, don't download again + log.debug { "${request.indexFile.name} cached, not downloading." } + hasChanged = false + return } - private var hasChanged = false - private var fileSize: Long? = request.indexFile.size + hasChanged = true + downloadToFile() + } - override fun getInputStream(resumable: Boolean): InputStream { - throw NotImplementedError("Use getInputStreamSuspend instead.") + private fun downloadToFile() { + var resumable = false + val fileLength = outputFile.length() + if (fileLength > (fileSize ?: -1)) { + if (!outputFile.delete()) log.warn { "Warning: outputFile not deleted" } + } else if (fileLength == fileSize && outputFile.isFile) { + log.debug { "Already have outputFile, not downloading: ${outputFile.name}" } + return // already have it! + } else if (fileLength > 0) { + resumable = true } - - @Throws(IOException::class, NoResumeException::class, NotFoundException::class) - protected override suspend fun getBytes(resumable: Boolean, receiver: BytesReceiver) { - val skipBytes = if (resumable) outputFile.length() else null - return try { - httpManager.get(request, skipBytes, receiver) - } catch (e: ResponseException) { - if (e.response.status == NotFound) throw NotFoundException(e) - else throw IOException(e) - } + log.debug { "Downloading ${request.indexFile.name} (is resumable: $resumable)" } + runBlocking { + try { + downloadFromBytesReceiver(resumable) + } catch (_: NoResumeException) { + if (!outputFile.delete()) log.warn { "Warning: outputFile not deleted" } + downloadFromBytesReceiver(false) + } } + } - /** - * Get a remote file, checking the HTTP response code, if it has changed since - * the last time a download was tried. - * - * - * If the `ETag` does not match, it could be caused by the previous - * download of the same file coming from a mirror running on a different - * webserver, e.g. Apache vs Nginx. `Content-Length` and - * `Last-Modified` are used to check whether the file has changed since - * those are more standardized than `ETag`. Plus, Nginx and Apache 2.4 - * defaults use only those two values to generate the `ETag` anyway. - * Unfortunately, other webservers and CDNs have totally different methods - * for generating the `ETag`. And mirrors that are syncing using a - * method other than `rsync` could easily have different `Last-Modified` - * times on the exact same file. On top of that, some services like GitHub's - * raw file support `raw.githubusercontent.com` and GitLab's raw file - * support do not set the `Last-Modified` header at all. So ultimately, - * then `ETag` needs to be used first and foremost, then this calculated - * `ETag` can serve as a common fallback. - * - * - * In order to prevent the `ETag` from being used as a form of tracking - * cookie, this code never sends the `ETag` to the server. Instead, it - * uses a `HEAD` request to get the `ETag` from the server, then - * only issues a `GET` if the `ETag` has changed. - * - * - * This uses a integer value for `Last-Modified` to avoid enabling the - * use of that value as some kind of "cookieless cookie". One second time - * resolution should be plenty since these files change more on the time - * space of minutes or hours. - * - * @see [update index from any available mirror](https://gitlab.com/fdroid/fdroidclient/issues/1708) - * - * @see [Cookieless cookies](http://lucb1e.com/rp/cookielesscookies) - */ - @Suppress("DEPRECATION") - @Deprecated("Use only for v1 repos") - @Throws(IOException::class, InterruptedException::class) - public override fun download() { - val headInfo = runBlocking { - httpManager.head(request, cacheTag) ?: throw IOException() - } - val expectedETag = cacheTag - cacheTag = headInfo.eTag - fileSize = headInfo.contentLength ?: request.indexFile.size ?: -1 + override fun totalDownloadSize(): Long = fileSize ?: -1L - // If the ETag does not match, it could be because the file is on a mirror - // running a different webserver, e.g. Apache vs Nginx. - // Content-Length and Last-Modified could be used as well. - // Nginx and Apache 2.4 defaults use only those two values to generate the ETag. - // Unfortunately, other webservers and CDNs have totally different methods. - // And mirrors that are syncing using a method other than rsync - // could easily have different Last-Modified times on the exact same file. - // On top of that, some services like GitHub's and GitLab's raw file support - // do not set the header at all. - val lastModified = try { - // this method is not available multi-platform, so for now only done in JVM - @Suppress("Deprecation") - Date.parse(headInfo.lastModified) / 1000 - } catch (e: Exception) { - 0L - } - val calculatedEtag: String = - String.format("%x-%x", lastModified, headInfo.contentLength) - - // !headInfo.eTagChanged: expectedETag == headInfo.eTag (the expected ETag was in server response) - // calculatedEtag == expectedETag (ETag calculated from server response matches expected ETag) - if (!headInfo.eTagChanged || calculatedEtag == expectedETag) { - // ETag has not changed, don't download again - log.debug { "${request.indexFile.name} cached, not downloading." } - hasChanged = false - return - } - - hasChanged = true - downloadToFile() - } - - private fun downloadToFile() { - var resumable = false - val fileLength = outputFile.length() - if (fileLength > (fileSize ?: -1)) { - if (!outputFile.delete()) log.warn { "Warning: outputFile not deleted" } - } else if (fileLength == fileSize && outputFile.isFile) { - log.debug { "Already have outputFile, not downloading: ${outputFile.name}" } - return // already have it! - } else if (fileLength > 0) { - resumable = true - } - log.debug { "Downloading ${request.indexFile.name} (is resumable: $resumable)" } - runBlocking { - try { - downloadFromBytesReceiver(resumable) - } catch (e: NoResumeException) { - if (!outputFile.delete()) log.warn { "Warning: outputFile not deleted" } - downloadFromBytesReceiver(false) - } - } - } - - protected override fun totalDownloadSize(): Long = fileSize ?: -1L - - @Suppress("DEPRECATION") - @Deprecated("Only for v1 repos") - override fun hasChanged(): Boolean { - return hasChanged - } - - override fun close() { - } + @Suppress("DEPRECATION") + @Deprecated("Only for v1 repos") + override fun hasChanged(): Boolean { + return hasChanged + } + override fun close() {} } diff --git a/libs/download/src/androidMain/kotlin/org/fdroid/download/HttpDownloaderV2.kt b/libs/download/src/androidMain/kotlin/org/fdroid/download/HttpDownloaderV2.kt index fe25c5085..e28dabcff 100644 --- a/libs/download/src/androidMain/kotlin/org/fdroid/download/HttpDownloaderV2.kt +++ b/libs/download/src/androidMain/kotlin/org/fdroid/download/HttpDownloaderV2.kt @@ -23,104 +23,100 @@ package org.fdroid.download import io.ktor.client.plugins.ResponseException import io.ktor.http.HttpStatusCode.Companion.NotFound -import kotlinx.coroutines.runBlocking -import mu.KotlinLogging -import org.fdroid.fdroid.toHex import java.io.File import java.io.IOException import java.io.InputStream import java.security.MessageDigest import java.security.NoSuchAlgorithmException +import kotlinx.coroutines.runBlocking +import mu.KotlinLogging +import org.fdroid.fdroid.toHex -/** - * Download files over HTTP, with support for proxies, `.onion` addresses, HTTP Basic Auth, etc. - */ -public class HttpDownloaderV2 constructor( - private val httpManager: HttpManager, - private val request: DownloadRequest, - destFile: File, +/** Download files over HTTP, with support for proxies, `.onion` addresses, HTTP Basic Auth, etc. */ +public class HttpDownloaderV2( + private val httpManager: HttpManager, + private val request: DownloadRequest, + destFile: File, ) : Downloader(request.indexFile, destFile) { - private companion object { - val log = KotlinLogging.logger {} - } + private companion object { + val log = KotlinLogging.logger {} + } - override fun getInputStream(resumable: Boolean): InputStream { - throw NotImplementedError("Use getInputStreamSuspend instead.") - } + override fun getInputStream(resumable: Boolean): InputStream { + throw NotImplementedError("Use getInputStreamSuspend instead.") + } - @Throws(IOException::class, NoResumeException::class, NotFoundException::class) - protected override suspend fun getBytes(resumable: Boolean, receiver: BytesReceiver) { - val skipBytes = if (resumable) outputFile.length() else null - return try { - httpManager.get(request, skipBytes, receiver) - } catch (e: ResponseException) { - if (e.response.status == NotFound) throw NotFoundException(e) - else throw IOException(e) + @Throws(IOException::class, NoResumeException::class, NotFoundException::class) + override suspend fun getBytes(resumable: Boolean, receiver: BytesReceiver) { + val skipBytes = if (resumable) outputFile.length() else null + return try { + httpManager.get(request, skipBytes, receiver) + } catch (e: ResponseException) { + if (e.response.status == NotFound) throw NotFoundException(e) else throw IOException(e) + } + } + + @Throws(IOException::class, InterruptedException::class, NotFoundException::class) + public override fun download() { + var resumable = false + val fileLength = outputFile.length() + if (fileLength > (request.indexFile.size ?: -1)) { + // file was larger than expected, so delete and re-download + if (!outputFile.delete()) log.warn { "Warning: outputFile not deleted" } + } else if (fileLength == request.indexFile.size && outputFile.isFile) { + log.debug { "Already have outputFile, not downloading: ${outputFile.name}" } + if (request.indexFile.sha256 == null) { + // no way to check file, so we trust that what we have is legit (v1 only) + return + } else { + if (hashFile(outputFile) == request.indexFile.sha256) { + // hash matched, so we already have the good file, don't download again + return + } else { + log.warn { "Hash mismatch for ${request.indexFile}" } + // delete file and continue + if (!outputFile.delete()) log.warn { "Warning: outputFile not deleted" } } + } + } else if (fileLength > 0) { + resumable = true } - - @Throws(IOException::class, InterruptedException::class, NotFoundException::class) - public override fun download() { - var resumable = false - val fileLength = outputFile.length() - if (fileLength > (request.indexFile.size ?: -1)) { - // file was larger than expected, so delete and re-download - if (!outputFile.delete()) log.warn { "Warning: outputFile not deleted" } - } else if (fileLength == request.indexFile.size && outputFile.isFile) { - log.debug { "Already have outputFile, not downloading: ${outputFile.name}" } - if (request.indexFile.sha256 == null) { - // no way to check file, so we trust that what we have is legit (v1 only) - return - } else { - if (hashFile(outputFile) == request.indexFile.sha256) { - // hash matched, so we already have the good file, don't download again - return - } else { - log.warn { "Hash mismatch for ${request.indexFile}" } - // delete file and continue - if (!outputFile.delete()) log.warn { "Warning: outputFile not deleted" } - } - } - } else if (fileLength > 0) { - resumable = true - } - log.debug { "Downloading ${request.indexFile.name} (is resumable: $resumable)" } - runBlocking { - try { - downloadFromBytesReceiver(resumable) - } catch (e: NoResumeException) { - if (!outputFile.delete()) log.warn { "Warning: outputFile not deleted" } - downloadFromBytesReceiver(false) - } - } + log.debug { "Downloading ${request.indexFile.name} (is resumable: $resumable)" } + runBlocking { + try { + downloadFromBytesReceiver(resumable) + } catch (_: NoResumeException) { + if (!outputFile.delete()) log.warn { "Warning: outputFile not deleted" } + downloadFromBytesReceiver(false) + } } + } - protected override fun totalDownloadSize(): Long = request.indexFile.size ?: -1L + override fun totalDownloadSize(): Long = request.indexFile.size ?: -1L - @Deprecated("Only for v1 repos") - override fun hasChanged(): Boolean { - error("hasChanged() was called for V2 where it should not be needed.") + @Deprecated("Only for v1 repos") + override fun hasChanged(): Boolean { + error("hasChanged() was called for V2 where it should not be needed.") + } + + override fun close() {} + + private fun hashFile(file: File): String { + val messageDigest: MessageDigest = + try { + MessageDigest.getInstance("SHA-256") + } catch (e: NoSuchAlgorithmException) { + throw AssertionError(e) + } + file.inputStream().use { inputStream -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var bytes = inputStream.read(buffer) + while (bytes >= 0) { + messageDigest.update(buffer, 0, bytes) + bytes = inputStream.read(buffer) + } } - - override fun close() { - } - - private fun hashFile(file: File): String { - val messageDigest: MessageDigest = try { - MessageDigest.getInstance("SHA-256") - } catch (e: NoSuchAlgorithmException) { - throw AssertionError(e) - } - file.inputStream().use { inputStream -> - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - var bytes = inputStream.read(buffer) - while (bytes >= 0) { - messageDigest.update(buffer, 0, bytes) - bytes = inputStream.read(buffer) - } - } - return messageDigest.digest().toHex() - } - + return messageDigest.digest().toHex() + } } diff --git a/libs/download/src/androidMain/kotlin/org/fdroid/download/HttpManager.kt b/libs/download/src/androidMain/kotlin/org/fdroid/download/HttpManager.kt index 96e0ae523..1ea2f17fa 100644 --- a/libs/download/src/androidMain/kotlin/org/fdroid/download/HttpManager.kt +++ b/libs/download/src/androidMain/kotlin/org/fdroid/download/HttpManager.kt @@ -6,70 +6,68 @@ import io.ktor.client.engine.HttpClientEngineFactory import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.engine.okhttp.OkHttpConfig import io.ktor.utils.io.jvm.javaio.toInputStream +import java.io.InputStream +import java.net.InetAddress +import java.security.DigestInputStream +import java.security.MessageDigest import okhttp3.ConnectionSpec.Companion.CLEARTEXT import okhttp3.ConnectionSpec.Companion.MODERN_TLS import okhttp3.ConnectionSpec.Companion.RESTRICTED_TLS import okhttp3.Dns import okhttp3.internal.tls.OkHostnameVerifier -import java.io.InputStream -import java.net.InetAddress -import java.security.DigestInputStream -import java.security.MessageDigest internal actual fun getHttpClientEngineFactory(customDns: Dns?): HttpClientEngineFactory<*> { - return object : HttpClientEngineFactory { - private val connectionSpecs = listOf( - RESTRICTED_TLS, // order matters here, so we put restricted before modern - MODERN_TLS, - CLEARTEXT, // needed for swap connections, allowed in fdroidclient:app as well - ) + return object : HttpClientEngineFactory { + private val connectionSpecs = + listOf( + RESTRICTED_TLS, // order matters here, so we put restricted before modern + MODERN_TLS, + CLEARTEXT, // needed for swap connections, allowed in fdroidclient:app as well + ) - override fun create(block: OkHttpConfig.() -> Unit): HttpClientEngine = OkHttp.create { - block() - config { - if (proxy.isTor()) { // don't allow DNS requests when using Tor - dns(NoDns()) - } else if (customDns != null) { - dns(customDns) - } - hostnameVerifier { hostname, session -> - try { - session?.sessionContext?.sessionTimeout = 10 - } catch (e: NullPointerException) { - // com.android.org.conscrypt.AbstractSessionContext.setSessionTimeout() - // can throw this internally, so let's not crash due to this - Log.e("HttpManager", "Error setting session timeout: ", e) - } - // use default hostname verifier - OkHostnameVerifier.verify(hostname, session) - } - connectionSpecs(connectionSpecs) + override fun create(block: OkHttpConfig.() -> Unit): HttpClientEngine = + OkHttp.create { + block() + config { + if (proxy.isTor()) { // don't allow DNS requests when using Tor + dns(NoDns()) + } else if (customDns != null) { + dns(customDns) + } + hostnameVerifier { hostname, session -> + try { + session?.sessionContext?.sessionTimeout = 10 + } catch (e: NullPointerException) { + // com.android.org.conscrypt.AbstractSessionContext.setSessionTimeout() + // can throw this internally, so let's not crash due to this + Log.e("HttpManager", "Error setting session timeout: ", e) } + // use default hostname verifier + OkHostnameVerifier.verify(hostname, session) + } + connectionSpecs(connectionSpecs) } - } + } + } } public suspend fun HttpManager.getInputStream(request: DownloadRequest): InputStream { - return getChannel(request).toInputStream() + return getChannel(request).toInputStream() } /** - * Gets the [InputStream] for the given [request] as a [DigestInputStream], - * so you can verify the SHA-256 hash. - * If you don't need to verify the hash, use [getInputStream] instead. + * Gets the [InputStream] for the given [request] as a [DigestInputStream], so you can verify the + * SHA-256 hash. If you don't need to verify the hash, use [getInputStream] instead. */ public suspend fun HttpManager.getDigestInputStream(request: DownloadRequest): DigestInputStream { - val digest = MessageDigest.getInstance("SHA-256") - val inputStream = getChannel(request).toInputStream() - return DigestInputStream(inputStream, digest) + val digest = MessageDigest.getInstance("SHA-256") + val inputStream = getChannel(request).toInputStream() + return DigestInputStream(inputStream, digest) } -/** - * Prevent DNS requests. - * Important when proxying all requests over Tor to not leak DNS queries. - */ +/** Prevent DNS requests. Important when proxying all requests over Tor to not leak DNS queries. */ private class NoDns : Dns { - override fun lookup(hostname: String): List { - return listOf(InetAddress.getByAddress(hostname, ByteArray(4))) - } + override fun lookup(hostname: String): List { + return listOf(InetAddress.getByAddress(hostname, ByteArray(4))) + } } diff --git a/libs/download/src/androidMain/kotlin/org/fdroid/download/HttpPoster.kt b/libs/download/src/androidMain/kotlin/org/fdroid/download/HttpPoster.kt index 5d1da134b..a8bd51419 100644 --- a/libs/download/src/androidMain/kotlin/org/fdroid/download/HttpPoster.kt +++ b/libs/download/src/androidMain/kotlin/org/fdroid/download/HttpPoster.kt @@ -1,26 +1,20 @@ package org.fdroid.download import io.ktor.client.plugins.ResponseException -import kotlinx.coroutines.runBlocking import java.io.IOException +import kotlinx.coroutines.runBlocking -/** - * HTTP POST a JSON string to the URL configured in the constructor. - */ -public class HttpPoster( - private val httpManager: HttpManager, - private val url: String, -) { +/** HTTP POST a JSON string to the URL configured in the constructor. */ +public class HttpPoster(private val httpManager: HttpManager, private val url: String) { - @Throws(IOException::class) - public fun post(json: String) { - runBlocking { - try { - httpManager.post(url, json) - } catch (e: ResponseException) { - throw IOException(e) - } - } + @Throws(IOException::class) + public fun post(json: String) { + runBlocking { + try { + httpManager.post(url, json) + } catch (e: ResponseException) { + throw IOException(e) + } } - + } } diff --git a/libs/download/src/androidMain/kotlin/org/fdroid/download/coil/DownloadRequestFetcher.kt b/libs/download/src/androidMain/kotlin/org/fdroid/download/coil/DownloadRequestFetcher.kt index dbf7c1e24..08c2c29eb 100644 --- a/libs/download/src/androidMain/kotlin/org/fdroid/download/coil/DownloadRequestFetcher.kt +++ b/libs/download/src/androidMain/kotlin/org/fdroid/download/coil/DownloadRequestFetcher.kt @@ -1,9 +1,7 @@ /** * Contains disk cache related code from https://github.com/coil-kt/coil - * coil-network-core/src/commonMain/kotlin/coil3/network/NetworkFetcher.kt - * under Apache-2.0 license. + * coil-network-core/src/commonMain/kotlin/coil3/network/NetworkFetcher.kt under Apache-2.0 license. */ - package org.fdroid.download.coil import coil3.ImageLoader @@ -17,6 +15,7 @@ import coil3.fetch.SourceFetchResult import coil3.request.Options import coil3.util.MimeTypeMap import io.ktor.utils.io.jvm.javaio.toInputStream +import javax.inject.Inject import okio.BufferedSource import okio.FileSystem import okio.buffer @@ -25,126 +24,125 @@ import org.fdroid.download.DownloadRequest import org.fdroid.download.HttpManager import org.fdroid.download.glide.AutoVerifyingInputStream import org.fdroid.download.glide.getKey -import javax.inject.Inject public class DownloadRequestFetcher( - private val httpManager: HttpManager, - private val downloadRequest: DownloadRequest, - private val options: Options, - private val diskCache: Lazy, + private val httpManager: HttpManager, + private val downloadRequest: DownloadRequest, + private val options: Options, + private val diskCache: Lazy, ) : Fetcher { - private val fileSystem: FileSystem - get() = diskCache.value?.fileSystem ?: options.fileSystem + private val fileSystem: FileSystem + get() = diskCache.value?.fileSystem ?: options.fileSystem - private val diskCacheKey: String - get() = options.diskCacheKey ?: downloadRequest.getKey() + private val diskCacheKey: String + get() = options.diskCacheKey ?: downloadRequest.getKey() - @OptIn(InternalCoilApi::class) - private val mimeType: String? - get() = MimeTypeMap.getMimeTypeFromUrl(downloadRequest.indexFile.name) + @OptIn(InternalCoilApi::class) + private val mimeType: String? + get() = MimeTypeMap.getMimeTypeFromUrl(downloadRequest.indexFile.name) - override suspend fun fetch(): FetchResult? { - var snapshot = readFromDiskCache() - try { - if (snapshot != null) { - // we have the request cached, so return it right away - return SourceFetchResult( - source = snapshot.toImageSource(), - mimeType = mimeType, - dataSource = DataSource.DISK, - ) - } - // TODO use channel directly and auto-verify hash without InputStream wrapper - // may need https://github.com/Kotlin/kotlinx-io/blob/master/integration/kotlinx-io-okio/Module.md - val inputStream = httpManager.getChannel(downloadRequest).toInputStream() - val sha256 = downloadRequest.indexFile.sha256 - val bufferedSource = if (sha256 == null) { - inputStream - } else { - AutoVerifyingInputStream(inputStream, sha256) - }.source().buffer() - snapshot = writeToDiskCache(snapshot, bufferedSource) - if (snapshot == null) { - // we couldn't write the snapshot, so try returning directly - return SourceFetchResult( - source = ImageSource( - source = bufferedSource, - fileSystem = FileSystem.SYSTEM, - metadata = null, - ), - mimeType = mimeType, - dataSource = DataSource.NETWORK, - ) - } - return SourceFetchResult( - source = snapshot.toImageSource(), - mimeType = mimeType, - dataSource = DataSource.NETWORK, - ) - } finally { - snapshot?.close() - } - } - - private fun readFromDiskCache(): DiskCache.Snapshot? { - return if (options.diskCachePolicy.readEnabled) { - diskCache.value?.openSnapshot(downloadRequest.getKey()) - } else { - null - } - } - - private fun writeToDiskCache( - snapshot: DiskCache.Snapshot?, - bufferedSource: BufferedSource, - ): DiskCache.Snapshot? { - // Short circuit if we're not allowed to cache this response. - if (!options.diskCachePolicy.writeEnabled) return null - - // Open a new editor. Return null if we're unable to write to this entry. - val editor = if (snapshot != null) { - snapshot.closeAndOpenEditor() - } else { - diskCache.value?.openEditor(diskCacheKey) - } ?: return null - - return try { - fileSystem.write(editor.data) { - writeAll(bufferedSource) - } - editor.commitAndOpenSnapshot() - } catch (e: Exception) { - try { - editor.abort() - } catch (_: Exception) { - // ignore - } - throw e - } - } - - private fun DiskCache.Snapshot.toImageSource(): ImageSource { - return ImageSource( - file = data, - fileSystem = fileSystem, - diskCacheKey = diskCacheKey, - closeable = this, + override suspend fun fetch(): FetchResult? { + var snapshot = readFromDiskCache() + try { + if (snapshot != null) { + // we have the request cached, so return it right away + return SourceFetchResult( + source = snapshot.toImageSource(), + mimeType = mimeType, + dataSource = DataSource.DISK, ) - } - - public class Factory @Inject constructor( - private val httpManager: HttpManager, - ) : Fetcher.Factory { - override fun create( - data: DownloadRequest, - options: Options, - imageLoader: ImageLoader, - ): Fetcher? = DownloadRequestFetcher( - httpManager = httpManager, - downloadRequest = data, - options = options, - diskCache = lazy { imageLoader.diskCache }, + } + // TODO use channel directly and auto-verify hash without InputStream wrapper + // may need + // https://github.com/Kotlin/kotlinx-io/blob/master/integration/kotlinx-io-okio/Module.md + val inputStream = httpManager.getChannel(downloadRequest).toInputStream() + val sha256 = downloadRequest.indexFile.sha256 + val bufferedSource = + if (sha256 == null) { + inputStream + } else { + AutoVerifyingInputStream(inputStream, sha256) + } + .source() + .buffer() + snapshot = writeToDiskCache(snapshot, bufferedSource) + if (snapshot == null) { + // we couldn't write the snapshot, so try returning directly + return SourceFetchResult( + source = + ImageSource(source = bufferedSource, fileSystem = FileSystem.SYSTEM, metadata = null), + mimeType = mimeType, + dataSource = DataSource.NETWORK, ) + } + return SourceFetchResult( + source = snapshot.toImageSource(), + mimeType = mimeType, + dataSource = DataSource.NETWORK, + ) + } finally { + snapshot?.close() } + } + + private fun readFromDiskCache(): DiskCache.Snapshot? { + return if (options.diskCachePolicy.readEnabled) { + diskCache.value?.openSnapshot(downloadRequest.getKey()) + } else { + null + } + } + + private fun writeToDiskCache( + snapshot: DiskCache.Snapshot?, + bufferedSource: BufferedSource, + ): DiskCache.Snapshot? { + // Short circuit if we're not allowed to cache this response. + if (!options.diskCachePolicy.writeEnabled) return null + + // Open a new editor. Return null if we're unable to write to this entry. + val editor = + if (snapshot != null) { + snapshot.closeAndOpenEditor() + } else { + diskCache.value?.openEditor(diskCacheKey) + } ?: return null + + return try { + fileSystem.write(editor.data) { writeAll(bufferedSource) } + editor.commitAndOpenSnapshot() + } catch (e: Exception) { + try { + editor.abort() + } catch (_: Exception) { + // ignore + } + throw e + } + } + + private fun DiskCache.Snapshot.toImageSource(): ImageSource { + return ImageSource( + file = data, + fileSystem = fileSystem, + diskCacheKey = diskCacheKey, + closeable = this, + ) + } + + public class Factory @Inject constructor(private val httpManager: HttpManager) : + Fetcher.Factory { + override fun create( + data: DownloadRequest, + options: Options, + imageLoader: ImageLoader, + ): Fetcher = + DownloadRequestFetcher( + httpManager = httpManager, + downloadRequest = data, + options = options, + diskCache = lazy { imageLoader.diskCache }, + ) + } } diff --git a/libs/download/src/androidMain/kotlin/org/fdroid/download/glide/AutoVerifyingInputStream.kt b/libs/download/src/androidMain/kotlin/org/fdroid/download/glide/AutoVerifyingInputStream.kt index 69346198a..2fa6160b1 100644 --- a/libs/download/src/androidMain/kotlin/org/fdroid/download/glide/AutoVerifyingInputStream.kt +++ b/libs/download/src/androidMain/kotlin/org/fdroid/download/glide/AutoVerifyingInputStream.kt @@ -1,47 +1,47 @@ package org.fdroid.download.glide -import org.fdroid.fdroid.isMatching import java.io.IOException import java.io.InputStream import java.security.DigestInputStream import java.security.MessageDigest +import org.fdroid.fdroid.isMatching /** - * An [InputStream] that automatically verifies the [expectedHash] in lower-case SHA-256 format - * and ensures that not more than [maxBytesToRead] are read from the stream. - * This is useful to put an upper bound on data read to not exhaust memory. + * An [InputStream] that automatically verifies the [expectedHash] in lower-case SHA-256 format and + * ensures that not more than [maxBytesToRead] are read from the stream. This is useful to put an + * upper bound on data read to not exhaust memory. */ internal class AutoVerifyingInputStream( - inputStream: InputStream, - private val expectedHash: String, - private val maxBytesToRead: Long = Runtime.getRuntime().maxMemory() / 2, + inputStream: InputStream, + private val expectedHash: String, + private val maxBytesToRead: Long = Runtime.getRuntime().maxMemory() / 2, ) : DigestInputStream(inputStream, MessageDigest.getInstance("SHA-256")) { - private var bytesRead = 0 + private var bytesRead = 0 - override fun read(): Int { - val readByte = super.read() - if (readByte != -1) { - bytesRead++ - if (bytesRead > maxBytesToRead) { - throw IOException("Read $bytesRead bytes, above maximum allowed.") - } - } else { - if (!digest.isMatching(expectedHash)) throw IOException("Hash not matching.") - } - return readByte + override fun read(): Int { + val readByte = super.read() + if (readByte != -1) { + bytesRead++ + if (bytesRead > maxBytesToRead) { + throw IOException("Read $bytesRead bytes, above maximum allowed.") + } + } else { + if (!digest.isMatching(expectedHash)) throw IOException("Hash not matching.") } + return readByte + } - override fun read(b: ByteArray?, off: Int, len: Int): Int { - val read = super.read(b, off, len) - if (read != -1) { - bytesRead += read - if (bytesRead > maxBytesToRead) { - throw IOException("Read $bytesRead bytes, above maximum allowed.") - } - } else { - if (!digest.isMatching(expectedHash)) throw IOException("Hash not matching.") - } - return read + override fun read(b: ByteArray?, off: Int, len: Int): Int { + val read = super.read(b, off, len) + if (read != -1) { + bytesRead += read + if (bytesRead > maxBytesToRead) { + throw IOException("Read $bytesRead bytes, above maximum allowed.") + } + } else { + if (!digest.isMatching(expectedHash)) throw IOException("Hash not matching.") } + return read + } } diff --git a/libs/download/src/androidMain/kotlin/org/fdroid/download/glide/DownloadRequestLoader.kt b/libs/download/src/androidMain/kotlin/org/fdroid/download/glide/DownloadRequestLoader.kt index 47c748526..d8557710c 100644 --- a/libs/download/src/androidMain/kotlin/org/fdroid/download/glide/DownloadRequestLoader.kt +++ b/libs/download/src/androidMain/kotlin/org/fdroid/download/glide/DownloadRequestLoader.kt @@ -6,45 +6,42 @@ import com.bumptech.glide.load.model.ModelLoader.LoadData import com.bumptech.glide.load.model.ModelLoaderFactory import com.bumptech.glide.load.model.MultiModelLoaderFactory import com.bumptech.glide.signature.ObjectKey +import java.io.InputStream import org.fdroid.download.DownloadRequest import org.fdroid.download.HttpManager -import java.io.InputStream -public class DownloadRequestLoader( - private val httpManager: HttpManager, -) : ModelLoader { +public class DownloadRequestLoader(private val httpManager: HttpManager) : + ModelLoader { - override fun handles(downloadRequest: DownloadRequest): Boolean { - return true - } - - override fun buildLoadData( - downloadRequest: DownloadRequest, - width: Int, - height: Int, - options: Options, - ): LoadData { - return LoadData(downloadRequest.getObjectKey(), HttpFetcher(httpManager, downloadRequest)) - } - - public class Factory( - private val httpManager: HttpManager, - ) : ModelLoaderFactory { - override fun build( - multiFactory: MultiModelLoaderFactory, - ): ModelLoader { - return DownloadRequestLoader(httpManager) - } - - override fun teardown() {} + override fun handles(downloadRequest: DownloadRequest): Boolean { + return true + } + + override fun buildLoadData( + downloadRequest: DownloadRequest, + width: Int, + height: Int, + options: Options, + ): LoadData { + return LoadData(downloadRequest.getObjectKey(), HttpFetcher(httpManager, downloadRequest)) + } + + public class Factory(private val httpManager: HttpManager) : + ModelLoaderFactory { + override fun build( + multiFactory: MultiModelLoaderFactory + ): ModelLoader { + return DownloadRequestLoader(httpManager) } + override fun teardown() {} + } } internal fun DownloadRequest.getObjectKey(): ObjectKey { - return ObjectKey(getKey()) + return ObjectKey(getKey()) } internal fun DownloadRequest.getKey(): String { - return indexFile.sha256 ?: (mirrors[0].baseUrl + indexFile.name) + return indexFile.sha256 ?: (mirrors[0].baseUrl + indexFile.name) } diff --git a/libs/download/src/androidMain/kotlin/org/fdroid/download/glide/HttpFetcher.kt b/libs/download/src/androidMain/kotlin/org/fdroid/download/glide/HttpFetcher.kt index f6a475ff1..36eaf44f4 100644 --- a/libs/download/src/androidMain/kotlin/org/fdroid/download/glide/HttpFetcher.kt +++ b/libs/download/src/androidMain/kotlin/org/fdroid/download/glide/HttpFetcher.kt @@ -4,6 +4,7 @@ import com.bumptech.glide.Priority import com.bumptech.glide.load.DataSource import com.bumptech.glide.load.data.DataFetcher import io.ktor.utils.io.jvm.javaio.toInputStream +import java.io.InputStream import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -11,46 +12,46 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.launch import org.fdroid.download.DownloadRequest import org.fdroid.download.HttpManager -import java.io.InputStream internal class HttpFetcher( - private val httpManager: HttpManager, - private val downloadRequest: DownloadRequest, + private val httpManager: HttpManager, + private val downloadRequest: DownloadRequest, ) : DataFetcher { - private var job: Job? = null + private var job: Job? = null - @OptIn(DelicateCoroutinesApi::class) - override fun loadData(priority: Priority, callback: DataFetcher.DataCallback) { - job = GlobalScope.launch(Dispatchers.IO) { - try { - // glide should take care of closing this stream and the underlying channel - val inputStream = httpManager.getChannel(downloadRequest).toInputStream() - val sha256 = downloadRequest.indexFile.sha256 - if (sha256 == null) { - callback.onDataReady(inputStream) - } else { - callback.onDataReady(AutoVerifyingInputStream(inputStream, sha256)) - } - } catch (e: Exception) { - callback.onLoadFailed(e) - } + @OptIn(DelicateCoroutinesApi::class) + override fun loadData(priority: Priority, callback: DataFetcher.DataCallback) { + job = + GlobalScope.launch(Dispatchers.IO) { + try { + // glide should take care of closing this stream and the underlying channel + val inputStream = httpManager.getChannel(downloadRequest).toInputStream() + val sha256 = downloadRequest.indexFile.sha256 + if (sha256 == null) { + callback.onDataReady(inputStream) + } else { + callback.onDataReady(AutoVerifyingInputStream(inputStream, sha256)) + } + } catch (e: Exception) { + callback.onLoadFailed(e) } - } + } + } - override fun cleanup() { - job = null - } + override fun cleanup() { + job = null + } - override fun cancel() { - job?.cancel() - } + override fun cancel() { + job?.cancel() + } - override fun getDataClass(): Class { - return InputStream::class.java - } + override fun getDataClass(): Class { + return InputStream::class.java + } - override fun getDataSource(): DataSource { - return DataSource.REMOTE - } + override fun getDataSource(): DataSource { + return DataSource.REMOTE + } } diff --git a/libs/download/src/androidMain/kotlin/org/fdroid/fdroid/DigestInputStream.kt b/libs/download/src/androidMain/kotlin/org/fdroid/fdroid/DigestInputStream.kt index 9ba6df3ae..55f4460c0 100644 --- a/libs/download/src/androidMain/kotlin/org/fdroid/fdroid/DigestInputStream.kt +++ b/libs/download/src/androidMain/kotlin/org/fdroid/fdroid/DigestInputStream.kt @@ -8,11 +8,10 @@ package org.fdroid.fdroid import java.security.DigestInputStream /** - * Completes the hash computation by performing final operations such as padding - * and returns the resulting hash as a hex string. - * The digest is reset after this call is made, - * so call this only once and hang on to the result. + * Completes the hash computation by performing final operations such as padding and returns the + * resulting hash as a hex string. The digest is reset after this call is made, so call this only + * once and hang on to the result. */ public fun DigestInputStream.getDigestHex(): String { - return messageDigest.digest().toHex() + return messageDigest.digest().toHex() } diff --git a/libs/download/src/androidMain/kotlin/org/fdroid/fdroid/HashUtils.kt b/libs/download/src/androidMain/kotlin/org/fdroid/fdroid/HashUtils.kt index 64e421b35..920ba4468 100644 --- a/libs/download/src/androidMain/kotlin/org/fdroid/fdroid/HashUtils.kt +++ b/libs/download/src/androidMain/kotlin/org/fdroid/fdroid/HashUtils.kt @@ -3,11 +3,10 @@ package org.fdroid.fdroid import java.security.MessageDigest internal fun MessageDigest?.isMatching(sha256: String): Boolean { - if (this == null) return false - val hexDigest = digest().toHex() - return hexDigest.equals(sha256, ignoreCase = true) + if (this == null) return false + val hexDigest = digest().toHex() + return hexDigest.equals(sha256, ignoreCase = true) } -internal fun ByteArray.toHex(): String = joinToString(separator = "") { eachByte -> - "%02x".format(eachByte) -} +internal fun ByteArray.toHex(): String = + joinToString(separator = "") { eachByte -> "%02x".format(eachByte) } diff --git a/libs/download/src/androidUnitTest/kotlin/org/fdroid/download/HttpDownloaderTest.kt b/libs/download/src/androidUnitTest/kotlin/org/fdroid/download/HttpDownloaderTest.kt index f920b58ea..11f5a534c 100644 --- a/libs/download/src/androidUnitTest/kotlin/org/fdroid/download/HttpDownloaderTest.kt +++ b/libs/download/src/androidUnitTest/kotlin/org/fdroid/download/HttpDownloaderTest.kt @@ -15,16 +15,6 @@ import io.ktor.http.HttpMethod.Companion.Head import io.ktor.http.HttpStatusCode.Companion.OK import io.ktor.http.HttpStatusCode.Companion.PartialContent import io.ktor.http.headersOf -import kotlinx.io.Buffer -import org.fdroid.TestByteReadChannel -import org.fdroid.get -import org.fdroid.getByteRangeFrom -import org.fdroid.getIndexFile -import org.fdroid.getRandomString -import org.fdroid.runSuspend -import org.junit.Assume.assumeTrue -import org.junit.Rule -import org.junit.rules.TemporaryFolder import java.io.File import java.io.IOException import java.net.BindException @@ -37,331 +27,336 @@ import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertTrue import kotlin.test.fail +import kotlinx.io.Buffer +import org.fdroid.TestByteReadChannel +import org.fdroid.get +import org.fdroid.getByteRangeFrom +import org.fdroid.getIndexFile +import org.fdroid.getRandomString +import org.fdroid.runSuspend +import org.junit.Assume.assumeTrue +import org.junit.Rule +import org.junit.rules.TemporaryFolder private const val TOR_SOCKS_PORT = 9050 @Suppress("BlockingMethodInNonBlockingContext", "DEPRECATION") internal class HttpDownloaderTest { - @get:Rule - var folder = TemporaryFolder() + @get:Rule var folder = TemporaryFolder() - private val userAgent = getRandomString() - private val mirror1 = Mirror("http://example.org") - private val mirrors = listOf(mirror1) - private val downloadRequest = DownloadRequest(getIndexFile("foo/bar"), mirrors) + private val userAgent = getRandomString() + private val mirror1 = Mirror("http://example.org") + private val mirrors = listOf(mirror1) + private val downloadRequest = DownloadRequest(getIndexFile("foo/bar"), mirrors) - @Test - fun testDownload() = runSuspend { - val file = folder.newFile() - val bytes = Random.nextBytes(1024) + @Test + fun testDownload() = runSuspend { + val file = folder.newFile() + val bytes = Random.nextBytes(1024) - val mockEngine = MockEngine { respond(bytes) } - val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine)) - val httpDownloader = HttpDownloader(httpManager, downloadRequest, file) - httpDownloader.download() + val mockEngine = MockEngine { respond(bytes) } + val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine)) + val httpDownloader = HttpDownloader(httpManager, downloadRequest, file) + httpDownloader.download() - assertContentEquals(bytes, file.readBytes()) + assertContentEquals(bytes, file.readBytes()) + } + + @Test + fun testDownloadWithCorrectHash() = runSuspend { + val file = folder.newFile() + val bytes = "We know the hash for this string".encodeToByteArray() + val indexFile = + getIndexFile( + name = "/foo/bar", + sha256 = "e3802e5f8ae3dc7bbf5f1f4f7fb825d9bce9d1ddce50ac564fcbcfdeb31f1b90", + size = bytes.size.toLong(), + ) + val downloadRequest = DownloadRequest(indexFile, mirrors = mirrors) + var progressReported = false + + val mockEngine = MockEngine { respond(bytes) } + val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine)) + val httpDownloader = HttpDownloader(httpManager, downloadRequest, file) + httpDownloader.setListener { _, totalBytes -> + assertEquals(bytes.size.toLong(), totalBytes) + progressReported = true } + httpDownloader.download() - @Test - fun testDownloadWithCorrectHash() = runSuspend { - val file = folder.newFile() - val bytes = "We know the hash for this string".encodeToByteArray() - val indexFile = getIndexFile( - name = "/foo/bar", - sha256 = "e3802e5f8ae3dc7bbf5f1f4f7fb825d9bce9d1ddce50ac564fcbcfdeb31f1b90", - size = bytes.size.toLong(), - ) - val downloadRequest = DownloadRequest(indexFile, mirrors = mirrors) - var progressReported = false + assertContentEquals(bytes, file.readBytes()) + assertTrue(progressReported) + } - val mockEngine = MockEngine { respond(bytes) } - val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine)) - val httpDownloader = HttpDownloader(httpManager, downloadRequest, file) - httpDownloader.setListener { _, totalBytes -> - assertEquals(bytes.size.toLong(), totalBytes) - progressReported = true + @Test(expected = IOException::class) + fun testDownloadWithWrongHash() = runSuspend { + val file = folder.newFile() + val bytes = "We know the hash for this string".encodeToByteArray() + val indexFile = + getIndexFile( + name = "/foo/bar", + sha256 = "This is not the right hash", + size = bytes.size.toLong(), + ) + val downloadRequest = DownloadRequest(indexFile, mirrors = mirrors) + + val mockEngine = MockEngine { respond(bytes) } + val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine)) + val httpDownloader = HttpDownloader(httpManager, downloadRequest, file) + httpDownloader.download() + + assertContentEquals(bytes, file.readBytes()) + } + + @Test + fun testResumeSuccess() = runSuspend { + val file = folder.newFile() + val firstBytes = Random.nextBytes(1024) + file.writeBytes(firstBytes) + val secondBytes = Random.nextBytes(1024) + + var numRequest = 1 + val mockEngine = MockEngine { + if (numRequest++ == 1) respond("", OK, headers = headersOf(ContentLength, "2048")) + else respond(secondBytes, PartialContent) + } + val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine)) + val httpDownloader = HttpDownloader(httpManager, downloadRequest, file) + httpDownloader.download() + + assertContentEquals(firstBytes + secondBytes, file.readBytes()) + assertEquals(2, mockEngine.responseHistory.size) + } + + /** + * Tests that a failed download in one mirror will be automatically resumed with the next mirror + * and then restarted if that mirror doesn't support [PartialContent]. + */ + @Test + @Ignore("It isn't possible anymore to mock failed reads") + fun testMirrorNoResume() = runSuspend { + // we need at least two mirrors + val mirror2 = Mirror("http://example.net") + val mirrors = listOf(mirror1, mirror2) + val downloadRequest = DownloadRequest(getIndexFile("foo/bar"), mirrors) + + val file = folder.newFile() + val firstBytes = Random.nextBytes(DEFAULT_BUFFER_SIZE * 64) + val secondBytes = Random.nextBytes(DEFAULT_BUFFER_SIZE) + val totalSize = firstBytes.size + secondBytes.size + val buffer = Buffer().also { it.write(firstBytes, startIndex = 0, endIndex = firstBytes.size) } + val readChannel = TestByteReadChannel(buffer) + val mockEngine = + MockEngine.config { + reuseHandlers = false + // first response reads from channel that errors after sending firstBytes + addHandler { respond(readChannel, OK, headers = headersOf(ContentLength, "$totalSize")) } + // second request tries to resume, but doesn't get PartialContent response + addHandler { + val from = it.getByteRangeFrom() + assertTrue(from > 0) + assertTrue(from <= firstBytes.size) + respond( + content = firstBytes + secondBytes, + status = OK, + headers = headersOf(ContentLength, "$totalSize"), + ) } - httpDownloader.download() - - assertContentEquals(bytes, file.readBytes()) - assertTrue(progressReported) - } - - @Test(expected = IOException::class) - fun testDownloadWithWrongHash() = runSuspend { - val file = folder.newFile() - val bytes = "We know the hash for this string".encodeToByteArray() - val indexFile = getIndexFile( - name = "/foo/bar", - sha256 = "This is not the right hash", - size = bytes.size.toLong(), - ) - val downloadRequest = DownloadRequest(indexFile, mirrors = mirrors) - - val mockEngine = MockEngine { respond(bytes) } - val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine)) - val httpDownloader = HttpDownloader(httpManager, downloadRequest, file) - httpDownloader.download() - - assertContentEquals(bytes, file.readBytes()) - } - - @Test - fun testResumeSuccess() = runSuspend { - val file = folder.newFile() - val firstBytes = Random.nextBytes(1024) - file.writeBytes(firstBytes) - val secondBytes = Random.nextBytes(1024) - - var numRequest = 1 - val mockEngine = MockEngine { - if (numRequest++ == 1) respond("", OK, headers = headersOf(ContentLength, "2048")) - else respond(secondBytes, PartialContent) + // download is tried again without resuming + addHandler { + assertTrue(Range !in it.headers) + respond( + content = firstBytes + secondBytes, + status = OK, + headers = headersOf(ContentLength, "$totalSize"), + ) } - val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine)) - val httpDownloader = HttpDownloader(httpManager, downloadRequest, file) - httpDownloader.download() + } + val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = mockEngine) + val httpDownloader = HttpDownloaderV2(httpManager, downloadRequest, file) + httpDownloader.download() - assertContentEquals(firstBytes + secondBytes, file.readBytes()) - assertEquals(2, mockEngine.responseHistory.size) + assertContentEquals(firstBytes + secondBytes, file.readBytes()) + } + + /** + * Tests resuming a download with hash verification. This can fail if the hashing doesn't take the + * already downloaded bytes into account. + */ + @Test + fun testResumeWithHashSuccess() = runSuspend { + val file = folder.newFile() + val firstBytes = "These are the first bytes that were already downloaded.".encodeToByteArray() + file.writeBytes(firstBytes) + val secondBytes = + "These are the last bytes that still need to be downloaded.".encodeToByteArray() + val totalSize = firstBytes.size + secondBytes.size + // specifying the sha256 hash forces its validation + val sha256 = "efabb260da949061c88173c19f369b4aa0eaa82003c7c2dec08b5dfe75525368" + val downloadRequest = DownloadRequest(getIndexFile("foo/bar", sha256), mirrors) + + val mockEngine = + MockEngine.config { + reuseHandlers = false + addHandler { respond("", OK, headers = headersOf(ContentLength, "$totalSize")) } + addHandler { respond(secondBytes, PartialContent) } + } + val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = mockEngine) + val httpDownloader = HttpDownloader(httpManager, downloadRequest, file) + // this throws if the hash doesn't match while downloading + httpDownloader.download() + + assertContentEquals(firstBytes + secondBytes, file.readBytes()) + } + + /** + * Tests re-using an already downloaded file with hash verification. This can fail if the hashing + * doesn't take the already downloaded bytes into account. + */ + @Test + fun testCompleteResumeWithHashSuccess() = runSuspend { + val sha256 = "efabb260da949061c88173c19f369b4aa0eaa82003c7c2dec08b5dfe75525368" + val file = File(folder.newFolder(), sha256).apply { createNewFile() } + val bytes = + ("These are the first bytes that were already downloaded." + + "These are the last bytes that still need to be downloaded.") + .encodeToByteArray() + file.writeBytes(bytes) + // specifying the sha256 hash forces its validation + val indexFile = getIndexFile("foo/bar", sha256, bytes.size.toLong()) + val downloadRequest = DownloadRequest(indexFile, mirrors) + + val httpManager = HttpManager(userAgent, null) + val httpDownloader = HttpDownloaderV2(httpManager, downloadRequest, file) + // this throws if the hash doesn't match while downloading + httpDownloader.download() + + assertContentEquals(bytes, file.readBytes()) + } + + @Test + fun testCompleteResumeWithHashFailure() = runSuspend { + val sha256 = "efabb260da949061c88173c19f369b4aa0eaa82003c7c2dec08b5dfe75525368" + val file = File(folder.newFolder(), sha256).apply { createNewFile() } + val bytes = + ("These are the first bytes that were already downloaded." + + "These are the last bytes that still need to be downloaded.") + .encodeToByteArray() + file.writeBytes(bytes) + // specifying the sha256 hash forces its validation + val indexFile = getIndexFile("foo/bar", sha256.replaceFirst('e', 'f'), bytes.size.toLong()) + val downloadRequest = DownloadRequest(indexFile, mirrors) + val mockEngine = + MockEngine.config { + reuseHandlers = false + addHandler { respond("", OK) } + } + val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = mockEngine) + val httpDownloader = HttpDownloaderV2(httpManager, downloadRequest, file) + val e = assertFailsWith { httpDownloader.download() } + assertEquals("Hash not matching", e.message) + } + + @Test + fun testResumeError() = runSuspend { + val file = folder.newFile() + val firstBytes = Random.nextBytes(1024) + file.writeBytes(firstBytes) + val secondBytes = Random.nextBytes(1024) + val allBytes = firstBytes + secondBytes + + var numRequest = 1 + val mockEngine = MockEngine { + when (numRequest++) { + 1 -> respond("", OK, headers = headersOf(ContentLength, "2048")) + 2 -> respond(allBytes, OK) // not replying with PartialContent + 3 -> respond(allBytes, OK) + else -> fail("Unexpected additional request") + } + } + val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine)) + val httpDownloader = HttpDownloader(httpManager, downloadRequest, file) + httpDownloader.download() + + assertContentEquals(allBytes, file.readBytes()) + assertEquals(3, mockEngine.responseHistory.size) + } + + @Test + fun testNoETagNotTreatedAsNoChange() = runSuspend { + val mockEngine = MockEngine { respondOk() } + val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine)) + val httpDownloader = HttpDownloader(httpManager, downloadRequest, folder.newFile()) + httpDownloader.cacheTag = null + httpDownloader.download() + + assertEquals(2, mockEngine.requestHistory.size) + val headRequest = mockEngine.requestHistory[0] + val getRequest = mockEngine.requestHistory[1] + assertEquals(Head, headRequest.method) + assertEquals(Get, getRequest.method) + } + + @Test + fun testExpectedETagSkipsDownload() = runSuspend { + val eTag = getRandomString() + + val mockEngine = MockEngine { respond("", OK, headers = headersOf(ETag, eTag)) } + val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine)) + val httpDownloader = HttpDownloader(httpManager, downloadRequest, folder.newFile()) + httpDownloader.cacheTag = eTag + httpDownloader.download() + + assertEquals(eTag, httpDownloader.cacheTag) + assertEquals(1, mockEngine.requestHistory.size) + assertEquals(Head, mockEngine.requestHistory[0].method) + } + + @Test + @Ignore("We can not yet handle this scenario. See: #1708") + fun testCalculatedETagSkipsDownload() = runSuspend { + val eTag = "61de7e31-60a29a" + val headers = buildHeaders { + append(ETag, eTag) + append(LastModified, "Wed, 12 Jan 2022 07:07:29 GMT") + append(ContentLength, "6333082") } - /** - * Tests that a failed download in one mirror will be automatically resumed - * with the next mirror and then restarted if that mirror doesn't support [PartialContent]. - */ - @Test - @Ignore("It isn't possible anymore to mock failed reads") - fun testMirrorNoResume() = runSuspend { - // we need at least two mirrors - val mirror2 = Mirror("http://example.net") - val mirrors = listOf(mirror1, mirror2) - val downloadRequest = DownloadRequest(getIndexFile("foo/bar"), mirrors) + val mockEngine = MockEngine { respond("", OK, headers = headers) } + val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine)) + val httpDownloader = HttpDownloader(httpManager, downloadRequest, folder.newFile()) + // the ETag is calculated, but we expect a real ETag + httpDownloader.cacheTag = "60a29a-5d55d390de574" + httpDownloader.download() - val file = folder.newFile() - val firstBytes = Random.nextBytes(DEFAULT_BUFFER_SIZE * 64) - val secondBytes = Random.nextBytes(DEFAULT_BUFFER_SIZE) - val totalSize = firstBytes.size + secondBytes.size - val buffer = Buffer().also { - it.write(firstBytes, startIndex = 0, endIndex = firstBytes.size) - } - val readChannel = TestByteReadChannel(buffer) - val mockEngine = MockEngine.config { - reuseHandlers = false - // first response reads from channel that errors after sending firstBytes - addHandler { - respond(readChannel, OK, headers = headersOf(ContentLength, "$totalSize")) - } - // second request tries to resume, but doesn't get PartialContent response - addHandler { - val from = it.getByteRangeFrom() - assertTrue(from > 0) - assertTrue(from <= firstBytes.size) - respond( - content = firstBytes + secondBytes, - status = OK, - headers = headersOf(ContentLength, "$totalSize"), - ) - } - // download is tried again without resuming - addHandler { - assertTrue(Range !in it.headers) - respond( - content = firstBytes + secondBytes, - status = OK, - headers = headersOf(ContentLength, "$totalSize"), - ) - } - } - val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = mockEngine) - val httpDownloader = HttpDownloaderV2(httpManager, downloadRequest, file) - httpDownloader.download() + assertEquals(eTag, httpDownloader.cacheTag) + assertEquals(1, mockEngine.requestHistory.size) + assertEquals(Head, mockEngine.requestHistory[0].method) + } - assertContentEquals(firstBytes + secondBytes, file.readBytes()) - } + @Test + fun testTorProxy() = runSuspend { + assumeTrue(isTorRunning()) - /** - * Tests resuming a download with hash verification. - * This can fail if the hashing doesn't take the already downloaded bytes into account. - */ - @Test - fun testResumeWithHashSuccess() = runSuspend { - val file = folder.newFile() - val firstBytes = - "These are the first bytes that were already downloaded.".encodeToByteArray() - file.writeBytes(firstBytes) - val secondBytes = - "These are the last bytes that still need to be downloaded.".encodeToByteArray() - val totalSize = firstBytes.size + secondBytes.size - // specifying the sha256 hash forces its validation - val sha256 = "efabb260da949061c88173c19f369b4aa0eaa82003c7c2dec08b5dfe75525368" - val downloadRequest = DownloadRequest(getIndexFile("foo/bar", sha256), mirrors) + val file = folder.newFile() - val mockEngine = MockEngine.config { - reuseHandlers = false - addHandler { - respond("", OK, headers = headersOf(ContentLength, "$totalSize")) - } - addHandler { - respond(secondBytes, PartialContent) - } - } - val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = mockEngine) - val httpDownloader = HttpDownloader(httpManager, downloadRequest, file) - // this throws if the hash doesn't match while downloading - httpDownloader.download() + val httpManager = HttpManager(userAgent, null) + // tor-project.org + val torHost = "http://2gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion" + val proxy = ProxyBuilder.socks("localhost", TOR_SOCKS_PORT) + val downloadRequest = DownloadRequest("index.html", listOf(Mirror(torHost)), proxy) + val httpDownloader = HttpDownloader(httpManager, downloadRequest, file) + httpDownloader.download() - assertContentEquals(firstBytes + secondBytes, file.readBytes()) - } + assertTrue { file.length() > 1024 } + } - /** - * Tests re-using an already downloaded file with hash verification. - * This can fail if the hashing doesn't take the already downloaded bytes into account. - */ - @Test - fun testCompleteResumeWithHashSuccess() = runSuspend { - val sha256 = "efabb260da949061c88173c19f369b4aa0eaa82003c7c2dec08b5dfe75525368" - val file = File(folder.newFolder(), sha256).apply { createNewFile() } - val bytes = ("These are the first bytes that were already downloaded." + - "These are the last bytes that still need to be downloaded.").encodeToByteArray() - file.writeBytes(bytes) - // specifying the sha256 hash forces its validation - val indexFile = getIndexFile("foo/bar", sha256, bytes.size.toLong()) - val downloadRequest = DownloadRequest(indexFile, mirrors) - - val httpManager = HttpManager(userAgent, null) - val httpDownloader = HttpDownloaderV2(httpManager, downloadRequest, file) - // this throws if the hash doesn't match while downloading - httpDownloader.download() - - assertContentEquals(bytes, file.readBytes()) - } - - @Test - fun testCompleteResumeWithHashFailure() = runSuspend { - val sha256 = "efabb260da949061c88173c19f369b4aa0eaa82003c7c2dec08b5dfe75525368" - val file = File(folder.newFolder(), sha256).apply { createNewFile() } - val bytes = ("These are the first bytes that were already downloaded." + - "These are the last bytes that still need to be downloaded.").encodeToByteArray() - file.writeBytes(bytes) - // specifying the sha256 hash forces its validation - val indexFile = getIndexFile("foo/bar", sha256.replaceFirst('e', 'f'), bytes.size.toLong()) - val downloadRequest = DownloadRequest(indexFile, mirrors) - val mockEngine = MockEngine.config { - reuseHandlers = false - addHandler { - respond("", OK) - } - } - val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = mockEngine) - val httpDownloader = HttpDownloaderV2(httpManager, downloadRequest, file) - val e = assertFailsWith { - httpDownloader.download() - } - assertEquals("Hash not matching", e.message) - } - - @Test - fun testResumeError() = runSuspend { - val file = folder.newFile() - val firstBytes = Random.nextBytes(1024) - file.writeBytes(firstBytes) - val secondBytes = Random.nextBytes(1024) - val allBytes = firstBytes + secondBytes - - var numRequest = 1 - val mockEngine = MockEngine { - when (numRequest++) { - 1 -> respond("", OK, headers = headersOf(ContentLength, "2048")) - 2 -> respond(allBytes, OK) // not replying with PartialContent - 3 -> respond(allBytes, OK) - else -> fail("Unexpected additional request") - } - } - val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine)) - val httpDownloader = HttpDownloader(httpManager, downloadRequest, file) - httpDownloader.download() - - assertContentEquals(allBytes, file.readBytes()) - assertEquals(3, mockEngine.responseHistory.size) - } - - @Test - fun testNoETagNotTreatedAsNoChange() = runSuspend { - val mockEngine = MockEngine { respondOk() } - val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine)) - val httpDownloader = HttpDownloader(httpManager, downloadRequest, folder.newFile()) - httpDownloader.cacheTag = null - httpDownloader.download() - - assertEquals(2, mockEngine.requestHistory.size) - val headRequest = mockEngine.requestHistory[0] - val getRequest = mockEngine.requestHistory[1] - assertEquals(Head, headRequest.method) - assertEquals(Get, getRequest.method) - } - - @Test - fun testExpectedETagSkipsDownload() = runSuspend { - val eTag = getRandomString() - - val mockEngine = MockEngine { respond("", OK, headers = headersOf(ETag, eTag)) } - val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine)) - val httpDownloader = HttpDownloader(httpManager, downloadRequest, folder.newFile()) - httpDownloader.cacheTag = eTag - httpDownloader.download() - - assertEquals(eTag, httpDownloader.cacheTag) - assertEquals(1, mockEngine.requestHistory.size) - assertEquals(Head, mockEngine.requestHistory[0].method) - } - - @Test - @Ignore("We can not yet handle this scenario. See: #1708") - fun testCalculatedETagSkipsDownload() = runSuspend { - val eTag = "61de7e31-60a29a" - val headers = buildHeaders { - append(ETag, eTag) - append(LastModified, "Wed, 12 Jan 2022 07:07:29 GMT") - append(ContentLength, "6333082") - } - - val mockEngine = MockEngine { respond("", OK, headers = headers) } - val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine)) - val httpDownloader = HttpDownloader(httpManager, downloadRequest, folder.newFile()) - // the ETag is calculated, but we expect a real ETag - httpDownloader.cacheTag = "60a29a-5d55d390de574" - httpDownloader.download() - - assertEquals(eTag, httpDownloader.cacheTag) - assertEquals(1, mockEngine.requestHistory.size) - assertEquals(Head, mockEngine.requestHistory[0].method) - } - - @Test - fun testTorProxy() = runSuspend { - assumeTrue(isTorRunning()) - - val file = folder.newFile() - - val httpManager = HttpManager(userAgent, null) - // tor-project.org - val torHost = "http://2gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion" - val proxy = ProxyBuilder.socks("localhost", TOR_SOCKS_PORT) - val downloadRequest = DownloadRequest("index.html", listOf(Mirror(torHost)), proxy) - val httpDownloader = HttpDownloader(httpManager, downloadRequest, file) - httpDownloader.download() - - assertTrue { file.length() > 1024 } - } - - private fun isTorRunning(): Boolean = try { - ServerSocket(TOR_SOCKS_PORT) - false + private fun isTorRunning(): Boolean = + try { + ServerSocket(TOR_SOCKS_PORT) + false } catch (e: BindException) { - true + true } - } diff --git a/libs/download/src/androidUnitTest/kotlin/org/fdroid/download/HttpPosterTest.kt b/libs/download/src/androidUnitTest/kotlin/org/fdroid/download/HttpPosterTest.kt index 07ecd116e..8cff0e5cf 100644 --- a/libs/download/src/androidUnitTest/kotlin/org/fdroid/download/HttpPosterTest.kt +++ b/libs/download/src/androidUnitTest/kotlin/org/fdroid/download/HttpPosterTest.kt @@ -5,43 +5,40 @@ import io.ktor.client.engine.mock.respondError import io.ktor.client.engine.mock.respondOk import io.ktor.client.engine.mock.toByteArray import io.ktor.http.HttpStatusCode.Companion.BadRequest -import org.fdroid.get -import org.fdroid.getRandomString -import org.fdroid.runSuspend import java.io.IOException import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import org.fdroid.get +import org.fdroid.getRandomString +import org.fdroid.runSuspend -@Suppress("BlockingMethodInNonBlockingContext") internal class HttpPosterTest { - private val userAgent = getRandomString() + private val userAgent = getRandomString() - @Test - fun testPostSucceeds() = runSuspend { - val body = """{ "foo": "bar" }""" - val mockEngine = MockEngine { respondOk() } - val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine)) - val httpPoster = HttpPoster(httpManager, "http://example.org") - httpPoster.post(body) + @Test + fun testPostSucceeds() = runSuspend { + val body = """{ "foo": "bar" }""" + val mockEngine = MockEngine { respondOk() } + val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine)) + val httpPoster = HttpPoster(httpManager, "http://example.org") + httpPoster.post(body) - assertEquals(1, mockEngine.requestHistory.size) - mockEngine.requestHistory.forEach { request -> - assertEquals(body, request.body.toByteArray().decodeToString()) - } + assertEquals(1, mockEngine.requestHistory.size) + mockEngine.requestHistory.forEach { request -> + assertEquals(body, request.body.toByteArray().decodeToString()) } + } - @Test - fun testPostThrowsIOExceptionOnError() = runSuspend { - val body = """{ "foo": "bar" }""" - val mockEngine = MockEngine { respondError(BadRequest) } - val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine)) - val httpPoster = HttpPoster(httpManager, "http://example.org") + @Test + fun testPostThrowsIOExceptionOnError() = runSuspend { + val body = """{ "foo": "bar" }""" + val mockEngine = MockEngine { respondError(BadRequest) } + val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine)) + val httpPoster = HttpPoster(httpManager, "http://example.org") - assertFailsWith { - httpPoster.post(body) - } - assertEquals(1, mockEngine.requestHistory.size) - } + assertFailsWith { httpPoster.post(body) } + assertEquals(1, mockEngine.requestHistory.size) + } } diff --git a/libs/download/src/androidUnitTest/kotlin/org/fdroid/download/glide/AutoVerifyingInputStreamTest.kt b/libs/download/src/androidUnitTest/kotlin/org/fdroid/download/glide/AutoVerifyingInputStreamTest.kt index 942e16ef1..acfe76570 100644 --- a/libs/download/src/androidUnitTest/kotlin/org/fdroid/download/glide/AutoVerifyingInputStreamTest.kt +++ b/libs/download/src/androidUnitTest/kotlin/org/fdroid/download/glide/AutoVerifyingInputStreamTest.kt @@ -9,61 +9,59 @@ import kotlin.test.assertFailsWith internal class AutoVerifyingInputStreamTest { - private val testBytes = """ - this is test data for the AutoVerifyingInputStream - this is test data for the AutoVerifyingInputStream - this is test data for the AutoVerifyingInputStream - this is test data for the AutoVerifyingInputStream - this is test data for the AutoVerifyingInputStream - this is test data for the AutoVerifyingInputStream - this is test data for the AutoVerifyingInputStream - this is test data for the AutoVerifyingInputStream - this is test data for the AutoVerifyingInputStream - this is test data for the AutoVerifyingInputStream - this is test data for the AutoVerifyingInputStream - this is test data for the AutoVerifyingInputStream - this is test data for the AutoVerifyingInputStream - this is test data for the AutoVerifyingInputStream - this is test data for the AutoVerifyingInputStream - this is test data for the AutoVerifyingInputStream - """.trimIndent().toByteArray() - private val testHash = "784973023c9e3f32a750f3bc566bb13ee3f46b3811c2f269e2d11b47f07d1dab" + private val testBytes = + """ + this is test data for the AutoVerifyingInputStream + this is test data for the AutoVerifyingInputStream + this is test data for the AutoVerifyingInputStream + this is test data for the AutoVerifyingInputStream + this is test data for the AutoVerifyingInputStream + this is test data for the AutoVerifyingInputStream + this is test data for the AutoVerifyingInputStream + this is test data for the AutoVerifyingInputStream + this is test data for the AutoVerifyingInputStream + this is test data for the AutoVerifyingInputStream + this is test data for the AutoVerifyingInputStream + this is test data for the AutoVerifyingInputStream + this is test data for the AutoVerifyingInputStream + this is test data for the AutoVerifyingInputStream + this is test data for the AutoVerifyingInputStream + this is test data for the AutoVerifyingInputStream + """ + .trimIndent() + .toByteArray() + private val testHash = "784973023c9e3f32a750f3bc566bb13ee3f46b3811c2f269e2d11b47f07d1dab" - @Test - fun testSuccess() { + @Test + fun testSuccess() { + AutoVerifyingInputStream(inputStream = ByteArrayInputStream(testBytes), expectedHash = testHash) + .use { inputStream -> assertContentEquals(testBytes, inputStream.readBytes()) } + } + + @Test + fun testHashMismatch() { + val e = + assertFailsWith { + AutoVerifyingInputStream( + inputStream = ByteArrayInputStream(testBytes), + expectedHash = "684973023c9e3f32a750f3bc566bb13ee3f46b3811c2f269e2d11b47f07d1dab", + ) + .use { inputStream -> inputStream.readBytes() } + } + assertEquals("Hash not matching.", e.message) + } + + @Test + fun testMaxBytesToRead() { + val e = + assertFailsWith { AutoVerifyingInputStream( inputStream = ByteArrayInputStream(testBytes), expectedHash = testHash, - ).use { inputStream -> - assertContentEquals(testBytes, inputStream.readBytes()) - } - } - - @Test - fun testHashMismatch() { - val e = assertFailsWith { - AutoVerifyingInputStream( - inputStream = ByteArrayInputStream(testBytes), - expectedHash = "684973023c9e3f32a750f3bc566bb13ee3f46b3811c2f269e2d11b47f07d1dab", - ).use { inputStream -> - inputStream.readBytes() - } - } - assertEquals("Hash not matching.", e.message) - } - - @Test - fun testMaxBytesToRead() { - val e = assertFailsWith { - AutoVerifyingInputStream( - inputStream = ByteArrayInputStream(testBytes), - expectedHash = testHash, - maxBytesToRead = 42, - ).use { inputStream -> - inputStream.readBytes() - } - } - assertEquals("Read ${testBytes.size} bytes, above maximum allowed.", e.message) - } - + maxBytesToRead = 42, + ) + .use { inputStream -> inputStream.readBytes() } + } + assertEquals("Read ${testBytes.size} bytes, above maximum allowed.", e.message) + } } diff --git a/libs/download/src/commonMain/kotlin/org/fdroid/download/DownloadRequest.kt b/libs/download/src/commonMain/kotlin/org/fdroid/download/DownloadRequest.kt index 410d43399..4754b8225 100644 --- a/libs/download/src/commonMain/kotlin/org/fdroid/download/DownloadRequest.kt +++ b/libs/download/src/commonMain/kotlin/org/fdroid/download/DownloadRequest.kt @@ -3,40 +3,51 @@ package org.fdroid.download import io.ktor.client.engine.ProxyConfig import org.fdroid.IndexFile -public data class DownloadRequest @JvmOverloads constructor( - val indexFile: IndexFile, - val mirrors: List, - val proxy: ProxyConfig? = null, - val username: String? = null, - val password: String? = null, - /** - * Signals the [MirrorChooser] that this mirror should be tried before all other mirrors. - * This could be useful for index updates for repositories with mirrors that update infrequently, - * so that the official repository can be tried first to get updates fast. - * - * If this mirror is not in [mirrors], e.g. when the user has disabled it, - * then setting this has no effect. - */ - val tryFirstMirror: Mirror? = null, +public data class DownloadRequest +@JvmOverloads +constructor( + val indexFile: IndexFile, + val mirrors: List, + val proxy: ProxyConfig? = null, + val username: String? = null, + val password: String? = null, + /** + * Signals the [MirrorChooser] that this mirror should be tried before all other mirrors. This + * could be useful for index updates for repositories with mirrors that update infrequently, so + * that the official repository can be tried first to get updates fast. + * + * If this mirror is not in [mirrors], e.g. when the user has disabled it, then setting this has + * no effect. + */ + val tryFirstMirror: Mirror? = null, ) { - @JvmOverloads - @Deprecated("Use other constructor instead") - public constructor( - path: String, - mirrors: List, - proxy: ProxyConfig? = null, - username: String? = null, - password: String? = null, - tryFirstMirror: Mirror? = null, - ) : this(object : IndexFile { + @JvmOverloads + @Deprecated("Use other constructor instead") + public constructor( + path: String, + mirrors: List, + proxy: ProxyConfig? = null, + username: String? = null, + password: String? = null, + tryFirstMirror: Mirror? = null, + ) : this( + indexFile = + object : IndexFile { override val name = path override val sha256: String? = null override val size = 0L override val ipfsCidV1: String? = null - override fun serialize(): String { - throw NotImplementedError("Serialization is not implemented.") - } - }, mirrors, proxy, username, password, tryFirstMirror) - val hasCredentials: Boolean = username != null && password != null + override fun serialize(): String { + throw NotImplementedError("Serialization is not implemented.") + } + }, + mirrors = mirrors, + proxy = proxy, + username = username, + password = password, + tryFirstMirror = tryFirstMirror, + ) + + val hasCredentials: Boolean = username != null && password != null } diff --git a/libs/download/src/commonMain/kotlin/org/fdroid/download/HeadInfo.kt b/libs/download/src/commonMain/kotlin/org/fdroid/download/HeadInfo.kt index fa8dd9a16..3aef47a7d 100644 --- a/libs/download/src/commonMain/kotlin/org/fdroid/download/HeadInfo.kt +++ b/libs/download/src/commonMain/kotlin/org/fdroid/download/HeadInfo.kt @@ -1,8 +1,8 @@ package org.fdroid.download public data class HeadInfo( - val eTagChanged: Boolean, - val eTag: String?, - val contentLength: Long?, - val lastModified: String?, + val eTagChanged: Boolean, + val eTag: String?, + val contentLength: Long?, + val lastModified: String?, ) diff --git a/libs/download/src/commonMain/kotlin/org/fdroid/download/HttpManager.kt b/libs/download/src/commonMain/kotlin/org/fdroid/download/HttpManager.kt index 48b473112..bc0274fc6 100644 --- a/libs/download/src/commonMain/kotlin/org/fdroid/download/HttpManager.kt +++ b/libs/download/src/commonMain/kotlin/org/fdroid/download/HttpManager.kt @@ -33,223 +33,213 @@ import io.ktor.utils.io.ByteReadChannel import io.ktor.utils.io.InternalAPI import io.ktor.utils.io.exhausted import io.ktor.utils.io.readRemaining +import java.io.ByteArrayOutputStream +import kotlin.coroutines.cancellation.CancellationException import kotlinx.io.readByteArray import mu.KotlinLogging import okhttp3.Dns import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import java.io.ByteArrayOutputStream -import kotlin.coroutines.cancellation.CancellationException internal expect fun getHttpClientEngineFactory(customDns: Dns?): HttpClientEngineFactory<*> -public open class HttpManager @JvmOverloads constructor( - private val userAgent: String, - queryString: String? = null, - proxyConfig: ProxyConfig? = null, - customDns: Dns? = null, - private val mirrorParameterManager: MirrorParameterManager? = null, - private val highTimeouts: Boolean = false, - private val mirrorChooser: MirrorChooser = MirrorChooserWithParameters(mirrorParameterManager), - private val httpClientEngineFactory: HttpClientEngineFactory<*> = getHttpClientEngineFactory( - customDns - ), +public open class HttpManager +@JvmOverloads +constructor( + private val userAgent: String, + queryString: String? = null, + proxyConfig: ProxyConfig? = null, + customDns: Dns? = null, + private val mirrorParameterManager: MirrorParameterManager? = null, + private val highTimeouts: Boolean = false, + private val mirrorChooser: MirrorChooser = MirrorChooserWithParameters(mirrorParameterManager), + private val httpClientEngineFactory: HttpClientEngineFactory<*> = + getHttpClientEngineFactory(customDns), ) { - public companion object { - internal val log = KotlinLogging.logger {} - internal const val READ_BUFFER = 8 * 1024 - private const val TIMEOUT_MILLIS_HIGH = 300_000L - public fun isInvalidHttpUrl(url: String): Boolean = url.toHttpUrlOrNull() == null + public companion object { + internal val log = KotlinLogging.logger {} + internal const val READ_BUFFER = 8 * 1024 + private const val TIMEOUT_MILLIS_HIGH = 300_000L + + public fun isInvalidHttpUrl(url: String): Boolean = url.toHttpUrlOrNull() == null + } + + private var httpClient = getNewHttpClient(proxyConfig) + + /** + * Only exists because KTor doesn't keep a reference to the proxy its client uses. Should only get + * set in [getNewHttpClient]. + */ + internal var currentProxy: ProxyConfig? = null + private set + + private val parameters = + queryString?.split('&')?.map { p -> + val (key, value) = p.split('=') + Pair(key, value) } - private var httpClient = getNewHttpClient(proxyConfig) - - /** - * Only exists because KTor doesn't keep a reference to the proxy its client uses. - * Should only get set in [getNewHttpClient]. - */ - internal var currentProxy: ProxyConfig? = null - private set - - private val parameters = queryString?.split('&')?.map { p -> - val (key, value) = p.split('=') - Pair(key, value) - } - - private fun getNewHttpClient(proxyConfig: ProxyConfig? = null): HttpClient { - currentProxy = proxyConfig - return HttpClient(httpClientEngineFactory) { - followRedirects = false - expectSuccess = true - engine { - pipelining = true - proxy = proxyConfig - } - install(UserAgent) { - agent = userAgent - } - install(HttpTimeout) { - if (highTimeouts || proxyConfig.isTor()) { - connectTimeoutMillis = TIMEOUT_MILLIS_HIGH - socketTimeoutMillis = TIMEOUT_MILLIS_HIGH - requestTimeoutMillis = TIMEOUT_MILLIS_HIGH - } - } + private fun getNewHttpClient(proxyConfig: ProxyConfig? = null): HttpClient { + currentProxy = proxyConfig + return HttpClient(httpClientEngineFactory) { + followRedirects = false + expectSuccess = true + engine { + pipelining = true + proxy = proxyConfig + } + install(UserAgent) { agent = userAgent } + install(HttpTimeout) { + if (highTimeouts || proxyConfig.isTor()) { + connectTimeoutMillis = TIMEOUT_MILLIS_HIGH + socketTimeoutMillis = TIMEOUT_MILLIS_HIGH + requestTimeoutMillis = TIMEOUT_MILLIS_HIGH } + } } + } - /** - * Performs a HEAD request and returns [HeadInfo]. - * - * This is useful for checking if the repository index has changed before downloading it again. - * However, due to non-standard ETags on mirrors, change detection is unreliable. - */ - @Throws(NotFoundException::class) - public suspend fun head(request: DownloadRequest, eTag: String? = null): HeadInfo? { - val response: HttpResponse = try { - mirrorChooser.mirrorRequest(request) { mirror, url -> - resetProxyIfNeeded(request.proxy, mirror) - log.debug { "HEAD $url" } - httpClient.head(url) { - addQueryParameters() - // add authorization header from username / password if set - basicAuth(request) - // increase connect timeout if using Tor mirror - if (mirror.isOnion()) timeout { connectTimeoutMillis = 10_000 } - } - } - } catch (e: ResponseException) { - log.warn { "Error getting HEAD: ${e.response.status}" } - if (e.response.status == NotFound) throw NotFoundException() - return null - } - val contentLength = response.contentLength() - val lastModified = response.headers[LastModified] - if (eTag != null && response.headers[ETag] == eTag) { - return HeadInfo(false, response.headers[ETag], contentLength, lastModified) - } - return HeadInfo(true, response.headers[ETag], contentLength, lastModified) - } - - @OptIn(InternalAPI::class) - @JvmOverloads - @Throws(ResponseException::class, NoResumeException::class, CancellationException::class) - public suspend fun get( - request: DownloadRequest, - skipFirstBytes: Long? = null, - receiver: BytesReceiver, - ) { - // remember what we've read already, so we can pass it to the next mirror if needed - var skipBytes = skipFirstBytes ?: 0L + /** + * Performs a HEAD request and returns [HeadInfo]. + * + * This is useful for checking if the repository index has changed before downloading it again. + * However, due to non-standard ETags on mirrors, change detection is unreliable. + */ + @Throws(NotFoundException::class) + public suspend fun head(request: DownloadRequest, eTag: String? = null): HeadInfo? { + val response: HttpResponse = + try { mirrorChooser.mirrorRequest(request) { mirror, url -> - getHttpStatement(request, mirror, url, skipBytes).execute { response -> - val contentLength = response.contentLength() - if (skipBytes > 0L && response.status != PartialContent) { - throw NoResumeException() - } - val channel: ByteReadChannel = response.bodyAsChannel() - val readBufferSize = DEFAULT_BUFFER_SIZE.toLong() * 8 - while (!channel.exhausted()) { - val packet = channel.readRemaining(readBufferSize) - val readBytes = packet.readByteArray() - receiver.receive(readBytes, contentLength) - skipBytes += readBytes.size - } - } - } - } - - private suspend fun getHttpStatement( - request: DownloadRequest, - mirror: Mirror, - url: Url, - skipFirstBytes: Long, - ): HttpStatement { - resetProxyIfNeeded(request.proxy, mirror) - log.debug { "GET $url" } - return httpClient.prepareGet(url) { + resetProxyIfNeeded(request.proxy, mirror) + log.debug { "HEAD $url" } + httpClient.head(url) { addQueryParameters() // add authorization header from username / password if set basicAuth(request) // increase connect timeout if using Tor mirror - if (mirror.isOnion()) timeout { connectTimeoutMillis = 20_000 } - // add range header if set - if (skipFirstBytes > 0) header(Range, "bytes=$skipFirstBytes-") + if (mirror.isOnion()) timeout { connectTimeoutMillis = 10_000 } + } } + } catch (e: ResponseException) { + log.warn { "Error getting HEAD: ${e.response.status}" } + if (e.response.status == NotFound) throw NotFoundException() + return null + } + val contentLength = response.contentLength() + val lastModified = response.headers[LastModified] + if (eTag != null && response.headers[ETag] == eTag) { + return HeadInfo(false, response.headers[ETag], contentLength, lastModified) } + return HeadInfo(true, response.headers[ETag], contentLength, lastModified) + } - /** - * Returns a [ByteChannel] for streaming download. - */ - internal suspend fun getChannel( - request: DownloadRequest, - skipFirstBytes: Long? = null, - ): ByteReadChannel { - // TODO check if closed - return mirrorChooser.mirrorRequest(request) { mirror, url -> - getHttpStatement(request, mirror, url, skipFirstBytes ?: 0L).body() + @OptIn(InternalAPI::class) + @JvmOverloads + @Throws(ResponseException::class, NoResumeException::class, CancellationException::class) + public suspend fun get( + request: DownloadRequest, + skipFirstBytes: Long? = null, + receiver: BytesReceiver, + ) { + // remember what we've read already, so we can pass it to the next mirror if needed + var skipBytes = skipFirstBytes ?: 0L + mirrorChooser.mirrorRequest(request) { mirror, url -> + getHttpStatement(request, mirror, url, skipBytes).execute { response -> + val contentLength = response.contentLength() + if (skipBytes > 0L && response.status != PartialContent) { + throw NoResumeException() } - } - - /** - * Same as [get], but returns all bytes. - * Use this only when you are sure that a response will be small. - * Thus, this is intentionally visible internally only. - * Does not use [getChannel] so, it gets the [NoResumeException] as in the public API. - */ - internal suspend fun getBytes( - request: DownloadRequest, - skipFirstBytes: Long? = null, - ): ByteArray { - val outputStream = ByteArrayOutputStream() - outputStream.use { - get(request, skipFirstBytes) { bytes, _ -> - it.write(bytes) - } + val channel: ByteReadChannel = response.bodyAsChannel() + val readBufferSize = DEFAULT_BUFFER_SIZE.toLong() * 8 + while (!channel.exhausted()) { + val packet = channel.readRemaining(readBufferSize) + val readBytes = packet.readByteArray() + receiver.receive(readBytes, contentLength) + skipBytes += readBytes.size } - return outputStream.toByteArray() + } } + } - public suspend fun post(url: String, json: String, proxy: ProxyConfig? = null) { - resetProxyIfNeeded(proxy) - httpClient.post { - addQueryParameters() - url(url) - header(ContentType, "application/json; utf-8") - setBody(json) - } + private suspend fun getHttpStatement( + request: DownloadRequest, + mirror: Mirror, + url: Url, + skipFirstBytes: Long, + ): HttpStatement { + resetProxyIfNeeded(request.proxy, mirror) + log.debug { "GET $url" } + return httpClient.prepareGet(url) { + addQueryParameters() + // add authorization header from username / password if set + basicAuth(request) + // increase connect timeout if using Tor mirror + if (mirror.isOnion()) timeout { connectTimeoutMillis = 20_000 } + // add range header if set + if (skipFirstBytes > 0) header(Range, "bytes=$skipFirstBytes-") } + } - private fun resetProxyIfNeeded(proxyConfig: ProxyConfig?, mirror: Mirror? = null) { - // force no-proxy when trying to hit a local mirror - val newProxy = if (mirror.isLocal() && proxyConfig != null) { - if (currentProxy != null) log.debug { - "Forcing mirror to null, because mirror is local: $mirror" - } - null - } else proxyConfig - if (currentProxy != newProxy) { - log.debug { "Switching proxy from [$currentProxy] to [$newProxy]" } - httpClient.close() - httpClient = getNewHttpClient(newProxy) - } + /** Returns a [ByteChannel] for streaming download. */ + internal suspend fun getChannel( + request: DownloadRequest, + skipFirstBytes: Long? = null, + ): ByteReadChannel { + // TODO check if closed + return mirrorChooser.mirrorRequest(request) { mirror, url -> + getHttpStatement(request, mirror, url, skipFirstBytes ?: 0L).body() } + } - private fun HttpMessageBuilder.basicAuth(request: DownloadRequest) { - // non-null if hasCredentials is true - if (request.hasCredentials) basicAuth(request.username!!, request.password!!) - } + /** + * Same as [get], but returns all bytes. Use this only when you are sure that a response will be + * small. Thus, this is intentionally visible internally only. Does not use [getChannel] so, it + * gets the [NoResumeException] as in the public API. + */ + internal suspend fun getBytes(request: DownloadRequest, skipFirstBytes: Long? = null): ByteArray { + val outputStream = ByteArrayOutputStream() + outputStream.use { get(request, skipFirstBytes) { bytes, _ -> it.write(bytes) } } + return outputStream.toByteArray() + } - private fun HttpRequestBuilder.addQueryParameters() { - // add query string parameters if existing - this@HttpManager.parameters?.forEach { (key, value) -> - parameter(key, value) - } + public suspend fun post(url: String, json: String, proxy: ProxyConfig? = null) { + resetProxyIfNeeded(proxy) + httpClient.post { + addQueryParameters() + url(url) + header(ContentType, "application/json; utf-8") + setBody(json) } + } + + private fun resetProxyIfNeeded(proxyConfig: ProxyConfig?, mirror: Mirror? = null) { + // force no-proxy when trying to hit a local mirror + val newProxy = + if (mirror.isLocal() && proxyConfig != null) { + if (currentProxy != null) + log.debug { "Forcing mirror to null, because mirror is local: $mirror" } + null + } else proxyConfig + if (currentProxy != newProxy) { + log.debug { "Switching proxy from [$currentProxy] to [$newProxy]" } + httpClient.close() + httpClient = getNewHttpClient(newProxy) + } + } + + private fun HttpMessageBuilder.basicAuth(request: DownloadRequest) { + // non-null if hasCredentials is true + if (request.hasCredentials) basicAuth(request.username!!, request.password!!) + } + + private fun HttpRequestBuilder.addQueryParameters() { + // add query string parameters if existing + this@HttpManager.parameters?.forEach { (key, value) -> parameter(key, value) } + } } public fun interface BytesReceiver { - public suspend fun receive(bytes: ByteArray, numTotalBytes: Long?) + public suspend fun receive(bytes: ByteArray, numTotalBytes: Long?) } /** @@ -258,8 +248,7 @@ public fun interface BytesReceiver { public class NoResumeException : Exception() /** - * Thrown when a file was not found. - * Catching this is useful when checking if a new index version exists - * and then falling back to an older version. + * Thrown when a file was not found. Catching this is useful when checking if a new index version + * exists and then falling back to an older version. */ public class NotFoundException(e: Throwable? = null) : Exception(e) diff --git a/libs/download/src/commonMain/kotlin/org/fdroid/download/Mirror.kt b/libs/download/src/commonMain/kotlin/org/fdroid/download/Mirror.kt index e9d7858e5..4b2a6999a 100644 --- a/libs/download/src/commonMain/kotlin/org/fdroid/download/Mirror.kt +++ b/libs/download/src/commonMain/kotlin/org/fdroid/download/Mirror.kt @@ -6,69 +6,69 @@ import io.ktor.http.Url import io.ktor.http.appendPathSegments import mu.KotlinLogging -public data class Mirror @JvmOverloads constructor( - val baseUrl: String, - val countryCode: String? = null, - /** - * If this is true, this as an IPFS HTTP gateway that only accepts CIDv1 and not regular paths. - * So use this mirror only, if you have a CIDv1 available for supplying it to [getUrl]. - */ - val isIpfsGateway: Boolean = false, +public data class Mirror +@JvmOverloads +constructor( + val baseUrl: String, + val countryCode: String? = null, + /** + * If this is true, this as an IPFS HTTP gateway that only accepts CIDv1 and not regular paths. So + * use this mirror only, if you have a CIDv1 available for supplying it to [getUrl]. + */ + val isIpfsGateway: Boolean = false, ) { - public val url: Url by lazy { - try { - URLBuilder(baseUrl.trimEnd('/')).build() - // we fall back to a non-existent URL if someone tries to sneak in an invalid mirror URL to crash us - // to make it easier for potential callers - } catch (_: URLParserException) { - val log = KotlinLogging.logger {} - log.warn { "Someone gave us an invalid URL: $baseUrl" } - URLBuilder("http://127.0.0.1:64335").build() - } catch (_: IllegalArgumentException) { - val log = KotlinLogging.logger {} - log.warn { "Someone gave us an invalid URL: $baseUrl" } - URLBuilder("http://127.0.0.1:64335").build() - } + public val url: Url by lazy { + try { + URLBuilder(baseUrl.trimEnd('/')).build() + // we fall back to a non-existent URL if someone tries to sneak in an invalid mirror URL to + // crash us + // to make it easier for potential callers + } catch (_: URLParserException) { + val log = KotlinLogging.logger {} + log.warn { "Someone gave us an invalid URL: $baseUrl" } + URLBuilder("http://127.0.0.1:64335").build() + } catch (_: IllegalArgumentException) { + val log = KotlinLogging.logger {} + log.warn { "Someone gave us an invalid URL: $baseUrl" } + URLBuilder("http://127.0.0.1:64335").build() } + } - public fun getUrl(path: String): Url { - // Since Ktor 2.0 this adds double slash if not trimming slash from path - return URLBuilder(url).appendPathSegments(path.trimStart('/')).build() - } + public fun getUrl(path: String): Url { + // Since Ktor 2.0 this adds double slash if not trimming slash from path + return URLBuilder(url).appendPathSegments(path.trimStart('/')).build() + } - public fun getFDroidLinkUrl(repoFingerprint: String): String { - return "https://fdroid.link/#$url?fingerprint=$repoFingerprint" - } + public fun getFDroidLinkUrl(repoFingerprint: String): String { + return "https://fdroid.link/#$url?fingerprint=$repoFingerprint" + } - public fun isOnion(): Boolean = url.isOnion() + public fun isOnion(): Boolean = url.isOnion() - public fun isLocal(): Boolean = url.isLocal() + public fun isLocal(): Boolean = url.isLocal() - public fun isHttp(): Boolean = url.protocol.name.startsWith("http") + public fun isHttp(): Boolean = url.protocol.name.startsWith("http") - public companion object { - @JvmStatic - public fun fromStrings(list: List): List = list.map { Mirror(it) } - } + public companion object { + @JvmStatic public fun fromStrings(list: List): List = list.map { Mirror(it) } + } } internal fun Mirror?.isLocal(): Boolean = this?.isLocal() == true internal fun Url.isOnion(): Boolean = host.endsWith(".onion") -/** - * Returns true when no proxy should be used for connecting to this [Url]. - */ +/** Returns true when no proxy should be used for connecting to this [Url]. */ internal fun Url.isLocal(): Boolean { - if (!host.matches(Regex("[0-9.]{7,15}"))) return false - if (host.startsWith("172.")) { - val second = host.substring(4..6) - if (!second.endsWith('.')) return false - val num = second.trimEnd('.').toIntOrNull() ?: return false - return num in 16..31 - } - return host.startsWith("169.254.") || - host.startsWith("10.") || - host.startsWith("192.168.") || - host == "127.0.0.1" + if (!host.matches(Regex("[0-9.]{7,15}"))) return false + if (host.startsWith("172.")) { + val second = host.substring(4..6) + if (!second.endsWith('.')) return false + val num = second.trimEnd('.').toIntOrNull() ?: return false + return num in 16..31 + } + return host.startsWith("169.254.") || + host.startsWith("10.") || + host.startsWith("192.168.") || + host == "127.0.0.1" } diff --git a/libs/download/src/commonMain/kotlin/org/fdroid/download/MirrorChooser.kt b/libs/download/src/commonMain/kotlin/org/fdroid/download/MirrorChooser.kt index beba71c0b..ca4ed32c3 100644 --- a/libs/download/src/commonMain/kotlin/org/fdroid/download/MirrorChooser.kt +++ b/libs/download/src/commonMain/kotlin/org/fdroid/download/MirrorChooser.kt @@ -8,218 +8,212 @@ import kotlinx.io.IOException import mu.KotlinLogging public interface MirrorChooser { - public fun orderMirrors(downloadRequest: DownloadRequest): List - public suspend fun mirrorRequest( - downloadRequest: DownloadRequest, - request: suspend (mirror: Mirror, url: Url) -> T, - ): T + public fun orderMirrors(downloadRequest: DownloadRequest): List + + public suspend fun mirrorRequest( + downloadRequest: DownloadRequest, + request: suspend (mirror: Mirror, url: Url) -> T, + ): T } internal abstract class MirrorChooserImpl : MirrorChooser { - companion object { - protected val log = KotlinLogging.logger {} + companion object { + protected val log = KotlinLogging.logger {} + } + + /** Executes the given request on the best mirror and tries the next best ones if that fails. */ + override suspend fun mirrorRequest( + downloadRequest: DownloadRequest, + request: suspend (mirror: Mirror, url: Url) -> T, + ): T { + val mirrors = + if (downloadRequest.proxy == null) { + // keep ordered mirror list rather than reverting back to raw list from request + val orderedMirrors = orderMirrors(downloadRequest) + // if we don't use a proxy, filter out onion mirrors (won't work without Orbot) + val filteredMirrors = orderedMirrors.filter { mirror -> !mirror.isOnion() } + filteredMirrors.ifEmpty { + // if we only have onion mirrors, take what we have and expect errors + orderedMirrors + } + } else { + orderMirrors(downloadRequest) + } + + if (mirrors.isEmpty()) { + error("No valid mirrors were found. Check settings.") } - /** - * Executes the given request on the best mirror and tries the next best ones if that fails. - */ - override suspend fun mirrorRequest( - downloadRequest: DownloadRequest, - request: suspend (mirror: Mirror, url: Url) -> T, - ): T { - val mirrors = if (downloadRequest.proxy == null) { - // keep ordered mirror list rather than reverting back to raw list from request - val orderedMirrors = orderMirrors(downloadRequest) - // if we don't use a proxy, filter out onion mirrors (won't work without Orbot) - val filteredMirrors = orderedMirrors.filter { mirror -> !mirror.isOnion() } - if (filteredMirrors.isEmpty()) { - // if we only have onion mirrors, take what we have and expect errors - orderedMirrors - } else { - filteredMirrors - } + mirrors.forEachIndexed { index, mirror -> + val ipfsCidV1 = downloadRequest.indexFile.ipfsCidV1 + val url = + if (mirror.isIpfsGateway) { + if (ipfsCidV1 == null) { + val e = IOException("Got IPFS gateway without CID") + handleException(e, mirror, index, mirrors.size) + return@forEachIndexed + } else mirror.getUrl(ipfsCidV1) } else { - orderMirrors(downloadRequest) + mirror.getUrl(downloadRequest.indexFile.name) } - - if (mirrors.isEmpty()) { - error("No valid mirrors were found. Check settings.") - } - - mirrors.forEachIndexed { index, mirror -> - val ipfsCidV1 = downloadRequest.indexFile.ipfsCidV1 - val url = if (mirror.isIpfsGateway) { - if (ipfsCidV1 == null) { - val e = IOException("Got IPFS gateway without CID") - handleException(e, mirror, index, mirrors.size) - return@forEachIndexed - } else mirror.getUrl(ipfsCidV1) - } else { - mirror.getUrl(downloadRequest.indexFile.name) - } - try { - return executeRequest(mirror, url, request) - } catch (e: ResponseException) { - // don't try other mirrors if we got Forbidden response, but supplied credentials - if (downloadRequest.hasCredentials && e.response.status == Forbidden) throw e - // don't try other mirrors if we got NotFount response and downloaded a repo - if (downloadRequest.tryFirstMirror != null && e.response.status == NotFound) throw e - // also throw if this is the last mirror to try, otherwise try next - handleException(e, mirror, index, mirrors.size) - } catch (e: IOException) { - handleException(e, mirror, index, mirrors.size) - } catch (e: NoResumeException) { - // continue to next mirror, if we need to resume, but this one doesn't support it - handleException(e, mirror, index, mirrors.size) - } - } - error("Reached code that was thought to be unreachable.") + try { + return executeRequest(mirror, url, request) + } catch (e: ResponseException) { + // don't try other mirrors if we got Forbidden response, but supplied credentials + if (downloadRequest.hasCredentials && e.response.status == Forbidden) throw e + // don't try other mirrors if we got NotFount response and downloaded a repo + if (downloadRequest.tryFirstMirror != null && e.response.status == NotFound) throw e + // also throw if this is the last mirror to try, otherwise try next + handleException(e, mirror, index, mirrors.size) + } catch (e: IOException) { + handleException(e, mirror, index, mirrors.size) + } catch (e: NoResumeException) { + // continue to next mirror, if we need to resume, but this one doesn't support it + handleException(e, mirror, index, mirrors.size) + } } + error("Reached code that was thought to be unreachable.") + } - open suspend fun executeRequest( - mirror: Mirror, - url: Url, - request: suspend (mirror: Mirror, url: Url) -> T, - ): T { - return request(mirror, url) - } + open suspend fun executeRequest( + mirror: Mirror, + url: Url, + request: suspend (mirror: Mirror, url: Url) -> T, + ): T { + return request(mirror, url) + } - open fun handleException(e: Exception, mirror: Mirror, mirrorIndex: Int, mirrorCount: Int) { - val wasLastMirror = mirrorIndex == mirrorCount - 1 - log.info { - val info = if (e is ResponseException) e.response.status.toString() - else e::class.simpleName ?: "" - if (wasLastMirror) "Last mirror, rethrowing... ($info)" - else "Trying other mirror now... ($info)" - } - if (wasLastMirror) throw e + open fun handleException(e: Exception, mirror: Mirror, mirrorIndex: Int, mirrorCount: Int) { + val wasLastMirror = mirrorIndex == mirrorCount - 1 + log.info { + val info = + if (e is ResponseException) e.response.status.toString() else e::class.simpleName ?: "" + if (wasLastMirror) "Last mirror, rethrowing... ($info)" + else "Trying other mirror now... ($info)" } + if (wasLastMirror) throw e + } } internal class MirrorChooserRandom : MirrorChooserImpl() { - /** - * Returns a list of mirrors with the best mirrors first. - */ - override fun orderMirrors(downloadRequest: DownloadRequest): List { - // simple random selection for now - return downloadRequest.mirrors.toMutableList().apply { shuffle() }.also { mirrors -> - // respect the mirror to try first, if set - if (downloadRequest.tryFirstMirror != null) { - mirrors.sortBy { if (it == downloadRequest.tryFirstMirror) 0 else 1 } - } + /** Returns a list of mirrors with the best mirrors first. */ + override fun orderMirrors(downloadRequest: DownloadRequest): List { + // simple random selection for now + return downloadRequest.mirrors + .toMutableList() + .apply { shuffle() } + .also { mirrors -> + // respect the mirror to try first, if set + if (downloadRequest.tryFirstMirror != null) { + mirrors.sortBy { if (it == downloadRequest.tryFirstMirror) 0 else 1 } } - } - + } + } } internal class MirrorChooserWithParameters( - private val mirrorParameterManager: MirrorParameterManager? = null + private val mirrorParameterManager: MirrorParameterManager? = null ) : MirrorChooserImpl() { - override fun orderMirrors(downloadRequest: DownloadRequest): List { - val errorComparator = Comparator { mirror1: Mirror, mirror2: Mirror -> - // if no parameter manager is available, default to 0 (should return equal) - val error1 = mirrorParameterManager?.getMirrorErrorCount(mirror1.baseUrl) ?: 0 - val error2 = mirrorParameterManager?.getMirrorErrorCount(mirror2.baseUrl) ?: 0 + override fun orderMirrors(downloadRequest: DownloadRequest): List { + val errorComparator = Comparator { mirror1: Mirror, mirror2: Mirror -> + // if no parameter manager is available, default to 0 (should return equal) + val error1 = mirrorParameterManager?.getMirrorErrorCount(mirror1.baseUrl) ?: 0 + val error2 = mirrorParameterManager?.getMirrorErrorCount(mirror2.baseUrl) ?: 0 - // prefer mirrors with fewer errors - error1.compareTo(error2) - } - - val mirrorList: MutableList = mutableListOf() - - if (mirrorParameterManager != null && - mirrorParameterManager.getCurrentLocation().isNotEmpty() - ) { - // if we have access to mirror parameters and the current location, - // then use that information to sort the mirror list - val mirrorFilteredList: List = sortMirrorsByLocation( - mirrorParameterManager.preferForeignMirrors(), - downloadRequest.mirrors, - mirrorParameterManager.getCurrentLocation(), - errorComparator - ) - mirrorList.addAll(mirrorFilteredList) - } else { - // shuffle initial list so all viable mirrors will be tried - // then sort list to avoid mirrors that have caused errors - val mirrorCompleteList: List = - downloadRequest.mirrors - .toMutableList() - .apply { shuffle() } - .sortedWith(errorComparator) - mirrorList.addAll(mirrorCompleteList) - } - - // respect the mirror to try first, if set - if (downloadRequest.tryFirstMirror != null) { - mirrorList.sortBy { if (it == downloadRequest.tryFirstMirror) 0 else 1 } - } - - return mirrorList + // prefer mirrors with fewer errors + error1.compareTo(error2) } - private fun sortMirrorsByLocation( - foreignMirrorsPreferred: Boolean, - availableMirrorList: List, - currentLocation: String, - mirrorComparator: Comparator - ): List { - // shuffle initial list so all viable mirrors will be tried - // then sort list to avoid mirrors that have caused errors - val mirrorList: MutableList = mutableListOf() - val sortedList: List = availableMirrorList - .toMutableList() - .apply { shuffle() } - .sortedWith(mirrorComparator) + val mirrorList: MutableList = mutableListOf() - val domesticList: List = sortedList.filter { mirror -> - !mirror.countryCode.isNullOrEmpty() && currentLocation == mirror.countryCode - } - val foreignList: List = sortedList.filter { mirror -> - !mirror.countryCode.isNullOrEmpty() && currentLocation != mirror.countryCode - } - val unknownList: List = sortedList.filter { mirror -> - mirror.countryCode.isNullOrEmpty() - } - - if (foreignMirrorsPreferred) { - mirrorList.addAll(foreignList) - mirrorList.addAll(unknownList) - mirrorList.addAll(domesticList) - } else { - mirrorList.addAll(domesticList) - mirrorList.addAll(unknownList) - mirrorList.addAll(foreignList) - } - return mirrorList + if ( + mirrorParameterManager != null && mirrorParameterManager.getCurrentLocation().isNotEmpty() + ) { + // if we have access to mirror parameters and the current location, + // then use that information to sort the mirror list + val mirrorFilteredList: List = + sortMirrorsByLocation( + mirrorParameterManager.preferForeignMirrors(), + downloadRequest.mirrors, + mirrorParameterManager.getCurrentLocation(), + errorComparator, + ) + mirrorList.addAll(mirrorFilteredList) + } else { + // shuffle initial list so all viable mirrors will be tried + // then sort list to avoid mirrors that have caused errors + val mirrorCompleteList: List = + downloadRequest.mirrors.toMutableList().apply { shuffle() }.sortedWith(errorComparator) + mirrorList.addAll(mirrorCompleteList) } - override suspend fun executeRequest( - mirror: Mirror, - url: Url, - request: suspend (mirror: Mirror, url: Url) -> T, - ): T { - return try { - request(mirror, url) - } catch (e: Exception) { - // in case of an exception, potentially attempt a single retry - if (mirrorParameterManager != null && - mirrorParameterManager.shouldRetryRequest(url.host) - ) { - request(mirror, url) - } else { - throw e - } - } + // respect the mirror to try first, if set + if (downloadRequest.tryFirstMirror != null) { + mirrorList.sortBy { if (it == downloadRequest.tryFirstMirror) 0 else 1 } } - override fun handleException(e: Exception, mirror: Mirror, mirrorIndex: Int, mirrorCount: Int) { - if (e is ResponseException || e is IOException) { - mirrorParameterManager?.incrementMirrorErrorCount(mirror.baseUrl) - } - super.handleException(e, mirror, mirrorIndex, mirrorCount) + return mirrorList + } + + private fun sortMirrorsByLocation( + foreignMirrorsPreferred: Boolean, + availableMirrorList: List, + currentLocation: String, + mirrorComparator: Comparator, + ): List { + // shuffle initial list so all viable mirrors will be tried + // then sort list to avoid mirrors that have caused errors + val mirrorList: MutableList = mutableListOf() + val sortedList: List = + availableMirrorList.toMutableList().apply { shuffle() }.sortedWith(mirrorComparator) + + val domesticList: List = + sortedList.filter { mirror -> + !mirror.countryCode.isNullOrEmpty() && currentLocation == mirror.countryCode + } + val foreignList: List = + sortedList.filter { mirror -> + !mirror.countryCode.isNullOrEmpty() && currentLocation != mirror.countryCode + } + val unknownList: List = + sortedList.filter { mirror -> mirror.countryCode.isNullOrEmpty() } + + if (foreignMirrorsPreferred) { + mirrorList.addAll(foreignList) + mirrorList.addAll(unknownList) + mirrorList.addAll(domesticList) + } else { + mirrorList.addAll(domesticList) + mirrorList.addAll(unknownList) + mirrorList.addAll(foreignList) } + return mirrorList + } + + override suspend fun executeRequest( + mirror: Mirror, + url: Url, + request: suspend (mirror: Mirror, url: Url) -> T, + ): T { + return try { + request(mirror, url) + } catch (e: Exception) { + // in case of an exception, potentially attempt a single retry + if (mirrorParameterManager != null && mirrorParameterManager.shouldRetryRequest(url.host)) { + request(mirror, url) + } else { + throw e + } + } + } + + override fun handleException(e: Exception, mirror: Mirror, mirrorIndex: Int, mirrorCount: Int) { + if (e is ResponseException || e is IOException) { + mirrorParameterManager?.incrementMirrorErrorCount(mirror.baseUrl) + } + super.handleException(e, mirror, mirrorIndex, mirrorCount) + } } diff --git a/libs/download/src/commonMain/kotlin/org/fdroid/download/MirrorParameterManager.kt b/libs/download/src/commonMain/kotlin/org/fdroid/download/MirrorParameterManager.kt index e1d27db55..41dcb8f6f 100644 --- a/libs/download/src/commonMain/kotlin/org/fdroid/download/MirrorParameterManager.kt +++ b/libs/download/src/commonMain/kotlin/org/fdroid/download/MirrorParameterManager.kt @@ -2,37 +2,33 @@ package org.fdroid.download /** * This is an interface for providing access to stored parameters for mirrors without adding - * additional dependencies. The expectation is that this will be used to store and retrieve - * data about mirror performance to use when ordering mirror for subsequent tests. + * additional dependencies. The expectation is that this will be used to store and retrieve data + * about mirror performance to use when ordering mirror for subsequent tests. * * Currently it supports success and error count, but other parameters could be added later. */ - public interface MirrorParameterManager { - /** - * Set or get the number of failed attempts to access the specified mirror. The intent - * is to order mirrors for subsequent tests based on the number of failures. - */ - public fun incrementMirrorErrorCount(mirrorUrl: String) + /** + * Set or get the number of failed attempts to access the specified mirror. The intent is to order + * mirrors for subsequent tests based on the number of failures. + */ + public fun incrementMirrorErrorCount(mirrorUrl: String) - public fun getMirrorErrorCount(mirrorUrl: String): Int + public fun getMirrorErrorCount(mirrorUrl: String): Int - /** - * Returns true or false depending on whether a particular mirror should be retried before - * moving on to the next one (typically based on checking dns results) - */ - public fun shouldRetryRequest(mirrorUrl: String): Boolean + /** + * Returns true or false depending on whether a particular mirror should be retried before moving + * on to the next one (typically based on checking dns results) + */ + public fun shouldRetryRequest(mirrorUrl: String): Boolean - /** - * Returns true or false depending on whether the location preference has been enabled. This - * preference reflects whether mirrors matching your location should get priority. - */ - public fun preferForeignMirrors(): Boolean - - /** - * Returns the country code of the user's current location - */ - public fun getCurrentLocation(): String + /** + * Returns true or false depending on whether the location preference has been enabled. This + * preference reflects whether mirrors matching your location should get priority. + */ + public fun preferForeignMirrors(): Boolean + /** Returns the country code of the user's current location */ + public fun getCurrentLocation(): String } diff --git a/libs/download/src/commonMain/kotlin/org/fdroid/download/Proxy.kt b/libs/download/src/commonMain/kotlin/org/fdroid/download/Proxy.kt index aefeb7e04..d1b0bfe87 100644 --- a/libs/download/src/commonMain/kotlin/org/fdroid/download/Proxy.kt +++ b/libs/download/src/commonMain/kotlin/org/fdroid/download/Proxy.kt @@ -13,8 +13,8 @@ private const val DEFAULT_PROXY_HTTP_PORT = 8118 private const val DEFAULT_PROXY_SOCKS_PORT = 9050 internal fun ProxyConfig?.isTor(): Boolean { - if (this == null || !hostIsIp(DEFAULT_PROXY_HOST)) return false - val address = resolveAddress() - return (type == HTTP && address.port == DEFAULT_PROXY_HTTP_PORT) || - (type == SOCKS && address.port == DEFAULT_PROXY_SOCKS_PORT) + if (this == null || !hostIsIp(DEFAULT_PROXY_HOST)) return false + val address = resolveAddress() + return (type == HTTP && address.port == DEFAULT_PROXY_HTTP_PORT) || + (type == SOCKS && address.port == DEFAULT_PROXY_SOCKS_PORT) } diff --git a/libs/download/src/commonMain/kotlin/org/fdroid/fdroid/ProgressListener.kt b/libs/download/src/commonMain/kotlin/org/fdroid/fdroid/ProgressListener.kt index b4a47b3b8..693ec5518 100644 --- a/libs/download/src/commonMain/kotlin/org/fdroid/fdroid/ProgressListener.kt +++ b/libs/download/src/commonMain/kotlin/org/fdroid/fdroid/ProgressListener.kt @@ -1,21 +1,17 @@ package org.fdroid.fdroid /** - * This is meant only to send download progress for any URL (e.g. index - * updates, APKs, etc). This also keeps this class pure Java so that classes - * that use `ProgressListener` can be tested on the JVM, without requiring - * an Android device or emulator. - * - * - * The full URL of a download is used as the unique identifier throughout - * F-Droid. I can take a few forms: - * - * * [URL] instances - * * [android.net.Uri] instances - * * `String` instances, i.e. [URL.toString] - * * `int`s, i.e. [String.hashCode] + * This is meant only to send download progress for any URL (e.g. index updates, APKs, etc). This + * also keeps this class pure Java so that classes that use `ProgressListener` can be tested on the + * JVM, without requiring an Android device or emulator. * + * The full URL of a download is used as the unique identifier throughout F-Droid. I can take a few + * forms: + * * [URL] instances + * * [android.net.Uri] instances + * * `String` instances, i.e. [URL.toString] + * * `int`s, i.e. [String.hashCode] */ public fun interface ProgressListener { - public fun onProgress(bytesRead: Long, totalBytes: Long) + public fun onProgress(bytesRead: Long, totalBytes: Long) } diff --git a/libs/download/src/commonTest/kotlin/org/fdroid/TestUtils.kt b/libs/download/src/commonTest/kotlin/org/fdroid/TestUtils.kt index 46e546dd9..f5253cd4c 100644 --- a/libs/download/src/commonTest/kotlin/org/fdroid/TestUtils.kt +++ b/libs/download/src/commonTest/kotlin/org/fdroid/TestUtils.kt @@ -8,71 +8,68 @@ import io.ktor.client.request.HttpRequestData import io.ktor.http.HttpHeaders.Range import io.ktor.utils.io.ByteReadChannel import io.ktor.utils.io.InternalAPI -import kotlinx.coroutines.runBlocking -import kotlinx.io.Buffer -import kotlinx.io.Source import java.net.SocketTimeoutException import kotlin.random.Random import kotlin.test.assertEquals import kotlin.test.fail +import kotlinx.coroutines.runBlocking +import kotlinx.io.Buffer +import kotlinx.io.Source fun getRandomString(length: Int = Random.nextInt(4, 16)): String { - val allowedChars = ('A'..'Z') + ('a'..'z') + ('0'..'9') - return (1..length) - .map { allowedChars.random() } - .joinToString("") + val allowedChars = ('A'..'Z') + ('a'..'z') + ('0'..'9') + return (1..length).map { allowedChars.random() }.joinToString("") } -fun runSuspend(block: suspend () -> Unit) = runBlocking { - block() -} +fun runSuspend(block: suspend () -> Unit) = runBlocking { block() } fun HttpRequestData.getByteRangeFrom(): Int { - val (fromStr, endStr) = (headers[Range] ?: fail("No Range header")) - .replace("bytes=", "") - .split('-') - assertEquals("", endStr) - return fromStr.toIntOrNull() ?: fail("No valid content range ${headers[Range]}") + val (fromStr, endStr) = + (headers[Range] ?: fail("No Range header")).replace("bytes=", "").split('-') + assertEquals("", endStr) + return fromStr.toIntOrNull() ?: fail("No valid content range ${headers[Range]}") } -fun get(mockEngine: MockEngine) = object : HttpClientEngineFactory { +fun get(mockEngine: MockEngine) = + object : HttpClientEngineFactory { override fun create(block: MockEngineConfig.() -> Unit): HttpClientEngine { - return mockEngine + return mockEngine } -} + } internal fun getIndexFile( - name: String, - sha256: String? = null, - size: Long? = null, - ipfsCidV1: String? = null, + name: String, + sha256: String? = null, + size: Long? = null, + ipfsCidV1: String? = null, ): IndexFile { - return object : IndexFile { - override val name: String = name - override val sha256: String? = sha256 - override val size: Long? = size - override val ipfsCidV1: String? = ipfsCidV1 - override fun serialize(): String = error("Not yet implemented") - } + return object : IndexFile { + override val name: String = name + override val sha256: String? = sha256 + override val size: Long? = size + override val ipfsCidV1: String? = ipfsCidV1 + + override fun serialize(): String = error("Not yet implemented") + } } /** - * This class isn't reliable and produces flaky tests. - * It doesn't seem to be possible to mock failed HTTP downloads where partial data gets transferred. + * This class isn't reliable and produces flaky tests. It doesn't seem to be possible to mock failed + * HTTP downloads where partial data gets transferred. */ @Suppress("OVERRIDE_DEPRECATION", "OverridingDeprecatedMember", "DEPRECATION") internal class TestByteReadChannel(private val buffer: Buffer) : ByteReadChannel { - @InternalAPI - override val readBuffer: Source = buffer - override val closedCause: Throwable? = null - override val isClosedForRead: Boolean - get() { - if (buffer.exhausted()) { - throw SocketTimeoutException("boom!") - } - return false - } + @InternalAPI override val readBuffer: Source = buffer + override val closedCause: Throwable? = null + override val isClosedForRead: Boolean + get() { + if (buffer.exhausted()) { + throw SocketTimeoutException("boom!") + } + return false + } - override suspend fun awaitContent(min: Int): Boolean = true - override fun cancel(cause: Throwable?) = error("Not yet implemented") + override suspend fun awaitContent(min: Int): Boolean = true + + override fun cancel(cause: Throwable?) = error("Not yet implemented") } diff --git a/libs/download/src/commonTest/kotlin/org/fdroid/download/HttpManagerIntegrationTest.kt b/libs/download/src/commonTest/kotlin/org/fdroid/download/HttpManagerIntegrationTest.kt index 6a75252d2..0126fbff1 100644 --- a/libs/download/src/commonTest/kotlin/org/fdroid/download/HttpManagerIntegrationTest.kt +++ b/libs/download/src/commonTest/kotlin/org/fdroid/download/HttpManagerIntegrationTest.kt @@ -2,39 +2,37 @@ package org.fdroid.download import io.ktor.client.engine.ProxyBuilder import io.ktor.http.Url -import kotlinx.io.IOException -import org.fdroid.getRandomString -import org.fdroid.runSuspend import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlinx.io.IOException +import org.fdroid.getRandomString +import org.fdroid.runSuspend class HttpManagerIntegrationTest { - private val userAgent = getRandomString() - private val mirrors = - listOf(Mirror("https://f-droid.org/"), Mirror("https://cloudflare.f-droid.org/")) - private val downloadRequest = DownloadRequest(".well-known/security.txt", mirrors) + private val userAgent = getRandomString() + private val mirrors = + listOf(Mirror("https://f-droid.org/"), Mirror("https://cloudflare.f-droid.org/")) + private val downloadRequest = DownloadRequest(".well-known/security.txt", mirrors) - @Test - fun testResumeOnExample() = runSuspend { - val httpManager = HttpManager(userAgent, null) + @Test + fun testResumeOnExample() = runSuspend { + val httpManager = HttpManager(userAgent, null) - val lastLine = httpManager.getBytes(downloadRequest, 974).decodeToString() - assertEquals("-----END PGP SIGNATURE-----\n", lastLine) - } + val lastLine = httpManager.getBytes(downloadRequest, 974).decodeToString() + assertEquals("-----END PGP SIGNATURE-----\n", lastLine) + } - @Test - fun testProxy() = runSuspend { - val proxyRequest = downloadRequest.copy(proxy = ProxyBuilder.http(Url("http://127.0.0.1"))) - val httpManager = HttpManager(userAgent, null) + @Test + fun testProxy() = runSuspend { + val proxyRequest = downloadRequest.copy(proxy = ProxyBuilder.http(Url("http://127.0.0.1"))) + val httpManager = HttpManager(userAgent, null) - val e = assertFailsWith { - httpManager.getBytes(proxyRequest) - } - assertEquals("Failed to connect to /127.0.0.1:80", e.message) + val e = assertFailsWith { httpManager.getBytes(proxyRequest) } + assertEquals("Failed to connect to /127.0.0.1:80", e.message) - val lastLine = httpManager.getBytes(downloadRequest, 974).decodeToString() - assertEquals("-----END PGP SIGNATURE-----\n", lastLine) - } + val lastLine = httpManager.getBytes(downloadRequest, 974).decodeToString() + assertEquals("-----END PGP SIGNATURE-----\n", lastLine) + } } diff --git a/libs/download/src/commonTest/kotlin/org/fdroid/download/HttpManagerTest.kt b/libs/download/src/commonTest/kotlin/org/fdroid/download/HttpManagerTest.kt index 8892d6d22..6944c14d9 100644 --- a/libs/download/src/commonTest/kotlin/org/fdroid/download/HttpManagerTest.kt +++ b/libs/download/src/commonTest/kotlin/org/fdroid/download/HttpManagerTest.kt @@ -24,14 +24,6 @@ import io.ktor.http.HttpStatusCode.Companion.PartialContent import io.ktor.http.HttpStatusCode.Companion.TemporaryRedirect import io.ktor.http.Url import io.ktor.http.headersOf -import kotlinx.io.Buffer -import kotlinx.io.readByteArray -import org.fdroid.TestByteReadChannel -import org.fdroid.get -import org.fdroid.getByteRangeFrom -import org.fdroid.getRandomString -import org.fdroid.runSuspend -import org.junit.Ignore import kotlin.random.Random import kotlin.test.Test import kotlin.test.assertContentEquals @@ -42,307 +34,297 @@ import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.test.fail +import kotlinx.io.Buffer +import kotlinx.io.readByteArray +import org.fdroid.TestByteReadChannel +import org.fdroid.get +import org.fdroid.getByteRangeFrom +import org.fdroid.getRandomString +import org.fdroid.runSuspend +import org.junit.Ignore internal class HttpManagerTest { - private val userAgent = getRandomString() - private val mirrors = listOf(Mirror("http://example.org"), Mirror("http://example.net/")) - private val downloadRequest = DownloadRequest("foo", mirrors) + private val userAgent = getRandomString() + private val mirrors = listOf(Mirror("http://example.org"), Mirror("http://example.net/")) + private val downloadRequest = DownloadRequest("foo", mirrors) - @Test - fun testUserAgent() = runSuspend { - val mockEngine = MockEngine { respondOk() } - val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine)) + @Test + fun testUserAgent() = runSuspend { + val mockEngine = MockEngine { respondOk() } + val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine)) - httpManager.head(downloadRequest) - httpManager.getBytes(downloadRequest) + httpManager.head(downloadRequest) + httpManager.getBytes(downloadRequest) - mockEngine.requestHistory.forEach { request -> - assertEquals(userAgent, request.headers[UserAgent]) - } + mockEngine.requestHistory.forEach { request -> + assertEquals(userAgent, request.headers[UserAgent]) } + } - @Test - fun testQueryString() = runSuspend { - val id = getRandomString() - val version = getRandomString() - val queryString = "id=$id&client_version=$version" - val mockEngine = MockEngine { respondOk() } - val httpManager = - HttpManager(userAgent, queryString, httpClientEngineFactory = get(mockEngine)) + @Test + fun testQueryString() = runSuspend { + val id = getRandomString() + val version = getRandomString() + val queryString = "id=$id&client_version=$version" + val mockEngine = MockEngine { respondOk() } + val httpManager = HttpManager(userAgent, queryString, httpClientEngineFactory = get(mockEngine)) - httpManager.head(downloadRequest) - httpManager.getBytes(downloadRequest) + httpManager.head(downloadRequest) + httpManager.getBytes(downloadRequest) - mockEngine.requestHistory.forEach { request -> - assertEquals(id, request.url.parameters["id"]) - assertEquals(version, request.url.parameters["client_version"]) - } + mockEngine.requestHistory.forEach { request -> + assertEquals(id, request.url.parameters["id"]) + assertEquals(version, request.url.parameters["client_version"]) } + } - @Test - fun testBasicAuth() = runSuspend { - val downloadRequest = DownloadRequest("foo", mirrors, null, "Foo", "Bar") + @Test + fun testBasicAuth() = runSuspend { + val downloadRequest = DownloadRequest("foo", mirrors, null, "Foo", "Bar") - val mockEngine = MockEngine { respondOk() } - val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine)) + val mockEngine = MockEngine { respondOk() } + val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine)) - httpManager.head(downloadRequest) - httpManager.getBytes(downloadRequest) + httpManager.head(downloadRequest) + httpManager.getBytes(downloadRequest) - mockEngine.requestHistory.forEach { request -> - assertEquals("Basic Rm9vOkJhcg==", request.headers[Authorization]) - } + mockEngine.requestHistory.forEach { request -> + assertEquals("Basic Rm9vOkJhcg==", request.headers[Authorization]) } + } - @Test - fun testHeadETagCheck() = runSuspend { - val eTag = getRandomString() - val headers = headersOf(ETag, eTag) - val mockEngine = MockEngine { respond("", headers = headers) } - val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine)) + @Test + fun testHeadETagCheck() = runSuspend { + val eTag = getRandomString() + val headers = headersOf(ETag, eTag) + val mockEngine = MockEngine { respond("", headers = headers) } + val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine)) - // ETag is considered changed when none (null) passed into the request - assertTrue(httpManager.head(downloadRequest)!!.eTagChanged) - // Random ETag will be different than what we expect - assertTrue(httpManager.head(downloadRequest, getRandomString())!!.eTagChanged) - // Expected ETag should match response, so it hasn't changed - assertFalse(httpManager.head(downloadRequest, eTag)!!.eTagChanged) + // ETag is considered changed when none (null) passed into the request + assertTrue(httpManager.head(downloadRequest)!!.eTagChanged) + // Random ETag will be different than what we expect + assertTrue(httpManager.head(downloadRequest, getRandomString())!!.eTagChanged) + // Expected ETag should match response, so it hasn't changed + assertFalse(httpManager.head(downloadRequest, eTag)!!.eTagChanged) + } + + @Test + fun testDownload() = runSuspend { + val content = Random.nextBytes(1024) + + val mockEngine = MockEngine { respond(content) } + val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine)) + + assertContentEquals(content, httpManager.getBytes(downloadRequest)) + } + + @Test + fun testResumeDownload() = runSuspend { + val skipBytes = Random.nextInt(0, 1024) + val content = Random.nextBytes(1024) + + var requestNum = 1 + val mockEngine = MockEngine { request -> + val from = request.getByteRangeFrom() + assertEquals(skipBytes, from) + if (requestNum++ == 1) respond(content.copyOfRange(from, content.size), PartialContent) + else respond(content, OK) } + val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine)) - @Test - fun testDownload() = runSuspend { - val content = Random.nextBytes(1024) + // first request gets only the skipped bytes + assertContentEquals( + content.copyOfRange(skipBytes, content.size), + httpManager.getBytes(downloadRequest, skipBytes.toLong()), + ) + // second request fails, because it responds with OK and full content + assertFailsWith { httpManager.getBytes(downloadRequest, skipBytes.toLong()) } + } - val mockEngine = MockEngine { respond(content) } - val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine)) + @Test + @Ignore("It isn't possible anymore to mock failed reads") + fun testResumeDownloadWhenMirrorFailOver() = runSuspend { + val numFailBytes = DEFAULT_BUFFER_SIZE * 64 + val content = Random.nextBytes(numFailBytes * 2) - assertContentEquals(content, httpManager.getBytes(downloadRequest)) + val buffer = Buffer().also { it.write(content, startIndex = 0, endIndex = numFailBytes) } + val readChannel = TestByteReadChannel(buffer) + val mockEngine = + MockEngine.config { + reuseHandlers = false + addHandler { respond(readChannel, OK) } + addHandler { request -> + val from = request.getByteRangeFrom() + assertTrue(from > 0) + assertTrue(from <= numFailBytes) + respond(content.copyOfRange(from, content.size), PartialContent) + } + } + val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = mockEngine) + + val sink = Buffer() + httpManager.get(downloadRequest) { bytes, numTotalBytes -> sink.write(bytes) } + assertContentEquals(sink.readByteArray(), content) + } + + @Test + fun testMirrorFallback() = runSuspend { + val mockEngine = MockEngine { respondError(InternalServerError) } + val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine)) + + assertNull(httpManager.head(downloadRequest)) + assertFailsWith { httpManager.getBytes(downloadRequest) } + + // assert that URLs for each mirror get tried + val urls = mockEngine.requestHistory.map { request -> request.url.toString() }.toSet() + assertEquals(setOf("http://example.org/foo", "http://example.net/foo"), urls) + } + + @Test + fun testFirstMirrorSuccess() = runSuspend { + val mockEngine = MockEngine { respondOk() } + val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine)) + + assertNotNull(httpManager.head(downloadRequest)) + httpManager.getBytes(downloadRequest) + + // assert there is only one request per API call using one of the mirrors + assertEquals(2, mockEngine.requestHistory.size) + mockEngine.requestHistory.forEach { request -> + val url = request.url.toString() + assertTrue(url == "http://example.org/foo" || url == "http://example.net/foo") } + } - @Test - fun testResumeDownload() = runSuspend { - val skipBytes = Random.nextInt(0, 1024) - val content = Random.nextBytes(1024) + @Test + fun testNoMoreMirrorsWhenForbiddenWithCredentials() = runSuspend { + val downloadRequest = + downloadRequest.copy(username = getRandomString(), password = getRandomString()) + val mockEngine = MockEngine { respond("", Forbidden) } + val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine)) - var requestNum = 1 - val mockEngine = MockEngine { request -> - val from = request.getByteRangeFrom() - assertEquals(skipBytes, from) - if (requestNum++ == 1) respond(content.copyOfRange(from, content.size), PartialContent) - else respond(content, OK) - } - val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine)) + assertTrue(downloadRequest.hasCredentials) - // first request gets only the skipped bytes - assertContentEquals( - content.copyOfRange(skipBytes, content.size), - httpManager.getBytes(downloadRequest, skipBytes.toLong()) - ) - // second request fails, because it responds with OK and full content - assertFailsWith { - httpManager.getBytes(downloadRequest, skipBytes.toLong()) - } + assertNull(httpManager.head(downloadRequest)) + val e = assertFailsWith { httpManager.getBytes(downloadRequest) } + + // assert that the exception reflects the forbidden + assertEquals(Forbidden, e.response.status) + // assert there is only one request per API call using one of the mirrors + assertEquals(2, mockEngine.requestHistory.size) + mockEngine.requestHistory.forEach { request -> + val url = request.url.toString() + assertTrue(url == "http://example.org/foo" || url == "http://example.net/foo") } + } - @Test - @Ignore("It isn't possible anymore to mock failed reads") - fun testResumeDownloadWhenMirrorFailOver() = runSuspend { - val numFailBytes = DEFAULT_BUFFER_SIZE * 64 - val content = Random.nextBytes(numFailBytes * 2) + @Test + fun testNoMoreMirrorsWhenRepoDownloadNotFound() = runSuspend { + val downloadRequest = downloadRequest.copy(tryFirstMirror = mirrors[0]) + val mockEngine = MockEngine { respond("", NotFound) } + val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine)) - val buffer = Buffer().also { - it.write(content, startIndex = 0, endIndex = numFailBytes) - } - val readChannel = TestByteReadChannel(buffer) - val mockEngine = MockEngine.config { - reuseHandlers = false - addHandler { - respond(readChannel, OK) - } - addHandler { request -> - val from = request.getByteRangeFrom() - assertTrue(from > 0) - assertTrue(from <= numFailBytes) - respond(content.copyOfRange(from, content.size), PartialContent) - } - } - val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = mockEngine) + assertTrue(downloadRequest.tryFirstMirror != null) - val sink = Buffer() - httpManager.get(downloadRequest) { bytes, numTotalBytes -> - sink.write(bytes) - } - assertContentEquals(sink.readByteArray(), content) + assertFailsWith { httpManager.head(downloadRequest) } + val e = assertFailsWith { httpManager.getBytes(downloadRequest) } + + // assert that the exception reflects the NotFound error + assertEquals(NotFound, e.response.status) + // assert there is only one request per API call using one of the mirrors + assertEquals(2, mockEngine.requestHistory.size) + mockEngine.requestHistory.forEach { request -> + val url = request.url.toString() + assertTrue(url == "http://example.org/foo" || url == "http://example.net/foo") } + } - @Test - fun testMirrorFallback() = runSuspend { - val mockEngine = MockEngine { respondError(InternalServerError) } - val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine)) + @Test + fun testNoRedirect() = runSuspend { + val mockEngine = MockEngine { respondRedirect("http://example.com") } + val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine)) - assertNull(httpManager.head(downloadRequest)) - assertFailsWith { - httpManager.getBytes(downloadRequest) - } + assertNull(httpManager.head(downloadRequest)) + assertFailsWith { httpManager.getBytes(downloadRequest) } - // assert that URLs for each mirror get tried - val urls = mockEngine.requestHistory.map { request -> request.url.toString() }.toSet() - assertEquals(setOf("http://example.org/foo", "http://example.net/foo"), urls) + // HEAD and GET try another mirror, so 4 requests + assertEquals(4, mockEngine.requestHistory.size) + mockEngine.responseHistory.forEach { response -> + assertEquals(TemporaryRedirect, response.statusCode) } + } - @Test - fun testFirstMirrorSuccess() = runSuspend { - val mockEngine = MockEngine { respondOk() } - val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine)) + @Test + fun testProxyGetsApplied() = runSuspend { + val proxyConfig = ProxyBuilder.http(Url("http://127.0.0.1:5050")) + val proxyRequest = DownloadRequest("foo", mirrors, proxyConfig) + val noProxyRequest = DownloadRequest("foo", mirrors) - assertNotNull(httpManager.head(downloadRequest)) - httpManager.getBytes(downloadRequest) - - // assert there is only one request per API call using one of the mirrors - assertEquals(2, mockEngine.requestHistory.size) - mockEngine.requestHistory.forEach { request -> - val url = request.url.toString() - assertTrue(url == "http://example.org/foo" || url == "http://example.net/foo") + var numRequests = 0 + val factory = + object : HttpClientEngineFactory { + override fun create(block: MockEngineConfig.() -> Unit): HttpClientEngine { + return when (++numRequests) { + 1 -> MockEngine { respondOk() } + 2 -> MockEngine { respondOk() } + 3 -> MockEngine { respondOk() } + else -> fail("Too many engine creations") + } } - } + } + val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = factory) + assertNull(httpManager.currentProxy) - @Test - fun testNoMoreMirrorsWhenForbiddenWithCredentials() = runSuspend { - val downloadRequest = - downloadRequest.copy(username = getRandomString(), password = getRandomString()) - val mockEngine = MockEngine { respond("", Forbidden) } - val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine)) + // does not need a new engine, because also doesn't use a proxy + assertNotNull(httpManager.head(noProxyRequest)) + assertNull(httpManager.currentProxy) - assertTrue(downloadRequest.hasCredentials) + // now wants proxy, creates new engine (2) + assertNotNull(httpManager.head(proxyRequest)) + assertEquals(proxyConfig, httpManager.currentProxy) - assertNull(httpManager.head(downloadRequest)) - val e = assertFailsWith { - httpManager.getBytes(downloadRequest) + // no more proxy, creates new engine (3) + httpManager.getBytes(noProxyRequest) + assertNull(httpManager.currentProxy) + + assertEquals(3, numRequests) + } + + @Test + fun testNoProxyWithLocalMirror() = runSuspend { + val mirror = Mirror("http://192.168.49.5") + assertTrue(mirror.isLocal()) + val proxyConfig = ProxyBuilder.http(Url("http://127.0.0.1:5050")) + val localRequest = DownloadRequest("foo", listOf(mirror), proxyConfig) + val internetRequest = DownloadRequest("foo", mirrors, proxyConfig) + + var numEngines = 0 + val factory = + object : HttpClientEngineFactory { + override fun create(block: MockEngineConfig.() -> Unit): HttpClientEngine { + return when (++numEngines) { + 1 -> MockEngine { respondOk() } + 2 -> MockEngine { respondOk() } + else -> fail("Too many engine creations") + } } + } + val httpManager = HttpManager(userAgent, null, proxyConfig, httpClientEngineFactory = factory) + assertEquals(proxyConfig, httpManager.currentProxy) - // assert that the exception reflects the forbidden - assertEquals(Forbidden, e.response.status) - // assert there is only one request per API call using one of the mirrors - assertEquals(2, mockEngine.requestHistory.size) - mockEngine.requestHistory.forEach { request -> - val url = request.url.toString() - assertTrue(url == "http://example.org/foo" || url == "http://example.net/foo") - } - } + // does not need a new engine, because also does use a proxy (1) + assertNotNull(httpManager.head(internetRequest)) + assertEquals(proxyConfig, httpManager.currentProxy) - @Test - fun testNoMoreMirrorsWhenRepoDownloadNotFound() = runSuspend { - val downloadRequest = downloadRequest.copy(tryFirstMirror = mirrors[0]) - val mockEngine = MockEngine { respond("", NotFound) } - val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine)) + // now no proxy, because local mirror, creates new engine (2) + assertNotNull(httpManager.head(localRequest)) + assertNull(httpManager.currentProxy) - assertTrue(downloadRequest.tryFirstMirror != null) - - assertFailsWith { - httpManager.head(downloadRequest) - } - val e = assertFailsWith { - httpManager.getBytes(downloadRequest) - } - - // assert that the exception reflects the NotFound error - assertEquals(NotFound, e.response.status) - // assert there is only one request per API call using one of the mirrors - assertEquals(2, mockEngine.requestHistory.size) - mockEngine.requestHistory.forEach { request -> - val url = request.url.toString() - assertTrue(url == "http://example.org/foo" || url == "http://example.net/foo") - } - } - - @Test - fun testNoRedirect() = runSuspend { - val mockEngine = MockEngine { respondRedirect("http://example.com") } - val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine)) - - assertNull(httpManager.head(downloadRequest)) - assertFailsWith { - httpManager.getBytes(downloadRequest) - } - - // HEAD and GET try another mirror, so 4 requests - assertEquals(4, mockEngine.requestHistory.size) - mockEngine.responseHistory.forEach { response -> - assertEquals(TemporaryRedirect, response.statusCode) - } - } - - @Test - fun testProxyGetsApplied() = runSuspend { - val proxyConfig = ProxyBuilder.http(Url("http://127.0.0.1:5050")) - val proxyRequest = DownloadRequest("foo", mirrors, proxyConfig) - val noProxyRequest = DownloadRequest("foo", mirrors) - - var numRequests = 0 - val factory = object : HttpClientEngineFactory { - override fun create(block: MockEngineConfig.() -> Unit): HttpClientEngine { - return when (++numRequests) { - 1 -> MockEngine { respondOk() } - 2 -> MockEngine { respondOk() } - 3 -> MockEngine { respondOk() } - else -> fail("Too many engine creations") - } - } - } - val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = factory) - assertNull(httpManager.currentProxy) - - // does not need a new engine, because also doesn't use a proxy - assertNotNull(httpManager.head(noProxyRequest)) - assertNull(httpManager.currentProxy) - - // now wants proxy, creates new engine (2) - assertNotNull(httpManager.head(proxyRequest)) - assertEquals(proxyConfig, httpManager.currentProxy) - - // no more proxy, creates new engine (3) - httpManager.getBytes(noProxyRequest) - assertNull(httpManager.currentProxy) - - assertEquals(3, numRequests) - } - - @Test - fun testNoProxyWithLocalMirror() = runSuspend { - val mirror = Mirror("http://192.168.49.5") - assertTrue(mirror.isLocal()) - val proxyConfig = ProxyBuilder.http(Url("http://127.0.0.1:5050")) - val localRequest = DownloadRequest("foo", listOf(mirror), proxyConfig) - val internetRequest = DownloadRequest("foo", mirrors, proxyConfig) - - var numEngines = 0 - val factory = object : HttpClientEngineFactory { - override fun create(block: MockEngineConfig.() -> Unit): HttpClientEngine { - return when (++numEngines) { - 1 -> MockEngine { respondOk() } - 2 -> MockEngine { respondOk() } - else -> fail("Too many engine creations") - } - } - } - val httpManager = - HttpManager(userAgent, null, proxyConfig, httpClientEngineFactory = factory) - assertEquals(proxyConfig, httpManager.currentProxy) - - // does not need a new engine, because also does use a proxy (1) - assertNotNull(httpManager.head(internetRequest)) - assertEquals(proxyConfig, httpManager.currentProxy) - - // now no proxy, because local mirror, creates new engine (2) - assertNotNull(httpManager.head(localRequest)) - assertNull(httpManager.currentProxy) - - // still no proxy, because local mirror as well, should not create new engine - assertNotNull(httpManager.getBytes(localRequest)) - assertNull(httpManager.currentProxy) - - assertEquals(2, numEngines) - } + // still no proxy, because local mirror as well, should not create new engine + assertNotNull(httpManager.getBytes(localRequest)) + assertNull(httpManager.currentProxy) + assertEquals(2, numEngines) + } } diff --git a/libs/download/src/commonTest/kotlin/org/fdroid/download/MirrorChooserTest.kt b/libs/download/src/commonTest/kotlin/org/fdroid/download/MirrorChooserTest.kt index 3c3449d4f..8107dc2dc 100644 --- a/libs/download/src/commonTest/kotlin/org/fdroid/download/MirrorChooserTest.kt +++ b/libs/download/src/commonTest/kotlin/org/fdroid/download/MirrorChooserTest.kt @@ -2,275 +2,274 @@ package org.fdroid.download import io.mockk.every import io.mockk.mockk -import kotlinx.io.IOException -import org.fdroid.getIndexFile -import org.fdroid.runSuspend import java.net.SocketTimeoutException import kotlin.random.Random import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertTrue +import kotlinx.io.IOException +import org.fdroid.getIndexFile +import org.fdroid.runSuspend class MirrorChooserTest { - private val mirrors = listOf( - Mirror("foo"), - Mirror("bar"), - Mirror("42"), - Mirror("1337")) - private val mirrorsLocation = listOf( - Mirror(baseUrl = "unknown_1", countryCode = null), - Mirror(baseUrl = "unknown_2", countryCode = null), - Mirror(baseUrl = "unknown_3", countryCode = null), - Mirror(baseUrl = "local_1", countryCode = "HERE"), - Mirror(baseUrl = "local_2", countryCode = "HERE"), - Mirror(baseUrl = "local_3", countryCode = "HERE"), - Mirror(baseUrl = "remote_1", countryCode = "THERE"), - Mirror(baseUrl = "remote_2", countryCode = "THERE"), - Mirror(baseUrl = "remote_3", countryCode = "THERE")) - private val downloadRequest = DownloadRequest("foo", mirrors) - private val downloadRequestLocation = DownloadRequest("location", mirrorsLocation) - private val downloadRequestTryFIrst = DownloadRequest( - path = "location", - mirrors = mirrorsLocation, - tryFirstMirror = Mirror(baseUrl = "remote_1", countryCode = "THERE")) + private val mirrors = listOf(Mirror("foo"), Mirror("bar"), Mirror("42"), Mirror("1337")) + private val mirrorsLocation = + listOf( + Mirror(baseUrl = "unknown_1", countryCode = null), + Mirror(baseUrl = "unknown_2", countryCode = null), + Mirror(baseUrl = "unknown_3", countryCode = null), + Mirror(baseUrl = "local_1", countryCode = "HERE"), + Mirror(baseUrl = "local_2", countryCode = "HERE"), + Mirror(baseUrl = "local_3", countryCode = "HERE"), + Mirror(baseUrl = "remote_1", countryCode = "THERE"), + Mirror(baseUrl = "remote_2", countryCode = "THERE"), + Mirror(baseUrl = "remote_3", countryCode = "THERE"), + ) + private val downloadRequest = DownloadRequest("foo", mirrors) + private val downloadRequestLocation = DownloadRequest("location", mirrorsLocation) + private val downloadRequestTryFIrst = + DownloadRequest( + path = "location", + mirrors = mirrorsLocation, + tryFirstMirror = Mirror(baseUrl = "remote_1", countryCode = "THERE"), + ) - private val ipfsIndexFile = getIndexFile(name = "foo", ipfsCidV1 = "CIDv1") + private val ipfsIndexFile = getIndexFile(name = "foo", ipfsCidV1 = "CIDv1") - @Test - fun testMirrorChooserDefaultImpl() = runSuspend { - val mirrorChooser = MirrorChooserRandom() - val expectedResult = Random.nextInt() + @Test + fun testMirrorChooserDefaultImpl() = runSuspend { + val mirrorChooser = MirrorChooserRandom() + val expectedResult = Random.nextInt() - val result = mirrorChooser.mirrorRequest(downloadRequest) { mirror, url -> - assertTrue { mirrors.contains(mirror) } - assertEquals(mirror.getUrl(downloadRequest.indexFile.name), url) - expectedResult + val result = + mirrorChooser.mirrorRequest(downloadRequest) { mirror, url -> + assertTrue { mirrors.contains(mirror) } + assertEquals(mirror.getUrl(downloadRequest.indexFile.name), url) + expectedResult + } + assertEquals(expectedResult, result) + } + + @Test + fun testFallbackToNextMirrorWithIOException() = runSuspend { + val mirrorChooser = MirrorChooserRandom() + val expectedResult = Random.nextInt() + + val result = + mirrorChooser.mirrorRequest(downloadRequest) { mirror, url -> + assertEquals(mirror.getUrl(downloadRequest.indexFile.name), url) + // fails with all except last mirror + if (mirror != downloadRequest.mirrors.last()) throw IOException("foo") + expectedResult + } + assertEquals(expectedResult, result) + } + + @Test + fun testFallbackToNextMirrorWithSocketTimeoutException() = runSuspend { + val mirrorChooser = MirrorChooserRandom() + val expectedResult = Random.nextInt() + + val result = + mirrorChooser.mirrorRequest(downloadRequest) { mirror, url -> + assertEquals(mirror.getUrl(downloadRequest.indexFile.name), url) + // fails with all except last mirror + if (mirror != downloadRequest.mirrors.last()) throw SocketTimeoutException("foo") + expectedResult + } + assertEquals(expectedResult, result) + } + + @Test + fun testFallbackToNextMirrorWithNoResumeException() = runSuspend { + val mirrorChooser = MirrorChooserRandom() + val expectedResult = Random.nextInt() + + val result = + mirrorChooser.mirrorRequest(downloadRequest) { mirror, url -> + assertEquals(mirror.getUrl(downloadRequest.indexFile.name), url) + // fails with all except last mirror + if (mirror != downloadRequest.mirrors.last()) throw NoResumeException() + expectedResult + } + assertEquals(expectedResult, result) + } + + @Test + fun testMirrorChooserRandom() { + val mirrorChooser = MirrorChooserRandom() + + val orderedMirrors = mirrorChooser.orderMirrors(downloadRequest) + + // set of input mirrors is equal to set of output mirrors + assertEquals(mirrors.toSet(), orderedMirrors.toSet()) + } + + @Test + fun testMirrorChooserRandomRespectsTryFirstMirror() { + val mirrorChooser = MirrorChooserRandom() + + val tryFirstRequest = downloadRequest.copy(tryFirstMirror = Mirror("42")) + val orderedMirrors = mirrorChooser.orderMirrors(tryFirstRequest) + + // try-first mirror is first in list + assertEquals(tryFirstRequest.tryFirstMirror, orderedMirrors[0]) + // set of input mirrors is equal to set of output mirrors + assertEquals(mirrors.toSet(), orderedMirrors.toSet()) + } + + @Test + fun testMirrorChooserRandomIgnoresMissingTryFirstMirror() { + val mirrorChooser = MirrorChooserRandom() + + val tryFirstRequest = downloadRequest.copy(tryFirstMirror = Mirror("missing")) + val orderedMirrors = mirrorChooser.orderMirrors(tryFirstRequest) + + // set of input mirrors is equal to set of output mirrors + assertEquals(mirrors.toSet(), orderedMirrors.toSet()) + } + + @Test + fun testMirrorChooserIgnoresIpfsGatewayIfNoCid() = runSuspend { + val mirrorChooser = + object : MirrorChooserImpl() { + override fun orderMirrors(downloadRequest: DownloadRequest): List { + return downloadRequest.mirrors // keep mirror list stable, no random please } - assertEquals(expectedResult, result) - } - - @Test - fun testFallbackToNextMirrorWithIOException() = runSuspend { - val mirrorChooser = MirrorChooserRandom() - val expectedResult = Random.nextInt() - - val result = mirrorChooser.mirrorRequest(downloadRequest) { mirror, url -> - assertEquals(mirror.getUrl(downloadRequest.indexFile.name), url) - // fails with all except last mirror - if (mirror != downloadRequest.mirrors.last()) throw IOException("foo") - expectedResult - } - assertEquals(expectedResult, result) - } - - @Test - fun testFallbackToNextMirrorWithSocketTimeoutException() = runSuspend { - val mirrorChooser = MirrorChooserRandom() - val expectedResult = Random.nextInt() - - val result = mirrorChooser.mirrorRequest(downloadRequest) { mirror, url -> - assertEquals(mirror.getUrl(downloadRequest.indexFile.name), url) - // fails with all except last mirror - if (mirror != downloadRequest.mirrors.last()) throw SocketTimeoutException("foo") - expectedResult - } - assertEquals(expectedResult, result) - } - - @Test - fun testFallbackToNextMirrorWithNoResumeException() = runSuspend { - val mirrorChooser = MirrorChooserRandom() - val expectedResult = Random.nextInt() - - val result = mirrorChooser.mirrorRequest(downloadRequest) { mirror, url -> - assertEquals(mirror.getUrl(downloadRequest.indexFile.name), url) - // fails with all except last mirror - if (mirror != downloadRequest.mirrors.last()) throw NoResumeException() - expectedResult - } - assertEquals(expectedResult, result) - } - - @Test - fun testMirrorChooserRandom() { - val mirrorChooser = MirrorChooserRandom() - - val orderedMirrors = mirrorChooser.orderMirrors(downloadRequest) - - // set of input mirrors is equal to set of output mirrors - assertEquals(mirrors.toSet(), orderedMirrors.toSet()) - } - - @Test - fun testMirrorChooserRandomRespectsTryFirstMirror() { - val mirrorChooser = MirrorChooserRandom() - - val tryFirstRequest = downloadRequest.copy(tryFirstMirror = Mirror("42")) - val orderedMirrors = mirrorChooser.orderMirrors(tryFirstRequest) - - // try-first mirror is first in list - assertEquals(tryFirstRequest.tryFirstMirror, orderedMirrors[0]) - // set of input mirrors is equal to set of output mirrors - assertEquals(mirrors.toSet(), orderedMirrors.toSet()) - } - - @Test - fun testMirrorChooserRandomIgnoresMissingTryFirstMirror() { - val mirrorChooser = MirrorChooserRandom() - - val tryFirstRequest = downloadRequest.copy(tryFirstMirror = Mirror("missing")) - val orderedMirrors = mirrorChooser.orderMirrors(tryFirstRequest) - - // set of input mirrors is equal to set of output mirrors - assertEquals(mirrors.toSet(), orderedMirrors.toSet()) - } - - @Test - fun testMirrorChooserIgnoresIpfsGatewayIfNoCid() = runSuspend { - val mirrorChooser = object : MirrorChooserImpl() { - override fun orderMirrors(downloadRequest: DownloadRequest): List { - return downloadRequest.mirrors // keep mirror list stable, no random please - } - } - val mirrors = listOf( - Mirror("http://ipfs.com", isIpfsGateway = true), - Mirror("http://example.com", isIpfsGateway = false), - ) - val ipfsRequest = downloadRequest.copy(mirrors = mirrors) - - val result = mirrorChooser.mirrorRequest(ipfsRequest) { _, url -> - url.toString() - } - assertEquals("http://example.com/foo", result) - } - - @Test - fun testMirrorChooserThrowsIfOnlyIpfsGateways() = runSuspend { - val mirrorChooser = MirrorChooserRandom() - val mirrors = listOf( - Mirror("foo/bar", isIpfsGateway = true), - Mirror("bar/foo", isIpfsGateway = true), - ) - val ipfsRequest = downloadRequest.copy(mirrors = mirrors) - - val e = assertFailsWith { - mirrorChooser.mirrorRequest(ipfsRequest) { _, _ -> - } - } - assertEquals("Got IPFS gateway without CID", e.message) - } - - @Test - fun testMirrorChooserDomesticLocation() { - val mockManager = mockk(relaxed = true) - every { mockManager.getCurrentLocation() } returns "HERE" - every { mockManager.preferForeignMirrors() } returns false - - val mirrorChooser = MirrorChooserWithParameters(mockManager) - - // test domestic mirror preference - val domesticList = mirrorChooser.orderMirrors(downloadRequestLocation) - // confirm the list contains all mirrors - assertEquals(9, domesticList.size) - // mirrors that are local should be included first - assertEquals("HERE", domesticList[0].countryCode) - assertEquals("HERE", domesticList[1].countryCode) - assertEquals("HERE", domesticList[2].countryCode) - assertEquals(null, domesticList[3].countryCode) - } - - @Test - fun testMirrorChooserForeignLocation() { - val mockManager = mockk(relaxed = true) - every { mockManager.getCurrentLocation() } returns "HERE" - every { mockManager.preferForeignMirrors() } returns true - - val mirrorChooser = MirrorChooserWithParameters(mockManager) - - // test foreign mirror preference - val foreignList = mirrorChooser.orderMirrors(downloadRequestLocation) - // confirm the list contains all mirrors - assertEquals(9, foreignList.size) - // mirrors that are remote should be included first - assertEquals("THERE", foreignList[0].countryCode) - assertEquals("THERE", foreignList[1].countryCode) - assertEquals("THERE", foreignList[2].countryCode) - assertEquals(null, foreignList[3].countryCode) - } - - @Test - fun testMirrorChooserErrorSort() { - val mockManager = mockk(relaxed = true) - every { mockManager.getCurrentLocation() } returns "HERE" - every { mockManager.preferForeignMirrors() } returns false - every { mockManager.getMirrorErrorCount("local_1") } returns 5 - every { mockManager.getMirrorErrorCount("local_2") } returns 3 - every { mockManager.getMirrorErrorCount("local_3") } returns 1 - - val mirrorChooser = MirrorChooserWithParameters(mockManager) - - // test error sorting with domestic mirror preference - val orderedList = mirrorChooser.orderMirrors(downloadRequestLocation) - // confirm the list contains all mirrors - assertEquals(9, orderedList.size) - // mirrors that have fewer errors should be included first - assertEquals("local_3", orderedList[0].baseUrl) - assertEquals("local_2", orderedList[1].baseUrl) - assertEquals("local_1", orderedList[2].baseUrl) - } - - @Test - fun testMirrorChooserDomesticWithTryFirst() { - val mockManager = mockk(relaxed = true) - every { mockManager.getCurrentLocation() } returns "HERE" - every { mockManager.preferForeignMirrors() } returns false - - val mirrorChooser = MirrorChooserWithParameters(mockManager) - - // test tryfirst mirror parameter - val tryFirstList = mirrorChooser.orderMirrors(downloadRequestTryFIrst) - // confirm the list contains all mirrors - assertEquals(9, tryFirstList.size) - // tryfirst mirror should be included before local mirrors - assertEquals("remote_1", tryFirstList[0].baseUrl) - assertEquals("HERE", tryFirstList[1].countryCode) - assertEquals("HERE", tryFirstList[2].countryCode) - assertEquals("HERE", tryFirstList[3].countryCode) - } - - @Test - fun testMirrorChooserRandomization() { - val mockManager = mockk(relaxed = true) - every { mockManager.getCurrentLocation() } returns "HERE" - every { mockManager.preferForeignMirrors() } returns false - - val mirrorChooser = MirrorChooserWithParameters(mockManager) - - // repeat test to verify that if error count is equal the order isn't always the same - var count1 = 0 - var count2 = 0 - var count3 = 0 - var countX = 0 - repeat(100) { - // test error sorting with domestic mirror preference - val orderedList = mirrorChooser.orderMirrors(downloadRequestLocation) - if (orderedList[0].baseUrl.equals("local_1")) { - count1++ - } else if (orderedList[0].baseUrl.equals("local_2")) { - count2++ - } else if (orderedList[0].baseUrl.equals("local_3")) { - count3++ - } else { - countX++ - } - } - // all domestic urls should have appeared first in the list at least once - assertTrue { count1 > 0 } - assertTrue { count2 > 0 } - assertTrue { count3 > 0 } - // no foreign urls should should have appeared first in the list - assertEquals(0, countX) + } + val mirrors = + listOf( + Mirror("http://ipfs.com", isIpfsGateway = true), + Mirror("http://example.com", isIpfsGateway = false), + ) + val ipfsRequest = downloadRequest.copy(mirrors = mirrors) + + val result = mirrorChooser.mirrorRequest(ipfsRequest) { _, url -> url.toString() } + assertEquals("http://example.com/foo", result) + } + + @Test + fun testMirrorChooserThrowsIfOnlyIpfsGateways() = runSuspend { + val mirrorChooser = MirrorChooserRandom() + val mirrors = + listOf(Mirror("foo/bar", isIpfsGateway = true), Mirror("bar/foo", isIpfsGateway = true)) + val ipfsRequest = downloadRequest.copy(mirrors = mirrors) + + val e = assertFailsWith { mirrorChooser.mirrorRequest(ipfsRequest) { _, _ -> } } + assertEquals("Got IPFS gateway without CID", e.message) + } + + @Test + fun testMirrorChooserDomesticLocation() { + val mockManager = mockk(relaxed = true) + every { mockManager.getCurrentLocation() } returns "HERE" + every { mockManager.preferForeignMirrors() } returns false + + val mirrorChooser = MirrorChooserWithParameters(mockManager) + + // test domestic mirror preference + val domesticList = mirrorChooser.orderMirrors(downloadRequestLocation) + // confirm the list contains all mirrors + assertEquals(9, domesticList.size) + // mirrors that are local should be included first + assertEquals("HERE", domesticList[0].countryCode) + assertEquals("HERE", domesticList[1].countryCode) + assertEquals("HERE", domesticList[2].countryCode) + assertEquals(null, domesticList[3].countryCode) + } + + @Test + fun testMirrorChooserForeignLocation() { + val mockManager = mockk(relaxed = true) + every { mockManager.getCurrentLocation() } returns "HERE" + every { mockManager.preferForeignMirrors() } returns true + + val mirrorChooser = MirrorChooserWithParameters(mockManager) + + // test foreign mirror preference + val foreignList = mirrorChooser.orderMirrors(downloadRequestLocation) + // confirm the list contains all mirrors + assertEquals(9, foreignList.size) + // mirrors that are remote should be included first + assertEquals("THERE", foreignList[0].countryCode) + assertEquals("THERE", foreignList[1].countryCode) + assertEquals("THERE", foreignList[2].countryCode) + assertEquals(null, foreignList[3].countryCode) + } + + @Test + fun testMirrorChooserErrorSort() { + val mockManager = mockk(relaxed = true) + every { mockManager.getCurrentLocation() } returns "HERE" + every { mockManager.preferForeignMirrors() } returns false + every { mockManager.getMirrorErrorCount("local_1") } returns 5 + every { mockManager.getMirrorErrorCount("local_2") } returns 3 + every { mockManager.getMirrorErrorCount("local_3") } returns 1 + + val mirrorChooser = MirrorChooserWithParameters(mockManager) + + // test error sorting with domestic mirror preference + val orderedList = mirrorChooser.orderMirrors(downloadRequestLocation) + // confirm the list contains all mirrors + assertEquals(9, orderedList.size) + // mirrors that have fewer errors should be included first + assertEquals("local_3", orderedList[0].baseUrl) + assertEquals("local_2", orderedList[1].baseUrl) + assertEquals("local_1", orderedList[2].baseUrl) + } + + @Test + fun testMirrorChooserDomesticWithTryFirst() { + val mockManager = mockk(relaxed = true) + every { mockManager.getCurrentLocation() } returns "HERE" + every { mockManager.preferForeignMirrors() } returns false + + val mirrorChooser = MirrorChooserWithParameters(mockManager) + + // test tryfirst mirror parameter + val tryFirstList = mirrorChooser.orderMirrors(downloadRequestTryFIrst) + // confirm the list contains all mirrors + assertEquals(9, tryFirstList.size) + // tryfirst mirror should be included before local mirrors + assertEquals("remote_1", tryFirstList[0].baseUrl) + assertEquals("HERE", tryFirstList[1].countryCode) + assertEquals("HERE", tryFirstList[2].countryCode) + assertEquals("HERE", tryFirstList[3].countryCode) + } + + @Test + fun testMirrorChooserRandomization() { + val mockManager = mockk(relaxed = true) + every { mockManager.getCurrentLocation() } returns "HERE" + every { mockManager.preferForeignMirrors() } returns false + + val mirrorChooser = MirrorChooserWithParameters(mockManager) + + // repeat test to verify that if error count is equal the order isn't always the same + var count1 = 0 + var count2 = 0 + var count3 = 0 + var countX = 0 + repeat(100) { + // test error sorting with domestic mirror preference + val orderedList = mirrorChooser.orderMirrors(downloadRequestLocation) + if (orderedList[0].baseUrl.equals("local_1")) { + count1++ + } else if (orderedList[0].baseUrl.equals("local_2")) { + count2++ + } else if (orderedList[0].baseUrl.equals("local_3")) { + count3++ + } else { + countX++ + } } + // all domestic urls should have appeared first in the list at least once + assertTrue { count1 > 0 } + assertTrue { count2 > 0 } + assertTrue { count3 > 0 } + // no foreign urls should should have appeared first in the list + assertEquals(0, countX) + } } diff --git a/libs/download/src/commonTest/kotlin/org/fdroid/download/MirrorTest.kt b/libs/download/src/commonTest/kotlin/org/fdroid/download/MirrorTest.kt index a69d02645..7e7653648 100644 --- a/libs/download/src/commonTest/kotlin/org/fdroid/download/MirrorTest.kt +++ b/libs/download/src/commonTest/kotlin/org/fdroid/download/MirrorTest.kt @@ -7,73 +7,73 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue /** - * This class is not to test actual mirror behavior (done elsewhere), but to test the [Mirror] class. + * This class is not to test actual mirror behavior (done elsewhere), but to test the [Mirror] + * class. */ internal class MirrorTest { - @Test - fun testGetUrl() { - assertEquals( - "https://example.org/entry.jar", - Mirror("https://example.org/").getUrl("/entry.jar").toString() - ) - assertEquals( - "https://example.org/entry.jar", - Mirror("https://example.org").getUrl("/entry.jar").toString() - ) - assertEquals( - "https://gitlab.com/fdroidclient/fdroid/repo/entry.jar", - Mirror("https://gitlab.com/fdroidclient/fdroid/repo").getUrl("/entry.jar").toString() - ) - } + @Test + fun testGetUrl() { + assertEquals( + "https://example.org/entry.jar", + Mirror("https://example.org/").getUrl("/entry.jar").toString(), + ) + assertEquals( + "https://example.org/entry.jar", + Mirror("https://example.org").getUrl("/entry.jar").toString(), + ) + assertEquals( + "https://gitlab.com/fdroidclient/fdroid/repo/entry.jar", + Mirror("https://gitlab.com/fdroidclient/fdroid/repo").getUrl("/entry.jar").toString(), + ) + } - @Test - fun testInvalidUrlDoesNotCrash() { - val fallbackInvalidUrl = Url("http://127.0.0.1:64335") - assertEquals(fallbackInvalidUrl, Mirror(":/foo/bar").url) - assertEquals(fallbackInvalidUrl, Mirror("http://192.168.0.1:6465161/foo").url) - assertEquals(fallbackInvalidUrl, Mirror("mailto:x").url) - assertEquals(fallbackInvalidUrl, Mirror("file:root").url) - } + @Test + fun testInvalidUrlDoesNotCrash() { + val fallbackInvalidUrl = Url("http://127.0.0.1:64335") + assertEquals(fallbackInvalidUrl, Mirror(":/foo/bar").url) + assertEquals(fallbackInvalidUrl, Mirror("http://192.168.0.1:6465161/foo").url) + assertEquals(fallbackInvalidUrl, Mirror("mailto:x").url) + assertEquals(fallbackInvalidUrl, Mirror("file:root").url) + } - @Test - fun testIsOnion() { - assertTrue( - Mirror( - "http://ftpfaudev4triw2vxiwzf4334e3mynz7osqgtozhbc77fixncqzbyoyd.onion/fdroid/repo" - ).isOnion() - ) - assertFalse(Mirror("https://www.f-droid.org/fdroid/repo").isOnion()) - assertFalse(Mirror("http://192.168.0.1/fdroid/repo").isOnion()) - } + @Test + fun testIsOnion() { + assertTrue( + Mirror("http://ftpfaudev4triw2vxiwzf4334e3mynz7osqgtozhbc77fixncqzbyoyd.onion/fdroid/repo") + .isOnion() + ) + assertFalse(Mirror("https://www.f-droid.org/fdroid/repo").isOnion()) + assertFalse(Mirror("http://192.168.0.1/fdroid/repo").isOnion()) + } - @Test - fun testIsLocal() { - assertTrue(Url("http://127.0.0.1/foo/bar").isLocal()) + @Test + fun testIsLocal() { + assertTrue(Url("http://127.0.0.1/foo/bar").isLocal()) - assertTrue(Url("http://10.0.0.0").isLocal()) - assertTrue(Url("http://10.1.2.3").isLocal()) - assertTrue(Url("http://10.255.255.255").isLocal()) + assertTrue(Url("http://10.0.0.0").isLocal()) + assertTrue(Url("http://10.1.2.3").isLocal()) + assertTrue(Url("http://10.255.255.255").isLocal()) - assertTrue(Url("http://169.254.0.0").isLocal()) - assertTrue(Url("http://169.254.255.255").isLocal()) - assertFalse(Url("http://169.253.255.255").isLocal()) - assertFalse(Url("http://169.255.255.255").isLocal()) + assertTrue(Url("http://169.254.0.0").isLocal()) + assertTrue(Url("http://169.254.255.255").isLocal()) + assertFalse(Url("http://169.253.255.255").isLocal()) + assertFalse(Url("http://169.255.255.255").isLocal()) - assertTrue(Url("http://172.16.0.0:8888").isLocal()) - assertTrue(Url("http://172.16.255.255").isLocal()) - assertFalse(Url("http://172.161.0.0:8888").isLocal()) - assertTrue(Url("http://172.27.1.255").isLocal()) - assertTrue(Url("http://172.31.255.255").isLocal()) - assertFalse(Url("http://172.32.0.0").isLocal()) + assertTrue(Url("http://172.16.0.0:8888").isLocal()) + assertTrue(Url("http://172.16.255.255").isLocal()) + assertFalse(Url("http://172.161.0.0:8888").isLocal()) + assertTrue(Url("http://172.27.1.255").isLocal()) + assertTrue(Url("http://172.31.255.255").isLocal()) + assertFalse(Url("http://172.32.0.0").isLocal()) - assertFalse(Url("http://192.168.0.example.org").isLocal()) - assertTrue(Url("http://192.168.0.112:8888").isLocal()) - assertTrue(Url("http://192.168.1.112:8888/foo/bar").isLocal()) - assertTrue(Url("http://192.168.0.112:80").isLocal()) - assertTrue(Url("http://192.168.255.255:8041").isLocal()) + assertFalse(Url("http://192.168.0.example.org").isLocal()) + assertTrue(Url("http://192.168.0.112:8888").isLocal()) + assertTrue(Url("http://192.168.1.112:8888/foo/bar").isLocal()) + assertTrue(Url("http://192.168.0.112:80").isLocal()) + assertTrue(Url("http://192.168.255.255:8041").isLocal()) - assertFalse(Url("https://malware.com:8888").isLocal()) - assertFalse(Url("https://www.google.com").isLocal()) - } + assertFalse(Url("https://malware.com:8888").isLocal()) + assertFalse(Url("https://www.google.com").isLocal()) + } } diff --git a/libs/download/src/jvmMain/kotlin/org/fdroid/download/HttpManager.kt b/libs/download/src/jvmMain/kotlin/org/fdroid/download/HttpManager.kt index a8de8629d..48d07902a 100644 --- a/libs/download/src/jvmMain/kotlin/org/fdroid/download/HttpManager.kt +++ b/libs/download/src/jvmMain/kotlin/org/fdroid/download/HttpManager.kt @@ -4,5 +4,5 @@ import io.ktor.client.engine.HttpClientEngineFactory import io.ktor.client.engine.cio.CIO internal actual fun getHttpClientEngineFactory(customDns: Dns?): HttpClientEngineFactory<*> { - return CIO + return CIO } diff --git a/libs/index/build.gradle.kts b/libs/index/build.gradle.kts index d172b8909..8a531b1ea 100644 --- a/libs/index/build.gradle.kts +++ b/libs/index/build.gradle.kts @@ -1,102 +1,85 @@ plugins { - alias(libs.plugins.jetbrains.kotlin.multiplatform) - alias(libs.plugins.jetbrains.kotlin.plugin.serialization) - alias(libs.plugins.android.library) - alias(libs.plugins.jetbrains.dokka) - alias(libs.plugins.vanniktech.maven.publish) + alias(libs.plugins.jetbrains.kotlin.multiplatform) + alias(libs.plugins.jetbrains.kotlin.plugin.serialization) + alias(libs.plugins.android.library) + alias(libs.plugins.jetbrains.dokka) + alias(libs.plugins.vanniktech.maven.publish) + alias(libs.plugins.ktfmt) } kotlin { - androidTarget { - compilerOptions { - jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 - } - publishLibraryVariants("release") + androidTarget { + compilerOptions { jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 } + publishLibraryVariants("release") + } + compilerOptions { optIn.add("kotlin.RequiresOptIn") } + explicitApi() + @OptIn(org.jetbrains.kotlin.gradle.dsl.abi.ExperimentalAbiValidation::class) + abiValidation { enabled = true } + sourceSets { + commonMain { + dependencies { + implementation(project(":libs:core")) + implementation(libs.kotlinx.serialization.json) + implementation(libs.microutils.kotlin.logging) + } } - compilerOptions { - optIn.add("kotlin.RequiresOptIn") + commonTest { + dependencies { + implementation(project(":libs:sharedTest")) + implementation(kotlin("test")) + implementation(libs.goncalossilva.resources) + } } - explicitApi() - @OptIn(org.jetbrains.kotlin.gradle.dsl.abi.ExperimentalAbiValidation::class) - abiValidation { - enabled = true + // JVM is disabled for now, because Android app is including it instead of Android library + jvmMain { dependencies {} } + jvmTest { dependencies { implementation(libs.junit) } } + androidMain { + dependencies { + implementation(libs.kotlin.reflect) + implementation(libs.androidx.core.ktx) + } } - sourceSets { - commonMain { - dependencies { - implementation(project(":libs:core")) - implementation(libs.kotlinx.serialization.json) - implementation(libs.microutils.kotlin.logging) - } - } - commonTest { - dependencies { - implementation(project(":libs:sharedTest")) - implementation(kotlin("test")) - implementation(libs.goncalossilva.resources) - } - } - // JVM is disabled for now, because Android app is including it instead of Android library - jvmMain { - dependencies { - } - } - jvmTest { - dependencies { - implementation(libs.junit) - } - } - androidMain { - dependencies { - implementation(libs.kotlin.reflect) - implementation(libs.androidx.core.ktx) - } - } - androidUnitTest { - dependencies { - implementation(libs.junit) - implementation(libs.mockk) - } - } - androidInstrumentedTest { - dependencies { - implementation(project(":libs:sharedTest")) - implementation(kotlin("test")) - implementation(libs.androidx.test.runner) - implementation(libs.androidx.test.ext.junit) - } - } + androidUnitTest { + dependencies { + implementation(libs.junit) + implementation(libs.mockk) + } } + androidInstrumentedTest { + dependencies { + implementation(project(":libs:sharedTest")) + implementation(kotlin("test")) + implementation(libs.androidx.test.runner) + implementation(libs.androidx.test.ext.junit) + } + } + } } android { - namespace = "org.fdroid.index" - @Suppress("ktlint:standard:chain-method-continuation") - compileSdk = libs.versions.compileSdk.get().toInt() - defaultConfig { - minSdk = 21 - consumerProguardFiles("consumer-rules.pro") - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - testInstrumentationRunnerArguments["disableAnalytics"] = "true" - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - testOptions { - unitTests { - isIncludeAndroidResources = true - } - } + namespace = "org.fdroid.index" + compileSdk = libs.versions.compileSdk.get().toInt() + defaultConfig { + minSdk = 21 + consumerProguardFiles("consumer-rules.pro") + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunnerArguments["disableAnalytics"] = "true" + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + testOptions { unitTests { isIncludeAndroidResources = true } } } -signing { - useGpgCmd() -} +ktfmt { googleStyle() } + +signing { useGpgCmd() } dokka { - pluginsConfiguration.html { - customAssets.from("${file("${rootProject.rootDir}/logo-icon.svg")}") - footerMessage.set("© 2010-2025 F-Droid Limited and Contributors") - } + pluginsConfiguration.html { + customAssets.from("${file("${rootProject.rootDir}/logo-icon.svg")}") + footerMessage.set("© 2010-2025 F-Droid Limited and Contributors") + } } diff --git a/libs/index/src/androidInstrumentedTest/kotlin/org/fdroid/BestLocaleTest.kt b/libs/index/src/androidInstrumentedTest/kotlin/org/fdroid/BestLocaleTest.kt index 18b4eb923..86c37ab43 100644 --- a/libs/index/src/androidInstrumentedTest/kotlin/org/fdroid/BestLocaleTest.kt +++ b/libs/index/src/androidInstrumentedTest/kotlin/org/fdroid/BestLocaleTest.kt @@ -4,329 +4,258 @@ import android.os.Build import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat.getEmptyLocaleList import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlin.test.assertEquals +import kotlin.test.assertNull import org.fdroid.LocaleChooser.getBestLocale import org.junit.Assume.assumeTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import kotlin.test.assertEquals -import kotlin.test.assertNull -/** - * Needs to run on-device to get access to real [LocaleListCompat.getFirstMatch]. - */ +/** Needs to run on-device to get access to real [LocaleListCompat.getFirstMatch]. */ @RunWith(AndroidJUnit4::class) internal class BestLocaleTest { - @Before - fun check() { - // Locale lists were introduced in SDK 24 - assumeTrue(Build.VERSION.SDK_INT >= 24) - } + @Before + fun check() { + // Locale lists were introduced in SDK 24 + assumeTrue(Build.VERSION.SDK_INT >= 24) + } - @Test - fun testEmptyLocalesReturnsNull() { - assertNull(emptyMap().getBestLocale(getLocaleList("en-US,de-DE"))) - } + @Test + fun testEmptyLocalesReturnsNull() { + assertNull(emptyMap().getBestLocale(getLocaleList("en-US,de-DE"))) + } - @Test - fun testFallbackToEn() { - assertEquals( - "en-US", - getMap("fr-FR", "en-US", "de-DE").getBestLocale(getEmptyLocaleList()) - ) + @Test + fun testFallbackToEn() { + assertEquals("en-US", getMap("fr-FR", "en-US", "de-DE").getBestLocale(getEmptyLocaleList())) - assertEquals( - "en", - getMap("de-AT", "de-DE", "en").getBestLocale(getLocaleList("fr-FR")), - ) - } + assertEquals("en", getMap("de-AT", "de-DE", "en").getBestLocale(getLocaleList("fr-FR"))) + } - @Test - fun testFallbackToFirst() { - assertEquals( - "de-AT", - getMap("de-AT", "de-DE", "uk").getBestLocale(getLocaleList("fr-FR")), - ) - } + @Test + fun testFallbackToFirst() { + assertEquals("de-AT", getMap("de-AT", "de-DE", "uk").getBestLocale(getLocaleList("fr-FR"))) + } - @Test - fun testMatchLanguageAndScript() { - assertEquals( - "en", - getMap("en-Shaw", "en-Shaw-US", "en-GB", "en").getBestLocale(getLocaleList("en-NL")), - ) + @Test + fun testMatchLanguageAndScript() { + assertEquals( + "en", + getMap("en-Shaw", "en-Shaw-US", "en-GB", "en").getBestLocale(getLocaleList("en-NL")), + ) - assertEquals( - "sr-Cyrl", - getMap("en", "sr-Cyrl", "sr-Latn").getBestLocale(getLocaleList("sr-RS")), - ) + assertEquals( + "sr-Cyrl", + getMap("en", "sr-Cyrl", "sr-Latn").getBestLocale(getLocaleList("sr-RS")), + ) - assertEquals( - "uz-Latn", - getMap("en", "uz-Cyrl", "uz-Latn").getBestLocale(getLocaleList("uz")), - ) + assertEquals("uz-Latn", getMap("en", "uz-Cyrl", "uz-Latn").getBestLocale(getLocaleList("uz"))) - assertEquals( - "zh-Hant", - getMap("en", "zh-Hans", "zh-Hant").getBestLocale(getLocaleList("zh-TW")), - ) + assertEquals( + "zh-Hant", + getMap("en", "zh-Hans", "zh-Hant").getBestLocale(getLocaleList("zh-TW")), + ) - assertEquals( - "sr-Latn", - getMap("en", "sr", "sr-RS", "sr-Latn").getBestLocale(getLocaleList("sr-Latn-RS")), - ) + assertEquals( + "sr-Latn", + getMap("en", "sr", "sr-RS", "sr-Latn").getBestLocale(getLocaleList("sr-Latn-RS")), + ) - assertEquals( - "uz-Cyrl", - getMap("en", "uz", "uz-Cyrl").getBestLocale(getLocaleList("uz-Cyrl-UZ")), - ) + assertEquals( + "uz-Cyrl", + getMap("en", "uz", "uz-Cyrl").getBestLocale(getLocaleList("uz-Cyrl-UZ")), + ) - assertEquals( - "zh-Hant", - getMap("en", "zh", "zh-Hant").getBestLocale(getLocaleList("zh-TW")), - ) + assertEquals("zh-Hant", getMap("en", "zh", "zh-Hant").getBestLocale(getLocaleList("zh-TW"))) - assertEquals( - "zh-TW", - getMap("zh", "zh-CN", "zh-TW", "en").getBestLocale(getLocaleList("zh-HK,de")), - ) + assertEquals( + "zh-TW", + getMap("zh", "zh-CN", "zh-TW", "en").getBestLocale(getLocaleList("zh-HK,de")), + ) - assertEquals( - "zh-Hans", - getMap("en", "zh-Hant", "zh-Hans").getBestLocale(getLocaleList("zh")), - ) + assertEquals("zh-Hans", getMap("en", "zh-Hant", "zh-Hans").getBestLocale(getLocaleList("zh"))) - assertEquals( - "zh-Hant", - getMap("en", "zh-Hans", "zh-Hant").getBestLocale(getLocaleList("zh-HK")), - ) + assertEquals( + "zh-Hant", + getMap("en", "zh-Hans", "zh-Hant").getBestLocale(getLocaleList("zh-HK")), + ) - assertEquals( - "de", - getMap("zh", "de", "en").getBestLocale(getLocaleList("zh-HK,de")), - ) + assertEquals("de", getMap("zh", "de", "en").getBestLocale(getLocaleList("zh-HK,de"))) - assertEquals( - "zh-HK", - getMap("zh", "zh-CN", "zh-TW", "zh-HK").getBestLocale(getLocaleList("zh-Hant-HK")), - ) + assertEquals( + "zh-HK", + getMap("zh", "zh-CN", "zh-TW", "zh-HK").getBestLocale(getLocaleList("zh-Hant-HK")), + ) - assertEquals( - "zh-Hant-HK", - getMap( - "zh", - "zh-Hans-CN", - "zh-Hant-TW", - "zh-Hant-HK" - ).getBestLocale(getLocaleList("zh-HK")), - ) - } + assertEquals( + "zh-Hant-HK", + getMap("zh", "zh-Hans-CN", "zh-Hant-TW", "zh-Hant-HK").getBestLocale(getLocaleList("zh-HK")), + ) + } - @Test - fun testRankingPriority() { - // an exact match is the best match (and calls it a day) - assertEquals( - "en-US", - getMap( - "en-Shaw-US", - "en-Latn", - "en", - "en-US", - "en-Latn-US" - ).getBestLocale(getLocaleList("en-US")), - ) + @Test + fun testRankingPriority() { + // an exact match is the best match (and calls it a day) + assertEquals( + "en-US", + getMap("en-Shaw-US", "en-Latn", "en", "en-US", "en-Latn-US") + .getBestLocale(getLocaleList("en-US")), + ) - assertEquals( - "zh-TW", - getMap( - "zh", - "zh-CN", - "zh-Hant", - "zh-Hant-HK", - "zh-TW" - ).getBestLocale(getLocaleList("zh-TW")), - ) + assertEquals( + "zh-TW", + getMap("zh", "zh-CN", "zh-Hant", "zh-Hant-HK", "zh-TW").getBestLocale(getLocaleList("zh-TW")), + ) - // else dive into the haystack in reverse order of specificity -- from specific to generic, - // starting from the most specific form: language-script-country - assertEquals( - "zh-Hant-HK", - getMap("zh", "zh-CN", "zh-Hant", "zh-Hant-HK").getBestLocale(getLocaleList("zh-HK")), - ) + // else dive into the haystack in reverse order of specificity -- from specific to generic, + // starting from the most specific form: language-script-country + assertEquals( + "zh-Hant-HK", + getMap("zh", "zh-CN", "zh-Hant", "zh-Hant-HK").getBestLocale(getLocaleList("zh-HK")), + ) - // followed by language-country and language-script - assertEquals( - "zh-TW", - getMap("zh", "zh-CN", "zh-Hant", "zh-TW").getBestLocale(getLocaleList("zh-Hant-TW")), - ) + // followed by language-country and language-script + assertEquals( + "zh-TW", + getMap("zh", "zh-CN", "zh-Hant", "zh-TW").getBestLocale(getLocaleList("zh-Hant-TW")), + ) - assertEquals( - "sr-RS", - getMap("en", "sr", "sr-Latn", "sr-RS").getBestLocale(getLocaleList("sr-Cyrl-RS")), - ) + assertEquals( + "sr-RS", + getMap("en", "sr", "sr-Latn", "sr-RS").getBestLocale(getLocaleList("sr-Cyrl-RS")), + ) - assertEquals( - "zh-MO", - getMap("en", "zh", "zh-Hant", "zh-MO").getBestLocale(getLocaleList("zh-Hant-MO")), - ) + assertEquals( + "zh-MO", + getMap("en", "zh", "zh-Hant", "zh-MO").getBestLocale(getLocaleList("zh-Hant-MO")), + ) - assertEquals( - "zh-Hans", - getMap("en", "zh", "zh-Hans", "zh-MO").getBestLocale(getLocaleList("zh-Hans-MO")), - ) + assertEquals( + "zh-Hans", + getMap("en", "zh", "zh-Hans", "zh-MO").getBestLocale(getLocaleList("zh-Hans-MO")), + ) - assertEquals( - "zh-Hant", - getMap("zh", "zh-CN", "zh-Hant", "zh-Hant-HK").getBestLocale(getLocaleList("zh-TW")), - ) + assertEquals( + "zh-Hant", + getMap("zh", "zh-CN", "zh-Hant", "zh-Hant-HK").getBestLocale(getLocaleList("zh-TW")), + ) - assertEquals( - "sr-Latn", - getMap("en", "sr", "sr-Latn", "sr-RS").getBestLocale(getLocaleList("sr-Latn-RS")), - ) + assertEquals( + "sr-Latn", + getMap("en", "sr", "sr-Latn", "sr-RS").getBestLocale(getLocaleList("sr-Latn-RS")), + ) - assertEquals( - "en-Latn", - getMap( - "en-Shaw-US", - "en", - "en-US", - "en-Latn", - "en-Latn-US" - ).getBestLocale(getLocaleList("en-GB")), - ) + assertEquals( + "en-Latn", + getMap("en-Shaw-US", "en", "en-US", "en-Latn", "en-Latn-US") + .getBestLocale(getLocaleList("en-GB")), + ) - // finally language only if script matches - assertEquals( - "de", - getMap("zh", "zh-CN", "en", "de").getBestLocale(getLocaleList("zh-HK,de")), - ) + // finally language only if script matches + assertEquals("de", getMap("zh", "zh-CN", "en", "de").getBestLocale(getLocaleList("zh-HK,de"))) - assertEquals( - "fr", - getMap("zh", "en", "fr").getBestLocale(getLocaleList("en-Shaw-GB,fr")), - ) + assertEquals("fr", getMap("zh", "en", "fr").getBestLocale(getLocaleList("en-Shaw-GB,fr"))) - assertEquals( - "en", - getMap("fr", "en", "sr").getBestLocale(getLocaleList("sr-Latn-RS,en")), - ) + assertEquals("en", getMap("fr", "en", "sr").getBestLocale(getLocaleList("sr-Latn-RS,en"))) - // failing which the first one with same script wins - assertEquals( - "en-GB", - getMap("en-Shaw-US", "en-GB", "en-US").getBestLocale(getLocaleList("en-NL")), - ) + // failing which the first one with same script wins + assertEquals( + "en-GB", + getMap("en-Shaw-US", "en-GB", "en-US").getBestLocale(getLocaleList("en-NL")), + ) - assertEquals( - "en-AR", - getMap("en-AR", "en-GB", "en-US").getBestLocale(getLocaleList("en-NL")), - ) + assertEquals("en-AR", getMap("en-AR", "en-GB", "en-US").getBestLocale(getLocaleList("en-NL"))) - assertEquals( - "zh-HK", - getMap("en", "zh", "zh-CN", "zh-HK", "zh-TW").getBestLocale(getLocaleList("zh-MO")), - ) - } + assertEquals( + "zh-HK", + getMap("en", "zh", "zh-CN", "zh-HK", "zh-TW").getBestLocale(getLocaleList("zh-MO")), + ) + } - /** - * Ported from old LocaleSelectionTest. - */ - @Test - fun testListConversion() { - // just select the matching en-US locale, nothing special here - assertEquals( - "en-US", - getMap("de-AT", "de-DE", "en-US").getBestLocale(getLocaleList("en-US,de-DE")), - ) + /** Ported from old LocaleSelectionTest. */ + @Test + fun testListConversion() { + // just select the matching en-US locale, nothing special here + assertEquals( + "en-US", + getMap("de-AT", "de-DE", "en-US").getBestLocale(getLocaleList("en-US,de-DE")), + ) - // fall back to another en locale before de - assertEquals( - "en-US", - getMap("de-AT", "de-DE", "en-US").getBestLocale(getLocaleList("en-SE,de-DE")), - ) + // fall back to another en locale before de + assertEquals( + "en-US", + getMap("de-AT", "de-DE", "en-US").getBestLocale(getLocaleList("en-SE,de-DE")), + ) - // full match against a non-default locale - assertEquals( - "de-AT", - getMap("de-AT", "de-DE", "en-GB", "en-US").getBestLocale(getLocaleList("de-AT,de-DE")), - ) - assertEquals( - "de", - getMap("de-AT", "de", "en-GB", "en-US").getBestLocale(getLocaleList("de-CH,en-US")), - ) + // full match against a non-default locale + assertEquals( + "de-AT", + getMap("de-AT", "de-DE", "en-GB", "en-US").getBestLocale(getLocaleList("de-AT,de-DE")), + ) + assertEquals( + "de", + getMap("de-AT", "de", "en-GB", "en-US").getBestLocale(getLocaleList("de-CH,en-US")), + ) - // no match at all, fall back to an english locale - assertEquals( - "en-US", - getMap("en", "en-US").getBestLocale(getLocaleList("zh-Hant-TW,zh-Hans-CN")), - ) + // no match at all, fall back to an english locale + assertEquals( + "en-US", + getMap("en", "en-US").getBestLocale(getLocaleList("zh-Hant-TW,zh-Hans-CN")), + ) - // handle stripped script (Hans/Hant) - assertEquals( - "zh-TW", - getMap( - "en-US", - "zh-CN", - "zh-HK", - "zh-TW", - ).getBestLocale(getLocaleList("zh-Hant-TW,zh-Hans-CN")), - ) - assertEquals( - "zh-CN", - getMap("en-US", "zh-CN").getBestLocale(getLocaleList("zh-Hant-TW,zh-Hans-CN")), - ) + // handle stripped script (Hans/Hant) + assertEquals( + "zh-TW", + getMap("en-US", "zh-CN", "zh-HK", "zh-TW") + .getBestLocale(getLocaleList("zh-Hant-TW,zh-Hans-CN")), + ) + assertEquals( + "zh-CN", + getMap("en-US", "zh-CN").getBestLocale(getLocaleList("zh-Hant-TW,zh-Hans-CN")), + ) - // https://developer.android.com/guide/topics/resources/multilingual-support#resource-resolution-examples - assertEquals( - "fr-FR", - getMap("en-US", "de-DE", "es-ES", "fr-FR", "it-IT") - .getBestLocale(getLocaleList("fr-CH")), - ) + // https://developer.android.com/guide/topics/resources/multilingual-support#resource-resolution-examples + assertEquals( + "fr-FR", + getMap("en-US", "de-DE", "es-ES", "fr-FR", "it-IT").getBestLocale(getLocaleList("fr-CH")), + ) - // https://developer.android.com/guide/topics/resources/multilingual-support#t-2d-choice - assertEquals( - "it-IT", - getMap("en-US", "de-DE", "es-ES", "it-IT") - .getBestLocale(getLocaleList("fr-CH,it-CH")), - ) - } + // https://developer.android.com/guide/topics/resources/multilingual-support#t-2d-choice + assertEquals( + "it-IT", + getMap("en-US", "de-DE", "es-ES", "it-IT").getBestLocale(getLocaleList("fr-CH,it-CH")), + ) + } - @Test - fun testInvalidLocales() { - // underscores - assertEquals( - "en-US", - getMap("de_AT", "de_DE", "en-US").getBestLocale(getLocaleList("en-US,de-DE")), - ) + @Test + fun testInvalidLocales() { + // underscores + assertEquals( + "en-US", + getMap("de_AT", "de_DE", "en-US").getBestLocale(getLocaleList("en-US,de-DE")), + ) - // different case - assertEquals( - "en-US", - getMap("DE_at", "dE_De", "en-US").getBestLocale(getLocaleList("en-US,de-DE")), - ) + // different case + assertEquals( + "en-US", + getMap("DE_at", "dE_De", "en-US").getBestLocale(getLocaleList("en-US,de-DE")), + ) - // garbage in given locales - assertEquals( - "en-US", - getMap( - "foo-Bar", - "324;kfj4297h4c2oj", - "2342142143", - "de_DE", - "#$%#!$^#&^%#*", - "en-US", - ).getBestLocale(getLocaleList("en-US,de-DE")), - ) - } + // garbage in given locales + assertEquals( + "en-US", + getMap("foo-Bar", "324;kfj4297h4c2oj", "2342142143", "de_DE", "#$%#!$^#&^%#*", "en-US") + .getBestLocale(getLocaleList("en-US,de-DE")), + ) + } - private fun getLocaleList(tags: String): LocaleListCompat { - return LocaleListCompat.forLanguageTags(tags) - } - - private fun getMap(vararg locales: String): Map { - return locales.associate { Pair(it, it) } - } + private fun getLocaleList(tags: String): LocaleListCompat { + return LocaleListCompat.forLanguageTags(tags) + } + private fun getMap(vararg locales: String): Map { + return locales.associateWith { it } + } } diff --git a/libs/index/src/androidInstrumentedTest/kotlin/org/fdroid/index/v1/IndexV1CreatorTest.kt b/libs/index/src/androidInstrumentedTest/kotlin/org/fdroid/index/v1/IndexV1CreatorTest.kt index ed51b2d0b..512b7c371 100644 --- a/libs/index/src/androidInstrumentedTest/kotlin/org/fdroid/index/v1/IndexV1CreatorTest.kt +++ b/libs/index/src/androidInstrumentedTest/kotlin/org/fdroid/index/v1/IndexV1CreatorTest.kt @@ -4,38 +4,42 @@ import android.content.Context import android.content.pm.ApplicationInfo.FLAG_SYSTEM import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.fdroid.index.IndexParser -import org.fdroid.test.TestDataMinV1 -import org.junit.Rule -import org.junit.rules.TemporaryFolder -import org.junit.runner.RunWith import java.io.File import kotlin.random.Random import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue +import org.fdroid.index.IndexParser +import org.fdroid.test.TestDataMinV1 +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) internal class IndexV1CreatorTest { - @get:Rule - var tmpFolder: TemporaryFolder = TemporaryFolder() + @get:Rule var tmpFolder: TemporaryFolder = TemporaryFolder() - private val context: Context = ApplicationProvider.getApplicationContext() + private val context: Context = ApplicationProvider.getApplicationContext() - @Test - fun test() { - val repoDir = tmpFolder.newFolder() - val repo = TestDataMinV1.repo - val packageNames = context.packageManager.getInstalledPackages(0).filter { - (it.applicationInfo!!.flags and FLAG_SYSTEM == 0) and (Random.nextInt(0, 3) == 0) - }.map { it.packageName }.toSet() - val indexCreator = IndexV1Creator(context.packageManager, repoDir, packageNames, repo) - val indexV1 = indexCreator.createRepo() + @Test + fun test() { + val repoDir = tmpFolder.newFolder() + val repo = TestDataMinV1.repo + val packageNames = + context.packageManager + .getInstalledPackages(0) + .filter { + (it.applicationInfo!!.flags and FLAG_SYSTEM == 0) and (Random.nextInt(0, 3) == 0) + } + .map { it.packageName } + .toSet() + val indexCreator = IndexV1Creator(context.packageManager, repoDir, packageNames, repo) + val indexV1 = indexCreator.createRepo() - val indexFile = File(repoDir, DATA_FILE_NAME) - assertTrue(indexFile.exists()) - val indexStr = indexFile.readBytes().decodeToString() - assertEquals(indexV1, IndexParser.parseV1(indexStr)) - } + val indexFile = File(repoDir, DATA_FILE_NAME) + assertTrue(indexFile.exists()) + val indexStr = indexFile.readBytes().decodeToString() + assertEquals(indexV1, IndexParser.parseV1(indexStr)) + } } diff --git a/libs/index/src/androidMain/kotlin/org/fdroid/CompatibilityChecker.kt b/libs/index/src/androidMain/kotlin/org/fdroid/CompatibilityChecker.kt index ab57f6bd1..34ea304a2 100644 --- a/libs/index/src/androidMain/kotlin/org/fdroid/CompatibilityChecker.kt +++ b/libs/index/src/androidMain/kotlin/org/fdroid/CompatibilityChecker.kt @@ -3,69 +3,66 @@ package org.fdroid import android.content.pm.PackageManager import android.os.Build.SUPPORTED_ABIS import android.os.Build.VERSION.SDK_INT +import org.fdroid.CompatibilityCheckerUtils.minInstallableTargetSdk import org.fdroid.index.v2.PackageManifest public fun interface CompatibilityChecker { - public fun isCompatible(manifest: PackageManifest): Boolean + public fun isCompatible(manifest: PackageManifest): Boolean } -/** - * This class checks if an APK is compatible with the user's device. - */ -public class CompatibilityCheckerImpl @JvmOverloads constructor( - packageManager: PackageManager, - private val forceTouchApps: Boolean = false, - private val sdkInt: Int = SDK_INT, - private val supportedAbis: Array = SUPPORTED_ABIS, +/** This class checks if an APK is compatible with the user's device. */ +public class CompatibilityCheckerImpl +@JvmOverloads +constructor( + packageManager: PackageManager, + private val forceTouchApps: Boolean = false, + private val sdkInt: Int = SDK_INT, + private val supportedAbis: Array = SUPPORTED_ABIS, ) : CompatibilityChecker { - private val features = HashMap().apply { - // the docs still say that this can be null, so better be on the safe side - @Suppress("UNNECESSARY_SAFE_CALL") - packageManager.systemAvailableFeatures?.forEach { featureInfo -> - put(featureInfo.name, if (SDK_INT >= 24) featureInfo.version else 0) - } + private val features = + HashMap().apply { + // the docs still say that this can be null, so better be on the safe side + @Suppress("UNNECESSARY_SAFE_CALL") + packageManager.systemAvailableFeatures?.forEach { featureInfo -> + put(featureInfo.name, if (SDK_INT >= 24) featureInfo.version else 0) + } } - public override fun isCompatible(manifest: PackageManifest): Boolean { - if (sdkInt < (manifest.minSdkVersion ?: 0)) return false - if (sdkInt > (manifest.maxSdkVersion ?: Int.MAX_VALUE)) return false - if ((manifest.targetSdkVersion ?: 1) < - CompatibilityCheckerUtils.minInstallableTargetSdk(sdkInt)) return false - if (!isNativeCodeCompatible(manifest)) return false - manifest.featureNames?.iterator()?.forEach { feature -> - if (forceTouchApps && feature == "android.hardware.touchscreen") return@forEach - if (!features.containsKey(feature)) return false - } - return true + public override fun isCompatible(manifest: PackageManifest): Boolean { + if (sdkInt < (manifest.minSdkVersion ?: 0)) return false + if (sdkInt > (manifest.maxSdkVersion ?: Int.MAX_VALUE)) return false + if ((manifest.targetSdkVersion ?: 1) < minInstallableTargetSdk(sdkInt)) return false + if (!isNativeCodeCompatible(manifest)) return false + manifest.featureNames?.iterator()?.forEach { feature -> + if (forceTouchApps && feature == "android.hardware.touchscreen") return@forEach + if (!features.containsKey(feature)) return false } + return true + } - private fun isNativeCodeCompatible(manifest: PackageManifest): Boolean { - val nativeCode = manifest.nativecode - if (nativeCode.isNullOrEmpty()) return true - supportedAbis.forEach { supportedAbi -> - if (nativeCode.contains(supportedAbi)) return true - } - return false - } + private fun isNativeCodeCompatible(manifest: PackageManifest): Boolean { + val nativeCode = manifest.nativecode + if (nativeCode.isNullOrEmpty()) return true + supportedAbis.forEach { supportedAbi -> if (nativeCode.contains(supportedAbi)) return true } + return false + } } -/** - * Contains helper methods for checking compatibility of an APK - */ +/** Contains helper methods for checking compatibility of an APK */ public object CompatibilityCheckerUtils { - // Mirrored from AOSP due to lack of public APIs - // frameworks/base/services/core/java/com/android/server/pm/PackageManagerService.java - // search for MIN_INSTALLABLE_TARGET_SDK - // https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-16.0.0_r1/services/core/java/com/android/server/pm/PackageManagerService.java - // TODO: Keep this in sync with AOSP to avoid INSTALL_FAILED_DEPRECATED_SDK_VERSION errors - @JvmOverloads - public fun minInstallableTargetSdk(sdkInt: Int = SDK_INT): Int { - return when (sdkInt) { - 34 -> 23 // Android 6.0, M - 35 -> 24 // Android 7.0, N - 36 -> 24 // Android 7.0, N (didn't change) - else -> 1 // Android 1.0, BASE - } + // Mirrored from AOSP due to lack of public APIs + // frameworks/base/services/core/java/com/android/server/pm/PackageManagerService.java + // search for MIN_INSTALLABLE_TARGET_SDK + // https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-16.0.0_r1/services/core/java/com/android/server/pm/PackageManagerService.java + // TODO: Keep this in sync with AOSP to avoid INSTALL_FAILED_DEPRECATED_SDK_VERSION errors + @JvmOverloads + public fun minInstallableTargetSdk(sdkInt: Int = SDK_INT): Int { + return when (sdkInt) { + 34 -> 23 // Android 6.0, M + 35 -> 24 // Android 7.0, N + 36 -> 24 // Android 7.0, N (didn't change) + else -> 1 // Android 1.0, BASE } + } } diff --git a/libs/index/src/androidMain/kotlin/org/fdroid/LocaleChooser.kt b/libs/index/src/androidMain/kotlin/org/fdroid/LocaleChooser.kt index b95c29fdb..4fa195c78 100644 --- a/libs/index/src/androidMain/kotlin/org/fdroid/LocaleChooser.kt +++ b/libs/index/src/androidMain/kotlin/org/fdroid/LocaleChooser.kt @@ -2,100 +2,117 @@ package org.fdroid import androidx.core.os.LocaleListCompat import androidx.core.text.ICUCompat +import java.util.Locale import org.fdroid.index.v2.LocalizedFileListV2 import org.fdroid.index.v2.LocalizedFileV2 import org.fdroid.index.v2.LocalizedTextV2 -import java.util.Locale public object LocaleChooser { - /** - * Gets the best localization for the given [localeList] - * from collections like [LocalizedTextV2], [LocalizedFileV2], or [LocalizedFileListV2]. - */ - public fun Map?.getBestLocale(localeList: LocaleListCompat): T? { - if (isNullOrEmpty()) return null - if (size == 1) return values.first() - return when (localeList.size()) { - 0 -> null - 1 -> localeList.get(0) - else -> localeList.getFirstMatch(keys.toTypedArray()) - }?.let { firstMatch -> - // try first matched tag first (usually has region tag, e.g. de-DE) - get(firstMatch.toLanguageTag()) ?: run { - // search by ranking priority if no exact match is found, - // determining its script if not supplied - val tried = (if (firstMatch.script.isNullOrEmpty()) 0 else 1) + - (if (firstMatch.country.isNullOrEmpty()) 0 else 2) - if (firstMatch.script.isNullOrEmpty()) { - ICUCompat.maximizeAndGetScript(firstMatch)?.takeUnless { it.isEmpty() } - ?.let { script -> getInRankingOrder(firstMatch, tried + 1, script, tried) } - } else { - if (tried > 1) { - getInRankingOrder(firstMatch, tried - 1, firstMatch.script, tried) - } else { - null - } - } - // then language and other countries if script matches - ?: (if (tried == 0) null else get(firstMatch.language)?.takeIf { _ -> - LocaleListCompat.matchesLanguageAndScript( - getLocale(firstMatch.language), - firstMatch - ) - }) ?: getFirstSameScript(firstMatch) + /** + * Gets the best localization for the given [localeList] from collections like [LocalizedTextV2], + * [LocalizedFileV2], or [LocalizedFileListV2]. + */ + public fun Map?.getBestLocale(localeList: LocaleListCompat): T? { + if (isNullOrEmpty()) return null + if (size == 1) return values.first() + return when (localeList.size()) { + 0 -> null + 1 -> localeList.get(0) + else -> localeList.getFirstMatch(keys.toTypedArray()) + }?.let { firstMatch -> + // try first matched tag first (usually has region tag, e.g. de-DE) + get(firstMatch.toLanguageTag()) + ?: run { + // search by ranking priority if no exact match is found, + // determining its script if not supplied + val tried = + (if (firstMatch.script.isNullOrEmpty()) 0 else 1) + + (if (firstMatch.country.isNullOrEmpty()) 0 else 2) + if (firstMatch.script.isNullOrEmpty()) { + ICUCompat.maximizeAndGetScript(firstMatch) + ?.takeUnless { it.isEmpty() } + ?.let { script -> getInRankingOrder(firstMatch, tried + 1, script, tried) } + } else { + if (tried > 1) { + getInRankingOrder(firstMatch, tried - 1, firstMatch.script, tried) + } else { + null } - } - // or English and then just take the first of the list - ?: get("en-US") ?: get("en") ?: values.first() - } - - private tailrec fun Map.getInRankingOrder( - locale: Locale, - rank: Int, - script: String?, - tried: Int - ): T? { - if (rank <= 0) return null - if (rank != tried) getRankingTag(locale, rank, script)?.let { get(it) }?.let { return it } - return getInRankingOrder(locale, rank - 1, script, tried) - } - - private fun Map.getFirstSameScript(locale: Locale): T? { - val langLen = locale.language.length - entries.forEach { (key, value) -> - if (key.length > langLen && - key.startsWith(locale.language) && - key[langLen] == '-' && - LocaleListCompat.matchesLanguageAndScript(Locale.forLanguageTag(key), locale) - ) return value - } - return null - } - - private fun getRankingTag(locale: Locale, rank: Int, script: String?): String? { - if (rank >= 2 && locale.country.isNullOrEmpty()) return null - if (rank != 2 && script.isNullOrEmpty()) return null - return when (rank) { - 3 -> "${locale.language}-$script-${locale.country}" - 2 -> if (script.isNullOrEmpty() || - script.equals( - ICUCompat.maximizeAndGetScript(getLocale(locale.language, locale.country)), - true + } + // then language and other countries if script matches + ?: (if (tried == 0) null + else + get(firstMatch.language)?.takeIf { _ -> + LocaleListCompat.matchesLanguageAndScript( + getLocale(firstMatch.language), + firstMatch, ) - ) "${locale.language}-${locale.country}" else null - - 1 -> "${locale.language}-$script" - else -> null + }) + ?: getFirstSameScript(firstMatch) } } + // or English and then just take the first of the list + ?: get("en-US") + ?: get("en") + ?: values.first() + } - private fun getLocale(language: String, country: String = "") = - if (android.os.Build.VERSION.SDK_INT >= 36) { - Locale.of(language, country) - } else { - @Suppress("DEPRECATION") - Locale(language, country) + private tailrec fun Map.getInRankingOrder( + locale: Locale, + rank: Int, + script: String?, + tried: Int, + ): T? { + if (rank <= 0) return null + if (rank != tried) + getRankingTag(locale, rank, script) + ?.let { get(it) } + ?.let { + return it } + return getInRankingOrder(locale, rank - 1, script, tried) + } + private fun Map.getFirstSameScript(locale: Locale): T? { + val langLen = locale.language.length + entries.forEach { (key, value) -> + if ( + key.length > langLen && + key.startsWith(locale.language) && + key[langLen] == '-' && + LocaleListCompat.matchesLanguageAndScript(Locale.forLanguageTag(key), locale) + ) + return value + } + return null + } + + private fun getRankingTag(locale: Locale, rank: Int, script: String?): String? { + if (rank >= 2 && locale.country.isNullOrEmpty()) return null + if (rank != 2 && script.isNullOrEmpty()) return null + return when (rank) { + 3 -> "${locale.language}-$script-${locale.country}" + 2 -> + if ( + script.isNullOrEmpty() || + script.equals( + ICUCompat.maximizeAndGetScript(getLocale(locale.language, locale.country)), + true, + ) + ) + "${locale.language}-${locale.country}" + else null + + 1 -> "${locale.language}-$script" + else -> null + } + } + + private fun getLocale(language: String, country: String = "") = + if (android.os.Build.VERSION.SDK_INT >= 36) { + Locale.of(language, country) + } else { + @Suppress("DEPRECATION") Locale(language, country) + } } diff --git a/libs/index/src/androidMain/kotlin/org/fdroid/UpdateChecker.kt b/libs/index/src/androidMain/kotlin/org/fdroid/UpdateChecker.kt index cfb415df1..22d9b8d98 100644 --- a/libs/index/src/androidMain/kotlin/org/fdroid/UpdateChecker.kt +++ b/libs/index/src/androidMain/kotlin/org/fdroid/UpdateChecker.kt @@ -7,170 +7,173 @@ import org.fdroid.index.IndexUtils.getPackageSigner import org.fdroid.index.v2.PackageVersion public interface PackagePreference { - public val ignoreVersionCodeUpdate: Long - public val releaseChannels: List + public val ignoreVersionCodeUpdate: Long + public val releaseChannels: List } -public class UpdateChecker( - private val compatibilityChecker: CompatibilityChecker, -) { +public class UpdateChecker(private val compatibilityChecker: CompatibilityChecker) { - /** - * Returns a [PackageVersion] for the given [packageInfo] that is the suggested update - * or null if there is no suitable update in [versions]. - * - * @param versions a **sorted** list of [PackageVersion] with highest version code first. - * @param packageInfo needs to be retrieved with [GET_SIGNING_CERTIFICATES] - * @param releaseChannels optional list of release channels to consider on top of stable. - * If this is null or empty, only versions without channel (stable) will be considered. - * @param preferencesGetter an optional way to consider additional per app preferences - * @param includeKnownVulnerabilities if true, - * versions with the [PackageInfo.getLongVersionCode] will be returned - * if [PackageVersion.hasKnownVulnerability] is true, even without real update. - */ - public fun getUpdate( - versions: List, - packageInfo: PackageInfo, - releaseChannels: List? = null, - includeKnownVulnerabilities: Boolean = false, - preferencesGetter: (() -> PackagePreference?)? = null, - ): T? = getUpdate( - versions = versions, - allowedSignersGetter = { - // always gives us the oldest signer, even if they rotated certs by now - @Suppress("DEPRECATION") - packageInfo.signatures?.map { getPackageSigner(it.toByteArray()) }?.toSet() - }, - installedVersionCode = PackageInfoCompat.getLongVersionCode(packageInfo), - allowedReleaseChannels = releaseChannels, - preferencesGetter = preferencesGetter, - includeKnownVulnerabilities = includeKnownVulnerabilities, + /** + * Returns a [PackageVersion] for the given [packageInfo] that is the suggested update or null if + * there is no suitable update in [versions]. + * + * @param versions a **sorted** list of [PackageVersion] with highest version code first. + * @param packageInfo needs to be retrieved with [GET_SIGNING_CERTIFICATES] + * @param releaseChannels optional list of release channels to consider on top of stable. If this + * is null or empty, only versions without channel (stable) will be considered. + * @param preferencesGetter an optional way to consider additional per app preferences + * @param includeKnownVulnerabilities if true, versions with the [PackageInfo.getLongVersionCode] + * will be returned if [PackageVersion.hasKnownVulnerability] is true, even without real update. + */ + public fun getUpdate( + versions: List, + packageInfo: PackageInfo, + releaseChannels: List? = null, + includeKnownVulnerabilities: Boolean = false, + preferencesGetter: (() -> PackagePreference?)? = null, + ): T? = + getUpdate( + versions = versions, + allowedSignersGetter = { + // always gives us the oldest signer, even if they rotated certs by now + @Suppress("DEPRECATION") + packageInfo.signatures?.map { getPackageSigner(it.toByteArray()) }?.toSet() + }, + installedVersionCode = PackageInfoCompat.getLongVersionCode(packageInfo), + allowedReleaseChannels = releaseChannels, + preferencesGetter = preferencesGetter, + includeKnownVulnerabilities = includeKnownVulnerabilities, ) - /** - * Returns the [PackageVersion] that is suggested for a new installation - * or null if there is no suitable candidate in [versions]. - * - * @param versions a **sorted** list of [PackageVersion] with highest version code first. - * @param preferredSigner The SHA-256 hash of the signing certificate in lower-case hex. - * Only versions from this signer will be considered for installation. - * @param releaseChannels optional list of release channels to consider on top of stable. - * If this is null or empty, only versions without channel (stable) will be considered. - * @param preferencesGetter an optional way to consider additional per app preferences - */ - public fun getSuggestedVersion( - versions: List, - preferredSigner: String?, - releaseChannels: List? = null, - preferencesGetter: (() -> PackagePreference?)? = null, - ): T? = getUpdate( - versions = versions, - allowedSignersGetter = preferredSigner?.let { { setOf(it) } }, - allowedReleaseChannels = releaseChannels, - preferencesGetter = preferencesGetter, + /** + * Returns the [PackageVersion] that is suggested for a new installation or null if there is no + * suitable candidate in [versions]. + * + * @param versions a **sorted** list of [PackageVersion] with highest version code first. + * @param preferredSigner The SHA-256 hash of the signing certificate in lower-case hex. Only + * versions from this signer will be considered for installation. + * @param releaseChannels optional list of release channels to consider on top of stable. If this + * is null or empty, only versions without channel (stable) will be considered. + * @param preferencesGetter an optional way to consider additional per app preferences + */ + public fun getSuggestedVersion( + versions: List, + preferredSigner: String?, + releaseChannels: List? = null, + preferencesGetter: (() -> PackagePreference?)? = null, + ): T? = + getUpdate( + versions = versions, + allowedSignersGetter = preferredSigner?.let { { setOf(it) } }, + allowedReleaseChannels = releaseChannels, + preferencesGetter = preferencesGetter, ) - /** - * Returns the [PackageVersion] that is the suggested update - * for the given [installedVersionCode] or suggested for new installed if the given code is 0, - * or null if there is no suitable candidate in [versions]. - * - * @param versions a **sorted** list of [PackageVersion] with highest version code first. - * @param allowedSignersGetter should return set of SHA-256 hashes of the signing certificates - * in lower-case hex. Only versions from these signers will be considered for installation. - * This is is null or returns null, all signers will be allowed. - * If the set of signers is empty, no signers will be allowed, i.e. only apps without signer. - * @param allowedReleaseChannels optional list of release channels to consider on top of stable. - * If this is null or empty, only versions without channel (stable) will be considered. - * @param preferencesGetter an optional way to consider additional per app preferences. - * @param includeKnownVulnerabilities if true, versions with the [installedVersionCode] - * will be returned if [PackageVersion.hasKnownVulnerability] is true, even without real update. - */ - public fun getUpdate( - versions: List, - allowedSignersGetter: (() -> Set?)? = null, - installedVersionCode: Long = 0, - allowedReleaseChannels: List? = null, - includeKnownVulnerabilities: Boolean = false, - preferencesGetter: (() -> PackagePreference?)? = null, - ): T? = getUpdates( + /** + * Returns the [PackageVersion] that is the suggested update for the given [installedVersionCode] + * or suggested for new installed if the given code is 0, or null if there is no suitable + * candidate in [versions]. + * + * @param versions a **sorted** list of [PackageVersion] with highest version code first. + * @param allowedSignersGetter should return set of SHA-256 hashes of the signing certificates in + * lower-case hex. Only versions from these signers will be considered for installation. This is + * is null or returns null, all signers will be allowed. If the set of signers is empty, no + * signers will be allowed, i.e. only apps without signer. + * @param allowedReleaseChannels optional list of release channels to consider on top of stable. + * If this is null or empty, only versions without channel (stable) will be considered. + * @param preferencesGetter an optional way to consider additional per app preferences. + * @param includeKnownVulnerabilities if true, versions with the [installedVersionCode] will be + * returned if [PackageVersion.hasKnownVulnerability] is true, even without real update. + */ + public fun getUpdate( + versions: List, + allowedSignersGetter: (() -> Set?)? = null, + installedVersionCode: Long = 0, + allowedReleaseChannels: List? = null, + includeKnownVulnerabilities: Boolean = false, + preferencesGetter: (() -> PackagePreference?)? = null, + ): T? = + getUpdates( versions = versions, allowedSignersGetter = allowedSignersGetter, installedVersionCode = installedVersionCode, allowedReleaseChannels = allowedReleaseChannels, includeKnownVulnerabilities = includeKnownVulnerabilities, preferencesGetter = preferencesGetter, - ).firstOrNull() // just return matching update with highest version code, don't look at others + ) + .firstOrNull() // just return matching update with highest version code, don't look at others - /** - * Same as [getUpdate], but gets a list of all possible updates - * beginning from highest version code. - * - * This usually isn't useful unless you need to pick a certain update with your own criteria. - */ - public fun getUpdates( - versions: List, - allowedSignersGetter: (() -> Set?)? = null, - installedVersionCode: Long = 0, - allowedReleaseChannels: List? = null, - includeKnownVulnerabilities: Boolean = false, - preferencesGetter: (() -> PackagePreference?)? = null, - ): Sequence = sequence { - // getting signers is rather expensive, so we only do that when there's update candidates - val allowedSigners by lazy { allowedSignersGetter?.let { it() } } - versions.iterator().forEach versions@{ version -> - // if the installed version has a known vulnerability, we return it as well - if (includeKnownVulnerabilities && - version.versionCode == installedVersionCode && - version.hasKnownVulnerability - ) yield(version) - // if version code is not higher than installed skip package as list is sorted - if (version.versionCode <= installedVersionCode) return@sequence - // we don't support versions that have multiple signers - if (version.signer?.hasMultipleSigners == true) return@versions - // skip incompatible versions - if (!compatibilityChecker.isCompatible(version.packageManifest)) return@versions - // check if we should ignore this version code - val packagePreference = preferencesGetter?.let { it() } - val ignoreVersionCode = packagePreference?.ignoreVersionCodeUpdate ?: 0 - if (ignoreVersionCode >= version.versionCode) return@versions - // check if release channel of version is allowed - val hasAllowedReleaseChannel = hasAllowedReleaseChannel( - allowedReleaseChannels = allowedReleaseChannels?.toMutableSet() ?: LinkedHashSet(), - versionReleaseChannels = version.releaseChannels?.toSet(), - packagePreference = packagePreference, - ) - if (!hasAllowedReleaseChannel) return@versions - // check if this version's signer is allowed - val versionSigners = version.signer?.sha256?.toSet() - // F-Droid allows versions without a signer entry, allow those and if no allowed signers - if (versionSigners != null && allowedSigners != null) { - if (versionSigners.intersect(allowedSigners!!).isEmpty()) return@versions - } - // no need to see other versions, we got the highest version code per sorting - yield(version) - } + /** + * Same as [getUpdate], but gets a list of all possible updates beginning from highest version + * code. + * + * This usually isn't useful unless you need to pick a certain update with your own criteria. + */ + public fun getUpdates( + versions: List, + allowedSignersGetter: (() -> Set?)? = null, + installedVersionCode: Long = 0, + allowedReleaseChannels: List? = null, + includeKnownVulnerabilities: Boolean = false, + preferencesGetter: (() -> PackagePreference?)? = null, + ): Sequence = sequence { + // getting signers is rather expensive, so we only do that when there's update candidates + val allowedSigners by lazy { allowedSignersGetter?.let { it() } } + versions.iterator().forEach versions@{ version -> + // if the installed version has a known vulnerability, we return it as well + if ( + includeKnownVulnerabilities && + version.versionCode == installedVersionCode && + version.hasKnownVulnerability + ) + yield(version) + // if version code is not higher than installed skip package as list is sorted + if (version.versionCode <= installedVersionCode) return@sequence + // we don't support versions that have multiple signers + if (version.signer?.hasMultipleSigners == true) return@versions + // skip incompatible versions + if (!compatibilityChecker.isCompatible(version.packageManifest)) return@versions + // check if we should ignore this version code + val packagePreference = preferencesGetter?.let { it() } + val ignoreVersionCode = packagePreference?.ignoreVersionCodeUpdate ?: 0 + if (ignoreVersionCode >= version.versionCode) return@versions + // check if release channel of version is allowed + val hasAllowedReleaseChannel = + hasAllowedReleaseChannel( + allowedReleaseChannels = allowedReleaseChannels?.toMutableSet() ?: LinkedHashSet(), + versionReleaseChannels = version.releaseChannels?.toSet(), + packagePreference = packagePreference, + ) + if (!hasAllowedReleaseChannel) return@versions + // check if this version's signer is allowed + val versionSigners = version.signer?.sha256?.toSet() + // F-Droid allows versions without a signer entry, allow those and if no allowed signers + if (versionSigners != null && allowedSigners != null) { + if (versionSigners.intersect(allowedSigners!!).isEmpty()) return@versions + } + // no need to see other versions, we got the highest version code per sorting + yield(version) } + } - private fun hasAllowedReleaseChannel( - allowedReleaseChannels: MutableSet, - versionReleaseChannels: Set?, - packagePreference: PackagePreference?, - ): Boolean { - // no channels (aka stable version) is always allowed - if (versionReleaseChannels.isNullOrEmpty()) return true + private fun hasAllowedReleaseChannel( + allowedReleaseChannels: MutableSet, + versionReleaseChannels: Set?, + packagePreference: PackagePreference?, + ): Boolean { + // no channels (aka stable version) is always allowed + if (versionReleaseChannels.isNullOrEmpty()) return true - // add release channels from package preferences into the ones we allow - val extraChannels = packagePreference?.releaseChannels - if (!extraChannels.isNullOrEmpty()) { - allowedReleaseChannels.addAll(extraChannels) - } - // if allowed releases channels are empty (only stable) don't consider this version - if (allowedReleaseChannels.isEmpty()) return false - // don't consider version with non-matching release channel - if (allowedReleaseChannels.intersect(versionReleaseChannels).isEmpty()) return false - // one of the allowed channels is present in this version - return true + // add release channels from package preferences into the ones we allow + val extraChannels = packagePreference?.releaseChannels + if (!extraChannels.isNullOrEmpty()) { + allowedReleaseChannels.addAll(extraChannels) } - + // if allowed releases channels are empty (only stable) don't consider this version + if (allowedReleaseChannels.isEmpty()) return false + // don't consider version with non-matching release channel + if (allowedReleaseChannels.intersect(versionReleaseChannels).isEmpty()) return false + // one of the allowed channels is present in this version + return true + } } diff --git a/libs/index/src/androidMain/kotlin/org/fdroid/index/IndexCreator.kt b/libs/index/src/androidMain/kotlin/org/fdroid/index/IndexCreator.kt index 2cfcec168..068ef2533 100644 --- a/libs/index/src/androidMain/kotlin/org/fdroid/index/IndexCreator.kt +++ b/libs/index/src/androidMain/kotlin/org/fdroid/index/IndexCreator.kt @@ -4,124 +4,124 @@ import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.graphics.Bitmap import android.graphics.Bitmap.CompressFormat.PNG -import android.graphics.Bitmap.Config.ARGB_8888 import android.graphics.Canvas import android.graphics.drawable.BitmapDrawable import android.system.Os.symlink import androidx.core.content.pm.PackageInfoCompat -import org.fdroid.index.IndexUtils.toHex +import androidx.core.graphics.createBitmap import java.io.File import java.io.IOException import java.security.MessageDigest import java.security.NoSuchAlgorithmException import java.util.jar.JarFile import java.util.regex.Pattern +import org.fdroid.index.IndexUtils.toHex public abstract class IndexCreator( - protected val packageManager: PackageManager, - protected val repoDir: File, - protected val packageNames: Set, + protected val packageManager: PackageManager, + protected val repoDir: File, + protected val packageNames: Set, ) { - private val iconDir = File(repoDir, "icons") - private val iconDirs = - listOf("icons-120", "icons-160", "icons-240", "icons-320", "icons-480", "icons-640") - private val nativeCodePattern = Pattern.compile("^lib/([a-z0-9-]+)/.*") + private val iconDir = File(repoDir, "icons") + private val iconDirs = + listOf("icons-120", "icons-160", "icons-240", "icons-320", "icons-480", "icons-640") + private val nativeCodePattern = Pattern.compile("^lib/([a-z0-9-]+)/.*") - init { - require(repoDir.isDirectory) { "$repoDir is not a directory" } - require(repoDir.canWrite()) { "Can not write to $repoDir" } + init { + require(repoDir.isDirectory) { "$repoDir is not a directory" } + require(repoDir.canWrite()) { "Can not write to $repoDir" } + } + + @Throws(IOException::class) public abstract fun createRepo(): T + + protected fun prepareIconFolders() { + iconDir.mkdir() + iconDirs.forEach { dir -> + val file = File(repoDir, dir) + if (!file.exists()) symlink(iconDir.absolutePath, file.absolutePath) } + } - @Throws(IOException::class) - public abstract fun createRepo(): T - - protected fun prepareIconFolders() { - iconDir.mkdir() - iconDirs.forEach { dir -> - val file = File(repoDir, dir) - if (!file.exists()) symlink(iconDir.absolutePath, file.absolutePath) - } + /** + * Extracts the icon from an APK and writes it to the repo as a PNG. + * + * @return the name of the written icon file. + */ + protected fun copyIconToRepo(packageInfo: PackageInfo): String? { + val packageName = packageInfo.packageName + val versionCode = PackageInfoCompat.getLongVersionCode(packageInfo) + val drawable = packageInfo.applicationInfo?.loadIcon(packageManager) ?: return null + val bitmap: Bitmap + if (drawable is BitmapDrawable) { + bitmap = drawable.bitmap + } else { + bitmap = createBitmap(drawable.intrinsicWidth, drawable.intrinsicHeight) + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.draw(canvas) } - - /** - * Extracts the icon from an APK and writes it to the repo as a PNG. - * @return the name of the written icon file. - */ - protected fun copyIconToRepo(packageInfo: PackageInfo): String? { - val packageName = packageInfo.packageName - val versionCode = PackageInfoCompat.getLongVersionCode(packageInfo) - val drawable = packageInfo.applicationInfo?.loadIcon(packageManager) ?: return null - val bitmap: Bitmap - if (drawable is BitmapDrawable) { - bitmap = drawable.bitmap - } else { - bitmap = - Bitmap.createBitmap(drawable.intrinsicWidth, drawable.intrinsicHeight, ARGB_8888) - val canvas = Canvas(bitmap) - drawable.setBounds(0, 0, canvas.width, canvas.height) - drawable.draw(canvas) - } - val iconName = "${packageName}_$versionCode.png" - File(iconDir, iconName).outputStream().use { outputStream -> - bitmap.compress(PNG, 100, outputStream) - } - return iconName + val iconName = "${packageName}_$versionCode.png" + File(iconDir, iconName).outputStream().use { outputStream -> + bitmap.compress(PNG, 100, outputStream) } + return iconName + } - /** - * Symlinks the APK to the repo. Does not support split APKs. - * @return the name of the linked/copied APK file or null if no file exists. - * - * Roboletric apparently does not support Os.symlink, and some devices might - * have wonky implementations. Copying is slower and takes more disk space, - * but is much more reliable. So it is a workable fallback. - */ - protected fun copyApkToRepo(packageInfo: PackageInfo): File? { - val appInfo = packageInfo.applicationInfo ?: return null - val packageName = packageInfo.packageName - val versionCode = PackageInfoCompat.getLongVersionCode(packageInfo) - val apkName = "${packageName}_$versionCode.apk" - val apkFile = File(repoDir, apkName) - if (apkFile.exists()) apkFile.delete() - symlink(appInfo.publicSourceDir, apkFile.absolutePath) - if (!apkFile.exists()) { - File(appInfo.publicSourceDir).copyTo(apkFile) - } - return apkFile + /** + * Symlinks the APK to the repo. Does not support split APKs. + * + * @return the name of the linked/copied APK file or null if no file exists. + * + * Robolectric apparently does not support Os.symlink, and some devices might have wonky + * implementations. Copying is slower and takes more disk space, but is much more reliable. So it + * is a workable fallback. + */ + protected fun copyApkToRepo(packageInfo: PackageInfo): File? { + val appInfo = packageInfo.applicationInfo ?: return null + val packageName = packageInfo.packageName + val versionCode = PackageInfoCompat.getLongVersionCode(packageInfo) + val apkName = "${packageName}_$versionCode.apk" + val apkFile = File(repoDir, apkName) + if (apkFile.exists()) apkFile.delete() + symlink(appInfo.publicSourceDir, apkFile.absolutePath) + if (!apkFile.exists()) { + File(appInfo.publicSourceDir).copyTo(apkFile) } + return apkFile + } - protected fun hashFile(file: File): String { - val messageDigest: MessageDigest = try { - MessageDigest.getInstance("SHA-256") - } catch (e: NoSuchAlgorithmException) { - throw AssertionError(e) - } - file.inputStream().use { inputStream -> - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - var bytes = inputStream.read(buffer) - while (bytes >= 0) { - messageDigest.update(buffer, 0, bytes) - bytes = inputStream.read(buffer) - } - } - return messageDigest.digest().toHex() + protected fun hashFile(file: File): String { + val messageDigest: MessageDigest = + try { + MessageDigest.getInstance("SHA-256") + } catch (e: NoSuchAlgorithmException) { + throw AssertionError(e) + } + file.inputStream().use { inputStream -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var bytes = inputStream.read(buffer) + while (bytes >= 0) { + messageDigest.update(buffer, 0, bytes) + bytes = inputStream.read(buffer) + } } + return messageDigest.digest().toHex() + } - protected fun parseNativeCode(packageInfo: PackageInfo): List { - val appInfo = packageInfo.applicationInfo ?: return emptyList() - val apkJar = JarFile(appInfo.publicSourceDir) - val abis = HashSet() - val jarEntries = apkJar.entries() - while (jarEntries.hasMoreElements()) { - val jarEntry = jarEntries.nextElement() - val matcher = nativeCodePattern.matcher(jarEntry.name) - if (matcher.matches()) { - val group = matcher.group(1) - if (group != null) abis.add(group) - } - } - return abis.toList() + protected fun parseNativeCode(packageInfo: PackageInfo): List { + val appInfo = packageInfo.applicationInfo ?: return emptyList() + val apkJar = JarFile(appInfo.publicSourceDir) + val abis = HashSet() + val jarEntries = apkJar.entries() + while (jarEntries.hasMoreElements()) { + val jarEntry = jarEntries.nextElement() + val matcher = nativeCodePattern.matcher(jarEntry.name) + if (matcher.matches()) { + val group = matcher.group(1) + if (group != null) abis.add(group) + } } - + return abis.toList() + } } diff --git a/libs/index/src/androidMain/kotlin/org/fdroid/index/IndexParser.kt b/libs/index/src/androidMain/kotlin/org/fdroid/index/IndexParser.kt index 6005140b8..fe1e43328 100644 --- a/libs/index/src/androidMain/kotlin/org/fdroid/index/IndexParser.kt +++ b/libs/index/src/androidMain/kotlin/org/fdroid/index/IndexParser.kt @@ -1,23 +1,23 @@ package org.fdroid.index +import java.io.InputStream import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.decodeFromStream import org.fdroid.index.v1.IndexV1 import org.fdroid.index.v2.Entry import org.fdroid.index.v2.IndexV2 -import java.io.InputStream @OptIn(ExperimentalSerializationApi::class) public fun IndexParser.parseV1(inputStream: InputStream): IndexV1 { - return json.decodeFromStream(inputStream) + return json.decodeFromStream(inputStream) } @OptIn(ExperimentalSerializationApi::class) public fun IndexParser.parseV2(inputStream: InputStream): IndexV2 { - return json.decodeFromStream(inputStream) + return json.decodeFromStream(inputStream) } @OptIn(ExperimentalSerializationApi::class) public fun IndexParser.parseEntry(inputStream: InputStream): Entry { - return json.decodeFromStream(inputStream) + return json.decodeFromStream(inputStream) } diff --git a/libs/index/src/androidMain/kotlin/org/fdroid/index/IndexUtils.kt b/libs/index/src/androidMain/kotlin/org/fdroid/index/IndexUtils.kt index 946419108..53402a007 100644 --- a/libs/index/src/androidMain/kotlin/org/fdroid/index/IndexUtils.kt +++ b/libs/index/src/androidMain/kotlin/org/fdroid/index/IndexUtils.kt @@ -4,72 +4,68 @@ import java.security.MessageDigest import java.security.NoSuchAlgorithmException public object IndexUtils { - public fun getFingerprint(certificate: String): String { - return sha256(certificate.decodeHex()).toHex() - } + public fun getFingerprint(certificate: String): String { + return sha256(certificate.decodeHex()).toHex() + } - /** - * Get the standard, lowercase SHA-256 fingerprint used to represent an - * APK or JAR signing key. **NOTE**: this does not handle signers that - * have multiple X.509 signing certificates. - *

      - * Calling the X.509 signing certificate the "signature" is incorrect, e.g. - * [android.content.pm.PackageInfo.signatures] or [android.content.pm.Signature]. - * The Android docs about APK signatures call this the "signer". - * - * @see org.fdroid.fdroid.data.Apk#signer - * @see android.content.pm.PackageInfo#signatures - * @see APK Signature Scheme v2 - */ - public fun getPackageSigner(signerBytes: ByteArray): String { - return sha256(signerBytes).toHex() - } + /** + * Get the standard, lowercase SHA-256 fingerprint used to represent an APK or JAR signing key. + * **NOTE**: this does not handle signers that have multiple X.509 signing certificates. + * + *

      + * Calling the X.509 signing certificate the "signature" is incorrect, e.g. + * [android.content.pm.PackageInfo.signatures] or [android.content.pm.Signature]. The Android docs + * about APK signatures call this the "signer". + * + * @see org.fdroid.fdroid.data.Apk#signer + * @see android.content.pm.PackageInfo#signatures + * @see APK Signature + * Scheme v2 + */ + public fun getPackageSigner(signerBytes: ByteArray): String { + return sha256(signerBytes).toHex() + } - /** - * Get the fingerprint used to represent an APK signing key in F-Droid. - * This is a custom fingerprint algorithm that was kind of accidentally - * created. It is now here only for backwards compatibility. It should - * only ever be used for writing the `sig` value out to - * `index-v1.json`. - * - * @see getPackageSigner - * @see org.fdroid.fdroid.Utils.getPackageSigner - * @see org.fdroid.fdroid.data.Apk - */ - @Deprecated("Only here for backwards compatibility when writing out index-v1.json") - public fun getsig(signerBytes: ByteArray): String { - return md5(signerBytes.toHex().encodeToByteArray()).toHex() - } + /** + * Get the fingerprint used to represent an APK signing key in F-Droid. This is a custom + * fingerprint algorithm that was kind of accidentally created. It is now here only for backwards + * compatibility. It should only ever be used for writing the `sig` value out to `index-v1.json`. + * + * @see getPackageSigner + */ + @Deprecated("Only here for backwards compatibility when writing out index-v1.json") + public fun getsig(signerBytes: ByteArray): String { + return md5(signerBytes.toHex().encodeToByteArray()).toHex() + } - internal fun String.decodeHex(): ByteArray { - check(length % 2 == 0) { "Must have an even length" } - return chunked(2) - .map { it.toInt(16).toByte() } - .toByteArray() - } + internal fun String.decodeHex(): ByteArray { + check(length % 2 == 0) { "Must have an even length" } + return chunked(2).map { it.toInt(16).toByte() }.toByteArray() + } - internal fun ByteArray.toHex(): String = joinToString(separator = "") { eachByte -> - "%02x".format(eachByte) - } + internal fun ByteArray.toHex(): String = + joinToString(separator = "") { eachByte -> "%02x".format(eachByte) } - internal fun sha256(bytes: ByteArray): ByteArray { - val messageDigest: MessageDigest = try { - MessageDigest.getInstance("SHA-256") - } catch (e: NoSuchAlgorithmException) { - throw AssertionError(e) - } - messageDigest.update(bytes) - return messageDigest.digest() - } + internal fun sha256(bytes: ByteArray): ByteArray { + val messageDigest: MessageDigest = + try { + MessageDigest.getInstance("SHA-256") + } catch (e: NoSuchAlgorithmException) { + throw AssertionError(e) + } + messageDigest.update(bytes) + return messageDigest.digest() + } - @Deprecated("Only here for backwards compatibility when writing out index-v1.json") - internal fun md5(bytes: ByteArray): ByteArray { - val messageDigest: MessageDigest = try { - MessageDigest.getInstance("MD5") - } catch (e: NoSuchAlgorithmException) { - throw AssertionError(e) - } - messageDigest.update(bytes) - return messageDigest.digest() - } + @Deprecated("Only here for backwards compatibility when writing out index-v1.json") + internal fun md5(bytes: ByteArray): ByteArray { + val messageDigest: MessageDigest = + try { + MessageDigest.getInstance("MD5") + } catch (e: NoSuchAlgorithmException) { + throw AssertionError(e) + } + messageDigest.update(bytes) + return messageDigest.digest() + } } diff --git a/libs/index/src/androidMain/kotlin/org/fdroid/index/JarIndexVerifier.kt b/libs/index/src/androidMain/kotlin/org/fdroid/index/JarIndexVerifier.kt index 527c96f91..fda5c86ca 100644 --- a/libs/index/src/androidMain/kotlin/org/fdroid/index/JarIndexVerifier.kt +++ b/libs/index/src/androidMain/kotlin/org/fdroid/index/JarIndexVerifier.kt @@ -1,7 +1,5 @@ package org.fdroid.index -import org.fdroid.index.IndexUtils.sha256 -import org.fdroid.index.IndexUtils.toHex import java.io.File import java.io.IOException import java.io.InputStream @@ -9,106 +7,108 @@ import java.security.cert.X509Certificate import java.util.jar.Attributes import java.util.jar.JarEntry import java.util.jar.JarFile +import org.fdroid.index.IndexUtils.sha256 +import org.fdroid.index.IndexUtils.toHex public abstract class JarIndexVerifier( - private val jarFile: File, - private val expectedSigningCertificate: String?, - private val expectedSigningFingerprint: String?, + private val jarFile: File, + private val expectedSigningCertificate: String?, + private val expectedSigningFingerprint: String?, ) { - init { - require(expectedSigningCertificate == null || expectedSigningCertificate.isNotEmpty()) - require(expectedSigningFingerprint == null || expectedSigningFingerprint.isNotEmpty()) - require(expectedSigningCertificate == null || expectedSigningFingerprint == null) { - "Providing a signing certificate and a fingerprint makes no sense." - } + init { + require(expectedSigningCertificate == null || expectedSigningCertificate.isNotEmpty()) + require(expectedSigningFingerprint == null || expectedSigningFingerprint.isNotEmpty()) + require(expectedSigningCertificate == null || expectedSigningFingerprint == null) { + "Providing a signing certificate and a fingerprint makes no sense." } + } - protected abstract val jsonFileName: String + protected abstract val jsonFileName: String - @Throws(SigningException::class) - protected abstract fun checkAttributes(attributes: Attributes) + @Throws(SigningException::class) protected abstract fun checkAttributes(attributes: Attributes) - /** - * Opens the [jarFile], verifies it and then gets signing certificate - * as well as the index stream for further processing. - * The caller does not need to close the stream. - */ - @Throws(IOException::class, SigningException::class) - public fun getStreamAndVerify(certificateAndStream: (InputStream) -> T): Pair { - return JarFile(jarFile, true).use { file -> - val indexEntry = file.getEntry(jsonFileName) as? JarEntry - ?: throw SigningException("No entry for $jsonFileName") - if (indexEntry.attributes == null) { - throw SigningException("No attributes for $jsonFileName") - } else { - checkAttributes(indexEntry.attributes) - } - val t = try { - file.getInputStream(indexEntry).use { inputSteam -> - certificateAndStream(inputSteam) - } - } catch (e: SecurityException) { - throw SigningException(e) - } - val x509Certificate = getX509Certificate(indexEntry) - Pair(verifyAndGetSigningCertificate(x509Certificate), t) + /** + * Opens the [jarFile], verifies it and then gets signing certificate as well as the index stream + * for further processing. The caller does not need to close the stream. + */ + @Throws(IOException::class, SigningException::class) + public fun getStreamAndVerify(certificateAndStream: (InputStream) -> T): Pair { + return JarFile(jarFile, true).use { file -> + val indexEntry = + file.getEntry(jsonFileName) as? JarEntry + ?: throw SigningException("No entry for $jsonFileName") + if (indexEntry.attributes == null) { + throw SigningException("No attributes for $jsonFileName") + } else { + checkAttributes(indexEntry.attributes) + } + val t = + try { + file.getInputStream(indexEntry).use { inputSteam -> certificateAndStream(inputSteam) } + } catch (e: SecurityException) { + throw SigningException(e) } + val x509Certificate = getX509Certificate(indexEntry) + Pair(verifyAndGetSigningCertificate(x509Certificate), t) } + } - /** - * Returns the [X509Certificate] for the given [jarEntry]. - * - * F-Droid's JAR is signed using a particular format - * and does not allow other signing setups that would be valid for a regular jar - * @throws SigningException if those restrictions are not met. - */ - @Throws(SigningException::class) - private fun getX509Certificate(jarEntry: JarEntry): X509Certificate { - val codeSigners = jarEntry.codeSigners - if (codeSigners.isNullOrEmpty()) { - throw SigningException("No signature found in index, did you read stream until end?") - } - if (codeSigners.size != 1) { - // we could in theory support more than 1, but as of now we do not - throw SigningException("index.jar must be signed by a single code signer") - } - val certs = codeSigners[0].signerCertPath.certificates - if (certs.size != 1) { - throw SigningException("index.jar code signers must only have a single certificate") - } - return certs[0] as X509Certificate + /** + * Returns the [X509Certificate] for the given [jarEntry]. + * + * F-Droid's JAR is signed using a particular format and does not allow other signing setups that + * would be valid for a regular jar + * + * @throws SigningException if those restrictions are not met. + */ + @Throws(SigningException::class) + private fun getX509Certificate(jarEntry: JarEntry): X509Certificate { + val codeSigners = jarEntry.codeSigners + if (codeSigners.isNullOrEmpty()) { + throw SigningException("No signature found in index, did you read stream until end?") } - - /** - * Verifies that the fingerprint of the signing certificate used to sign [jsonFileName] - * matches the [expectedSigningFingerprint]. - * @return the fingerprint of the given [rawCertFromJar]. - */ - @Throws(SigningException::class) - private fun verifyAndGetSigningCertificate(rawCertFromJar: X509Certificate): String { - val certificate: String = rawCertFromJar.encoded.toHex() - if (certificate.isEmpty()) throw SigningException("No signing certificate") - if (certificate.length < 512) { - throw SigningException("Certificate size of ${certificate.length / 2} is too short.") - } - // if we have only the fingerprint, compare it against the given certificate - if (expectedSigningCertificate == null && expectedSigningFingerprint != null) { - val fingerprintFromJar = sha256(rawCertFromJar.encoded).toHex() - if (expectedSigningFingerprint != fingerprintFromJar) { - throw SigningException("Expected certificate fingerprint does not match") - } - } - // if we have the full certificate, compare it to the one from the jar - if (expectedSigningCertificate != null && expectedSigningCertificate != certificate) { - throw SigningException("Signing certificate does not match") - } - return certificate + if (codeSigners.size != 1) { + // we could in theory support more than 1, but as of now we do not + throw SigningException("index.jar must be signed by a single code signer") } + val certs = codeSigners[0].signerCertPath.certificates + if (certs.size != 1) { + throw SigningException("index.jar code signers must only have a single certificate") + } + return certs[0] as X509Certificate + } + /** + * Verifies that the fingerprint of the signing certificate used to sign [jsonFileName] matches + * the [expectedSigningFingerprint]. + * + * @return the fingerprint of the given [rawCertFromJar]. + */ + @Throws(SigningException::class) + private fun verifyAndGetSigningCertificate(rawCertFromJar: X509Certificate): String { + val certificate: String = rawCertFromJar.encoded.toHex() + if (certificate.isEmpty()) throw SigningException("No signing certificate") + if (certificate.length < 512) { + throw SigningException("Certificate size of ${certificate.length / 2} is too short.") + } + // if we have only the fingerprint, compare it against the given certificate + if (expectedSigningCertificate == null && expectedSigningFingerprint != null) { + val fingerprintFromJar = sha256(rawCertFromJar.encoded).toHex() + if (expectedSigningFingerprint != fingerprintFromJar) { + throw SigningException("Expected certificate fingerprint does not match") + } + } + // if we have the full certificate, compare it to the one from the jar + if (expectedSigningCertificate != null && expectedSigningCertificate != certificate) { + throw SigningException("Signing certificate does not match") + } + return certificate + } } public class SigningException(msg: String?, cause: Throwable?) : Exception(msg, cause) { - public constructor(msg: String) : this(msg, null) - public constructor(e: Throwable) : this(null, e) + public constructor(msg: String) : this(msg, null) + + public constructor(e: Throwable) : this(null, e) } diff --git a/libs/index/src/androidMain/kotlin/org/fdroid/index/v1/IndexV1Creator.kt b/libs/index/src/androidMain/kotlin/org/fdroid/index/v1/IndexV1Creator.kt index 8e1f4a5e8..3bab7ad6d 100644 --- a/libs/index/src/androidMain/kotlin/org/fdroid/index/v1/IndexV1Creator.kt +++ b/libs/index/src/androidMain/kotlin/org/fdroid/index/v1/IndexV1Creator.kt @@ -7,116 +7,107 @@ import android.content.pm.PackageManager.GET_SIGNATURES import android.os.Build.VERSION.SDK_INT import android.util.Log import androidx.core.content.pm.PackageInfoCompat +import java.io.File +import java.io.IOException import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.encodeToStream import org.fdroid.index.IndexCreator import org.fdroid.index.IndexParser import org.fdroid.index.IndexUtils.getPackageSigner import org.fdroid.index.IndexUtils.getsig -import java.io.File -import java.io.IOException /** - * Creates a deprecated V1 index from the given [packageNames] - * with information obtained from the [PackageManager]. + * Creates a deprecated V1 index from the given [packageNames] with information obtained from the + * [PackageManager]. * - * Attention: While [createRepo] creates `index-v1.json`, - * it does **not** create a signed `index-v1.jar`. - * The caller needs to handle this last signing step themselves. + * Attention: While [createRepo] creates `index-v1.json`, it does **not** create a signed + * `index-v1.jar`. The caller needs to handle this last signing step themselves. */ public class IndexV1Creator( - packageManager: PackageManager, - repoDir: File, - packageNames: Set, - private val repo: RepoV1, + packageManager: PackageManager, + repoDir: File, + packageNames: Set, + private val repo: RepoV1, ) : IndexCreator(packageManager, repoDir, packageNames) { - @Throws(IOException::class) - @OptIn(ExperimentalSerializationApi::class) - public override fun createRepo(): IndexV1 { - prepareIconFolders() - val index = createIndex() - val indexJsonFile = File(repoDir, DATA_FILE_NAME) - indexJsonFile.outputStream().use { outputStream -> - IndexParser.json.encodeToStream(index, outputStream) - } - return index + @Throws(IOException::class) + @OptIn(ExperimentalSerializationApi::class) + public override fun createRepo(): IndexV1 { + prepareIconFolders() + val index = createIndex() + val indexJsonFile = File(repoDir, DATA_FILE_NAME) + indexJsonFile.outputStream().use { outputStream -> + IndexParser.json.encodeToStream(index, outputStream) } + return index + } - private fun createIndex(): IndexV1 { - val apps = ArrayList(packageNames.size) - val packages = HashMap>(packageNames.size) - for (packageName in packageNames) { - addApp(packageName, apps, packages) - } - return IndexV1( - repo = repo, - apps = apps, - packages = packages, - ) + private fun createIndex(): IndexV1 { + val apps = ArrayList(packageNames.size) + val packages = HashMap>(packageNames.size) + for (packageName in packageNames) { + addApp(packageName, apps, packages) } + return IndexV1(repo = repo, apps = apps, packages = packages) + } - private fun addApp( - packageName: String, - apps: ArrayList, - packages: HashMap>, - ) { - @Suppress("DEPRECATION") - val flags = GET_SIGNATURES or GET_PERMISSIONS + private fun addApp( + packageName: String, + apps: ArrayList, + packages: HashMap>, + ) { + @Suppress("DEPRECATION") val flags = GET_SIGNATURES or GET_PERMISSIONS - try { - @Suppress("PackageManagerGetSignatures") - val packageInfo = packageManager.getPackageInfo(packageName, flags) - apps.add(getApp(packageInfo)) - val p = getPackage(packageInfo) - if (p == null) { - Log.w("IndexV1Creator", "Got no package for $packageName") - return - } - packages[packageName] = listOf(p) - } catch (e: PackageManager.NameNotFoundException) { - Log.i("IndexV1Creator", "app disappeared during addApp: ", e) - } + try { + @Suppress("PackageManagerGetSignatures") + val packageInfo = packageManager.getPackageInfo(packageName, flags) + apps.add(getApp(packageInfo)) + val p = getPackage(packageInfo) + if (p == null) { + Log.w("IndexV1Creator", "Got no package for $packageName") + return + } + packages[packageName] = listOf(p) + } catch (e: PackageManager.NameNotFoundException) { + Log.i("IndexV1Creator", "app disappeared during addApp: ", e) } + } - private fun getApp(packageInfo: PackageInfo): AppV1 { - val icon = copyIconToRepo(packageInfo) - return AppV1( - packageName = packageInfo.packageName, - name = packageInfo.applicationInfo?.loadLabel(packageManager).toString(), - license = "Unknown", - icon = icon, - ) - } + private fun getApp(packageInfo: PackageInfo): AppV1 { + val icon = copyIconToRepo(packageInfo) + return AppV1( + packageName = packageInfo.packageName, + name = packageInfo.applicationInfo?.loadLabel(packageManager).toString(), + license = "Unknown", + icon = icon, + ) + } - private fun getPackage(packageInfo: PackageInfo): PackageV1? { - val apk = copyApkToRepo(packageInfo) ?: return null - val appInfo = packageInfo.applicationInfo ?: return null - val signatures = packageInfo.signatures ?: return null - val hash = hashFile(apk) - val apkName = apk.name - val sig = getsig(signatures[0].toByteArray()) - val signer = getPackageSigner(signatures[0].toByteArray()) - return PackageV1( - packageName = packageInfo.packageName, - versionCode = PackageInfoCompat.getLongVersionCode(packageInfo), - versionName = packageInfo.versionName ?: PackageInfoCompat.getLongVersionCode( - packageInfo - ).toString(), - apkName = apkName, - hash = hash, - hashType = "sha256", - sig = sig, - signer = signer, - size = File(appInfo.publicSourceDir).length(), - minSdkVersion = if (SDK_INT >= 24) appInfo.minSdkVersion else null, - targetSdkVersion = appInfo.targetSdkVersion, - usesPermission = packageInfo.requestedPermissions?.map { - PermissionV1(it) - } ?: emptyList(), - usesPermission23 = emptyList(), - nativeCode = parseNativeCode(packageInfo), - features = packageInfo.reqFeatures?.map { it.name } ?: emptyList(), - ) - } + private fun getPackage(packageInfo: PackageInfo): PackageV1? { + val apk = copyApkToRepo(packageInfo) ?: return null + val appInfo = packageInfo.applicationInfo ?: return null + val signatures = packageInfo.signatures ?: return null + val hash = hashFile(apk) + val apkName = apk.name + val sig = getsig(signatures[0].toByteArray()) + val signer = getPackageSigner(signatures[0].toByteArray()) + return PackageV1( + packageName = packageInfo.packageName, + versionCode = PackageInfoCompat.getLongVersionCode(packageInfo), + versionName = + packageInfo.versionName ?: PackageInfoCompat.getLongVersionCode(packageInfo).toString(), + apkName = apkName, + hash = hash, + hashType = "sha256", + sig = sig, + signer = signer, + size = File(appInfo.publicSourceDir).length(), + minSdkVersion = if (SDK_INT >= 24) appInfo.minSdkVersion else null, + targetSdkVersion = appInfo.targetSdkVersion, + usesPermission = packageInfo.requestedPermissions?.map { PermissionV1(it) } ?: emptyList(), + usesPermission23 = emptyList(), + nativeCode = parseNativeCode(packageInfo), + features = packageInfo.reqFeatures?.map { it.name } ?: emptyList(), + ) + } } diff --git a/libs/index/src/androidMain/kotlin/org/fdroid/index/v1/IndexV1StreamProcessor.kt b/libs/index/src/androidMain/kotlin/org/fdroid/index/v1/IndexV1StreamProcessor.kt index 140946bbd..b4e93d651 100644 --- a/libs/index/src/androidMain/kotlin/org/fdroid/index/v1/IndexV1StreamProcessor.kt +++ b/libs/index/src/androidMain/kotlin/org/fdroid/index/v1/IndexV1StreamProcessor.kt @@ -1,5 +1,6 @@ package org.fdroid.index.v1 +import java.io.InputStream import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.KSerializer import kotlinx.serialization.SerializationException @@ -20,201 +21,202 @@ import org.fdroid.index.v2.AntiFeatureV2 import org.fdroid.index.v2.CategoryV2 import org.fdroid.index.v2.LocalizedTextV2 import org.fdroid.index.v2.PackageVersionV2 -import java.io.InputStream /** - * Processes a indexV1 stream and calls the given [indexStreamReceiver] while parsing it. - * Attention: This requires the following canonical top-level order in the JSON - * as produced by `fdroidserver`. + * Processes a indexV1 stream and calls the given [indexStreamReceiver] while parsing it. Attention: + * This requires the following canonical top-level order in the JSON as produced by `fdroidserver`. * * repo * * requests * * apps * * packages * - * Any other order of those elements will produce unexpected results - * or throw [IllegalArgumentException]. + * Any other order of those elements will produce unexpected results or throw + * [IllegalArgumentException]. */ @Suppress("DEPRECATION") @OptIn(ExperimentalSerializationApi::class) public class IndexV1StreamProcessor( - private val indexStreamReceiver: IndexV1StreamReceiver, - private val lastTimestamp: Long, - private val locale: String = DEFAULT_LOCALE, - private val json: Json = IndexParser.json, + private val indexStreamReceiver: IndexV1StreamReceiver, + private val lastTimestamp: Long, + private val locale: String = DEFAULT_LOCALE, + private val json: Json = IndexParser.json, ) { - @Throws(SerializationException::class, OldIndexException::class) - public fun process(inputStream: InputStream) { - json.decodeFromStream(IndexStreamSerializer(), inputStream) + @Throws(SerializationException::class, OldIndexException::class) + public fun process(inputStream: InputStream) { + json.decodeFromStream(IndexStreamSerializer(), inputStream) + } + + private inner class IndexStreamSerializer : KSerializer { + override val descriptor = IndexV1.serializer().descriptor + + override fun deserialize(decoder: Decoder): IndexV1? { + decoder as? JsonDecoder ?: error("Can be deserialized only by JSON") + + decoder.beginStructure(descriptor) + val index0 = decoder.decodeElementIndex(descriptor) + deserializeRepo(decoder, index0) + val index1 = decoder.decodeElementIndex(descriptor) + if (index1 == DECODE_DONE) { + updateRepoData(emptyMap()) + decoder.endStructure(descriptor) + return null + } + deserializeRequests(decoder, index1) + val index2 = decoder.decodeElementIndex(descriptor) + if (index2 == DECODE_DONE) { + updateRepoData(emptyMap()) + decoder.endStructure(descriptor) + return null + } + val appDataMap = deserializeApps(decoder, index2) + val index3 = decoder.decodeElementIndex(descriptor) + if (index3 == DECODE_DONE) { + updateRepoData(appDataMap) + decoder.endStructure(descriptor) + return null + } + deserializePackages(decoder, index3, appDataMap) + decoder.endStructure(descriptor) + + updateRepoData(appDataMap) + return null } - private inner class IndexStreamSerializer : KSerializer { - override val descriptor = IndexV1.serializer().descriptor - - override fun deserialize(decoder: Decoder): IndexV1? { - decoder as? JsonDecoder ?: error("Can be deserialized only by JSON") - - decoder.beginStructure(descriptor) - val index0 = decoder.decodeElementIndex(descriptor) - deserializeRepo(decoder, index0) - val index1 = decoder.decodeElementIndex(descriptor) - if (index1 == DECODE_DONE) { - updateRepoData(emptyMap()) - decoder.endStructure(descriptor) - return null - } - deserializeRequests(decoder, index1) - val index2 = decoder.decodeElementIndex(descriptor) - if (index2 == DECODE_DONE) { - updateRepoData(emptyMap()) - decoder.endStructure(descriptor) - return null - } - val appDataMap = deserializeApps(decoder, index2) - val index3 = decoder.decodeElementIndex(descriptor) - if (index3 == DECODE_DONE) { - updateRepoData(appDataMap) - decoder.endStructure(descriptor) - return null - } - deserializePackages(decoder, index3, appDataMap) - decoder.endStructure(descriptor) - - updateRepoData(appDataMap) - return null - } - - private fun deserializeRepo(decoder: JsonDecoder, index: Int) { - require(index == descriptor.getElementIndex("repo")) - val repo = decoder.decodeSerializableValue(RepoV1.serializer()) - if (lastTimestamp >= repo.timestamp) throw OldIndexException( - isSameTimestamp = lastTimestamp == repo.timestamp, - msg = "Old repo ${repo.address} ${repo.timestamp}", - ) - val repoV2 = repo.toRepoV2( - locale = DEFAULT_LOCALE, - antiFeatures = emptyMap(), - categories = emptyMap(), - releaseChannels = emptyMap() - ) - indexStreamReceiver.receive(repoV2, repo.version.toLong()) - } - - private fun deserializeRequests(decoder: JsonDecoder, index: Int) { - require(index == descriptor.getElementIndex("requests")) - decoder.decodeSerializableValue(Requests.serializer()) - // we ignore the requests here, don't act on them - } - - private fun deserializeApps(decoder: JsonDecoder, index: Int): Map { - require(index == descriptor.getElementIndex("apps")) - val appDataMap = HashMap() - val mapDescriptor = descriptor.getElementDescriptor(index) - val compositeDecoder = decoder.beginStructure(mapDescriptor) - while (true) { - val packageIndex = compositeDecoder.decodeElementIndex(descriptor) - if (packageIndex == DECODE_DONE) break - val appV1 = - decoder.decodeSerializableElement(descriptor, packageIndex, AppV1.serializer()) - val appV2 = appV1.toMetadataV2(null, locale) - indexStreamReceiver.receive(appV1.packageName, appV2) - appDataMap[appV1.packageName] = AppData( - antiFeatures = appV1.antiFeatures.associateWith { emptyMap() }, - whatsNew = appV1.localized?.mapValuesNotNull { it.value.whatsNew }, - suggestedVersionCode = appV1.suggestedVersionCode?.toLongOrNull(), - categories = appV1.categories, - ) - } - compositeDecoder.endStructure(mapDescriptor) - return appDataMap - } - - private fun deserializePackages( - decoder: JsonDecoder, - index: Int, - appDataMap: Map, - ) { - require(index == descriptor.getElementIndex("packages")) - val mapDescriptor = descriptor.getElementDescriptor(index) - val compositeDecoder = decoder.beginStructure(mapDescriptor) - while (true) { - val packageIndex = compositeDecoder.decodeElementIndex(descriptor) - if (packageIndex == DECODE_DONE) break - readPackageMapEntry( - decoder = compositeDecoder as JsonDecoder, - index = packageIndex, - appDataMap = appDataMap, - ) - } - compositeDecoder.endStructure(mapDescriptor) - } - - private fun readPackageMapEntry( - decoder: JsonDecoder, - index: Int, - appDataMap: Map, - ) { - val packageName = decoder.decodeStringElement(descriptor, index) - decoder.decodeElementIndex(descriptor) - val versions = HashMap() - - val listDescriptor = ListSerializer(PackageV1.serializer()).descriptor - val compositeDecoder = decoder.beginStructure(listDescriptor) - var isFirstVersion = true - while (true) { - val packageIndex = compositeDecoder.decodeElementIndex(descriptor) - if (packageIndex == DECODE_DONE) break - val packageVersionV1 = decoder.decodeSerializableElement( - descriptor = descriptor, - index = index + 1, - deserializer = PackageV1.serializer(), - ) - val versionCode = packageVersionV1.versionCode ?: 0 - val suggestedVersionCode = - appDataMap[packageName]?.suggestedVersionCode ?: 0 - val releaseChannels = if (versionCode > suggestedVersionCode) - listOf(RELEASE_CHANNEL_BETA) else emptyList() - val packageVersionV2 = packageVersionV1.toPackageVersionV2( - releaseChannels = releaseChannels, - appAntiFeatures = appDataMap[packageName]?.antiFeatures ?: emptyMap(), - whatsNew = if (suggestedVersionCode == versionCode) { - appDataMap[packageName]?.whatsNew - } else null - ) - if (isFirstVersion) { - indexStreamReceiver.updateAppMetadata(packageName, packageVersionV1.signer) - } - isFirstVersion = false - val versionId = packageVersionV2.file.sha256 - versions[versionId] = packageVersionV2 - } - indexStreamReceiver.receive(packageName, versions) - compositeDecoder.endStructure(listDescriptor) - } - - private fun updateRepoData(appDataMap: Map) { - val antiFeatures = HashMap() - val categories = HashMap() - appDataMap.values.forEach { appData -> - appData.antiFeatures.mapInto(antiFeatures) - appData.categories.mapInto(categories) - } - val releaseChannels = getV1ReleaseChannels() - indexStreamReceiver.updateRepo(antiFeatures, categories, releaseChannels) - } - - override fun serialize(encoder: Encoder, value: IndexV1?) { - error("Not implemented") - } + private fun deserializeRepo(decoder: JsonDecoder, index: Int) { + require(index == descriptor.getElementIndex("repo")) + val repo = decoder.decodeSerializableValue(RepoV1.serializer()) + if (lastTimestamp >= repo.timestamp) + throw OldIndexException( + isSameTimestamp = lastTimestamp == repo.timestamp, + msg = "Old repo ${repo.address} ${repo.timestamp}", + ) + val repoV2 = + repo.toRepoV2( + locale = DEFAULT_LOCALE, + antiFeatures = emptyMap(), + categories = emptyMap(), + releaseChannels = emptyMap(), + ) + indexStreamReceiver.receive(repoV2, repo.version.toLong()) } + private fun deserializeRequests(decoder: JsonDecoder, index: Int) { + require(index == descriptor.getElementIndex("requests")) + decoder.decodeSerializableValue(Requests.serializer()) + // we ignore the requests here, don't act on them + } + + private fun deserializeApps(decoder: JsonDecoder, index: Int): Map { + require(index == descriptor.getElementIndex("apps")) + val appDataMap = HashMap() + val mapDescriptor = descriptor.getElementDescriptor(index) + val compositeDecoder = decoder.beginStructure(mapDescriptor) + while (true) { + val packageIndex = compositeDecoder.decodeElementIndex(descriptor) + if (packageIndex == DECODE_DONE) break + val appV1 = decoder.decodeSerializableElement(descriptor, packageIndex, AppV1.serializer()) + val appV2 = appV1.toMetadataV2(null, locale) + indexStreamReceiver.receive(appV1.packageName, appV2) + appDataMap[appV1.packageName] = + AppData( + antiFeatures = appV1.antiFeatures.associateWith { emptyMap() }, + whatsNew = appV1.localized?.mapValuesNotNull { it.value.whatsNew }, + suggestedVersionCode = appV1.suggestedVersionCode?.toLongOrNull(), + categories = appV1.categories, + ) + } + compositeDecoder.endStructure(mapDescriptor) + return appDataMap + } + + private fun deserializePackages( + decoder: JsonDecoder, + index: Int, + appDataMap: Map, + ) { + require(index == descriptor.getElementIndex("packages")) + val mapDescriptor = descriptor.getElementDescriptor(index) + val compositeDecoder = decoder.beginStructure(mapDescriptor) + while (true) { + val packageIndex = compositeDecoder.decodeElementIndex(descriptor) + if (packageIndex == DECODE_DONE) break + readPackageMapEntry( + decoder = compositeDecoder as JsonDecoder, + index = packageIndex, + appDataMap = appDataMap, + ) + } + compositeDecoder.endStructure(mapDescriptor) + } + + private fun readPackageMapEntry( + decoder: JsonDecoder, + index: Int, + appDataMap: Map, + ) { + val packageName = decoder.decodeStringElement(descriptor, index) + decoder.decodeElementIndex(descriptor) + val versions = HashMap() + + val listDescriptor = ListSerializer(PackageV1.serializer()).descriptor + val compositeDecoder = decoder.beginStructure(listDescriptor) + var isFirstVersion = true + while (true) { + val packageIndex = compositeDecoder.decodeElementIndex(descriptor) + if (packageIndex == DECODE_DONE) break + val packageVersionV1 = + decoder.decodeSerializableElement( + descriptor = descriptor, + index = index + 1, + deserializer = PackageV1.serializer(), + ) + val versionCode = packageVersionV1.versionCode ?: 0 + val suggestedVersionCode = appDataMap[packageName]?.suggestedVersionCode ?: 0 + val releaseChannels = + if (versionCode > suggestedVersionCode) listOf(RELEASE_CHANNEL_BETA) else emptyList() + val packageVersionV2 = + packageVersionV1.toPackageVersionV2( + releaseChannels = releaseChannels, + appAntiFeatures = appDataMap[packageName]?.antiFeatures ?: emptyMap(), + whatsNew = + if (suggestedVersionCode == versionCode) { + appDataMap[packageName]?.whatsNew + } else null, + ) + if (isFirstVersion) { + indexStreamReceiver.updateAppMetadata(packageName, packageVersionV1.signer) + } + isFirstVersion = false + val versionId = packageVersionV2.file.sha256 + versions[versionId] = packageVersionV2 + } + indexStreamReceiver.receive(packageName, versions) + compositeDecoder.endStructure(listDescriptor) + } + + private fun updateRepoData(appDataMap: Map) { + val antiFeatures = HashMap() + val categories = HashMap() + appDataMap.values.forEach { appData -> + appData.antiFeatures.mapInto(antiFeatures) + appData.categories.mapInto(categories) + } + val releaseChannels = getV1ReleaseChannels() + indexStreamReceiver.updateRepo(antiFeatures, categories, releaseChannels) + } + + override fun serialize(encoder: Encoder, value: IndexV1?) { + error("Not implemented") + } + } } private class AppData( - val antiFeatures: Map, - val whatsNew: LocalizedTextV2?, - val suggestedVersionCode: Long?, - val categories: List, + val antiFeatures: Map, + val whatsNew: LocalizedTextV2?, + val suggestedVersionCode: Long?, + val categories: List, ) public class OldIndexException(public val isSameTimestamp: Boolean, msg: String) : Exception(msg) diff --git a/libs/index/src/androidMain/kotlin/org/fdroid/index/v1/IndexV1Verifier.kt b/libs/index/src/androidMain/kotlin/org/fdroid/index/v1/IndexV1Verifier.kt index 69364f352..8ae73192a 100644 --- a/libs/index/src/androidMain/kotlin/org/fdroid/index/v1/IndexV1Verifier.kt +++ b/libs/index/src/androidMain/kotlin/org/fdroid/index/v1/IndexV1Verifier.kt @@ -1,9 +1,9 @@ package org.fdroid.index.v1 -import org.fdroid.index.JarIndexVerifier -import org.fdroid.index.SigningException import java.io.File import java.util.jar.Attributes +import org.fdroid.index.JarIndexVerifier +import org.fdroid.index.SigningException public const val DATA_FILE_NAME: String = "index-v1.json" private const val SUPPORTED_DIGEST = "SHA1-Digest" @@ -13,27 +13,27 @@ private const val SUPPORTED_DIGEST = "SHA1-Digest" * * @param jarFile the signed jar file to verify. * @param expectedSigningCertificate The signing certificate of the repo encoded in lower case hex, - * if it is known already. This should only be null if the repo is unknown. - * Then we trust it on first use (TOFU). + * if it is known already. This should only be null if the repo is unknown. Then we trust it on + * first use (TOFU). * @param expectedSigningFingerprint The fingerprint, a SHA 256 hash of the - * [expectedSigningCertificate]'s byte encoding as a lower case hex string. - * Even if [expectedSigningFingerprint] is null, the fingerprint might be known and can be used to - * verify that it matches the signing certificate. + * [expectedSigningCertificate]'s byte encoding as a lower case hex string. Even if + * [expectedSigningFingerprint] is null, the fingerprint might be known and can be used to verify + * that it matches the signing certificate. */ public class IndexV1Verifier( - jarFile: File, - expectedSigningCertificate: String?, - expectedSigningFingerprint: String?, + jarFile: File, + expectedSigningCertificate: String?, + expectedSigningFingerprint: String?, ) : JarIndexVerifier(jarFile, expectedSigningCertificate, expectedSigningFingerprint) { - protected override val jsonFileName: String = DATA_FILE_NAME + protected override val jsonFileName: String = DATA_FILE_NAME - @Throws(SigningException::class) - protected override fun checkAttributes(attributes: Attributes) { - attributes.keys.forEach { key -> - if (key.toString() != SUPPORTED_DIGEST) { - throw SigningException("Unsupported digest: $key") - } - } + @Throws(SigningException::class) + protected override fun checkAttributes(attributes: Attributes) { + attributes.keys.forEach { key -> + if (key.toString() != SUPPORTED_DIGEST) { + throw SigningException("Unsupported digest: $key") + } } + } } diff --git a/libs/index/src/androidMain/kotlin/org/fdroid/index/v2/EntryVerifier.kt b/libs/index/src/androidMain/kotlin/org/fdroid/index/v2/EntryVerifier.kt index 9311be05b..095784cb6 100644 --- a/libs/index/src/androidMain/kotlin/org/fdroid/index/v2/EntryVerifier.kt +++ b/libs/index/src/androidMain/kotlin/org/fdroid/index/v2/EntryVerifier.kt @@ -1,42 +1,39 @@ package org.fdroid.index.v2 -import org.fdroid.index.JarIndexVerifier -import org.fdroid.index.SigningException import java.io.File import java.util.jar.Attributes +import org.fdroid.index.JarIndexVerifier +import org.fdroid.index.SigningException public const val DATA_FILE_NAME: String = "entry.json" -private val FORBIDDEN_DIGESTS = listOf( - "MD5-Digest", - "SHA1-Digest", -) +private val FORBIDDEN_DIGESTS = listOf("MD5-Digest", "SHA1-Digest") /** * Verifies the `entry.jar` file of Index V2. * * @param jarFile the signed `entry.jar` file to verify. * @param expectedSigningCertificate The signing certificate of the repo encoded in lower case hex, - * if it is known already. This should only be null if the repo is unknown. - * Then we trust it on first use (TOFU). + * if it is known already. This should only be null if the repo is unknown. Then we trust it on + * first use (TOFU). * @param expectedSigningFingerprint The fingerprint, a SHA 256 hash of the - * [expectedSigningCertificate]'s byte encoding as a lower case hex string. - * Even if [expectedSigningFingerprint] is null, the fingerprint might be known and can be used to - * verify that it matches the signing certificate. + * [expectedSigningCertificate]'s byte encoding as a lower case hex string. Even if + * [expectedSigningFingerprint] is null, the fingerprint might be known and can be used to verify + * that it matches the signing certificate. */ public class EntryVerifier( - jarFile: File, - expectedSigningCertificate: String?, - expectedSigningFingerprint: String?, + jarFile: File, + expectedSigningCertificate: String?, + expectedSigningFingerprint: String?, ) : JarIndexVerifier(jarFile, expectedSigningCertificate, expectedSigningFingerprint) { - protected override val jsonFileName: String = DATA_FILE_NAME + override val jsonFileName: String = DATA_FILE_NAME - @Throws(SigningException::class) - protected override fun checkAttributes(attributes: Attributes) { - attributes.keys.forEach { key -> - if (key.toString() in FORBIDDEN_DIGESTS) { - throw SigningException("Unsupported digest: $key") - } - } + @Throws(SigningException::class) + override fun checkAttributes(attributes: Attributes) { + attributes.keys.forEach { key -> + if (key.toString() in FORBIDDEN_DIGESTS) { + throw SigningException("Unsupported digest: $key") + } } + } } diff --git a/libs/index/src/androidMain/kotlin/org/fdroid/index/v2/IndexV2DiffStreamProcessor.kt b/libs/index/src/androidMain/kotlin/org/fdroid/index/v2/IndexV2DiffStreamProcessor.kt index c501d51b0..ce4c065f2 100644 --- a/libs/index/src/androidMain/kotlin/org/fdroid/index/v2/IndexV2DiffStreamProcessor.kt +++ b/libs/index/src/androidMain/kotlin/org/fdroid/index/v2/IndexV2DiffStreamProcessor.kt @@ -1,5 +1,6 @@ package org.fdroid.index.v2 +import java.io.InputStream import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.KSerializer import kotlinx.serialization.encoding.CompositeDecoder @@ -13,120 +14,120 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.decodeFromStream import kotlinx.serialization.json.jsonObject import org.fdroid.index.IndexParser -import java.io.InputStream @OptIn(ExperimentalSerializationApi::class) public class IndexV2DiffStreamProcessor( - private val indexStreamReceiver: IndexV2DiffStreamReceiver, - private val json: Json = IndexParser.json, + private val indexStreamReceiver: IndexV2DiffStreamReceiver, + private val json: Json = IndexParser.json, ) : IndexV2StreamProcessor { - public override fun process( - version: Long, - inputStream: InputStream, - onAppProcessed: (Int) -> Unit, - ) { - json.decodeFromStream(IndexStreamSerializer(version, onAppProcessed), inputStream) + public override fun process( + version: Long, + inputStream: InputStream, + onAppProcessed: (Int) -> Unit, + ) { + json.decodeFromStream(IndexStreamSerializer(version, onAppProcessed), inputStream) + } + + private inner class IndexStreamSerializer( + private val version: Long, + private val onAppProcessed: (Int) -> Unit, + ) : KSerializer { + override val descriptor = IndexV2.serializer().descriptor + private var appsProcessed: Int = 0 + + override fun deserialize(decoder: Decoder): IndexV2? { + decoder as? JsonDecoder ?: error("Can be deserialized only by JSON") + + decoder.beginStructure(descriptor) + val repoIndex = descriptor.getElementIndex("repo") + val packagesIndex = descriptor.getElementIndex("packages") + + when (val startIndex = decoder.decodeElementIndex(descriptor)) { + repoIndex -> { + diffRepo(version, decoder, startIndex) + val index = decoder.decodeElementIndex(descriptor) + if (index == packagesIndex) diffPackages(decoder, index) + } + + packagesIndex -> { + diffPackages(decoder, startIndex) + val index = decoder.decodeElementIndex(descriptor) + if (index == repoIndex) diffRepo(version, decoder, index) + } + + else -> error("Unexpected startIndex: $startIndex") + } + var currentIndex = 0 + while (currentIndex != CompositeDecoder.DECODE_DONE) { + currentIndex = decoder.decodeElementIndex(descriptor) + } + decoder.endStructure(descriptor) + indexStreamReceiver.onStreamEnded() + return null } - private inner class IndexStreamSerializer( - private val version: Long, - private val onAppProcessed: (Int) -> Unit, - ) : KSerializer { - override val descriptor = IndexV2.serializer().descriptor - private var appsProcessed: Int = 0 - - override fun deserialize(decoder: Decoder): IndexV2? { - decoder as? JsonDecoder ?: error("Can be deserialized only by JSON") - - decoder.beginStructure(descriptor) - val repoIndex = descriptor.getElementIndex("repo") - val packagesIndex = descriptor.getElementIndex("packages") - - when (val startIndex = decoder.decodeElementIndex(descriptor)) { - repoIndex -> { - diffRepo(version, decoder, startIndex) - val index = decoder.decodeElementIndex(descriptor) - if (index == packagesIndex) diffPackages(decoder, index) - } - - packagesIndex -> { - diffPackages(decoder, startIndex) - val index = decoder.decodeElementIndex(descriptor) - if (index == repoIndex) diffRepo(version, decoder, index) - } - - else -> error("Unexpected startIndex: $startIndex") - } - var currentIndex = 0 - while (currentIndex != CompositeDecoder.DECODE_DONE) { - currentIndex = decoder.decodeElementIndex(descriptor) - } - decoder.endStructure(descriptor) - indexStreamReceiver.onStreamEnded() - return null - } - - private fun diffRepo(version: Long, decoder: JsonDecoder, index: Int) { - require(index == descriptor.getElementIndex("repo")) - val repo = decoder.decodeJsonElement().jsonObject - indexStreamReceiver.receiveRepoDiff(version, repo) - } - - private fun diffPackages(decoder: JsonDecoder, index: Int) { - require(index == descriptor.getElementIndex("packages")) - val mapDescriptor = descriptor.getElementDescriptor(index) - val compositeDecoder = decoder.beginStructure(mapDescriptor) - while (true) { - val packageIndex = compositeDecoder.decodeElementIndex(descriptor) - if (packageIndex == CompositeDecoder.DECODE_DONE) break - readMapEntry(compositeDecoder, packageIndex) - appsProcessed += 1 - onAppProcessed(appsProcessed) - } - compositeDecoder.endStructure(mapDescriptor) - } - - private fun readMapEntry(decoder: CompositeDecoder, index: Int) { - val packageName = decoder.decodeStringElement(descriptor, index) - decoder.decodeElementIndex(descriptor) - val packageV2 = decoder.decodeSerializableElement( - descriptor = descriptor, - index = index + 1, - deserializer = JsonElement.serializer(), - ) - if (packageV2 is JsonNull) { - // delete app and existing metadata - indexStreamReceiver.receivePackageMetadataDiff(packageName, null) - return - } - // diff package metadata - val metadata = packageV2.jsonObject["metadata"] - if (metadata is JsonNull) { - // delete app and existing metadata - indexStreamReceiver.receivePackageMetadataDiff(packageName, null) - } else if (metadata is JsonObject) { - // if it is null, the diff doesn't change it, so only call receiver if not null - indexStreamReceiver.receivePackageMetadataDiff(packageName, metadata) - } - // diff package versions - if (packageV2.jsonObject["versions"] is JsonNull) { - // delete all versions of this app - indexStreamReceiver.receiveVersionsDiff(packageName, null) - } else { - val versions = packageV2.jsonObject["versions"]?.jsonObject?.mapValues { - if (it.value is JsonNull) null else it.value.jsonObject - } - if (versions != null) { - // if it is null, the diff doesn't change it, so only call receiver if not null - indexStreamReceiver.receiveVersionsDiff(packageName, versions) - } - } - } - - override fun serialize(encoder: Encoder, value: IndexV2?) { - error("Not implemented") - } + private fun diffRepo(version: Long, decoder: JsonDecoder, index: Int) { + require(index == descriptor.getElementIndex("repo")) + val repo = decoder.decodeJsonElement().jsonObject + indexStreamReceiver.receiveRepoDiff(version, repo) } + private fun diffPackages(decoder: JsonDecoder, index: Int) { + require(index == descriptor.getElementIndex("packages")) + val mapDescriptor = descriptor.getElementDescriptor(index) + val compositeDecoder = decoder.beginStructure(mapDescriptor) + while (true) { + val packageIndex = compositeDecoder.decodeElementIndex(descriptor) + if (packageIndex == CompositeDecoder.DECODE_DONE) break + readMapEntry(compositeDecoder, packageIndex) + appsProcessed += 1 + onAppProcessed(appsProcessed) + } + compositeDecoder.endStructure(mapDescriptor) + } + + private fun readMapEntry(decoder: CompositeDecoder, index: Int) { + val packageName = decoder.decodeStringElement(descriptor, index) + decoder.decodeElementIndex(descriptor) + val packageV2 = + decoder.decodeSerializableElement( + descriptor = descriptor, + index = index + 1, + deserializer = JsonElement.serializer(), + ) + if (packageV2 is JsonNull) { + // delete app and existing metadata + indexStreamReceiver.receivePackageMetadataDiff(packageName, null) + return + } + // diff package metadata + val metadata = packageV2.jsonObject["metadata"] + if (metadata is JsonNull) { + // delete app and existing metadata + indexStreamReceiver.receivePackageMetadataDiff(packageName, null) + } else if (metadata is JsonObject) { + // if it is null, the diff doesn't change it, so only call receiver if not null + indexStreamReceiver.receivePackageMetadataDiff(packageName, metadata) + } + // diff package versions + if (packageV2.jsonObject["versions"] is JsonNull) { + // delete all versions of this app + indexStreamReceiver.receiveVersionsDiff(packageName, null) + } else { + val versions = + packageV2.jsonObject["versions"]?.jsonObject?.mapValues { + if (it.value is JsonNull) null else it.value.jsonObject + } + if (versions != null) { + // if it is null, the diff doesn't change it, so only call receiver if not null + indexStreamReceiver.receiveVersionsDiff(packageName, versions) + } + } + } + + override fun serialize(encoder: Encoder, value: IndexV2?) { + error("Not implemented") + } + } } diff --git a/libs/index/src/androidMain/kotlin/org/fdroid/index/v2/IndexV2FullStreamProcessor.kt b/libs/index/src/androidMain/kotlin/org/fdroid/index/v2/IndexV2FullStreamProcessor.kt index 12c3bd45b..65a4eb491 100644 --- a/libs/index/src/androidMain/kotlin/org/fdroid/index/v2/IndexV2FullStreamProcessor.kt +++ b/libs/index/src/androidMain/kotlin/org/fdroid/index/v2/IndexV2FullStreamProcessor.kt @@ -1,5 +1,6 @@ package org.fdroid.index.v2 +import java.io.InputStream import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.KSerializer import kotlinx.serialization.SerializationException @@ -11,93 +12,92 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonDecoder import kotlinx.serialization.json.decodeFromStream import org.fdroid.index.IndexParser -import java.io.InputStream @OptIn(ExperimentalSerializationApi::class) public class IndexV2FullStreamProcessor( - private val indexStreamReceiver: IndexV2StreamReceiver, - private val json: Json = IndexParser.json, + private val indexStreamReceiver: IndexV2StreamReceiver, + private val json: Json = IndexParser.json, ) : IndexV2StreamProcessor { - @Throws(SerializationException::class, IllegalStateException::class) - public override fun process( - version: Long, - inputStream: InputStream, - onAppProcessed: (Int) -> Unit, - ) { - json.decodeFromStream(IndexStreamSerializer(version, onAppProcessed), inputStream) + @Throws(SerializationException::class, IllegalStateException::class) + public override fun process( + version: Long, + inputStream: InputStream, + onAppProcessed: (Int) -> Unit, + ) { + json.decodeFromStream(IndexStreamSerializer(version, onAppProcessed), inputStream) + } + + private inner class IndexStreamSerializer( + val version: Long, + private val onAppProcessed: (Int) -> Unit, + ) : KSerializer { + override val descriptor = IndexV2.serializer().descriptor + private var appsProcessed: Int = 0 + + override fun deserialize(decoder: Decoder): IndexV2? { + decoder as? JsonDecoder ?: error("Can be deserialized only by JSON") + + decoder.beginStructure(descriptor) + val repoIndex = descriptor.getElementIndex("repo") + val packagesIndex = descriptor.getElementIndex("packages") + + when (val startIndex = decoder.decodeElementIndex(descriptor)) { + repoIndex -> { + deserializeRepo(decoder, startIndex) + val index = decoder.decodeElementIndex(descriptor) + if (index == packagesIndex) deserializePackages(decoder, index) + } + packagesIndex -> { + deserializePackages(decoder, startIndex) + val index = decoder.decodeElementIndex(descriptor) + if (index == repoIndex) deserializeRepo(decoder, index) + } + else -> error("Unexpected startIndex: $startIndex") + } + var currentIndex = 0 + while (currentIndex != DECODE_DONE) { + currentIndex = decoder.decodeElementIndex(descriptor) + } + decoder.endStructure(descriptor) + indexStreamReceiver.onStreamEnded() + return null } - private inner class IndexStreamSerializer( - val version: Long, - private val onAppProcessed: (Int) -> Unit, - ) : KSerializer { - override val descriptor = IndexV2.serializer().descriptor - private var appsProcessed: Int = 0 - - override fun deserialize(decoder: Decoder): IndexV2? { - decoder as? JsonDecoder ?: error("Can be deserialized only by JSON") - - decoder.beginStructure(descriptor) - val repoIndex = descriptor.getElementIndex("repo") - val packagesIndex = descriptor.getElementIndex("packages") - - when (val startIndex = decoder.decodeElementIndex(descriptor)) { - repoIndex -> { - deserializeRepo(decoder, startIndex) - val index = decoder.decodeElementIndex(descriptor) - if (index == packagesIndex) deserializePackages(decoder, index) - } - packagesIndex -> { - deserializePackages(decoder, startIndex) - val index = decoder.decodeElementIndex(descriptor) - if (index == repoIndex) deserializeRepo(decoder, index) - } - else -> error("Unexpected startIndex: $startIndex") - } - var currentIndex = 0 - while (currentIndex != DECODE_DONE) { - currentIndex = decoder.decodeElementIndex(descriptor) - } - decoder.endStructure(descriptor) - indexStreamReceiver.onStreamEnded() - return null - } - - private fun deserializeRepo(decoder: JsonDecoder, index: Int) { - require(index == descriptor.getElementIndex("repo")) - val repo = decoder.decodeSerializableValue(RepoV2.serializer()) - indexStreamReceiver.receive(repo, version) - } - - private fun deserializePackages(decoder: JsonDecoder, index: Int) { - require(index == descriptor.getElementIndex("packages")) - val mapDescriptor = descriptor.getElementDescriptor(index) - val compositeDecoder = decoder.beginStructure(mapDescriptor) - while (true) { - val packageIndex = compositeDecoder.decodeElementIndex(descriptor) - if (packageIndex == DECODE_DONE) break - readMapEntry(compositeDecoder, packageIndex) - appsProcessed += 1 - onAppProcessed(appsProcessed) - } - compositeDecoder.endStructure(mapDescriptor) - } - - private fun readMapEntry(decoder: CompositeDecoder, index: Int) { - val packageName = decoder.decodeStringElement(descriptor, index) - decoder.decodeElementIndex(descriptor) - val packageV2 = decoder.decodeSerializableElement( - descriptor = descriptor, - index = index + 1, - deserializer = PackageV2.serializer(), - ) - indexStreamReceiver.receive(packageName, packageV2) - } - - override fun serialize(encoder: Encoder, value: IndexV2?) { - error("Not implemented") - } + private fun deserializeRepo(decoder: JsonDecoder, index: Int) { + require(index == descriptor.getElementIndex("repo")) + val repo = decoder.decodeSerializableValue(RepoV2.serializer()) + indexStreamReceiver.receive(repo, version) } + private fun deserializePackages(decoder: JsonDecoder, index: Int) { + require(index == descriptor.getElementIndex("packages")) + val mapDescriptor = descriptor.getElementDescriptor(index) + val compositeDecoder = decoder.beginStructure(mapDescriptor) + while (true) { + val packageIndex = compositeDecoder.decodeElementIndex(descriptor) + if (packageIndex == DECODE_DONE) break + readMapEntry(compositeDecoder, packageIndex) + appsProcessed += 1 + onAppProcessed(appsProcessed) + } + compositeDecoder.endStructure(mapDescriptor) + } + + private fun readMapEntry(decoder: CompositeDecoder, index: Int) { + val packageName = decoder.decodeStringElement(descriptor, index) + decoder.decodeElementIndex(descriptor) + val packageV2 = + decoder.decodeSerializableElement( + descriptor = descriptor, + index = index + 1, + deserializer = PackageV2.serializer(), + ) + indexStreamReceiver.receive(packageName, packageV2) + } + + override fun serialize(encoder: Encoder, value: IndexV2?) { + error("Not implemented") + } + } } diff --git a/libs/index/src/androidMain/kotlin/org/fdroid/index/v2/IndexV2StreamProcessor.kt b/libs/index/src/androidMain/kotlin/org/fdroid/index/v2/IndexV2StreamProcessor.kt index 097436677..b53f038e4 100644 --- a/libs/index/src/androidMain/kotlin/org/fdroid/index/v2/IndexV2StreamProcessor.kt +++ b/libs/index/src/androidMain/kotlin/org/fdroid/index/v2/IndexV2StreamProcessor.kt @@ -3,5 +3,5 @@ package org.fdroid.index.v2 import java.io.InputStream public interface IndexV2StreamProcessor { - public fun process(version: Long, inputStream: InputStream, onAppProcessed: (Int) -> Unit) + public fun process(version: Long, inputStream: InputStream, onAppProcessed: (Int) -> Unit) } diff --git a/libs/index/src/androidMain/kotlin/org/fdroid/index/v2/ReflectionDiffer.kt b/libs/index/src/androidMain/kotlin/org/fdroid/index/v2/ReflectionDiffer.kt index 083b3d00d..434d281a7 100644 --- a/libs/index/src/androidMain/kotlin/org/fdroid/index/v2/ReflectionDiffer.kt +++ b/libs/index/src/androidMain/kotlin/org/fdroid/index/v2/ReflectionDiffer.kt @@ -1,5 +1,12 @@ package org.fdroid.index.v2 +import kotlin.reflect.KClass +import kotlin.reflect.KFunction +import kotlin.reflect.KParameter +import kotlin.reflect.KType +import kotlin.reflect.full.memberProperties +import kotlin.reflect.full.primaryConstructor +import kotlin.reflect.typeOf import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray @@ -14,230 +21,226 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.longOrNull import kotlinx.serialization.serializer -import kotlin.reflect.KClass -import kotlin.reflect.KFunction -import kotlin.reflect.KParameter -import kotlin.reflect.KType -import kotlin.reflect.full.memberProperties -import kotlin.reflect.full.primaryConstructor -import kotlin.reflect.typeOf /** * A class using Kotlin reflection to implement JSON Merge Patch (RFC 7386) against data classes. * This approach loses type-safety, so you need to ensure that the structure of the [JsonObject] - * matches exactly the structure of the data class you want to apply the diff on. - * If something unexpected happens, [SerializationException] gets thrown. + * matches exactly the structure of the data class you want to apply the diff on. If something + * unexpected happens, [SerializationException] gets thrown. */ public object ReflectionDiffer { - @Throws(SerializationException::class) - public fun applyDiff(obj: T, diff: JsonObject): T { - val constructor = obj::class.primaryConstructor ?: e("no primary constructor ${obj::class}") - val params = HashMap() - constructor.parameters.forEach { parameter -> - val prop = obj::class.memberProperties.find { memberProperty -> - memberProperty.name == parameter.name - } ?: e("no member property for constructor, is data class?") - if (prop.name !in diff) { - params[parameter] = prop.getter.call(obj) - return@forEach - } - if (diff[prop.name] is JsonNull) { - if (parameter.type.isMarkedNullable) params[parameter] = null - else if (!parameter.isOptional) e("not nullable: ${parameter.name}") - return@forEach - } - params[parameter] = when (prop.returnType.classifier) { - Int::class -> diff[prop.name]?.primitiveOrNull()?.intOrNull - ?: e("${prop.name} no int") - Long::class -> diff[prop.name]?.primitiveOrNull()?.longOrNull - ?: e("${prop.name} no long") - String::class -> diff[prop.name]?.primitiveOrNull()?.contentOrNull - ?: e("${prop.name} no string") - List::class -> diff[prop.name]?.jsonArrayOrNull()?.map { - if (prop.name == "features") { // - it.jsonObjectOrNull()?.get("name")?.primitiveOrNull()?.contentOrNull - ?: e("features without primitive name: $it") - } else { - it.primitiveOrNull()?.contentOrNull ?: e("${prop.name} non-primitive array") - } - } ?: e("${prop.name} no array") - Map::class -> diffMap(prop.returnType, prop.getter.call(obj), prop.name, diff) - else -> { - val newObj = prop.getter.call(obj) - val jsonObject = diff[prop.name] as? JsonObject ?: e("${prop.name} no dict") - if (newObj == null) { - val factory = (prop.returnType.classifier as KClass<*>).primaryConstructor!! - constructFromJson(factory, jsonObject) - } else { - applyDiff(newObj, jsonObject) - } - } - } - } - return constructor.callBy(params) - } - - /** - * Used when the diff introduces a new object. - * As the object did not exist before, we can not apply a diff to it, - * but must construct it from scratch. - * We use the given [factory] for that which is typically a constructor of the object . - */ - @Throws(SerializationException::class) - internal fun constructFromJson( - factory: KFunction, - diff: JsonObject, - ): T { - val params = HashMap() - factory.parameters.forEach { prop -> - if (prop.name !in diff) { - if (prop.isOptional) return@forEach - else e("${prop.name} required but not found") - } - if (diff[prop.name] is JsonNull) { - if (prop.type.isMarkedNullable) params[prop] = null - else if (!prop.isOptional) e("not nullable: ${prop.name}") - return@forEach - } - params[prop] = when (prop.type.classifier) { - Int::class -> diff[prop.name]?.primitiveOrNull()?.intOrNull - ?: e("${prop.name} no int") - Long::class -> diff[prop.name]?.primitiveOrNull()?.longOrNull - ?: e("${prop.name} no long") - String::class -> diff[prop.name]?.primitiveOrNull()?.contentOrNull - ?: e("${prop.name} no string") - List::class -> diff[prop.name]?.jsonArrayOrNull()?.map { - it.primitiveOrNull()?.contentOrNull ?: e("${prop.name} non-primitive array") - } ?: e("${prop.name} no array") - Map::class -> diffMap(prop.type, null, prop.name, diff) - else -> constructFromJson( - factory = (prop.type.classifier as KClass<*>).primaryConstructor!!, - diff = diff[prop.name]?.jsonObjectOrNull() ?: e("${prop.name} no dict"), - ) - } - } - return factory.callBy(params) - } - - @Suppress("UNCHECKED_CAST") - private fun diffMap(type: KType, obj: T?, key: String?, diff: JsonObject) = when (type) { - typeOf() -> applyTextDiff( - obj = obj as? LocalizedTextV2 ?: HashMap(), - diff = diff[key]?.jsonObjectOrNull() ?: e("$key no map"), - ) - typeOf() -> applyTextDiff( - obj = obj as? LocalizedTextV2? ?: HashMap(), - diff = diff[key]?.jsonObjectOrNull() ?: e("$key no map"), - ) - typeOf() -> applyFileDiff( - obj = obj as? LocalizedFileV2 ?: HashMap(), - diff = diff[key]?.jsonObjectOrNull() ?: e("$key no map"), - ) - typeOf() -> applyFileDiff( - obj = obj as? LocalizedFileV2? ?: HashMap(), - diff = diff[key]?.jsonObjectOrNull() ?: e("$key no map"), - ) - typeOf>() -> applyMapTextDiff( - obj = obj as? Map ?: HashMap(), - diff = diff[key]?.jsonObjectOrNull() ?: e("$key no map"), - ) - typeOf?>() -> applyMapTextDiff( - obj = obj as? Map? ?: HashMap(), - diff = diff[key]?.jsonObjectOrNull() ?: e("$key no map"), - ) - else -> e("Unknown map: $key: $type = ${diff[key]}") - } - - @Throws(SerializationException::class) - private fun applyTextDiff( - obj: LocalizedTextV2, - diff: JsonObject, - ): LocalizedTextV2 = obj.toMutableMap().apply { - diff.entries.forEach { (locale, textElement) -> - if (textElement is JsonNull) { - remove(locale) - return@forEach - } - val text = textElement.primitiveOrNull()?.contentOrNull - ?: throw SerializationException("no string: $textElement") - set(locale, text) - } - } - - @Throws(SerializationException::class) - private fun applyFileDiff( - obj: LocalizedFileV2, - diff: JsonObject, - ): LocalizedFileV2 = obj.toMutableMap().apply { - diff.entries.forEach { (locale, fileV2Element) -> - if (fileV2Element is JsonNull) { - remove(locale) - return@forEach - } - val fileV2Object = fileV2Element.jsonObjectOrNull() - ?: throw SerializationException("no FileV2: $fileV2Element") - val fileV2 = if (locale in obj) { - applyDiff(obj[locale] as FileV2, fileV2Object) + @Throws(SerializationException::class) + public fun applyDiff(obj: T, diff: JsonObject): T { + val constructor = obj::class.primaryConstructor ?: e("no primary constructor ${obj::class}") + val params = HashMap() + constructor.parameters.forEach { parameter -> + val prop = + obj::class.memberProperties.find { memberProperty -> memberProperty.name == parameter.name } + ?: e("no member property for constructor, is data class?") + if (prop.name !in diff) { + params[parameter] = prop.getter.call(obj) + return@forEach + } + if (diff[prop.name] is JsonNull) { + if (parameter.type.isMarkedNullable) params[parameter] = null + else if (!parameter.isOptional) e("not nullable: ${parameter.name}") + return@forEach + } + params[parameter] = + when (prop.returnType.classifier) { + Int::class -> diff[prop.name]?.primitiveOrNull()?.intOrNull ?: e("${prop.name} no int") + Long::class -> diff[prop.name]?.primitiveOrNull()?.longOrNull ?: e("${prop.name} no long") + String::class -> + diff[prop.name]?.primitiveOrNull()?.contentOrNull ?: e("${prop.name} no string") + List::class -> + diff[prop.name]?.jsonArrayOrNull()?.map { + if (prop.name == "features") { // + it.jsonObjectOrNull()?.get("name")?.primitiveOrNull()?.contentOrNull + ?: e("features without primitive name: $it") + } else { + it.primitiveOrNull()?.contentOrNull ?: e("${prop.name} non-primitive array") + } + } ?: e("${prop.name} no array") + Map::class -> diffMap(prop.returnType, prop.getter.call(obj), prop.name, diff) + else -> { + val newObj = prop.getter.call(obj) + val jsonObject = diff[prop.name] as? JsonObject ?: e("${prop.name} no dict") + if (newObj == null) { + val factory = (prop.returnType.classifier as KClass<*>).primaryConstructor!! + constructFromJson(factory, jsonObject) } else { - constructFromJson(FileV2::class.primaryConstructor!!, fileV2Object) + applyDiff(newObj, jsonObject) } - set(locale, fileV2) + } } } + return constructor.callBy(params) + } - @Throws(SerializationException::class) - private fun applyMapTextDiff( - obj: Map, - diff: JsonObject, - ): Map = obj.toMutableMap().apply { - diff.entries.forEach { (key, localizedTextElement) -> - if (localizedTextElement is JsonNull) { - remove(key) - return@forEach - } - val localizedTextObject = localizedTextElement.jsonObjectOrNull() - ?: throw SerializationException("no FileV2: $localizedTextElement") - val localizedText = if (key in obj) { - applyTextDiff(obj[key] as LocalizedTextV2, localizedTextObject) - } else { - applyTextDiff(HashMap(), localizedTextObject) - } - set(key, localizedText) + /** + * Used when the diff introduces a new object. As the object did not exist before, we can not + * apply a diff to it, but must construct it from scratch. We use the given [factory] for that + * which is typically a constructor of the object . + */ + @Throws(SerializationException::class) + internal fun constructFromJson(factory: KFunction, diff: JsonObject): T { + val params = HashMap() + factory.parameters.forEach { prop -> + if (prop.name !in diff) { + if (prop.isOptional) return@forEach else e("${prop.name} required but not found") + } + if (diff[prop.name] is JsonNull) { + if (prop.type.isMarkedNullable) params[prop] = null + else if (!prop.isOptional) e("not nullable: ${prop.name}") + return@forEach + } + params[prop] = + when (prop.type.classifier) { + Int::class -> diff[prop.name]?.primitiveOrNull()?.intOrNull ?: e("${prop.name} no int") + Long::class -> diff[prop.name]?.primitiveOrNull()?.longOrNull ?: e("${prop.name} no long") + String::class -> + diff[prop.name]?.primitiveOrNull()?.contentOrNull ?: e("${prop.name} no string") + List::class -> + diff[prop.name]?.jsonArrayOrNull()?.map { + it.primitiveOrNull()?.contentOrNull ?: e("${prop.name} non-primitive array") + } ?: e("${prop.name} no array") + Map::class -> diffMap(prop.type, null, prop.name, diff) + else -> + constructFromJson( + factory = (prop.type.classifier as KClass<*>).primaryConstructor!!, + diff = diff[prop.name]?.jsonObjectOrNull() ?: e("${prop.name} no dict"), + ) } } + return factory.callBy(params) + } - private fun JsonElement.primitiveOrNull(): JsonPrimitive? = try { - jsonPrimitive - } catch (e: IllegalArgumentException) { - null + @Suppress("UNCHECKED_CAST") + private fun diffMap(type: KType, obj: T?, key: String?, diff: JsonObject) = + when (type) { + typeOf() -> + applyTextDiff( + obj = obj as? LocalizedTextV2 ?: HashMap(), + diff = diff[key]?.jsonObjectOrNull() ?: e("$key no map"), + ) + typeOf() -> + applyTextDiff( + obj = obj as? LocalizedTextV2? ?: HashMap(), + diff = diff[key]?.jsonObjectOrNull() ?: e("$key no map"), + ) + typeOf() -> + applyFileDiff( + obj = obj as? LocalizedFileV2 ?: HashMap(), + diff = diff[key]?.jsonObjectOrNull() ?: e("$key no map"), + ) + typeOf() -> + applyFileDiff( + obj = obj as? LocalizedFileV2? ?: HashMap(), + diff = diff[key]?.jsonObjectOrNull() ?: e("$key no map"), + ) + typeOf>() -> + applyMapTextDiff( + obj = obj as? Map ?: HashMap(), + diff = diff[key]?.jsonObjectOrNull() ?: e("$key no map"), + ) + typeOf?>() -> + applyMapTextDiff( + obj = obj as? Map? ?: HashMap(), + diff = diff[key]?.jsonObjectOrNull() ?: e("$key no map"), + ) + else -> e("Unknown map: $key: $type = ${diff[key]}") } - private fun JsonElement.jsonArrayOrNull(): JsonArray? = try { - jsonArray - } catch (e: IllegalArgumentException) { - null - } - - private fun JsonElement.jsonObjectOrNull(): JsonObject? = try { - jsonObject - } catch (e: IllegalArgumentException) { - null - } - - @Throws(SerializationException::class) - private fun e(msg: String): Nothing = throw SerializationException(msg) - - public inline fun Json.decodeOr( - key: String, - json: JsonObject, - default: () -> T, - ): T { - return if (json.containsKey(key)) { - decodeFromJsonElement(serializersModule.serializer(), json) - } else { - default() + @Throws(SerializationException::class) + private fun applyTextDiff(obj: LocalizedTextV2, diff: JsonObject): LocalizedTextV2 = + obj.toMutableMap().apply { + diff.entries.forEach { (locale, textElement) -> + if (textElement is JsonNull) { + remove(locale) + return@forEach } + val text = + textElement.primitiveOrNull()?.contentOrNull + ?: throw SerializationException("no string: $textElement") + set(locale, text) + } } + @Throws(SerializationException::class) + private fun applyFileDiff(obj: LocalizedFileV2, diff: JsonObject): LocalizedFileV2 = + obj.toMutableMap().apply { + diff.entries.forEach { (locale, fileV2Element) -> + if (fileV2Element is JsonNull) { + remove(locale) + return@forEach + } + val fileV2Object = + fileV2Element.jsonObjectOrNull() + ?: throw SerializationException("no FileV2: $fileV2Element") + val fileV2 = + if (locale in obj) { + applyDiff(obj[locale] as FileV2, fileV2Object) + } else { + constructFromJson(FileV2::class.primaryConstructor!!, fileV2Object) + } + set(locale, fileV2) + } + } + + @Throws(SerializationException::class) + private fun applyMapTextDiff( + obj: Map, + diff: JsonObject, + ): Map = + obj.toMutableMap().apply { + diff.entries.forEach { (key, localizedTextElement) -> + if (localizedTextElement is JsonNull) { + remove(key) + return@forEach + } + val localizedTextObject = + localizedTextElement.jsonObjectOrNull() + ?: throw SerializationException("no FileV2: $localizedTextElement") + val localizedText = + if (key in obj) { + applyTextDiff(obj[key] as LocalizedTextV2, localizedTextObject) + } else { + applyTextDiff(HashMap(), localizedTextObject) + } + set(key, localizedText) + } + } + + private fun JsonElement.primitiveOrNull(): JsonPrimitive? = + try { + jsonPrimitive + } catch (e: IllegalArgumentException) { + null + } + + private fun JsonElement.jsonArrayOrNull(): JsonArray? = + try { + jsonArray + } catch (e: IllegalArgumentException) { + null + } + + private fun JsonElement.jsonObjectOrNull(): JsonObject? = + try { + jsonObject + } catch (e: IllegalArgumentException) { + null + } + + @Throws(SerializationException::class) + private fun e(msg: String): Nothing = throw SerializationException(msg) + + public inline fun Json.decodeOr(key: String, json: JsonObject, default: () -> T): T { + return if (json.containsKey(key)) { + decodeFromJsonElement(serializersModule.serializer(), json) + } else { + default() + } + } } diff --git a/libs/index/src/androidUnitTest/kotlin/org/fdroid/CompatibilityCheckerTest.kt b/libs/index/src/androidUnitTest/kotlin/org/fdroid/CompatibilityCheckerTest.kt index 6db131619..fcd9d9a98 100644 --- a/libs/index/src/androidUnitTest/kotlin/org/fdroid/CompatibilityCheckerTest.kt +++ b/libs/index/src/androidUnitTest/kotlin/org/fdroid/CompatibilityCheckerTest.kt @@ -4,133 +4,133 @@ import android.content.pm.FeatureInfo import android.content.pm.PackageManager import io.mockk.every import io.mockk.mockk -import org.fdroid.index.v2.PackageManifest import kotlin.test.Test import kotlin.test.assertFalse import kotlin.test.assertTrue +import org.fdroid.index.v2.PackageManifest internal class CompatibilityCheckerTest { - private val sdkInt: Int = 30 - private val supportedAbis = arrayOf("x86") - private val packageManager: PackageManager = mockk() + private val sdkInt: Int = 30 + private val supportedAbis = arrayOf("x86") + private val packageManager: PackageManager = mockk() - init { - every { packageManager.systemAvailableFeatures } returns arrayOf( - FeatureInfo().apply { name = "foo bar" }, - FeatureInfo().apply { name = "1337" }, - ) - } + init { + every { packageManager.systemAvailableFeatures } returns + arrayOf(FeatureInfo().apply { name = "foo bar" }, FeatureInfo().apply { name = "1337" }) + } - private val checker = CompatibilityCheckerImpl( - packageManager = packageManager, - forceTouchApps = false, - sdkInt = sdkInt, - supportedAbis = supportedAbis, + private val checker = + CompatibilityCheckerImpl( + packageManager = packageManager, + forceTouchApps = false, + sdkInt = sdkInt, + supportedAbis = supportedAbis, ) - @Test - fun emptyManifestIsCompatible() { - val manifest = Manifest() - assertTrue(checker.isCompatible(manifest)) - } + @Test + fun emptyManifestIsCompatible() { + val manifest = Manifest() + assertTrue(checker.isCompatible(manifest)) + } - @Test - fun minSdkIsRespected() { - // smaller or equal minSdks are compatible - val manifest1 = Manifest(minSdkVersion = 1) - assertTrue(checker.isCompatible(manifest1)) - val manifest2 = Manifest(minSdkVersion = sdkInt) - assertTrue(checker.isCompatible(manifest2)) - // a minSdk higher than the system is not compatible - val manifest3 = Manifest(minSdkVersion = sdkInt + 1) - assertFalse(checker.isCompatible(manifest3)) - } + @Test + fun minSdkIsRespected() { + // smaller or equal minSdks are compatible + val manifest1 = Manifest(minSdkVersion = 1) + assertTrue(checker.isCompatible(manifest1)) + val manifest2 = Manifest(minSdkVersion = sdkInt) + assertTrue(checker.isCompatible(manifest2)) + // a minSdk higher than the system is not compatible + val manifest3 = Manifest(minSdkVersion = sdkInt + 1) + assertFalse(checker.isCompatible(manifest3)) + } - @Test - fun maxSdkIsRespected() { - // smaller maxSdks are not compatible - val manifest1 = Manifest(maxSdkVersion = sdkInt - 1) - assertFalse(checker.isCompatible(manifest1)) - // higher or equal are compatible - val manifest2 = Manifest(maxSdkVersion = sdkInt) - assertTrue(checker.isCompatible(manifest2)) - val manifest3 = Manifest(maxSdkVersion = sdkInt + 1) - assertTrue(checker.isCompatible(manifest3)) - } + @Test + fun maxSdkIsRespected() { + // smaller maxSdks are not compatible + val manifest1 = Manifest(maxSdkVersion = sdkInt - 1) + assertFalse(checker.isCompatible(manifest1)) + // higher or equal are compatible + val manifest2 = Manifest(maxSdkVersion = sdkInt) + assertTrue(checker.isCompatible(manifest2)) + val manifest3 = Manifest(maxSdkVersion = sdkInt + 1) + assertTrue(checker.isCompatible(manifest3)) + } - @Test - fun emptyNativeCodeIsCompatible() { - val manifest = Manifest(nativecode = emptyList()) - assertTrue(checker.isCompatible(manifest)) - } + @Test + fun emptyNativeCodeIsCompatible() { + val manifest = Manifest(nativecode = emptyList()) + assertTrue(checker.isCompatible(manifest)) + } - @Test - fun nativeCodeMustBeAvailable() { - val manifest1 = Manifest(nativecode = listOf("x86")) - assertTrue(checker.isCompatible(manifest1)) - val manifest2 = Manifest(nativecode = listOf("x86", "armeabi-v7a")) - assertTrue(checker.isCompatible(manifest2)) - val manifest3 = Manifest(nativecode = listOf("arm64-v8a", "armeabi-v7a")) - assertFalse(checker.isCompatible(manifest3)) - } + @Test + fun nativeCodeMustBeAvailable() { + val manifest1 = Manifest(nativecode = listOf("x86")) + assertTrue(checker.isCompatible(manifest1)) + val manifest2 = Manifest(nativecode = listOf("x86", "armeabi-v7a")) + assertTrue(checker.isCompatible(manifest2)) + val manifest3 = Manifest(nativecode = listOf("arm64-v8a", "armeabi-v7a")) + assertFalse(checker.isCompatible(manifest3)) + } - @Test - fun featuresMustBeAvailable() { - val manifest1 = Manifest(featureNames = listOf("foo bar")) - assertTrue(checker.isCompatible(manifest1)) - val manifest2 = Manifest(featureNames = listOf("1337", "foo bar")) - assertTrue(checker.isCompatible(manifest2)) - val manifest3 = Manifest(featureNames = listOf("1337", "foo bar", "42")) - assertFalse(checker.isCompatible(manifest3)) - val manifest4 = Manifest(featureNames = listOf("foo", "bar")) - assertFalse(checker.isCompatible(manifest4)) - } + @Test + fun featuresMustBeAvailable() { + val manifest1 = Manifest(featureNames = listOf("foo bar")) + assertTrue(checker.isCompatible(manifest1)) + val manifest2 = Manifest(featureNames = listOf("1337", "foo bar")) + assertTrue(checker.isCompatible(manifest2)) + val manifest3 = Manifest(featureNames = listOf("1337", "foo bar", "42")) + assertFalse(checker.isCompatible(manifest3)) + val manifest4 = Manifest(featureNames = listOf("foo", "bar")) + assertFalse(checker.isCompatible(manifest4)) + } - @Test - fun forceTouchScreenIsRespected() { - val checkerForce = CompatibilityCheckerImpl(packageManager, true, sdkInt, supportedAbis) + @Test + fun forceTouchScreenIsRespected() { + val checkerForce = CompatibilityCheckerImpl(packageManager, true, sdkInt, supportedAbis) - // when forced, apps that need touchscreen on non-touchscreen device are compatible - val manifest1 = Manifest(featureNames = listOf("android.hardware.touchscreen")) - assertTrue(checkerForce.isCompatible(manifest1)) - val manifest2 = Manifest(featureNames = listOf("android.hardware.touchscreen")) - assertTrue(checkerForce.isCompatible(manifest2)) - // when not forced, apps that need touchscreen on non-touchscreen device are not compatible - val manifest3 = Manifest(featureNames = listOf("android.hardware.touchscreen")) - assertFalse(checker.isCompatible(manifest3)) - val manifest4 = Manifest(featureNames = listOf("android.hardware.touchscreen")) - assertFalse(checker.isCompatible(manifest4)) - } + // when forced, apps that need touchscreen on non-touchscreen device are compatible + val manifest1 = Manifest(featureNames = listOf("android.hardware.touchscreen")) + assertTrue(checkerForce.isCompatible(manifest1)) + val manifest2 = Manifest(featureNames = listOf("android.hardware.touchscreen")) + assertTrue(checkerForce.isCompatible(manifest2)) + // when not forced, apps that need touchscreen on non-touchscreen device are not compatible + val manifest3 = Manifest(featureNames = listOf("android.hardware.touchscreen")) + assertFalse(checker.isCompatible(manifest3)) + val manifest4 = Manifest(featureNames = listOf("android.hardware.touchscreen")) + assertFalse(checker.isCompatible(manifest4)) + } - @Test - fun targetSdkIsRespected() { - // greater or equal than minInstallableTargetSdk are compatible - val manifest1 = Manifest(targetSdkVersion = Int.MAX_VALUE) - assertTrue(checker.isCompatible(manifest1)) - val manifest2 = Manifest(targetSdkVersion = 23) - val checker2 = CompatibilityCheckerImpl( - packageManager = packageManager, - sdkInt = 34, - supportedAbis = emptyArray() - ) - assertTrue(checker2.isCompatible(manifest2)) - // a targetSdk smaller than minInstallableTargetSdk is not compatible - val manifest3 = Manifest(targetSdkVersion = 22) - val checker3 = CompatibilityCheckerImpl( - packageManager = packageManager, - sdkInt = 34, - supportedAbis = emptyArray() - ) - assertFalse(checker3.isCompatible(manifest3)) - } - - private data class Manifest( - override val minSdkVersion: Int? = null, - override val maxSdkVersion: Int? = null, - override val featureNames: List? = null, - override val nativecode: List? = null, - override val targetSdkVersion: Int? = null, - ) : PackageManifest + @Test + fun targetSdkIsRespected() { + // greater or equal than minInstallableTargetSdk are compatible + val manifest1 = Manifest(targetSdkVersion = Int.MAX_VALUE) + assertTrue(checker.isCompatible(manifest1)) + val manifest2 = Manifest(targetSdkVersion = 23) + val checker2 = + CompatibilityCheckerImpl( + packageManager = packageManager, + sdkInt = 34, + supportedAbis = emptyArray(), + ) + assertTrue(checker2.isCompatible(manifest2)) + // a targetSdk smaller than minInstallableTargetSdk is not compatible + val manifest3 = Manifest(targetSdkVersion = 22) + val checker3 = + CompatibilityCheckerImpl( + packageManager = packageManager, + sdkInt = 34, + supportedAbis = emptyArray(), + ) + assertFalse(checker3.isCompatible(manifest3)) + } + private data class Manifest( + override val minSdkVersion: Int? = null, + override val maxSdkVersion: Int? = null, + override val featureNames: List? = null, + override val nativecode: List? = null, + override val targetSdkVersion: Int? = null, + ) : PackageManifest } diff --git a/libs/index/src/androidUnitTest/kotlin/org/fdroid/UpdateCheckerTest.kt b/libs/index/src/androidUnitTest/kotlin/org/fdroid/UpdateCheckerTest.kt index 29fcaa51f..37affbbdb 100644 --- a/libs/index/src/androidUnitTest/kotlin/org/fdroid/UpdateCheckerTest.kt +++ b/libs/index/src/androidUnitTest/kotlin/org/fdroid/UpdateCheckerTest.kt @@ -1,205 +1,199 @@ package org.fdroid +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull import org.fdroid.index.RELEASE_CHANNEL_BETA import org.fdroid.index.v2.PackageManifest import org.fdroid.index.v2.PackageVersion import org.fdroid.index.v2.SignerV2 -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull internal class UpdateCheckerTest { - private val updateChecker = UpdateChecker { true } - private val signer = "9f9261f0b911c60f8db722f5d430a9e9d557a3f8078ce43e1c07522ef41efedb" - private val signerV2 = SignerV2(listOf(signer)) - private val betaChannels = listOf(RELEASE_CHANNEL_BETA) - private val version1 = Version(1, "1", 23, 1) - private val version2 = Version(2, "2", 23, 2) - private val version3 = Version(3, "2", 23, 3) - private val versions = listOf(version3, version2, version1) + private val updateChecker = UpdateChecker { true } + private val signer = "9f9261f0b911c60f8db722f5d430a9e9d557a3f8078ce43e1c07522ef41efedb" + private val signerV2 = SignerV2(listOf(signer)) + private val betaChannels = listOf(RELEASE_CHANNEL_BETA) + private val version1 = Version(1, "1", 23, 1) + private val version2 = Version(2, "2", 23, 2) + private val version3 = Version(3, "2", 23, 3) + private val versions = listOf(version3, version2, version1) - @Test - fun highestVersionCode() { - assertEquals(version3, updateChecker.getUpdate(versions)) - assertEquals(version3, updateChecker.getUpdate(versions, installedVersionCode = 2)) - assertEquals(version3, updateChecker.getUpdate(versions, installedVersionCode = 1)) - assertEquals(version3, updateChecker.getUpdate(versions, installedVersionCode = 0)) + @Test + fun highestVersionCode() { + assertEquals(version3, updateChecker.getUpdate(versions)) + assertEquals(version3, updateChecker.getUpdate(versions, installedVersionCode = 2)) + assertEquals(version3, updateChecker.getUpdate(versions, installedVersionCode = 1)) + assertEquals(version3, updateChecker.getUpdate(versions, installedVersionCode = 0)) + } + + @Test + fun noUpdateIfSameOrHigherVersionInstalled() { + assertNull(updateChecker.getUpdate(versions, installedVersionCode = 3)) + assertNull(updateChecker.getUpdate(versions, installedVersionCode = 4)) + } + + @Test + fun testIncompatibleVersionNotConsidered() { + var versionNum = 0 + val updateChecker = UpdateChecker { + versionNum++ + versionNum != 1 // highest version is incompatible } + val v = updateChecker.getUpdate(versions) + assertEquals(version2, v) + } - @Test - fun noUpdateIfSameOrHigherVersionInstalled() { - assertNull(updateChecker.getUpdate(versions, installedVersionCode = 3)) - assertNull(updateChecker.getUpdate(versions, installedVersionCode = 4)) - } + @Test + fun ignoredVersionNotConsidered() { + val not4 = { AppPreferences(ignoreVersionCodeUpdate = 4) } + val not3 = { AppPreferences(ignoreVersionCodeUpdate = 3) } + val not2 = { AppPreferences(ignoreVersionCodeUpdate = 2) } + assertNull(updateChecker.getUpdate(versions, preferencesGetter = not4)) + assertNull(updateChecker.getUpdate(versions, preferencesGetter = not3)) + assertEquals(version3, updateChecker.getUpdate(versions, preferencesGetter = not2)) + } - @Test - fun testIncompatibleVersionNotConsidered() { - var versionNum = 0 - val updateChecker = UpdateChecker { - versionNum++ - versionNum != 1 // highest version is incompatible - } - val v = updateChecker.getUpdate(versions) - assertEquals(version2, v) - } + @Test + fun betaVersionOnlyReturnedWhenAllowed() { + val version3 = version3.copy(releaseChannels = betaChannels) + val versions = listOf(version3, version2, version1) + // beta not allowed, so 2 returned + assertEquals(version2, updateChecker.getUpdate(versions)) + // now beta is allowed, so 3 returned + assertEquals(version3, getWithAllowReleaseChannels(versions, betaChannels)) + } - @Test - fun ignoredVersionNotConsidered() { - val not4 = { AppPreferences(ignoreVersionCodeUpdate = 4) } - val not3 = { AppPreferences(ignoreVersionCodeUpdate = 3) } - val not2 = { AppPreferences(ignoreVersionCodeUpdate = 2) } - assertNull(updateChecker.getUpdate(versions, preferencesGetter = not4)) - assertNull(updateChecker.getUpdate(versions, preferencesGetter = not3)) - assertEquals(version3, updateChecker.getUpdate(versions, preferencesGetter = not2)) - } + @Test + fun emptyReleaseChannelsAlwaysIncluded() { + val version3 = version3.copy(releaseChannels = emptyList()) + val versions = listOf(version3, version2, version1) + // version with empty release channels gets returned + assertEquals(version3, updateChecker.getUpdate(versions)) + // version with empty release channels gets returned when allowing also beta + assertEquals(version3, updateChecker.getUpdate(versions, allowedReleaseChannels = betaChannels)) + // version with empty release channels gets returned when allow list is empty + assertEquals(version3, getWithAllowReleaseChannels(versions, emptyList())) + assertEquals(this.version3, getWithAllowReleaseChannels(this.versions, emptyList())) + } - @Test - fun betaVersionOnlyReturnedWhenAllowed() { - val version3 = version3.copy(releaseChannels = betaChannels) - val versions = listOf(version3, version2, version1) - // beta not allowed, so 2 returned - assertEquals(version2, updateChecker.getUpdate(versions)) - // now beta is allowed, so 3 returned - assertEquals(version3, getWithAllowReleaseChannels(versions, betaChannels)) - } + @Test + fun onlyAllowedReleaseChannelsGetIncluded() { + val version3 = version3.copy(releaseChannels = listOf("a")) + val version2 = version2.copy(releaseChannels = listOf("a", "b", "c")) + val versions = listOf(version3, version2, version1) + // only stable version gets returned + assertEquals(version1, getWithAllowReleaseChannels(versions, null)) + // as long as "a" is included, 3 gets returned + assertEquals(version3, getWithAllowReleaseChannels(versions, listOf("a"))) + assertEquals(version3, getWithAllowReleaseChannels(versions, listOf("a", "b"))) + assertEquals(version3, getWithAllowReleaseChannels(versions, listOf("a", "b", "z"))) + // as long as "b" or "c" is included, 2 gets returned + assertEquals(version2, getWithAllowReleaseChannels(versions, listOf("b"))) + assertEquals(version2, getWithAllowReleaseChannels(versions, listOf("b", "z"))) + assertEquals(version2, getWithAllowReleaseChannels(versions, listOf("c"))) + assertEquals(version2, getWithAllowReleaseChannels(versions, listOf("c", "z"))) + // if neither "a", "b" or "c" is included, 1 gets returned + assertEquals(version1, getWithAllowReleaseChannels(versions, listOf("x", "y", "z"))) + } - @Test - fun emptyReleaseChannelsAlwaysIncluded() { - val version3 = version3.copy(releaseChannels = emptyList()) - val versions = listOf(version3, version2, version1) - // version with empty release channels gets returned - assertEquals(version3, updateChecker.getUpdate(versions)) - // version with empty release channels gets returned when allowing also beta - assertEquals( - version3, - updateChecker.getUpdate(versions, allowedReleaseChannels = betaChannels) - ) - // version with empty release channels gets returned when allow list is empty - assertEquals(version3, getWithAllowReleaseChannels(versions, emptyList())) - assertEquals(this.version3, getWithAllowReleaseChannels(this.versions, emptyList())) - } + @Test + fun multipleSignersNotSupported() { + val version = version3.copy(signer = signerV2.copy(hasMultipleSigners = true)) + val versions = listOf(version) + assertNull(updateChecker.getUpdate(versions)) + } - @Test - fun onlyAllowedReleaseChannelsGetIncluded() { - val version3 = version3.copy(releaseChannels = listOf("a")) - val version2 = version2.copy(releaseChannels = listOf("a", "b", "c")) - val versions = listOf(version3, version2, version1) - // only stable version gets returned - assertEquals(version1, getWithAllowReleaseChannels(versions, null)) - // as long as "a" is included, 3 gets returned - assertEquals(version3, getWithAllowReleaseChannels(versions, listOf("a"))) - assertEquals(version3, getWithAllowReleaseChannels(versions, listOf("a", "b"))) - assertEquals(version3, getWithAllowReleaseChannels(versions, listOf("a", "b", "z"))) - // as long as "b" or "c" is included, 2 gets returned - assertEquals(version2, getWithAllowReleaseChannels(versions, listOf("b"))) - assertEquals(version2, getWithAllowReleaseChannels(versions, listOf("b", "z"))) - assertEquals(version2, getWithAllowReleaseChannels(versions, listOf("c"))) - assertEquals(version2, getWithAllowReleaseChannels(versions, listOf("c", "z"))) - // if neither "a", "b" or "c" is included, 1 gets returned - assertEquals(version1, getWithAllowReleaseChannels(versions, listOf("x", "y", "z"))) - } + @Test + fun onlyAllowedSignersGetIncluded() { + val version3 = version3.copy(signer = SignerV2(listOf("foo", "bar"))) + val version2 = version2.copy(signer = signerV2) + val versions = listOf(version3, version2, version1) + val v2Set = signerV2.sha256.toMutableSet() + // 3 gets returned if at least one of its signers are allowed, or all are allowed + assertEquals(version3, updateChecker.getUpdate(versions, { setOf("foo") })) + assertEquals(version3, updateChecker.getUpdate(versions, { setOf("bar") })) + assertEquals(version3, updateChecker.getUpdate(versions, { v2Set + "bar" })) + assertEquals(version3, updateChecker.getUpdate(versions, { setOf("foo", "bar") })) + assertEquals(version3, updateChecker.getUpdate(versions, { setOf("foo", "bar", "42") })) + assertEquals(version3, updateChecker.getUpdate(versions, { null })) + // 2 gets returned if at least one of its signers are allowed + assertEquals(version2, updateChecker.getUpdate(versions, { v2Set })) + assertEquals(version2, updateChecker.getUpdate(versions, { v2Set + "foo bar" })) + // empty set means no signers are allowed, only works for packages without "signer" + assertEquals(version1, updateChecker.getUpdate(versions, { emptySet() })) + // packages without "signer" entries get through everything + assertEquals(version1, updateChecker.getUpdate(versions, { setOf("no version") })) + // if no matching sig can be found, no version gets returned + assertNull(updateChecker.getUpdate(listOf(version3, version2), { setOf("no version") })) + } - @Test - fun multipleSignersNotSupported() { - val version = version3.copy(signer = signerV2.copy(hasMultipleSigners = true)) - val versions = listOf(version) - assertNull(updateChecker.getUpdate(versions)) - } + @Test + fun installedVulnerableVersionAlwaysReturned() { + val version3 = version3.copy(hasKnownVulnerability = true) + val versions = listOf(version3, version2, version1) + assertEquals(version3, updateChecker.getUpdate(versions, includeKnownVulnerabilities = true)) + assertEquals( + version3, + updateChecker.getUpdate( + versions, + installedVersionCode = 3, + includeKnownVulnerabilities = true, + ), + ) + assertEquals( + version3, + updateChecker.getUpdate( + versions, + installedVersionCode = 2, + includeKnownVulnerabilities = true, + ), + ) + // when not asking for known vulnerabilities, version3 isn't returned (no update here) + assertNull(updateChecker.getUpdate(versions, installedVersionCode = 3)) + } - @Test - fun onlyAllowedSignersGetIncluded() { - val version3 = version3.copy(signer = SignerV2(listOf("foo", "bar"))) - val version2 = version2.copy(signer = signerV2) - val versions = listOf(version3, version2, version1) - val v2Set = signerV2.sha256.toMutableSet() - // 3 gets returned if at least one of its signers are allowed, or all are allowed - assertEquals(version3, updateChecker.getUpdate(versions, { setOf("foo") })) - assertEquals(version3, updateChecker.getUpdate(versions, { setOf("bar") })) - assertEquals(version3, updateChecker.getUpdate(versions, { v2Set + "bar" })) - assertEquals(version3, updateChecker.getUpdate(versions, { setOf("foo", "bar") })) - assertEquals(version3, updateChecker.getUpdate(versions, { setOf("foo", "bar", "42") })) - assertEquals(version3, updateChecker.getUpdate(versions, { null })) - // 2 gets returned if at least one of its signers are allowed - assertEquals(version2, updateChecker.getUpdate(versions, { v2Set })) - assertEquals(version2, updateChecker.getUpdate(versions, { v2Set + "foo bar" })) - // empty set means no signers are allowed, only works for packages without "signer" - assertEquals(version1, updateChecker.getUpdate(versions, { emptySet() })) - // packages without "signer" entries get through everything - assertEquals(version1, updateChecker.getUpdate(versions, { setOf("no version") })) - // if no matching sig can be found, no version gets returned - assertNull(updateChecker.getUpdate(listOf(version3, version2), { setOf("no version") })) - } + private fun getWithAllowReleaseChannels( + versions: List, + releaseChannels: List?, + ): Version? { + val v1 = updateChecker.getUpdate(versions, allowedReleaseChannels = releaseChannels) + assertEquals( + v1, + updateChecker.getUpdate(versions) { + AppPreferences(releaseChannels = releaseChannels ?: emptyList()) + }, + ) + assertEquals( + v1, + updateChecker.getUpdate(versions, allowedReleaseChannels = releaseChannels) { + AppPreferences(releaseChannels = releaseChannels ?: emptyList()) + }, + ) + return v1 + } - @Test - fun installedVulnerableVersionAlwaysReturned() { - val version3 = version3.copy(hasKnownVulnerability = true) - val versions = listOf(version3, version2, version1) - assertEquals( - version3, - updateChecker.getUpdate(versions, includeKnownVulnerabilities = true) - ) - assertEquals( - version3, - updateChecker.getUpdate( - versions, - installedVersionCode = 3, - includeKnownVulnerabilities = true, - ) - ) - assertEquals( - version3, - updateChecker.getUpdate( - versions, - installedVersionCode = 2, - includeKnownVulnerabilities = true, - ) - ) - // when not asking for known vulnerabilities, version3 isn't returned (no update here) - assertNull( - updateChecker.getUpdate(versions, installedVersionCode = 3) - ) - } - - private fun getWithAllowReleaseChannels( - versions: List, - releaseChannels: List?, - ): Version? { - val v1 = updateChecker.getUpdate(versions, allowedReleaseChannels = releaseChannels) - assertEquals(v1, - updateChecker.getUpdate(versions) { - AppPreferences(releaseChannels = releaseChannels ?: emptyList()) - } - ) - assertEquals(v1, - updateChecker.getUpdate(versions, allowedReleaseChannels = releaseChannels) { - AppPreferences(releaseChannels = releaseChannels ?: emptyList()) - } - ) - return v1 - } - - private data class Version( - override val versionCode: Long, - override val versionName: String, - override val added: Long, - override val size: Long?, - override val signer: SignerV2? = null, - override val releaseChannels: List? = null, - // the manifest is only needed for compatibility checking which we can test differently - override val packageManifest: PackageManifest = object : PackageManifest { - override val minSdkVersion: Int? = null - override val maxSdkVersion: Int? = null - override val featureNames: List? = null - override val nativecode: List? = null - override val targetSdkVersion: Int? = null - }, - override val hasKnownVulnerability: Boolean = false, - ) : PackageVersion - - private data class AppPreferences( - override val ignoreVersionCodeUpdate: Long = 0, - override val releaseChannels: List = emptyList(), - ) : PackagePreference + private data class Version( + override val versionCode: Long, + override val versionName: String, + override val added: Long, + override val size: Long?, + override val signer: SignerV2? = null, + override val releaseChannels: List? = null, + // the manifest is only needed for compatibility checking which we can test differently + override val packageManifest: PackageManifest = + object : PackageManifest { + override val minSdkVersion: Int? = null + override val maxSdkVersion: Int? = null + override val featureNames: List? = null + override val nativecode: List? = null + override val targetSdkVersion: Int? = null + }, + override val hasKnownVulnerability: Boolean = false, + ) : PackageVersion + private data class AppPreferences( + override val ignoreVersionCodeUpdate: Long = 0, + override val releaseChannels: List = emptyList(), + ) : PackagePreference } diff --git a/libs/index/src/androidUnitTest/kotlin/org/fdroid/index/IndexUtilsTest.kt b/libs/index/src/androidUnitTest/kotlin/org/fdroid/index/IndexUtilsTest.kt index 52ddcedfd..dc83797ac 100644 --- a/libs/index/src/androidUnitTest/kotlin/org/fdroid/index/IndexUtilsTest.kt +++ b/libs/index/src/androidUnitTest/kotlin/org/fdroid/index/IndexUtilsTest.kt @@ -1,44 +1,44 @@ package org.fdroid.index +import kotlin.test.assertEquals import org.fdroid.index.IndexUtils.getsig import org.fdroid.index.IndexUtils.md5 import org.fdroid.index.IndexUtils.toHex import org.junit.Test -import kotlin.test.assertEquals internal class IndexUtilsTest { - /** - * Test the replacement for the ancient fingerprint algorithm. - * - * @see org.fdroid.fdroid.data.Apk.sig - * @see org.fdroid.fdroid.Utils.getsig + /** + * Test the replacement for the ancient fingerprint algorithm. + * + * @see org.fdroid.fdroid.data.Apk.sig + * @see org.fdroid.fdroid.Utils.getsig + */ + @Deprecated("Only here for backwards compatibility when writing out index-v1.json") + @Test + fun testGetsig() { + /* + * I don't fully understand the loop used here. I've copied it verbatim + * from getsig.java bundled with FDroidServer. I *believe* it is taking + * the raw byte encoding of the certificate & converting it to a byte + * array of the hex representation of the original certificate byte + * array. This is then MD5 sum'd. It's a really bad way to be doing this + * if I'm right... If I'm not right, I really don't know! see lines + * 67->75 in getsig.java bundled with Fdroidserver */ - @Deprecated("Only here for backwards compatibility when writing out index-v1.json") - @Test - fun testGetsig() { - /* - * I don't fully understand the loop used here. I've copied it verbatim - * from getsig.java bundled with FDroidServer. I *believe* it is taking - * the raw byte encoding of the certificate & converting it to a byte - * array of the hex representation of the original certificate byte - * array. This is then MD5 sum'd. It's a really bad way to be doing this - * if I'm right... If I'm not right, I really don't know! see lines - * 67->75 in getsig.java bundled with Fdroidserver - */ - for (length in intArrayOf(256, 345, 1233, 4032, 12092)) { - val rawCertBytes = ByteArray(length) - java.util.Random().nextBytes(rawCertBytes) - val fdroidSig = ByteArray(rawCertBytes.size * 2) - for (j in rawCertBytes.indices) { - val v = rawCertBytes[j].toInt() - var d = ((v shr 4) and 0x000F).toByte() // Java: int d = (v >> 4) & 0xF; - fdroidSig[j * 2] = (if (d >= 10) 'a'.code + d - 10 else '0'.code + d).toByte() - d = (v and 0x000F).toByte() // Java: d = v & 0xF - fdroidSig[j * 2 + 1] = (if (d >= 10) 'a'.code + d - 10 else '0'.code + d).toByte() - } - val sig = md5(fdroidSig).toHex() - assertEquals(sig, getsig(rawCertBytes)) - assertEquals(sig, md5(rawCertBytes.toHex().toByteArray()).toHex()) - } + for (length in intArrayOf(256, 345, 1233, 4032, 12092)) { + val rawCertBytes = ByteArray(length) + java.util.Random().nextBytes(rawCertBytes) + val fdroidSig = ByteArray(rawCertBytes.size * 2) + for (j in rawCertBytes.indices) { + val v = rawCertBytes[j].toInt() + var d = ((v shr 4) and 0x000F).toByte() // Java: int d = (v >> 4) & 0xF; + fdroidSig[j * 2] = (if (d >= 10) 'a'.code + d - 10 else '0'.code + d).toByte() + d = (v and 0x000F).toByte() // Java: d = v & 0xF + fdroidSig[j * 2 + 1] = (if (d >= 10) 'a'.code + d - 10 else '0'.code + d).toByte() + } + val sig = md5(fdroidSig).toHex() + assertEquals(sig, getsig(rawCertBytes)) + assertEquals(sig, md5(rawCertBytes.toHex().toByteArray()).toHex()) } + } } diff --git a/libs/index/src/androidUnitTest/kotlin/org/fdroid/index/v1/IndexV1StreamProcessorTest.kt b/libs/index/src/androidUnitTest/kotlin/org/fdroid/index/v1/IndexV1StreamProcessorTest.kt index c49d66212..ddfb6d0e6 100644 --- a/libs/index/src/androidUnitTest/kotlin/org/fdroid/index/v1/IndexV1StreamProcessorTest.kt +++ b/libs/index/src/androidUnitTest/kotlin/org/fdroid/index/v1/IndexV1StreamProcessorTest.kt @@ -1,5 +1,13 @@ package org.fdroid.index.v1 +import java.io.ByteArrayInputStream +import java.io.File +import java.io.FileInputStream +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.fail import kotlinx.serialization.SerializationException import org.fdroid.index.ASSET_PATH import org.fdroid.index.v2.AntiFeatureV2 @@ -16,184 +24,181 @@ import org.fdroid.test.TestDataMidV2 import org.fdroid.test.TestDataMinV2 import org.fdroid.test.v1compat import org.junit.Test -import java.io.ByteArrayInputStream -import java.io.File -import java.io.FileInputStream -import kotlin.test.assertContains -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertNull -import kotlin.test.fail internal class IndexV1StreamProcessorTest { - @Test - fun testEmpty() { - testStreamProcessing("$ASSET_PATH/index-empty-v1.json", TestDataEmptyV2.index.v1compat()) - } + @Test + fun testEmpty() { + testStreamProcessing("$ASSET_PATH/index-empty-v1.json", TestDataEmptyV2.index.v1compat()) + } - @Test(expected = OldIndexException::class) - fun testEmptyEqualTimestamp() { - testStreamProcessing( - "$ASSET_PATH/index-empty-v1.json", - TestDataEmptyV2.index.v1compat(), - TestDataEmptyV2.index.repo.timestamp, + @Test(expected = OldIndexException::class) + fun testEmptyEqualTimestamp() { + testStreamProcessing( + "$ASSET_PATH/index-empty-v1.json", + TestDataEmptyV2.index.v1compat(), + TestDataEmptyV2.index.repo.timestamp, + ) + } + + @Test(expected = OldIndexException::class) + fun testEmptyHigherTimestamp() { + testStreamProcessing( + "$ASSET_PATH/index-empty-v1.json", + TestDataEmptyV2.index.v1compat(), + TestDataEmptyV2.index.repo.timestamp + 1, + ) + } + + @Test + fun testMin() { + testStreamProcessing("$ASSET_PATH/index-min-v1.json", TestDataMinV2.index.v1compat()) + } + + @Test + fun testMid() { + testStreamProcessing("$ASSET_PATH/index-mid-v1.json", TestDataMidV2.indexCompat) + } + + @Test + fun testMax() { + testStreamProcessing("$ASSET_PATH/index-max-v1.json", TestDataMaxV2.indexCompat) + } + + @Test + fun testMalformedIndex() { + // empty dict + assertFailsWith { testStreamError("{ }") } + .also { assertContains(it.message!!, "Failed requirement") } + + // garbage input + assertFailsWith { testStreamError("{ 23^^%*dfDFG568 }") } + + // empty repo dict + assertFailsWith { + testStreamError( + """ + { + "repo": {} + } + """ + .trimIndent() ) - } + } + .also { assertContains(it.message!!, "timestamp") } - @Test(expected = OldIndexException::class) - fun testEmptyHigherTimestamp() { - testStreamProcessing( - "$ASSET_PATH/index-empty-v1.json", - TestDataEmptyV2.index.v1compat(), - TestDataEmptyV2.index.repo.timestamp + 1, + // timestamp not a number + assertFailsWith { + testStreamError( + """ + { + "repo": { "timestamp": "string" } + } + """ + .trimIndent() ) - } + } + .also { assertContains(it.message!!, "numeric literal") } - @Test - fun testMin() { - testStreamProcessing( - "$ASSET_PATH/index-min-v1.json", - TestDataMinV2.index.v1compat(), - ) - } + // remember valid repo for further tests + val validRepo = + """ + "repo": { + "timestamp": 42, + "version": 23, + "name": "foo", + "icon": "bar", + "address": "https://example.com", + "description": "desc" + } + """ + .trimIndent() - @Test - fun testMid() { - testStreamProcessing("$ASSET_PATH/index-mid-v1.json", TestDataMidV2.indexCompat) - } - - @Test - fun testMax() { - testStreamProcessing("$ASSET_PATH/index-max-v1.json", TestDataMaxV2.indexCompat) - } - - @Test - fun testMalformedIndex() { - // empty dict - assertFailsWith { - testStreamError("{ }") - }.also { assertContains(it.message!!, "Failed requirement") } - - // garbage input - assertFailsWith { - testStreamError("{ 23^^%*dfDFG568 }") - } - - // empty repo dict - assertFailsWith { - testStreamError( - """{ - "repo": {} - }""".trimIndent() - ) - }.also { assertContains(it.message!!, "timestamp") } - - // timestamp not a number - assertFailsWith { - testStreamError( - """{ - "repo": { "timestamp": "string" } - }""".trimIndent() - ) - }.also { assertContains(it.message!!, "numeric literal") } - - // remember valid repo for further tests - val validRepo = """ - "repo": { - "timestamp": 42, - "version": 23, - "name": "foo", - "icon": "bar", - "address": "https://example.com", - "description": "desc" - } - """.trimIndent() - - // apps is dict - assertFailsWith { - testStreamError( - """{ + // apps is dict + assertFailsWith { + testStreamError( + """{ $validRepo, "requests": {"install": [], "uninstall": []}, "apps": {} - }""".trimIndent() - ) - }.also { assertContains(it.message!!, "apps") } + }""" + .trimIndent() + ) + } + .also { assertContains(it.message!!, "apps") } - // packages is list - assertFailsWith { - testStreamError( - """{ + // packages is list + assertFailsWith { + testStreamError( + """{ $validRepo, "requests": {"install": [], "uninstall": []}, "apps": [], "packages": [] - }""".trimIndent() - ) - }.also { assertContains(it.message!!, "packages") } + }""" + .trimIndent() + ) + } + .also { assertContains(it.message!!, "packages") } + } + private fun testStreamProcessing( + filePath: String, + indexV2: IndexV2, + lastTimestamp: Long = indexV2.repo.timestamp - 1, + ) { + val file = File(filePath) + val testStreamReceiver = TestStreamReceiver() + val streamProcessor = IndexV1StreamProcessor(testStreamReceiver, lastTimestamp) + FileInputStream(file).use { streamProcessor.process(it) } + assertEquals(indexV2.repo, testStreamReceiver.repo) + assertEquals(indexV2.packages, testStreamReceiver.packages) + } + + private fun testStreamError(index: String) { + val testStreamReceiver = TestStreamReceiver() + val streamProcessor = IndexV1StreamProcessor(testStreamReceiver, -1) + ByteArrayInputStream(index.encodeToByteArray()).use { streamProcessor.process(it) } + assertNull(testStreamReceiver.repo) + assertEquals(0, testStreamReceiver.packages.size) + } + + @Suppress("DEPRECATION") + private class TestStreamReceiver : IndexV1StreamReceiver { + var repo: RepoV2? = null + val packages = HashMap() + + override fun receive(repo: RepoV2, version: Long) { + this.repo = repo } - private fun testStreamProcessing( - filePath: String, - indexV2: IndexV2, - lastTimestamp: Long = indexV2.repo.timestamp - 1, + override fun receive(packageName: String, m: MetadataV2) { + packages[packageName] = PackageV2(metadata = m, versions = emptyMap()) + } + + override fun receive(packageName: String, v: Map) { + packages[packageName] = packages[packageName]!!.copy(versions = v) + } + + override fun updateRepo( + antiFeatures: Map, + categories: Map, + releaseChannels: Map, ) { - val file = File(filePath) - val testStreamReceiver = TestStreamReceiver() - val streamProcessor = IndexV1StreamProcessor(testStreamReceiver, lastTimestamp) - FileInputStream(file).use { streamProcessor.process(it) } - assertEquals(indexV2.repo, testStreamReceiver.repo) - assertEquals(indexV2.packages, testStreamReceiver.packages) + repo = + repo?.copy( + antiFeatures = antiFeatures, + categories = categories, + releaseChannels = releaseChannels, + ) ?: fail() } - private fun testStreamError(index: String) { - val testStreamReceiver = TestStreamReceiver() - val streamProcessor = IndexV1StreamProcessor(testStreamReceiver, -1) - ByteArrayInputStream(index.encodeToByteArray()).use { streamProcessor.process(it) } - assertNull(testStreamReceiver.repo) - assertEquals(0, testStreamReceiver.packages.size) + override fun updateAppMetadata(packageName: String, preferredSigner: String?) { + val currentPackage = packages[packageName] ?: fail() + packages[packageName] = + currentPackage.copy( + metadata = currentPackage.metadata.copy(preferredSigner = preferredSigner) + ) } - - @Suppress("DEPRECATION") - private class TestStreamReceiver : IndexV1StreamReceiver { - var repo: RepoV2? = null - val packages = HashMap() - - override fun receive(repo: RepoV2, version: Long) { - this.repo = repo - } - - override fun receive(packageName: String, m: MetadataV2) { - packages[packageName] = PackageV2( - metadata = m, - versions = emptyMap(), - ) - } - - override fun receive(packageName: String, v: Map) { - packages[packageName] = packages[packageName]!!.copy(versions = v) - } - - override fun updateRepo( - antiFeatures: Map, - categories: Map, - releaseChannels: Map, - ) { - repo = repo?.copy( - antiFeatures = antiFeatures, - categories = categories, - releaseChannels = releaseChannels, - ) ?: fail() - } - - override fun updateAppMetadata(packageName: String, preferredSigner: String?) { - val currentPackage = packages[packageName] ?: fail() - packages[packageName] = currentPackage.copy( - metadata = currentPackage.metadata.copy(preferredSigner = preferredSigner), - ) - } - } - + } } diff --git a/libs/index/src/androidUnitTest/kotlin/org/fdroid/index/v1/IndexV1VerifierTest.kt b/libs/index/src/androidUnitTest/kotlin/org/fdroid/index/v1/IndexV1VerifierTest.kt index 730bc035e..1f6562f94 100644 --- a/libs/index/src/androidUnitTest/kotlin/org/fdroid/index/v1/IndexV1VerifierTest.kt +++ b/libs/index/src/androidUnitTest/kotlin/org/fdroid/index/v1/IndexV1VerifierTest.kt @@ -1,145 +1,150 @@ package org.fdroid.index.v1 +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue import org.fdroid.index.SigningException import org.fdroid.test.VerifierConstants.CERTIFICATE import org.fdroid.test.VerifierConstants.FINGERPRINT import org.fdroid.test.VerifierConstants.VERIFICATION_DIR import org.junit.Test -import java.io.File -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertTrue internal class IndexV1VerifierTest { - @Test - fun testNoCertWithFingerprint() { - val file = File("$VERIFICATION_DIR/valid-v1.jar") - assertFailsWith { - IndexV1Verifier(file, CERTIFICATE, FINGERPRINT) + @Test + fun testNoCertWithFingerprint() { + val file = File("$VERIFICATION_DIR/valid-v1.jar") + assertFailsWith { IndexV1Verifier(file, CERTIFICATE, FINGERPRINT) } + } + + @Test + fun testValid() { + val file = File("$VERIFICATION_DIR/valid-v1.jar") + + val verifier = IndexV1Verifier(file, null, null) + val (certificate, _) = + verifier.getStreamAndVerify { inputStream -> + assertEquals("foo\n", inputStream.readBytes().decodeToString()) + } + assertEquals(CERTIFICATE, certificate) + } + + @Test + fun testValidMatchesFingerprint() { + val file = File("$VERIFICATION_DIR/valid-v1.jar") + + val verifier = IndexV1Verifier(file, null, FINGERPRINT) + val (certificate, _) = + verifier.getStreamAndVerify { inputStream -> + assertEquals("foo\n", inputStream.readBytes().decodeToString()) + } + assertEquals(CERTIFICATE, certificate) + } + + @Test + fun testValidWrongFingerprint() { + val file = File("$VERIFICATION_DIR/valid-v1.jar") + + val verifier = IndexV1Verifier(file, null, "foo bar") + val e = + assertFailsWith { + verifier.getStreamAndVerify { inputStream -> + assertEquals("foo\n", inputStream.readBytes().decodeToString()) } - } + } + assertTrue(e.message!!.contains("fingerprint")) + } - @Test - fun testValid() { - val file = File("$VERIFICATION_DIR/valid-v1.jar") + @Test + fun testValidWithExpectedCertificate() { + val file = File("$VERIFICATION_DIR/valid-v1.jar") - val verifier = IndexV1Verifier(file, null, null) - val (certificate, _) = verifier.getStreamAndVerify { inputStream -> - assertEquals("foo\n", inputStream.readBytes().decodeToString()) + val verifier = IndexV1Verifier(file, CERTIFICATE, null) + val (certificate, _) = + verifier.getStreamAndVerify { inputStream -> + assertEquals("foo\n", inputStream.readBytes().decodeToString()) + } + assertEquals(CERTIFICATE, certificate) + } + + @Test + fun testValidWithWrongCertificate() { + val file = File("$VERIFICATION_DIR/valid-v1.jar") + + val verifier = IndexV1Verifier(file, FINGERPRINT, null) + val e = + assertFailsWith { + verifier.getStreamAndVerify { inputStream -> + assertEquals("foo\n", inputStream.readBytes().decodeToString()) } - assertEquals(CERTIFICATE, certificate) + } + assertTrue(e.message!!.contains("certificate")) + } + + @Test + fun testUnsigned() { + val file = File("$VERIFICATION_DIR/unsigned.jar") + + val verifier = IndexV1Verifier(file, null, null) + assertFailsWith { + verifier.getStreamAndVerify { inputStream -> + assertEquals("foo\n", inputStream.readBytes().decodeToString()) + } } + } - @Test - fun testValidMatchesFingerprint() { - val file = File("$VERIFICATION_DIR/valid-v1.jar") + @Test + fun testInvalid() { + val file = File("$VERIFICATION_DIR/invalid-v1.jar") - val verifier = IndexV1Verifier(file, null, FINGERPRINT) - val (certificate, _) = verifier.getStreamAndVerify { inputStream -> - assertEquals("foo\n", inputStream.readBytes().decodeToString()) + val verifier = IndexV1Verifier(file, null, null) + assertFailsWith { + verifier.getStreamAndVerify { inputStream -> + assertEquals("foo\n", inputStream.readBytes().decodeToString()) + } + } + } + + @Test + fun testWrongEntry() { + val file = File("$VERIFICATION_DIR/invalid-wrong-entry-v1.jar") + + val verifier = IndexV1Verifier(file, null, null) + val e = + assertFailsWith { + verifier.getStreamAndVerify { inputStream -> + assertEquals("foo\n", inputStream.readBytes().decodeToString()) } - assertEquals(CERTIFICATE, certificate) - } + } + assertTrue(e.message!!.contains(DATA_FILE_NAME)) + } - @Test - fun testValidWrongFingerprint() { - val file = File("$VERIFICATION_DIR/valid-v1.jar") + @Test + fun testMD5Digest() { + val file = File("$VERIFICATION_DIR/invalid-MD5-SHA1withRSA-v1.jar") - val verifier = IndexV1Verifier(file, null, "foo bar") - val e = assertFailsWith { - verifier.getStreamAndVerify { inputStream -> - assertEquals("foo\n", inputStream.readBytes().decodeToString()) - } + val verifier = IndexV1Verifier(file, null, null) + val e = + assertFailsWith { + verifier.getStreamAndVerify { inputStream -> + assertEquals("foo\n", inputStream.readBytes().decodeToString()) } - assertTrue(e.message!!.contains("fingerprint")) - } + } + assertTrue(e.message!!.contains("Unsupported digest")) + } - @Test - fun testValidWithExpectedCertificate() { - val file = File("$VERIFICATION_DIR/valid-v1.jar") + @Test + fun testMD5SignatureAlgo() { + val file = File("$VERIFICATION_DIR/invalid-MD5-MD5withRSA-v1.jar") - val verifier = IndexV1Verifier(file, CERTIFICATE, null) - val (certificate, _) = verifier.getStreamAndVerify { inputStream -> - assertEquals("foo\n", inputStream.readBytes().decodeToString()) + val verifier = IndexV1Verifier(file, null, null) + val e = + assertFailsWith { + verifier.getStreamAndVerify { inputStream -> + assertEquals("foo\n", inputStream.readBytes().decodeToString()) } - assertEquals(CERTIFICATE, certificate) - } - - @Test - fun testValidWithWrongCertificate() { - val file = File("$VERIFICATION_DIR/valid-v1.jar") - - val verifier = IndexV1Verifier(file, FINGERPRINT, null) - val e = assertFailsWith { - verifier.getStreamAndVerify { inputStream -> - assertEquals("foo\n", inputStream.readBytes().decodeToString()) - } - } - assertTrue(e.message!!.contains("certificate")) - } - - @Test - fun testUnsigned() { - val file = File("$VERIFICATION_DIR/unsigned.jar") - - val verifier = IndexV1Verifier(file, null, null) - assertFailsWith { - verifier.getStreamAndVerify { inputStream -> - assertEquals("foo\n", inputStream.readBytes().decodeToString()) - } - } - } - - @Test - fun testInvalid() { - val file = File("$VERIFICATION_DIR/invalid-v1.jar") - - val verifier = IndexV1Verifier(file, null, null) - assertFailsWith { - verifier.getStreamAndVerify { inputStream -> - assertEquals("foo\n", inputStream.readBytes().decodeToString()) - } - } - } - - @Test - fun testWrongEntry() { - val file = File("$VERIFICATION_DIR/invalid-wrong-entry-v1.jar") - - val verifier = IndexV1Verifier(file, null, null) - val e = assertFailsWith { - verifier.getStreamAndVerify { inputStream -> - assertEquals("foo\n", inputStream.readBytes().decodeToString()) - } - } - assertTrue(e.message!!.contains(DATA_FILE_NAME)) - } - - @Test - fun testMD5Digest() { - val file = File("$VERIFICATION_DIR/invalid-MD5-SHA1withRSA-v1.jar") - - val verifier = IndexV1Verifier(file, null, null) - val e = assertFailsWith { - verifier.getStreamAndVerify { inputStream -> - assertEquals("foo\n", inputStream.readBytes().decodeToString()) - } - } - assertTrue(e.message!!.contains("Unsupported digest")) - } - - @Test - fun testMD5SignatureAlgo() { - val file = File("$VERIFICATION_DIR/invalid-MD5-MD5withRSA-v1.jar") - - val verifier = IndexV1Verifier(file, null, null) - val e = assertFailsWith { - verifier.getStreamAndVerify { inputStream -> - assertEquals("foo\n", inputStream.readBytes().decodeToString()) - } - } - assertTrue(e.message!!.contains("Unsupported digest")) - } - + } + assertTrue(e.message!!.contains("Unsupported digest")) + } } diff --git a/libs/index/src/androidUnitTest/kotlin/org/fdroid/index/v2/EntryVerifierTest.kt b/libs/index/src/androidUnitTest/kotlin/org/fdroid/index/v2/EntryVerifierTest.kt index 04680a4aa..dc0b81630 100644 --- a/libs/index/src/androidUnitTest/kotlin/org/fdroid/index/v2/EntryVerifierTest.kt +++ b/libs/index/src/androidUnitTest/kotlin/org/fdroid/index/v2/EntryVerifierTest.kt @@ -1,159 +1,152 @@ package org.fdroid.index.v2 +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue import org.fdroid.index.SigningException import org.fdroid.test.VerifierConstants.CERTIFICATE import org.fdroid.test.VerifierConstants.FINGERPRINT import org.fdroid.test.VerifierConstants.VERIFICATION_DIR import org.junit.Test -import java.io.File -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertTrue internal class EntryVerifierTest { - @Test - fun testNoCertAndFingerprintAllowed() { - val file = File("$VERIFICATION_DIR/valid-v2.jar") - assertFailsWith { - EntryVerifier(file, CERTIFICATE, FINGERPRINT) + @Test + fun testNoCertAndFingerprintAllowed() { + val file = File("$VERIFICATION_DIR/valid-v2.jar") + assertFailsWith { EntryVerifier(file, CERTIFICATE, FINGERPRINT) } + } + + @Test + fun testValid() { + val file = File("$VERIFICATION_DIR/valid-v2.jar") + + val verifier = EntryVerifier(file, null, null) + val (certificate, _) = + verifier.getStreamAndVerify { inputStream -> + assertEquals("foo\n", inputStream.readBytes().decodeToString()) + } + assertEquals(CERTIFICATE, certificate) + } + + @Test + fun testValidApkSigner() { + val file = File("$VERIFICATION_DIR/valid-apksigner-v2.jar") + + val verifier = EntryVerifier(file, null, null) + val (certificate, _) = + verifier.getStreamAndVerify { inputStream -> + assertEquals("foo\n", inputStream.readBytes().decodeToString()) + } + assertEquals(CERTIFICATE, certificate) + } + + @Test + fun testValidMatchesFingerprint() { + val file = File("$VERIFICATION_DIR/valid-v2.jar") + + val verifier = EntryVerifier(file, null, FINGERPRINT) + val (certificate, _) = + verifier.getStreamAndVerify { inputStream -> + assertEquals("foo\n", inputStream.readBytes().decodeToString()) + } + assertEquals(CERTIFICATE, certificate) + } + + @Test + fun testValidWrongFingerprint() { + val file = File("$VERIFICATION_DIR/valid-v2.jar") + + val verifier = EntryVerifier(file, null, "foo bar") + val e = + assertFailsWith { + verifier.getStreamAndVerify { inputStream -> + assertEquals("foo\n", inputStream.readBytes().decodeToString()) } - } + } + assertTrue(e.message!!.contains("fingerprint")) + } - @Test - fun testValid() { - val file = File("$VERIFICATION_DIR/valid-v2.jar") + @Test + fun testValidWithExpectedCertificate() { + val file = File("$VERIFICATION_DIR/valid-v2.jar") - val verifier = EntryVerifier(file, null, null) - val (certificate, _) = verifier.getStreamAndVerify { inputStream -> - assertEquals("foo\n", inputStream.readBytes().decodeToString()) + val verifier = EntryVerifier(file, CERTIFICATE, null) + val (certificate, _) = + verifier.getStreamAndVerify { inputStream -> + assertEquals("foo\n", inputStream.readBytes().decodeToString()) + } + assertEquals(CERTIFICATE, certificate) + } + + @Test + fun testValidWithWrongCertificate() { + val file = File("$VERIFICATION_DIR/valid-v2.jar") + + val verifier = EntryVerifier(file, FINGERPRINT, null) + val e = + assertFailsWith { + verifier.getStreamAndVerify { inputStream -> + assertEquals("foo\n", inputStream.readBytes().decodeToString()) } - assertEquals(CERTIFICATE, certificate) + } + assertTrue(e.message!!.contains("certificate")) + } + + @Test + fun testUnsigned() { + val file = File("$VERIFICATION_DIR/unsigned.jar") + + val verifier = EntryVerifier(file, null, null) + assertFailsWith { + verifier.getStreamAndVerify { inputStream -> + assertEquals("foo\n", inputStream.readBytes().decodeToString()) + } } + } - @Test - fun testValidApkSigner() { - val file = File("$VERIFICATION_DIR/valid-apksigner-v2.jar") + @Test + fun testInvalid() { + val file = File("$VERIFICATION_DIR/invalid-v2.jar") - val verifier = EntryVerifier(file, null, null) - val (certificate, _) = verifier.getStreamAndVerify { inputStream -> - assertEquals("foo\n", inputStream.readBytes().decodeToString()) - } - assertEquals(CERTIFICATE, certificate) - } + val verifier = EntryVerifier(file, null, null) + assertFailsWith { verifier.getStreamAndVerify {} } + } - @Test - fun testValidMatchesFingerprint() { - val file = File("$VERIFICATION_DIR/valid-v2.jar") + @Test + fun testWrongEntry() { + val file = File("$VERIFICATION_DIR/invalid-wrong-entry-v1.jar") - val verifier = EntryVerifier(file, null, FINGERPRINT) - val (certificate, _) = verifier.getStreamAndVerify { inputStream -> - assertEquals("foo\n", inputStream.readBytes().decodeToString()) - } - assertEquals(CERTIFICATE, certificate) - } + val verifier = EntryVerifier(file, null, null) + val e = assertFailsWith { verifier.getStreamAndVerify {} } + assertTrue(e.message!!.contains(DATA_FILE_NAME)) + } - @Test - fun testValidWrongFingerprint() { - val file = File("$VERIFICATION_DIR/valid-v2.jar") + @Test + fun testSHA1Digest() { + val file = File("$VERIFICATION_DIR/invalid-SHA1-SHA1withRSA-v2.jar") - val verifier = EntryVerifier(file, null, "foo bar") - val e = assertFailsWith { - verifier.getStreamAndVerify { inputStream -> - assertEquals("foo\n", inputStream.readBytes().decodeToString()) - } - } - assertTrue(e.message!!.contains("fingerprint")) - } + val verifier = EntryVerifier(file, null, null) + val e = assertFailsWith { verifier.getStreamAndVerify {} } + assertTrue(e.message!!.contains("Unsupported digest")) + } - @Test - fun testValidWithExpectedCertificate() { - val file = File("$VERIFICATION_DIR/valid-v2.jar") + @Test + fun testMD5Digest() { + val file = File("$VERIFICATION_DIR/invalid-MD5-SHA1withRSA-v2.jar") - val verifier = EntryVerifier(file, CERTIFICATE, null) - val (certificate, _) = verifier.getStreamAndVerify { inputStream -> - assertEquals("foo\n", inputStream.readBytes().decodeToString()) - } - assertEquals(CERTIFICATE, certificate) - } + val verifier = EntryVerifier(file, null, null) + val e = assertFailsWith { verifier.getStreamAndVerify {} } + assertTrue(e.message!!.contains("Unsupported digest")) + } - @Test - fun testValidWithWrongCertificate() { - val file = File("$VERIFICATION_DIR/valid-v2.jar") - - val verifier = EntryVerifier(file, FINGERPRINT, null) - val e = assertFailsWith { - verifier.getStreamAndVerify { inputStream -> - assertEquals("foo\n", inputStream.readBytes().decodeToString()) - } - } - assertTrue(e.message!!.contains("certificate")) - } - - @Test - fun testUnsigned() { - val file = File("$VERIFICATION_DIR/unsigned.jar") - - val verifier = EntryVerifier(file, null, null) - assertFailsWith { - verifier.getStreamAndVerify { inputStream -> - assertEquals("foo\n", inputStream.readBytes().decodeToString()) - } - } - } - - @Test - fun testInvalid() { - val file = File("$VERIFICATION_DIR/invalid-v2.jar") - - val verifier = EntryVerifier(file, null, null) - assertFailsWith { - verifier.getStreamAndVerify { } - } - } - - @Test - fun testWrongEntry() { - val file = File("$VERIFICATION_DIR/invalid-wrong-entry-v1.jar") - - val verifier = EntryVerifier(file, null, null) - val e = assertFailsWith { - verifier.getStreamAndVerify { } - } - assertTrue(e.message!!.contains(DATA_FILE_NAME)) - } - - @Test - fun testSHA1Digest() { - val file = File("$VERIFICATION_DIR/invalid-SHA1-SHA1withRSA-v2.jar") - - val verifier = EntryVerifier(file, null, null) - val e = assertFailsWith { - verifier.getStreamAndVerify { } - } - assertTrue(e.message!!.contains("Unsupported digest")) - } - - @Test - fun testMD5Digest() { - val file = File("$VERIFICATION_DIR/invalid-MD5-SHA1withRSA-v2.jar") - - val verifier = EntryVerifier(file, null, null) - val e = assertFailsWith { - verifier.getStreamAndVerify { } - } - assertTrue(e.message!!.contains("Unsupported digest")) - } - - @Test - fun testMD5SignatureAlgo() { - val file = File("$VERIFICATION_DIR/invalid-MD5-MD5withRSA-v2.jar") - - val verifier = EntryVerifier(file, null, null) - val e = assertFailsWith { - verifier.getStreamAndVerify { } - } - assertTrue(e.message!!.contains("Unsupported digest")) - } + @Test + fun testMD5SignatureAlgo() { + val file = File("$VERIFICATION_DIR/invalid-MD5-MD5withRSA-v2.jar") + val verifier = EntryVerifier(file, null, null) + val e = assertFailsWith { verifier.getStreamAndVerify {} } + assertTrue(e.message!!.contains("Unsupported digest")) + } } diff --git a/libs/index/src/androidUnitTest/kotlin/org/fdroid/index/v2/IndexV2FullStreamProcessorTest.kt b/libs/index/src/androidUnitTest/kotlin/org/fdroid/index/v2/IndexV2FullStreamProcessorTest.kt index dfad218ac..b201a69a5 100644 --- a/libs/index/src/androidUnitTest/kotlin/org/fdroid/index/v2/IndexV2FullStreamProcessorTest.kt +++ b/libs/index/src/androidUnitTest/kotlin/org/fdroid/index/v2/IndexV2FullStreamProcessorTest.kt @@ -1,5 +1,13 @@ package org.fdroid.index.v2 +import java.io.ByteArrayInputStream +import java.io.File +import java.io.FileInputStream +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import kotlin.test.fail import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerializationException import org.fdroid.index.ASSET_PATH @@ -10,149 +18,150 @@ import org.fdroid.test.TestDataMinV2 import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder -import java.io.ByteArrayInputStream -import java.io.File -import java.io.FileInputStream -import kotlin.test.assertContains -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertTrue -import kotlin.test.fail @OptIn(ExperimentalSerializationApi::class) internal class IndexV2FullStreamProcessorTest { - @get:Rule - var folder: TemporaryFolder = TemporaryFolder() + @get:Rule var folder: TemporaryFolder = TemporaryFolder() - @Test - fun testEmpty() { - testStreamProcessing("$ASSET_PATH/index-empty-v2.json", TestDataEmptyV2.index, 0) - } + @Test + fun testEmpty() { + testStreamProcessing("$ASSET_PATH/index-empty-v2.json", TestDataEmptyV2.index, 0) + } - @Test - fun testMin() { - testStreamProcessing("$ASSET_PATH/index-min-v2.json", TestDataMinV2.index, 1) - } + @Test + fun testMin() { + testStreamProcessing("$ASSET_PATH/index-min-v2.json", TestDataMinV2.index, 1) + } - @Test - fun testMinReordered() { - testStreamProcessing("$ASSET_PATH/index-min-reordered-v2.json", TestDataMinV2.index, 1) - } + @Test + fun testMinReordered() { + testStreamProcessing("$ASSET_PATH/index-min-reordered-v2.json", TestDataMinV2.index, 1) + } - @Test - fun testMid() { - testStreamProcessing("$ASSET_PATH/index-mid-v2.json", TestDataMidV2.index, 2) - } + @Test + fun testMid() { + testStreamProcessing("$ASSET_PATH/index-mid-v2.json", TestDataMidV2.index, 2) + } - @Test - fun testMax() { - testStreamProcessing("$ASSET_PATH/index-max-v2.json", TestDataMaxV2.index, 3) - } + @Test + fun testMax() { + testStreamProcessing("$ASSET_PATH/index-max-v2.json", TestDataMaxV2.index, 3) + } - @Test - fun testMalformedIndex() { - // empty dict - assertFailsWith { - testStreamError("{ }") - }.also { assertContains(it.message!!, "Unexpected startIndex") } + @Test + fun testMalformedIndex() { + // empty dict + assertFailsWith { testStreamError("{ }") } + .also { assertContains(it.message!!, "Unexpected startIndex") } - // garbage input - assertFailsWith { - testStreamError("{ 23^^%*dfDFG568 }") - } + // garbage input + assertFailsWith { testStreamError("{ 23^^%*dfDFG568 }") } - // repo is a number - assertFailsWith { - testStreamError("""{ + // repo is a number + assertFailsWith { + testStreamError( + """ + { "repo": 1 - }""".trimIndent() - ) - }.also { assertContains(it.message!!, "object") } - - // repo is empty - assertFailsWith { - testStreamError("""{ - "repo": { } - }""".trimIndent() - ) - }.also { assertContains(it.message!!, "timestamp") } - - // repo misses address - assertFailsWith { - testStreamError("""{ - "repo": { - "timestamp": 23 - } - }""".trimIndent() - ) - }.also { assertContains(it.message!!, "address") } - - // packages is list - assertFailsWith { - testStreamError("""{ - "repo": { - "timestamp": 23, - "address": "http://example.com" - }, - "packages": [] - }""".trimIndent() - ) - }.also { assertContains(it.message!!, "object") } - } - - /** - * Tests that index parsed with a stream receiver is equal to the expected test data. - */ - private fun testStreamProcessing(filePath: String, index: IndexV2, expectedNumApps: Int) { - val file = File(filePath) - val testStreamReceiver = TestStreamReceiver() - val streamProcessor = IndexV2FullStreamProcessor(testStreamReceiver) - var totalApps = 0 - FileInputStream(file).use { - streamProcessor.process(42, it) { numAppsProcessed -> - totalApps = numAppsProcessed } - } + """ + .trimIndent() + ) + } + .also { assertContains(it.message!!, "object") } - assertTrue(testStreamReceiver.calledOnStreamEnded) - assertEquals(index.repo, testStreamReceiver.repo) - assertEquals(index.packages, testStreamReceiver.packages) - assertEquals(expectedNumApps, totalApps) + // repo is empty + assertFailsWith { + testStreamError( + """ + { + "repo": { } + } + """ + .trimIndent() + ) + } + .also { assertContains(it.message!!, "timestamp") } + + // repo misses address + assertFailsWith { + testStreamError( + """ + { + "repo": { + "timestamp": 23 + } + } + """ + .trimIndent() + ) + } + .also { assertContains(it.message!!, "address") } + + // packages is list + assertFailsWith { + testStreamError( + """ + { + "repo": { + "timestamp": 23, + "address": "http://example.com" + }, + "packages": [] + } + """ + .trimIndent() + ) + } + .also { assertContains(it.message!!, "object") } + } + + /** Tests that index parsed with a stream receiver is equal to the expected test data. */ + private fun testStreamProcessing(filePath: String, index: IndexV2, expectedNumApps: Int) { + val file = File(filePath) + val testStreamReceiver = TestStreamReceiver() + val streamProcessor = IndexV2FullStreamProcessor(testStreamReceiver) + var totalApps = 0 + FileInputStream(file).use { + streamProcessor.process(42, it) { numAppsProcessed -> totalApps = numAppsProcessed } } - private fun testStreamError(str: String) { - val testStreamReceiver = TestStreamReceiver() - val streamProcessor = IndexV2FullStreamProcessor(testStreamReceiver) - var totalApps = 0 - ByteArrayInputStream(str.encodeToByteArray()).use { - streamProcessor.process(42, it) { numAppsProcessed -> - totalApps = numAppsProcessed - } - } + assertTrue(testStreamReceiver.calledOnStreamEnded) + assertEquals(index.repo, testStreamReceiver.repo) + assertEquals(index.packages, testStreamReceiver.packages) + assertEquals(expectedNumApps, totalApps) + } - assertTrue(testStreamReceiver.calledOnStreamEnded) - assertEquals(0, testStreamReceiver.packages.size) - assertEquals(0, totalApps) + private fun testStreamError(str: String) { + val testStreamReceiver = TestStreamReceiver() + val streamProcessor = IndexV2FullStreamProcessor(testStreamReceiver) + var totalApps = 0 + ByteArrayInputStream(str.encodeToByteArray()).use { + streamProcessor.process(42, it) { numAppsProcessed -> totalApps = numAppsProcessed } } - private open class TestStreamReceiver : IndexV2StreamReceiver { - var repo: RepoV2? = null - val packages = HashMap() - var calledOnStreamEnded: Boolean = false + assertTrue(testStreamReceiver.calledOnStreamEnded) + assertEquals(0, testStreamReceiver.packages.size) + assertEquals(0, totalApps) + } - override fun receive(repo: RepoV2, version: Long) { - this.repo = repo - } + private open class TestStreamReceiver : IndexV2StreamReceiver { + var repo: RepoV2? = null + val packages = HashMap() + var calledOnStreamEnded: Boolean = false - override fun receive(packageName: String, p: PackageV2) { - packages[packageName] = p - } - - override fun onStreamEnded() { - if (calledOnStreamEnded) fail() - calledOnStreamEnded = true - } + override fun receive(repo: RepoV2, version: Long) { + this.repo = repo } + override fun receive(packageName: String, p: PackageV2) { + packages[packageName] = p + } + + override fun onStreamEnded() { + if (calledOnStreamEnded) fail() + calledOnStreamEnded = true + } + } } diff --git a/libs/index/src/androidUnitTest/kotlin/org/fdroid/index/v2/ReflectionDifferTest.kt b/libs/index/src/androidUnitTest/kotlin/org/fdroid/index/v2/ReflectionDifferTest.kt index 1dac1bf14..dde6aab45 100644 --- a/libs/index/src/androidUnitTest/kotlin/org/fdroid/index/v2/ReflectionDifferTest.kt +++ b/libs/index/src/androidUnitTest/kotlin/org/fdroid/index/v2/ReflectionDifferTest.kt @@ -1,20 +1,5 @@ package org.fdroid.index.v2 -import kotlinx.serialization.SerializationException -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.jsonObject -import org.fdroid.index.IndexParser -import org.fdroid.index.IndexParser.json -import org.fdroid.index.ASSET_PATH -import org.fdroid.index.parseV2 -import org.fdroid.test.DiffUtils.clean -import org.fdroid.test.DiffUtils.cleanMetadata -import org.fdroid.test.DiffUtils.cleanRepo -import org.fdroid.test.DiffUtils.cleanVersion -import org.fdroid.test.LOCALE import java.io.File import java.io.FileInputStream import kotlin.reflect.full.primaryConstructor @@ -22,208 +7,221 @@ import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject +import org.fdroid.index.ASSET_PATH +import org.fdroid.index.IndexParser +import org.fdroid.index.IndexParser.json +import org.fdroid.index.parseV2 +import org.fdroid.test.DiffUtils.clean +import org.fdroid.test.DiffUtils.cleanMetadata +import org.fdroid.test.DiffUtils.cleanRepo +import org.fdroid.test.DiffUtils.cleanVersion +import org.fdroid.test.LOCALE internal class ReflectionDifferTest { - @Test - fun testEmptyToMin() = testDiff( - diffPath = "$ASSET_PATH/diff-empty-min/23.json", - startPath = "$ASSET_PATH/index-empty-v2.json", - endPath = "$ASSET_PATH/index-min-v2.json", + @Test + fun testEmptyToMin() = + testDiff( + diffPath = "$ASSET_PATH/diff-empty-min/23.json", + startPath = "$ASSET_PATH/index-empty-v2.json", + endPath = "$ASSET_PATH/index-min-v2.json", ) - @Test - fun testEmptyToMid() = testDiff( - diffPath = "$ASSET_PATH/diff-empty-mid/23.json", - startPath = "$ASSET_PATH/index-empty-v2.json", - endPath = "$ASSET_PATH/index-mid-v2.json", + @Test + fun testEmptyToMid() = + testDiff( + diffPath = "$ASSET_PATH/diff-empty-mid/23.json", + startPath = "$ASSET_PATH/index-empty-v2.json", + endPath = "$ASSET_PATH/index-mid-v2.json", ) - @Test - fun testEmptyToMax() = testDiff( - diffPath = "$ASSET_PATH/diff-empty-max/23.json", - startPath = "$ASSET_PATH/index-empty-v2.json", - endPath = "$ASSET_PATH/index-max-v2.json", + @Test + fun testEmptyToMax() = + testDiff( + diffPath = "$ASSET_PATH/diff-empty-max/23.json", + startPath = "$ASSET_PATH/index-empty-v2.json", + endPath = "$ASSET_PATH/index-max-v2.json", ) - @Test - fun testMinToMid() = testDiff( - diffPath = "$ASSET_PATH/diff-empty-mid/42.json", - startPath = "$ASSET_PATH/index-min-v2.json", - endPath = "$ASSET_PATH/index-mid-v2.json", + @Test + fun testMinToMid() = + testDiff( + diffPath = "$ASSET_PATH/diff-empty-mid/42.json", + startPath = "$ASSET_PATH/index-min-v2.json", + endPath = "$ASSET_PATH/index-mid-v2.json", ) - @Test - fun testMinToMax() = testDiff( - diffPath = "$ASSET_PATH/diff-empty-max/42.json", - startPath = "$ASSET_PATH/index-min-v2.json", - endPath = "$ASSET_PATH/index-max-v2.json", + @Test + fun testMinToMax() = + testDiff( + diffPath = "$ASSET_PATH/diff-empty-max/42.json", + startPath = "$ASSET_PATH/index-min-v2.json", + endPath = "$ASSET_PATH/index-max-v2.json", ) - @Test - fun testMidToMax() = testDiff( - diffPath = "$ASSET_PATH/diff-empty-max/1337.json", - startPath = "$ASSET_PATH/index-mid-v2.json", - endPath = "$ASSET_PATH/index-max-v2.json", + @Test + fun testMidToMax() = + testDiff( + diffPath = "$ASSET_PATH/diff-empty-max/1337.json", + startPath = "$ASSET_PATH/index-mid-v2.json", + endPath = "$ASSET_PATH/index-max-v2.json", ) - @Test - fun testLocalizedFileV2() { - val category1 = CategoryV2( - name = mapOf(LOCALE to "Cat1"), - icon = mapOf(LOCALE to FileV2( - name = "file1", - sha256 = "hash", - size = 1, - )), + @Test + fun testLocalizedFileV2() { + val category1 = + CategoryV2( + name = mapOf(LOCALE to "Cat1"), + icon = mapOf(LOCALE to FileV2(name = "file1", sha256 = "hash", size = 1)), + ) + val category2 = CategoryV2(name = mapOf(LOCALE to "Cat2")) + val diff1 = + JsonObject( + mapOf( + "icon" to + JsonObject(mapOf(LOCALE to JsonObject(mapOf("name" to JsonPrimitive("file1b"))))) ) - val category2 = CategoryV2( - name = mapOf(LOCALE to "Cat2"), - ) - val diff1 = JsonObject( - mapOf("icon" to JsonObject( - mapOf(LOCALE to JsonObject( - mapOf("name" to JsonPrimitive("file1b")) - )) - )) - ) - val diff2 = JsonObject( - mapOf("icon" to JsonObject( - mapOf(LOCALE to JsonObject( + ) + val diff2 = + JsonObject( + mapOf( + "icon" to + JsonObject( + mapOf( + LOCALE to + JsonObject( mapOf( - "name" to JsonPrimitive("file2"), - "sha256" to JsonPrimitive("hash2"), - "size" to JsonPrimitive(2L), + "name" to JsonPrimitive("file2"), + "sha256" to JsonPrimitive("hash2"), + "size" to JsonPrimitive(2L), ) - )) - )) + ) + ) + ) ) - val diffedCat1 = ReflectionDiffer.applyDiff(category1, diff1) - val diffedCat2 = ReflectionDiffer.applyDiff(category2, diff2) - val diffedIcon1 = diffedCat1.icon[LOCALE] - val diffedIcon2 = diffedCat2.icon[LOCALE] - val expectedIcon1 = FileV2( - name = "file1b", - sha256 = "hash", - size = 1, + ) + val diffedCat1 = ReflectionDiffer.applyDiff(category1, diff1) + val diffedCat2 = ReflectionDiffer.applyDiff(category2, diff2) + val diffedIcon1 = diffedCat1.icon[LOCALE] + val diffedIcon2 = diffedCat2.icon[LOCALE] + val expectedIcon1 = FileV2(name = "file1b", sha256 = "hash", size = 1) + val expectedIcon2 = FileV2(name = "file2", sha256 = "hash2", size = 2) + assertEquals(expectedIcon1, diffedIcon1) + assertEquals(expectedIcon2, diffedIcon2) + } + + @Test + fun testClassWithoutPrimaryConstructor() { + class NoConstructor { + @Suppress("ConvertSecondaryConstructorToPrimary", "UNUSED_PARAMETER") constructor(i: Int) + } + assertFailsWith { + ReflectionDiffer.applyDiff(NoConstructor(0), JsonObject(emptyMap())) + } + .also { assertContains(it.message!!, "no primary constructor") } + } + + @Test + fun testNoMemberForConstructorParameter() { + @Suppress("UNUSED_PARAMETER") class NoConstructor(i: Int) + assertFailsWith { + ReflectionDiffer.applyDiff(NoConstructor(0), JsonObject(emptyMap())) + } + .also { assertContains(it.message!!, "no member property for constructor") } + } + + @Test + fun testNullingRequiredParameter() { + data class Required(val test: String) + assertFailsWith { + ReflectionDiffer.applyDiff(Required("foo"), JsonObject(mapOf("test" to JsonNull))) + } + .also { assertContains(it.message!!, "not nullable: test") } + } + + @Test + fun testWrongTypes() { + data class Types(val str: String? = null, val i: Int? = null, val l: Long? = null) + + // string as object + assertFailsWith { + ReflectionDiffer.applyDiff( + Types(str = "foo"), + JsonObject(mapOf("str" to JsonObject(emptyMap()))), ) - val expectedIcon2 = FileV2( - name = "file2", - sha256 = "hash2", - size = 2, + } + .also { assertContains(it.message!!, "str no string") } + + // int as string + assertFailsWith { + ReflectionDiffer.applyDiff(Types(i = 23), JsonObject(mapOf("i" to JsonPrimitive("test")))) + } + .also { assertContains(it.message!!, "i no int") } + + // int as long + assertFailsWith { + ReflectionDiffer.applyDiff( + Types(i = 23), + JsonObject(mapOf("i" to JsonPrimitive(Long.MAX_VALUE))), ) - assertEquals(expectedIcon1, diffedIcon1) - assertEquals(expectedIcon2, diffedIcon2) - } + } + .also { assertContains(it.message!!, "i no int") } - @Test - fun testClassWithoutPrimaryConstructor() { - class NoConstructor { - @Suppress("ConvertSecondaryConstructorToPrimary", "UNUSED_PARAMETER") - constructor(i: Int) - } - assertFailsWith { - ReflectionDiffer.applyDiff(NoConstructor(0), JsonObject(emptyMap())) - }.also { assertContains(it.message!!, "no primary constructor") } - } + // long as array + assertFailsWith { + ReflectionDiffer.applyDiff(Types(l = 23L), JsonObject(mapOf("l" to JsonArray(emptyList())))) + } + .also { assertContains(it.message!!, "l no long") } + } - @Test - fun testNoMemberForConstructorParameter() { - @Suppress("UNUSED_PARAMETER") - class NoConstructor(i: Int) - assertFailsWith { - ReflectionDiffer.applyDiff(NoConstructor(0), JsonObject(emptyMap())) - }.also { assertContains(it.message!!, "no member property for constructor") } - } + private fun testDiff(diffPath: String, startPath: String, endPath: String) { + val diffFile = File(diffPath) + val startFile = File(startPath) + val endFile = File(endPath) + val diff = json.parseToJsonElement(diffFile.readText()).jsonObject + val start = IndexParser.parseV2(FileInputStream(startFile)) + val end = IndexParser.parseV2(FileInputStream(endFile)) - @Test - fun testNullingRequiredParameter() { - data class Required(val test: String) - assertFailsWith { - ReflectionDiffer.applyDiff( - Required("foo"), - JsonObject(mapOf("test" to JsonNull)) - ) - }.also { assertContains(it.message!!, "not nullable: test") } - } - - @Test - fun testWrongTypes() { - data class Types(val str: String? = null, val i: Int? = null, val l: Long? = null) - - // string as object - assertFailsWith { - ReflectionDiffer.applyDiff( - Types(str = "foo"), - JsonObject(mapOf("str" to JsonObject(emptyMap()))) - ) - }.also { assertContains(it.message!!, "str no string") } - - // int as string - assertFailsWith { - ReflectionDiffer.applyDiff( - Types(i = 23), - JsonObject(mapOf("i" to JsonPrimitive("test"))) - ) - }.also { assertContains(it.message!!, "i no int") } - - // int as long - assertFailsWith { - ReflectionDiffer.applyDiff( - Types(i = 23), - JsonObject(mapOf("i" to JsonPrimitive(Long.MAX_VALUE))) - ) - }.also { assertContains(it.message!!, "i no int") } - - // long as array - assertFailsWith { - ReflectionDiffer.applyDiff( - Types(l = 23L), - JsonObject(mapOf("l" to JsonArray(emptyList()))) - ) - }.also { assertContains(it.message!!, "l no long") } - } - - private fun testDiff(diffPath: String, startPath: String, endPath: String) { - val diffFile = File(diffPath) - val startFile = File(startPath) - val endFile = File(endPath) - val diff = json.parseToJsonElement(diffFile.readText()).jsonObject - val start = IndexParser.parseV2(FileInputStream(startFile)) - val end = IndexParser.parseV2(FileInputStream(endFile)) - - // diff repo - val repoJson = diff["repo"]!!.jsonObject.cleanRepo() - val repo: RepoV2 = ReflectionDiffer.applyDiff(start.repo.clean(), repoJson) - assertEquals(end.repo.clean(), repo) - // apply diff to all start packages present in end index - end.packages.forEach packages@{ (packageName, packageV2) -> - val packageDiff = diff["packages"]?.jsonObject?.get(packageName)?.jsonObject - ?: return@packages - // apply diff to metadata - val metadataDiff = packageDiff.jsonObject["metadata"]?.jsonObject?.cleanMetadata() - if (metadataDiff != null) { - val startMetadata = start.packages[packageName]?.metadata?.clean() ?: run { - val factory = MetadataV2::class.primaryConstructor!! - ReflectionDiffer.constructFromJson(factory, metadataDiff) - } - val metadataV2: MetadataV2 = ReflectionDiffer.applyDiff(startMetadata, metadataDiff) - assertEquals(packageV2.metadata.clean(), metadataV2) + // diff repo + val repoJson = diff["repo"]!!.jsonObject.cleanRepo() + val repo: RepoV2 = ReflectionDiffer.applyDiff(start.repo.clean(), repoJson) + assertEquals(end.repo.clean(), repo) + // apply diff to all start packages present in end index + end.packages.forEach packages@{ (packageName, packageV2) -> + val packageDiff = + diff["packages"]?.jsonObject?.get(packageName)?.jsonObject ?: return@packages + // apply diff to metadata + val metadataDiff = packageDiff.jsonObject["metadata"]?.jsonObject?.cleanMetadata() + if (metadataDiff != null) { + val startMetadata = + start.packages[packageName]?.metadata?.clean() + ?: run { + val factory = MetadataV2::class.primaryConstructor!! + ReflectionDiffer.constructFromJson(factory, metadataDiff) } - // apply diff to all start versions present in end index - packageV2.versions.forEach versions@{ (versionId, packageVersionV2) -> - val versionsDiff = packageDiff.jsonObject["versions"]?.jsonObject - ?.get(versionId)?.jsonObject?.cleanVersion() ?: return@versions - val startVersion = start.packages[packageName]?.versions?.get(versionId)?.clean() - ?: run { - val factory = PackageVersionV2::class.primaryConstructor!! - ReflectionDiffer.constructFromJson(factory, versionsDiff) - } - val version: PackageVersionV2 = - ReflectionDiffer.applyDiff(startVersion, versionsDiff) - assertEquals(packageVersionV2.clean(), version) + val metadataV2: MetadataV2 = ReflectionDiffer.applyDiff(startMetadata, metadataDiff) + assertEquals(packageV2.metadata.clean(), metadataV2) + } + // apply diff to all start versions present in end index + packageV2.versions.forEach versions@{ (versionId, packageVersionV2) -> + val versionsDiff = + packageDiff.jsonObject["versions"]?.jsonObject?.get(versionId)?.jsonObject?.cleanVersion() + ?: return@versions + val startVersion = + start.packages[packageName]?.versions?.get(versionId)?.clean() + ?: run { + val factory = PackageVersionV2::class.primaryConstructor!! + ReflectionDiffer.constructFromJson(factory, versionsDiff) } - } + val version: PackageVersionV2 = ReflectionDiffer.applyDiff(startVersion, versionsDiff) + assertEquals(packageVersionV2.clean(), version) + } } - + } } diff --git a/libs/index/src/commonMain/kotlin/org/fdroid/index/IndexConverter.kt b/libs/index/src/commonMain/kotlin/org/fdroid/index/IndexConverter.kt index 5d494ae1b..8d4ed2b2e 100644 --- a/libs/index/src/commonMain/kotlin/org/fdroid/index/IndexConverter.kt +++ b/libs/index/src/commonMain/kotlin/org/fdroid/index/IndexConverter.kt @@ -12,80 +12,77 @@ import org.fdroid.index.v2.ReleaseChannelV2 public const val RELEASE_CHANNEL_BETA: String = "Beta" internal const val DEFAULT_LOCALE = "en-US" -public class IndexConverter( - private val defaultLocale: String = DEFAULT_LOCALE, -) { +public class IndexConverter(private val defaultLocale: String = DEFAULT_LOCALE) { - public fun toIndexV2(v1: IndexV1): IndexV2 { - val antiFeatures = HashMap() - val categories = HashMap() - val packagesV2 = HashMap(v1.apps.size) - v1.apps.forEach { app -> - val versions = v1.packages[app.packageName] - val preferredSigner = versions?.get(0)?.signer - val appAntiFeatures: Map = - app.antiFeatures.associateWith { emptyMap() } - val whatsNew: LocalizedTextV2? = app.localized?.mapValuesNotNull { it.value.whatsNew } - val packageV2 = PackageV2( - metadata = app.toMetadataV2(preferredSigner, defaultLocale), - versions = versions?.associate { - val versionCode = it.versionCode ?: 0 - val suggestedVersionCode = app.suggestedVersionCode?.toLongOrNull() ?: 0 - val versionReleaseChannels = if (versionCode > suggestedVersionCode) - listOf(RELEASE_CHANNEL_BETA) else emptyList() - val wn = if (suggestedVersionCode == versionCode) whatsNew else null - it.hash to it.toPackageVersionV2(versionReleaseChannels, appAntiFeatures, wn) - } ?: emptyMap(), - ) - appAntiFeatures.mapInto(antiFeatures) - app.categories.mapInto(categories) - packagesV2[app.packageName] = packageV2 - } - return IndexV2( - repo = v1.repo.toRepoV2( - locale = defaultLocale, - antiFeatures = antiFeatures, - categories = categories, - releaseChannels = getV1ReleaseChannels(), - ), - packages = packagesV2, + public fun toIndexV2(v1: IndexV1): IndexV2 { + val antiFeatures = HashMap() + val categories = HashMap() + val packagesV2 = HashMap(v1.apps.size) + v1.apps.forEach { app -> + val versions = v1.packages[app.packageName] + val preferredSigner = versions?.get(0)?.signer + val appAntiFeatures: Map = + app.antiFeatures.associateWith { emptyMap() } + val whatsNew: LocalizedTextV2? = app.localized?.mapValuesNotNull { it.value.whatsNew } + val packageV2 = + PackageV2( + metadata = app.toMetadataV2(preferredSigner, defaultLocale), + versions = + versions?.associate { + val versionCode = it.versionCode ?: 0 + val suggestedVersionCode = app.suggestedVersionCode?.toLongOrNull() ?: 0 + val versionReleaseChannels = + if (versionCode > suggestedVersionCode) listOf(RELEASE_CHANNEL_BETA) + else emptyList() + val wn = if (suggestedVersionCode == versionCode) whatsNew else null + it.hash to it.toPackageVersionV2(versionReleaseChannels, appAntiFeatures, wn) + } ?: emptyMap(), ) + appAntiFeatures.mapInto(antiFeatures) + app.categories.mapInto(categories) + packagesV2[app.packageName] = packageV2 } - + return IndexV2( + repo = + v1.repo.toRepoV2( + locale = defaultLocale, + antiFeatures = antiFeatures, + categories = categories, + releaseChannels = getV1ReleaseChannels(), + ), + packages = packagesV2, + ) + } } internal fun Collection.mapInto(map: HashMap, valueGetter: (String) -> T) { - forEach { key -> - if (!map.containsKey(key)) map[key] = valueGetter(key) - } + forEach { key -> if (!map.containsKey(key)) map[key] = valueGetter(key) } } internal fun List.mapInto(map: HashMap) { - mapInto(map) { key -> - CategoryV2(name = mapOf(DEFAULT_LOCALE to key)) - } + mapInto(map) { key -> CategoryV2(name = mapOf(DEFAULT_LOCALE to key)) } } internal fun Map.mapInto(map: HashMap) { - keys.mapInto(map) { key -> - AntiFeatureV2(name = mapOf(DEFAULT_LOCALE to key)) - } + keys.mapInto(map) { key -> AntiFeatureV2(name = mapOf(DEFAULT_LOCALE to key)) } } -public fun getV1ReleaseChannels(): Map = mapOf( - RELEASE_CHANNEL_BETA to ReleaseChannelV2( +public fun getV1ReleaseChannels(): Map = + mapOf( + RELEASE_CHANNEL_BETA to + ReleaseChannelV2( name = mapOf(DEFAULT_LOCALE to RELEASE_CHANNEL_BETA), description = emptyMap(), - ) -) + ) + ) internal fun Map.mapValuesNotNull( - transform: (Map.Entry) -> T?, + transform: (Map.Entry) -> T? ): Map? { - val map = LinkedHashMap(size) - for (element in this) { - val value = transform(element) - if (value != null) map[element.key] = value - } - return map.takeIf { map.isNotEmpty() } + val map = LinkedHashMap(size) + for (element in this) { + val value = transform(element) + if (value != null) map[element.key] = value + } + return map.takeIf { map.isNotEmpty() } } diff --git a/libs/index/src/commonMain/kotlin/org/fdroid/index/IndexParser.kt b/libs/index/src/commonMain/kotlin/org/fdroid/index/IndexParser.kt index a330905dc..edd7286c0 100644 --- a/libs/index/src/commonMain/kotlin/org/fdroid/index/IndexParser.kt +++ b/libs/index/src/commonMain/kotlin/org/fdroid/index/IndexParser.kt @@ -8,36 +8,30 @@ import org.fdroid.index.v2.IndexV2 public object IndexParser { - @Volatile - private var jsonInstance: Json? = null - - /** - * Initializing [Json] is expensive, so using this method is preferable as it keeps returning - * a single instance with the recommended settings. - */ - public val json: Json - @JvmStatic - get() { - return jsonInstance ?: synchronized(this) { - Json { - ignoreUnknownKeys = true - } - } - } + @Volatile private var jsonInstance: Json? = null + /** + * Initializing [Json] is expensive, so using this method is preferable as it keeps returning a + * single instance with the recommended settings. + */ + public val json: Json @JvmStatic - public fun parseV1(str: String): IndexV1 { - return json.decodeFromString(str) + get() { + return jsonInstance ?: synchronized(this) { Json { ignoreUnknownKeys = true } } } - @JvmStatic - public fun parseV2(str: String): IndexV2 { - return json.decodeFromString(str) - } + @JvmStatic + public fun parseV1(str: String): IndexV1 { + return json.decodeFromString(str) + } - @JvmStatic - public fun parseEntry(str: String): Entry { - return json.decodeFromString(str) - } + @JvmStatic + public fun parseV2(str: String): IndexV2 { + return json.decodeFromString(str) + } + @JvmStatic + public fun parseEntry(str: String): Entry { + return json.decodeFromString(str) + } } diff --git a/libs/index/src/commonMain/kotlin/org/fdroid/index/v1/AppV1.kt b/libs/index/src/commonMain/kotlin/org/fdroid/index/v1/AppV1.kt index 65cad28f5..8c778e45a 100644 --- a/libs/index/src/commonMain/kotlin/org/fdroid/index/v1/AppV1.kt +++ b/libs/index/src/commonMain/kotlin/org/fdroid/index/v1/AppV1.kt @@ -12,144 +12,133 @@ import org.fdroid.index.v2.Screenshots @Serializable public data class AppV1( - val categories: List = emptyList(), // missing in wind repo - val antiFeatures: List = emptyList(), - val summary: String? = null, - val description: String? = null, - val changelog: String? = null, - val translation: String? = null, - val issueTracker: String? = null, - val sourceCode: String? = null, - val binaries: String? = null, - val name: String? = null, - val authorName: String? = null, - val authorEmail: String? = null, - val authorWebSite: String? = null, - val authorPhone: String? = null, - val donate: String? = null, - val liberapayID: String? = null, - val liberapay: String? = null, - val openCollective: String? = null, - val bitcoin: String? = null, - val litecoin: String? = null, - val flattrID: String? = null, - val suggestedVersionName: String? = null, // missing in guardian project repo - val suggestedVersionCode: String? = null, // missing in wind repo - val license: String, - val webSite: String? = null, - val added: Long? = null, // missing in wind repo, - val icon: String? = null, - val packageName: String, - val lastUpdated: Long? = null, // missing in wind repo, - val localized: Map? = null, - val allowedAPKSigningKeys: List? = null, // guardian repo only, not needed for client? + val categories: List = emptyList(), // missing in wind repo + val antiFeatures: List = emptyList(), + val summary: String? = null, + val description: String? = null, + val changelog: String? = null, + val translation: String? = null, + val issueTracker: String? = null, + val sourceCode: String? = null, + val binaries: String? = null, + val name: String? = null, + val authorName: String? = null, + val authorEmail: String? = null, + val authorWebSite: String? = null, + val authorPhone: String? = null, + val donate: String? = null, + val liberapayID: String? = null, + val liberapay: String? = null, + val openCollective: String? = null, + val bitcoin: String? = null, + val litecoin: String? = null, + val flattrID: String? = null, + val suggestedVersionName: String? = null, // missing in guardian project repo + val suggestedVersionCode: String? = null, // missing in wind repo + val license: String, + val webSite: String? = null, + val added: Long? = null, // missing in wind repo, + val icon: String? = null, + val packageName: String, + val lastUpdated: Long? = null, // missing in wind repo, + val localized: Map? = null, + val allowedAPKSigningKeys: List? = null, // guardian repo only, not needed for client? ) { - public fun toMetadataV2( - preferredSigner: String?, - locale: String = DEFAULT_LOCALE, - ): MetadataV2 = MetadataV2( - name = getLocalizedTextV2(name, locale) { it.name }, - summary = getLocalizedTextV2(summary, locale) { it.summary }, - description = getLocalizedTextV2(description, locale) { it.description }, - added = added ?: 0, - lastUpdated = lastUpdated ?: 0, - webSite = webSite, - changelog = changelog, - license = license, - sourceCode = sourceCode, - issueTracker = issueTracker, - translation = translation, - preferredSigner = preferredSigner, - categories = categories, - authorName = authorName, - authorEmail = authorEmail, - authorWebSite = authorWebSite, - authorPhone = authorPhone, - donate = if (donate == null) emptyList() else listOf(donate), - liberapayID = liberapayID, - liberapay = liberapay, - openCollective = openCollective, - bitcoin = bitcoin, - litecoin = litecoin, - flattrID = flattrID, - icon = localized.toLocalizedFileV2 { it.icon } - ?: icon?.let { mapOf(locale to FileV2("/icons/$it")) }, - featureGraphic = localized.toLocalizedFileV2 { it.featureGraphic }, - promoGraphic = localized.toLocalizedFileV2 { it.promoGraphic }, - tvBanner = localized.toLocalizedFileV2 { it.tvBanner }, - video = localized.toLocalizedTextV2 { it.video }, - screenshots = Screenshots( - phone = localized.toLocalizedFileListV2("phoneScreenshots") { - it.phoneScreenshots - }, - sevenInch = localized.toLocalizedFileListV2("sevenInchScreenshots") { - it.sevenInchScreenshots - }, - tenInch = localized.toLocalizedFileListV2("tenInchScreenshots") { - it.tenInchScreenshots - }, - wear = localized.toLocalizedFileListV2("wearScreenshots") { - it.wearScreenshots - }, - tv = localized.toLocalizedFileListV2("tvScreenshots") { - it.tvScreenshots - }, - ).takeIf { !it.isNull }, + public fun toMetadataV2(preferredSigner: String?, locale: String = DEFAULT_LOCALE): MetadataV2 = + MetadataV2( + name = getLocalizedTextV2(name, locale) { it.name }, + summary = getLocalizedTextV2(summary, locale) { it.summary }, + description = getLocalizedTextV2(description, locale) { it.description }, + added = added ?: 0, + lastUpdated = lastUpdated ?: 0, + webSite = webSite, + changelog = changelog, + license = license, + sourceCode = sourceCode, + issueTracker = issueTracker, + translation = translation, + preferredSigner = preferredSigner, + categories = categories, + authorName = authorName, + authorEmail = authorEmail, + authorWebSite = authorWebSite, + authorPhone = authorPhone, + donate = if (donate == null) emptyList() else listOf(donate), + liberapayID = liberapayID, + liberapay = liberapay, + openCollective = openCollective, + bitcoin = bitcoin, + litecoin = litecoin, + flattrID = flattrID, + icon = + localized.toLocalizedFileV2 { it.icon } + ?: icon?.let { mapOf(locale to FileV2("/icons/$it")) }, + featureGraphic = localized.toLocalizedFileV2 { it.featureGraphic }, + promoGraphic = localized.toLocalizedFileV2 { it.promoGraphic }, + tvBanner = localized.toLocalizedFileV2 { it.tvBanner }, + video = localized.toLocalizedTextV2 { it.video }, + screenshots = + Screenshots( + phone = localized.toLocalizedFileListV2("phoneScreenshots") { it.phoneScreenshots }, + sevenInch = + localized.toLocalizedFileListV2("sevenInchScreenshots") { it.sevenInchScreenshots }, + tenInch = + localized.toLocalizedFileListV2("tenInchScreenshots") { it.tenInchScreenshots }, + wear = localized.toLocalizedFileListV2("wearScreenshots") { it.wearScreenshots }, + tv = localized.toLocalizedFileListV2("tvScreenshots") { it.tvScreenshots }, + ) + .takeIf { !it.isNull }, ) - private fun getLocalizedTextV2( - s: String?, - locale: String, - selector: (Localized) -> String?, - ): LocalizedTextV2? { - return if (s == null) localized?.toLocalizedTextV2(selector) else mapOf(locale to s) - } + private fun getLocalizedTextV2( + s: String?, + locale: String, + selector: (Localized) -> String?, + ): LocalizedTextV2? { + return if (s == null) localized?.toLocalizedTextV2(selector) else mapOf(locale to s) + } - private fun Map?.toLocalizedTextV2( - selector: (Localized) -> String?, - ): LocalizedTextV2? { - if (this == null) return null - return mapValuesNotNull { selector(it.value) } - } + private fun Map?.toLocalizedTextV2( + selector: (Localized) -> String? + ): LocalizedTextV2? { + if (this == null) return null + return mapValuesNotNull { selector(it.value) } + } - private fun Map?.toLocalizedFileV2( - selector: (Localized) -> String?, - ): LocalizedFileV2? { - if (this == null) return null - return mapValuesNotNull { - selector(it.value)?.let { file -> - FileV2("/$packageName/${it.key}/$file") - } - } + private fun Map?.toLocalizedFileV2( + selector: (Localized) -> String? + ): LocalizedFileV2? { + if (this == null) return null + return mapValuesNotNull { + selector(it.value)?.let { file -> FileV2("/$packageName/${it.key}/$file") } } + } - private fun Map?.toLocalizedFileListV2( - kind: String, - selector: (Localized) -> List?, - ): LocalizedFileListV2? { - if (this == null) return null - return mapValuesNotNull { - selector(it.value)?.map { file -> - FileV2("/$packageName/${it.key}/$kind/$file") - } - } + private fun Map?.toLocalizedFileListV2( + kind: String, + selector: (Localized) -> List?, + ): LocalizedFileListV2? { + if (this == null) return null + return mapValuesNotNull { + selector(it.value)?.map { file -> FileV2("/$packageName/${it.key}/$kind/$file") } } + } } @Serializable public data class Localized( - val description: String? = null, - val name: String? = null, - val icon: String? = null, - val whatsNew: String? = null, - val video: String? = null, - val phoneScreenshots: List? = null, - val sevenInchScreenshots: List? = null, - val tenInchScreenshots: List? = null, - val wearScreenshots: List? = null, - val tvScreenshots: List? = null, - val featureGraphic: String? = null, - val promoGraphic: String? = null, - val tvBanner: String? = null, - val summary: String? = null, + val description: String? = null, + val name: String? = null, + val icon: String? = null, + val whatsNew: String? = null, + val video: String? = null, + val phoneScreenshots: List? = null, + val sevenInchScreenshots: List? = null, + val tenInchScreenshots: List? = null, + val wearScreenshots: List? = null, + val tvScreenshots: List? = null, + val featureGraphic: String? = null, + val promoGraphic: String? = null, + val tvBanner: String? = null, + val summary: String? = null, ) diff --git a/libs/index/src/commonMain/kotlin/org/fdroid/index/v1/IndexV1.kt b/libs/index/src/commonMain/kotlin/org/fdroid/index/v1/IndexV1.kt index 567e5f723..04693803f 100644 --- a/libs/index/src/commonMain/kotlin/org/fdroid/index/v1/IndexV1.kt +++ b/libs/index/src/commonMain/kotlin/org/fdroid/index/v1/IndexV1.kt @@ -12,45 +12,41 @@ import org.fdroid.index.v2.RepoV2 @Serializable public data class IndexV1( - val repo: RepoV1, - val requests: Requests = Requests(emptyList(), emptyList()), - val apps: List = emptyList(), - val packages: Map> = emptyMap(), + val repo: RepoV1, + val requests: Requests = Requests(emptyList(), emptyList()), + val apps: List = emptyList(), + val packages: Map> = emptyMap(), ) @Serializable public data class RepoV1( - val timestamp: Long, - val version: Int, - @SerialName("maxage") - val maxAge: Int? = null, // missing in izzy repo - val name: String, - val icon: String, - val address: String, - val description: String, - val mirrors: List = emptyList(), // missing in izzy repo + val timestamp: Long, + val version: Int, + @SerialName("maxage") val maxAge: Int? = null, // missing in izzy repo + val name: String, + val icon: String, + val address: String, + val description: String, + val mirrors: List = emptyList(), // missing in izzy repo ) { - public fun toRepoV2( - locale: String = DEFAULT_LOCALE, - antiFeatures: Map, - categories: Map, - releaseChannels: Map, - ): RepoV2 = RepoV2( - name = mapOf(locale to name), - icon = mapOf(locale to FileV2("/icons/$icon")), - address = address, - webBaseUrl = null, - description = mapOf(locale to description), - mirrors = mirrors.map { MirrorV2(it) }, - timestamp = timestamp, - antiFeatures = antiFeatures, - categories = categories, - releaseChannels = releaseChannels, + public fun toRepoV2( + locale: String = DEFAULT_LOCALE, + antiFeatures: Map, + categories: Map, + releaseChannels: Map, + ): RepoV2 = + RepoV2( + name = mapOf(locale to name), + icon = mapOf(locale to FileV2("/icons/$icon")), + address = address, + webBaseUrl = null, + description = mapOf(locale to description), + mirrors = mirrors.map { MirrorV2(it) }, + timestamp = timestamp, + antiFeatures = antiFeatures, + categories = categories, + releaseChannels = releaseChannels, ) } -@Serializable -public data class Requests( - val install: List, - val uninstall: List, -) +@Serializable public data class Requests(val install: List, val uninstall: List) diff --git a/libs/index/src/commonMain/kotlin/org/fdroid/index/v1/IndexV1StreamReceiver.kt b/libs/index/src/commonMain/kotlin/org/fdroid/index/v1/IndexV1StreamReceiver.kt index fba3efadc..5bb7b1d9a 100644 --- a/libs/index/src/commonMain/kotlin/org/fdroid/index/v1/IndexV1StreamReceiver.kt +++ b/libs/index/src/commonMain/kotlin/org/fdroid/index/v1/IndexV1StreamReceiver.kt @@ -10,20 +10,21 @@ import org.fdroid.index.v2.RepoV2 @Deprecated("Use IndexV2 instead") public interface IndexV1StreamReceiver { - public fun receive(repo: RepoV2, version: Long) - public fun receive(packageName: String, m: MetadataV2) - public fun receive(packageName: String, v: Map) + public fun receive(repo: RepoV2, version: Long) - public fun updateRepo( - antiFeatures: Map, - categories: Map, - releaseChannels: Map, - ) + public fun receive(packageName: String, m: MetadataV2) - /** - * Updates [MetadataV2.preferredSigner] with the given [preferredSigner] - * for the given [packageName]. - */ - public fun updateAppMetadata(packageName: String, preferredSigner: String?) + public fun receive(packageName: String, v: Map) + public fun updateRepo( + antiFeatures: Map, + categories: Map, + releaseChannels: Map, + ) + + /** + * Updates [MetadataV2.preferredSigner] with the given [preferredSigner] for the given + * [packageName]. + */ + public fun updateAppMetadata(packageName: String, preferredSigner: String?) } diff --git a/libs/index/src/commonMain/kotlin/org/fdroid/index/v1/PackageV1.kt b/libs/index/src/commonMain/kotlin/org/fdroid/index/v1/PackageV1.kt index 90be3e7bc..a7e6f193f 100644 --- a/libs/index/src/commonMain/kotlin/org/fdroid/index/v1/PackageV1.kt +++ b/libs/index/src/commonMain/kotlin/org/fdroid/index/v1/PackageV1.kt @@ -27,91 +27,88 @@ import org.fdroid.index.v2.UsesSdkV2 @Serializable public data class PackageV1( - val added: Long? = null, - val apkName: String, - val hash: String, - val hashType: String, // TODO enum? - val minSdkVersion: Int? = null, - val maxSdkVersion: Int? = null, - val targetSdkVersion: Int? = minSdkVersion, - val packageName: String, - val sig: String? = null, - val signer: String? = null, - val size: Long, - @SerialName("srcname") - val srcName: String? = null, - @SerialName("uses-permission") - val usesPermission: List = emptyList(), - @SerialName("uses-permission-sdk-23") - val usesPermission23: List = emptyList(), - val versionCode: Long? = null, - val versionName: String, - @SerialName("nativecode") - val nativeCode: List? = null, - val features: List? = null, - val antiFeatures: List? = null, + val added: Long? = null, + val apkName: String, + val hash: String, + val hashType: String, // TODO enum? + val minSdkVersion: Int? = null, + val maxSdkVersion: Int? = null, + val targetSdkVersion: Int? = minSdkVersion, + val packageName: String, + val sig: String? = null, + val signer: String? = null, + val size: Long, + @SerialName("srcname") val srcName: String? = null, + @SerialName("uses-permission") val usesPermission: List = emptyList(), + @SerialName("uses-permission-sdk-23") val usesPermission23: List = emptyList(), + val versionCode: Long? = null, + val versionName: String, + @SerialName("nativecode") val nativeCode: List? = null, + val features: List? = null, + val antiFeatures: List? = null, ) { - public fun toPackageVersionV2( - releaseChannels: List, - appAntiFeatures: Map, - whatsNew: LocalizedTextV2?, - ): PackageVersionV2 = PackageVersionV2( - added = added ?: 0, - file = FileV1( - name = "/$apkName", - sha256 = hash.apply { require(hashType == "sha256") }, - size = size, + public fun toPackageVersionV2( + releaseChannels: List, + appAntiFeatures: Map, + whatsNew: LocalizedTextV2?, + ): PackageVersionV2 = + PackageVersionV2( + added = added ?: 0, + file = + FileV1( + name = "/$apkName", + sha256 = hash.apply { require(hashType == "sha256") }, + size = size, ), - src = srcName?.let { FileV2("/$it") }, - manifest = ManifestV2( - versionName = versionName, - versionCode = versionCode ?: 1, - usesSdk = if (minSdkVersion == null && targetSdkVersion == null) null else UsesSdkV2( + src = srcName?.let { FileV2("/$it") }, + manifest = + ManifestV2( + versionName = versionName, + versionCode = versionCode ?: 1, + usesSdk = + if (minSdkVersion == null && targetSdkVersion == null) null + else + UsesSdkV2( minSdkVersion = minSdkVersion ?: 1, targetSdkVersion = targetSdkVersion ?: minSdkVersion ?: 1, - ), - maxSdkVersion = maxSdkVersion, - signer = signer?.let { SignerV2(listOf(it)) }, - usesPermission = usesPermission.map { PermissionV2(it.name, it.maxSdk) }, - usesPermissionSdk23 = usesPermission23.map { PermissionV2(it.name, it.maxSdk) }, - nativecode = nativeCode ?: emptyList(), - features = features?.map { FeatureV2(it) } ?: emptyList(), + ), + maxSdkVersion = maxSdkVersion, + signer = signer?.let { SignerV2(listOf(it)) }, + usesPermission = usesPermission.map { PermissionV2(it.name, it.maxSdk) }, + usesPermissionSdk23 = usesPermission23.map { PermissionV2(it.name, it.maxSdk) }, + nativecode = nativeCode ?: emptyList(), + features = features?.map { FeatureV2(it) } ?: emptyList(), ), - releaseChannels = releaseChannels, - antiFeatures = appAntiFeatures + ( - antiFeatures?.associate { it to emptyMap() } ?: emptyMap() - ), - whatsNew = whatsNew ?: emptyMap(), + releaseChannels = releaseChannels, + antiFeatures = appAntiFeatures + (antiFeatures?.associate { it to emptyMap() } ?: emptyMap()), + whatsNew = whatsNew ?: emptyMap(), ) } @Serializable(with = PermissionV1Serializer::class) -public data class PermissionV1( - val name: String, - val maxSdk: Int? = null, -) +public data class PermissionV1(val name: String, val maxSdk: Int? = null) internal class PermissionV1Serializer : KSerializer { - override val descriptor = buildClassSerialDescriptor("PermissionV1") { - element("name") - element("maxSdk") + override val descriptor = + buildClassSerialDescriptor("PermissionV1") { + element("name") + element("maxSdk") } - override fun deserialize(decoder: Decoder): PermissionV1 { - val jsonInput = decoder as? JsonDecoder ?: error("Can be deserialized only by JSON") - val jsonArray = jsonInput.decodeJsonElement().jsonArray - if (jsonArray.size < 2) throw IllegalArgumentException("Invalid array: $jsonArray") - val name = jsonArray[0].jsonPrimitive.content - val maxSdk = jsonArray[1].jsonPrimitive.intOrNull - return PermissionV1(name, maxSdk) - } + override fun deserialize(decoder: Decoder): PermissionV1 { + val jsonInput = decoder as? JsonDecoder ?: error("Can be deserialized only by JSON") + val jsonArray = jsonInput.decodeJsonElement().jsonArray + if (jsonArray.size < 2) throw IllegalArgumentException("Invalid array: $jsonArray") + val name = jsonArray[0].jsonPrimitive.content + val maxSdk = jsonArray[1].jsonPrimitive.intOrNull + return PermissionV1(name, maxSdk) + } - @OptIn(ExperimentalSerializationApi::class) - override fun serialize(encoder: Encoder, value: PermissionV1) { - encoder.encodeCollection(JsonArray.serializer().descriptor, 2) { - encodeStringElement(descriptor, 0, value.name) - encodeNullableSerializableElement(descriptor, 1, Int.serializer(), value.maxSdk) - } + @OptIn(ExperimentalSerializationApi::class) + override fun serialize(encoder: Encoder, value: PermissionV1) { + encoder.encodeCollection(JsonArray.serializer().descriptor, 2) { + encodeStringElement(descriptor, 0, value.name) + encodeNullableSerializableElement(descriptor, 1, Int.serializer(), value.maxSdk) } - + } } diff --git a/libs/index/src/commonMain/kotlin/org/fdroid/index/v2/IndexV2.kt b/libs/index/src/commonMain/kotlin/org/fdroid/index/v2/IndexV2.kt index 732e6c4da..caee1b45c 100644 --- a/libs/index/src/commonMain/kotlin/org/fdroid/index/v2/IndexV2.kt +++ b/libs/index/src/commonMain/kotlin/org/fdroid/index/v2/IndexV2.kt @@ -9,124 +9,116 @@ import org.fdroid.index.IndexParser.json @Serializable public data class Entry( - val timestamp: Long, - val version: Long, - val maxAge: Int? = null, - val index: EntryFileV2, - val diffs: Map = emptyMap(), + val timestamp: Long, + val version: Long, + val maxAge: Int? = null, + val index: EntryFileV2, + val diffs: Map = emptyMap(), ) { - /** - * @return the diff for the given [timestamp] or null if none exists - * in which case the full [index] should be used. - */ - public fun getDiff(timestamp: Long): EntryFileV2? { - return diffs[timestamp.toString()] - } + /** + * @return the diff for the given [timestamp] or null if none exists in which case the full + * [index] should be used. + */ + public fun getDiff(timestamp: Long): EntryFileV2? { + return diffs[timestamp.toString()] + } } @Serializable public data class EntryFileV2( - override val name: String, - override val sha256: String, - override val size: Long, - @SerialName("ipfsCIDv1") - override val ipfsCidV1: String? = null, - val numPackages: Int, + override val name: String, + override val sha256: String, + override val size: Long, + @SerialName("ipfsCIDv1") override val ipfsCidV1: String? = null, + val numPackages: Int, ) : IndexFile { - public companion object { - public fun deserialize(string: String): EntryFileV2 { - return json.decodeFromString(string) - } + public companion object { + public fun deserialize(string: String): EntryFileV2 { + return json.decodeFromString(string) } + } - public override fun serialize(): String { - return json.encodeToString(this) - } + public override fun serialize(): String { + return json.encodeToString(this) + } } @Serializable public data class FileV2( - override val name: String, - override val sha256: String? = null, - override val size: Long? = null, - @SerialName("ipfsCIDv1") - override val ipfsCidV1: String? = null, + override val name: String, + override val sha256: String? = null, + override val size: Long? = null, + @SerialName("ipfsCIDv1") override val ipfsCidV1: String? = null, ) : IndexFile { - public companion object { - @JvmStatic - public fun deserialize(string: String?): FileV2? { - // we've seen serialized FileV2 objects becoming an empty string after parcelizing them, - // so we need to account for null *and* empty string here. - if (string.isNullOrEmpty()) return null - return json.decodeFromString(string) - } - - @JvmStatic - public fun fromPath(path: String): FileV2 = FileV2(path) + public companion object { + @JvmStatic + public fun deserialize(string: String?): FileV2? { + // we've seen serialized FileV2 objects becoming an empty string after parcelizing them, + // so we need to account for null *and* empty string here. + if (string.isNullOrEmpty()) return null + return json.decodeFromString(string) } - public override fun serialize(): String { - return json.encodeToString(this) - } + @JvmStatic public fun fromPath(path: String): FileV2 = FileV2(path) + } + + public override fun serialize(): String { + return json.encodeToString(this) + } } @Serializable -public data class IndexV2( - val repo: RepoV2, - val packages: Map = emptyMap(), -) { - public fun walkFiles(fileConsumer: (FileV2?) -> Unit) { - repo.walkFiles(fileConsumer) - packages.values.forEach { it.walkFiles(fileConsumer) } - } +public data class IndexV2(val repo: RepoV2, val packages: Map = emptyMap()) { + public fun walkFiles(fileConsumer: (FileV2?) -> Unit) { + repo.walkFiles(fileConsumer) + packages.values.forEach { it.walkFiles(fileConsumer) } + } } @Serializable public data class RepoV2( - val name: LocalizedTextV2 = emptyMap(), - val icon: LocalizedFileV2 = emptyMap(), - val address: String, - val webBaseUrl: String? = null, - val description: LocalizedTextV2 = emptyMap(), - val mirrors: List = emptyList(), - val timestamp: Long, - val antiFeatures: Map = emptyMap(), - val categories: Map = emptyMap(), - val releaseChannels: Map = emptyMap(), + val name: LocalizedTextV2 = emptyMap(), + val icon: LocalizedFileV2 = emptyMap(), + val address: String, + val webBaseUrl: String? = null, + val description: LocalizedTextV2 = emptyMap(), + val mirrors: List = emptyList(), + val timestamp: Long, + val antiFeatures: Map = emptyMap(), + val categories: Map = emptyMap(), + val releaseChannels: Map = emptyMap(), ) { - public fun walkFiles(fileConsumer: (FileV2?) -> Unit) { - icon.values.forEach { fileConsumer(it) } - antiFeatures.values.forEach { it.icon.values.forEach { icon -> fileConsumer(icon) } } - categories.values.forEach { it.icon.values.forEach { icon -> fileConsumer(icon) } } - } + public fun walkFiles(fileConsumer: (FileV2?) -> Unit) { + icon.values.forEach { fileConsumer(it) } + antiFeatures.values.forEach { it.icon.values.forEach { icon -> fileConsumer(icon) } } + categories.values.forEach { it.icon.values.forEach { icon -> fileConsumer(icon) } } + } } public typealias LocalizedTextV2 = Map + public typealias LocalizedFileV2 = Map + public typealias LocalizedFileListV2 = Map> -@Serializable -public data class MirrorV2( - val url: String, - val countryCode: String? = null, -) +@Serializable public data class MirrorV2(val url: String, val countryCode: String? = null) @Serializable public data class AntiFeatureV2( - val icon: LocalizedFileV2 = emptyMap(), - val name: LocalizedTextV2, - val description: LocalizedTextV2 = emptyMap(), + val icon: LocalizedFileV2 = emptyMap(), + val name: LocalizedTextV2, + val description: LocalizedTextV2 = emptyMap(), ) @Serializable public data class CategoryV2( - val icon: LocalizedFileV2 = emptyMap(), - val name: LocalizedTextV2, - val description: LocalizedTextV2 = emptyMap(), + val icon: LocalizedFileV2 = emptyMap(), + val name: LocalizedTextV2, + val description: LocalizedTextV2 = emptyMap(), ) @Serializable public data class ReleaseChannelV2( - val name: LocalizedTextV2, - val description: LocalizedTextV2 = emptyMap(), + val name: LocalizedTextV2, + val description: LocalizedTextV2 = emptyMap(), ) diff --git a/libs/index/src/commonMain/kotlin/org/fdroid/index/v2/IndexV2DiffStreamReceiver.kt b/libs/index/src/commonMain/kotlin/org/fdroid/index/v2/IndexV2DiffStreamReceiver.kt index 278d57827..9a4031c90 100644 --- a/libs/index/src/commonMain/kotlin/org/fdroid/index/v2/IndexV2DiffStreamReceiver.kt +++ b/libs/index/src/commonMain/kotlin/org/fdroid/index/v2/IndexV2DiffStreamReceiver.kt @@ -4,32 +4,26 @@ import kotlinx.serialization.json.JsonObject public interface IndexV2DiffStreamReceiver { - /** - * Receives the diff for the [RepoV2] from the index stream. - */ - public fun receiveRepoDiff(version: Long, repoJsonObject: JsonObject) + /** Receives the diff for the [RepoV2] from the index stream. */ + public fun receiveRepoDiff(version: Long, repoJsonObject: JsonObject) - /** - * Receives one diff for a [MetadataV2] from the index stream. - * This is called once for each package in the index diff. - * - * If the given [packageJsonObject] is null, the package should be removed. - */ - public fun receivePackageMetadataDiff(packageName: String, packageJsonObject: JsonObject?) + /** + * Receives one diff for a [MetadataV2] from the index stream. This is called once for each + * package in the index diff. + * + * If the given [packageJsonObject] is null, the package should be removed. + */ + public fun receivePackageMetadataDiff(packageName: String, packageJsonObject: JsonObject?) - /** - * Receives the diff for all versions of the give n [packageName] - * as a map of versions IDs to the diff [JsonObject]. - * This is called once for each package in the index diff (if versions have changed). - * - * If an entry in the given [versionsDiffMap] is null, - * the version with that ID should be removed. - */ - public fun receiveVersionsDiff(packageName: String, versionsDiffMap: Map?) - - /** - * Called when the stream has been processed to its end. - */ - public fun onStreamEnded() + /** + * Receives the diff for all versions of the give n [packageName] as a map of versions IDs to the + * diff [JsonObject]. This is called once for each package in the index diff (if versions have + * changed). + * + * If an entry in the given [versionsDiffMap] is null, the version with that ID should be removed. + */ + public fun receiveVersionsDiff(packageName: String, versionsDiffMap: Map?) + /** Called when the stream has been processed to its end. */ + public fun onStreamEnded() } diff --git a/libs/index/src/commonMain/kotlin/org/fdroid/index/v2/IndexV2StreamReceiver.kt b/libs/index/src/commonMain/kotlin/org/fdroid/index/v2/IndexV2StreamReceiver.kt index fa658817c..fda92476d 100644 --- a/libs/index/src/commonMain/kotlin/org/fdroid/index/v2/IndexV2StreamReceiver.kt +++ b/libs/index/src/commonMain/kotlin/org/fdroid/index/v2/IndexV2StreamReceiver.kt @@ -2,21 +2,18 @@ package org.fdroid.index.v2 public interface IndexV2StreamReceiver { - /** - * Receives the [RepoV2] from the index stream. - * Attention: This might get called after receiving packages. - */ - public fun receive(repo: RepoV2, version: Long) + /** + * Receives the [RepoV2] from the index stream. Attention: This might get called after receiving + * packages. + */ + public fun receive(repo: RepoV2, version: Long) - /** - * Receives one [PackageV2] from the index stream. - * This is called once for each package in the index. - */ - public fun receive(packageName: String, p: PackageV2) - - /** - * Called when the stream has been processed to its end. - */ - public fun onStreamEnded() + /** + * Receives one [PackageV2] from the index stream. This is called once for each package in the + * index. + */ + public fun receive(packageName: String, p: PackageV2) + /** Called when the stream has been processed to its end. */ + public fun onStreamEnded() } diff --git a/libs/index/src/commonMain/kotlin/org/fdroid/index/v2/PackageV2.kt b/libs/index/src/commonMain/kotlin/org/fdroid/index/v2/PackageV2.kt index 25fcd2757..97a805e47 100644 --- a/libs/index/src/commonMain/kotlin/org/fdroid/index/v2/PackageV2.kt +++ b/libs/index/src/commonMain/kotlin/org/fdroid/index/v2/PackageV2.kt @@ -7,178 +7,163 @@ import org.fdroid.index.IndexParser @Serializable public data class PackageV2( - val metadata: MetadataV2, - val versions: Map = emptyMap(), + val metadata: MetadataV2, + val versions: Map = emptyMap(), ) { - public fun walkFiles(fileConsumer: (FileV2?) -> Unit) { - metadata.walkFiles(fileConsumer) - versions.values.forEach { it.walkFiles(fileConsumer) } - } + public fun walkFiles(fileConsumer: (FileV2?) -> Unit) { + metadata.walkFiles(fileConsumer) + versions.values.forEach { it.walkFiles(fileConsumer) } + } } @Serializable public data class MetadataV2( - val name: LocalizedTextV2? = null, - val summary: LocalizedTextV2? = null, - val description: LocalizedTextV2? = null, - val added: Long, - val lastUpdated: Long, - val webSite: String? = null, - val changelog: String? = null, - val license: String? = null, - val sourceCode: String? = null, - val issueTracker: String? = null, - val translation: String? = null, - val preferredSigner: String? = null, - val categories: List = emptyList(), - val authorName: String? = null, - val authorEmail: String? = null, - val authorWebSite: String? = null, - val authorPhone: String? = null, - val donate: List = emptyList(), - val liberapayID: String? = null, - val liberapay: String? = null, - val openCollective: String? = null, - val bitcoin: String? = null, - val litecoin: String? = null, - val flattrID: String? = null, - val icon: LocalizedFileV2? = null, - val featureGraphic: LocalizedFileV2? = null, - val promoGraphic: LocalizedFileV2? = null, - val tvBanner: LocalizedFileV2? = null, - val video: LocalizedTextV2? = null, - val screenshots: Screenshots? = null, + val name: LocalizedTextV2? = null, + val summary: LocalizedTextV2? = null, + val description: LocalizedTextV2? = null, + val added: Long, + val lastUpdated: Long, + val webSite: String? = null, + val changelog: String? = null, + val license: String? = null, + val sourceCode: String? = null, + val issueTracker: String? = null, + val translation: String? = null, + val preferredSigner: String? = null, + val categories: List = emptyList(), + val authorName: String? = null, + val authorEmail: String? = null, + val authorWebSite: String? = null, + val authorPhone: String? = null, + val donate: List = emptyList(), + val liberapayID: String? = null, + val liberapay: String? = null, + val openCollective: String? = null, + val bitcoin: String? = null, + val litecoin: String? = null, + val flattrID: String? = null, + val icon: LocalizedFileV2? = null, + val featureGraphic: LocalizedFileV2? = null, + val promoGraphic: LocalizedFileV2? = null, + val tvBanner: LocalizedFileV2? = null, + val video: LocalizedTextV2? = null, + val screenshots: Screenshots? = null, ) { - public fun walkFiles(fileConsumer: (FileV2?) -> Unit) { - icon?.values?.forEach { fileConsumer(it) } - featureGraphic?.values?.forEach { fileConsumer(it) } - promoGraphic?.values?.forEach { fileConsumer(it) } - tvBanner?.values?.forEach { fileConsumer(it) } - screenshots?.phone?.values?.forEach { it.forEach(fileConsumer) } - screenshots?.sevenInch?.values?.forEach { it.forEach(fileConsumer) } - screenshots?.tenInch?.values?.forEach { it.forEach(fileConsumer) } - screenshots?.wear?.values?.forEach { it.forEach(fileConsumer) } - screenshots?.tv?.values?.forEach { it.forEach(fileConsumer) } - } + public fun walkFiles(fileConsumer: (FileV2?) -> Unit) { + icon?.values?.forEach { fileConsumer(it) } + featureGraphic?.values?.forEach { fileConsumer(it) } + promoGraphic?.values?.forEach { fileConsumer(it) } + tvBanner?.values?.forEach { fileConsumer(it) } + screenshots?.phone?.values?.forEach { it.forEach(fileConsumer) } + screenshots?.sevenInch?.values?.forEach { it.forEach(fileConsumer) } + screenshots?.tenInch?.values?.forEach { it.forEach(fileConsumer) } + screenshots?.wear?.values?.forEach { it.forEach(fileConsumer) } + screenshots?.tv?.values?.forEach { it.forEach(fileConsumer) } + } } @Serializable public data class Screenshots( - val phone: LocalizedFileListV2? = null, - val sevenInch: LocalizedFileListV2? = null, - val tenInch: LocalizedFileListV2? = null, - val wear: LocalizedFileListV2? = null, - val tv: LocalizedFileListV2? = null, + val phone: LocalizedFileListV2? = null, + val sevenInch: LocalizedFileListV2? = null, + val tenInch: LocalizedFileListV2? = null, + val wear: LocalizedFileListV2? = null, + val tv: LocalizedFileListV2? = null, ) { - val isNull: Boolean - get() = phone == null && sevenInch == null && tenInch == null && wear == null && tv == null + val isNull: Boolean + get() = phone == null && sevenInch == null && tenInch == null && wear == null && tv == null } public interface PackageVersion { - public val versionCode: Long - public val versionName: String - public val added: Long - public val size: Long? - public val signer: SignerV2? - public val releaseChannels: List? - public val packageManifest: PackageManifest - public val hasKnownVulnerability: Boolean + public val versionCode: Long + public val versionName: String + public val added: Long + public val size: Long? + public val signer: SignerV2? + public val releaseChannels: List? + public val packageManifest: PackageManifest + public val hasKnownVulnerability: Boolean } public const val ANTI_FEATURE_KNOWN_VULNERABILITY: String = "KnownVuln" @Serializable public data class PackageVersionV2( - override val added: Long, - val file: FileV1, - val src: FileV2? = null, - val manifest: ManifestV2, - override val releaseChannels: List = emptyList(), - val antiFeatures: Map = emptyMap(), - val whatsNew: LocalizedTextV2 = emptyMap(), + override val added: Long, + val file: FileV1, + val src: FileV2? = null, + val manifest: ManifestV2, + override val releaseChannels: List = emptyList(), + val antiFeatures: Map = emptyMap(), + val whatsNew: LocalizedTextV2 = emptyMap(), ) : PackageVersion { - override val versionCode: Long = manifest.versionCode - override val versionName: String = manifest.versionName - override val size: Long? = file.size - override val signer: SignerV2? = manifest.signer - override val packageManifest: PackageManifest = manifest - override val hasKnownVulnerability: Boolean - get() = antiFeatures.contains(ANTI_FEATURE_KNOWN_VULNERABILITY) + override val versionCode: Long = manifest.versionCode + override val versionName: String = manifest.versionName + override val size: Long? = file.size + override val signer: SignerV2? = manifest.signer + override val packageManifest: PackageManifest = manifest + override val hasKnownVulnerability: Boolean + get() = antiFeatures.contains(ANTI_FEATURE_KNOWN_VULNERABILITY) - public fun walkFiles(fileConsumer: (FileV2?) -> Unit) { - fileConsumer(src) - } + public fun walkFiles(fileConsumer: (FileV2?) -> Unit) { + fileConsumer(src) + } } /** - * Like [FileV2] with the only difference that the [sha256] hash can not be null. - * Even in index-v1 this must exist, so we can use it as a primary key in the DB. + * Like [FileV2] with the only difference that the [sha256] hash can not be null. Even in index-v1 + * this must exist, so we can use it as a primary key in the DB. */ @Serializable public data class FileV1( - override val name: String, - override val sha256: String, - override val size: Long? = null, - @SerialName("ipfsCIDv1") - override val ipfsCidV1: String? = null, + override val name: String, + override val sha256: String, + override val size: Long? = null, + @SerialName("ipfsCIDv1") override val ipfsCidV1: String? = null, ) : IndexFile { - public companion object { - @JvmStatic - public fun deserialize(string: String?): FileV1? { - if (string == null) return null - return IndexParser.json.decodeFromString(string) - } + public companion object { + @JvmStatic + public fun deserialize(string: String?): FileV1? { + if (string == null) return null + return IndexParser.json.decodeFromString(string) } + } - public override fun serialize(): String { - return IndexParser.json.encodeToString(this) - } + public override fun serialize(): String { + return IndexParser.json.encodeToString(this) + } } public interface PackageManifest { - public val minSdkVersion: Int? - public val maxSdkVersion: Int? - public val featureNames: List? - public val nativecode: List? - public val targetSdkVersion: Int? + public val minSdkVersion: Int? + public val maxSdkVersion: Int? + public val featureNames: List? + public val nativecode: List? + public val targetSdkVersion: Int? } @Serializable public data class ManifestV2( - val versionName: String, - val versionCode: Long, - val usesSdk: UsesSdkV2? = null, - override val maxSdkVersion: Int? = null, - val signer: SignerV2? = null, // yes this can be null for stuff like non-apps - val usesPermission: List = emptyList(), - val usesPermissionSdk23: List = emptyList(), - override val nativecode: List = emptyList(), - val features: List = emptyList(), + val versionName: String, + val versionCode: Long, + val usesSdk: UsesSdkV2? = null, + override val maxSdkVersion: Int? = null, + val signer: SignerV2? = null, // yes this can be null for stuff like non-apps + val usesPermission: List = emptyList(), + val usesPermissionSdk23: List = emptyList(), + override val nativecode: List = emptyList(), + val features: List = emptyList(), ) : PackageManifest { - override val minSdkVersion: Int? = usesSdk?.minSdkVersion - override val featureNames: List = features.map { it.name } - override val targetSdkVersion: Int? = usesSdk?.targetSdkVersion + override val minSdkVersion: Int? = usesSdk?.minSdkVersion + override val featureNames: List = features.map { it.name } + override val targetSdkVersion: Int? = usesSdk?.targetSdkVersion } -@Serializable -public data class UsesSdkV2( - val minSdkVersion: Int, - val targetSdkVersion: Int, -) +@Serializable public data class UsesSdkV2(val minSdkVersion: Int, val targetSdkVersion: Int) @Serializable -public data class SignerV2( - val sha256: List, - val hasMultipleSigners: Boolean = false, -) +public data class SignerV2(val sha256: List, val hasMultipleSigners: Boolean = false) -@Serializable -public data class PermissionV2( - val name: String, - val maxSdkVersion: Int? = null, -) +@Serializable public data class PermissionV2(val name: String, val maxSdkVersion: Int? = null) -@Serializable -public data class FeatureV2( - val name: String, -) +@Serializable public data class FeatureV2(val name: String) diff --git a/libs/index/src/commonTest/kotlin/org/fdroid/index/IndexConverterTest.kt b/libs/index/src/commonTest/kotlin/org/fdroid/index/IndexConverterTest.kt index 306f6081a..27189fbbe 100644 --- a/libs/index/src/commonTest/kotlin/org/fdroid/index/IndexConverterTest.kt +++ b/libs/index/src/commonTest/kotlin/org/fdroid/index/IndexConverterTest.kt @@ -1,6 +1,8 @@ package org.fdroid.index import com.goncalossilva.resources.Resource +import kotlin.test.Test +import kotlin.test.assertEquals import org.fdroid.index.IndexParser.parseV1 import org.fdroid.index.v2.IndexV2 import org.fdroid.test.TestDataEmptyV2 @@ -9,41 +11,38 @@ import org.fdroid.test.TestDataMidV2 import org.fdroid.test.TestDataMinV2 import org.fdroid.test.TestUtils.sorted import org.fdroid.test.v1compat -import kotlin.test.Test -import kotlin.test.assertEquals internal const val ASSET_PATH = "../sharedTest/src/main/assets" internal class IndexConverterTest { - @Test - fun testEmpty() { - testConversation("$ASSET_PATH/index-empty-v1.json", TestDataEmptyV2.index.v1compat()) - } + @Test + fun testEmpty() { + testConversation("$ASSET_PATH/index-empty-v1.json", TestDataEmptyV2.index.v1compat()) + } - @Test - fun testMin() { - testConversation("$ASSET_PATH/index-min-v1.json", TestDataMinV2.index.v1compat()) - } + @Test + fun testMin() { + testConversation("$ASSET_PATH/index-min-v1.json", TestDataMinV2.index.v1compat()) + } - @Test - fun testMid() { - testConversation("$ASSET_PATH/index-mid-v1.json", TestDataMidV2.indexCompat) - } + @Test + fun testMid() { + testConversation("$ASSET_PATH/index-mid-v1.json", TestDataMidV2.indexCompat) + } - @Test - fun testMax() { - testConversation("$ASSET_PATH/index-max-v1.json", TestDataMaxV2.indexCompat) - } + @Test + fun testMax() { + testConversation("$ASSET_PATH/index-max-v1.json", TestDataMaxV2.indexCompat) + } - private fun testConversation(file: String, expectedIndex: IndexV2) { - val indexV1Res = Resource(file) - val indexV1Str = indexV1Res.readText() - val indexV1 = parseV1(indexV1Str) + private fun testConversation(file: String, expectedIndex: IndexV2) { + val indexV1Res = Resource(file) + val indexV1Str = indexV1Res.readText() + val indexV1 = parseV1(indexV1Str) - val v2 = IndexConverter().toIndexV2(indexV1) - - assertEquals(expectedIndex.sorted(), v2.sorted()) - } + val v2 = IndexConverter().toIndexV2(indexV1) + assertEquals(expectedIndex.sorted(), v2.sorted()) + } } diff --git a/libs/index/src/commonTest/kotlin/org/fdroid/index/v1/IndexV1Test.kt b/libs/index/src/commonTest/kotlin/org/fdroid/index/v1/IndexV1Test.kt index ac40596f1..49b393c4f 100644 --- a/libs/index/src/commonTest/kotlin/org/fdroid/index/v1/IndexV1Test.kt +++ b/libs/index/src/commonTest/kotlin/org/fdroid/index/v1/IndexV1Test.kt @@ -1,123 +1,137 @@ package org.fdroid.index.v1 import com.goncalossilva.resources.Resource -import kotlinx.serialization.SerializationException -import org.fdroid.index.IndexParser.parseV1 -import org.fdroid.index.ASSET_PATH -import org.fdroid.test.TestDataEmptyV1 -import org.fdroid.test.TestDataMaxV1 -import org.fdroid.test.TestDataMidV1 -import org.fdroid.test.TestDataMinV1 import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlinx.serialization.SerializationException +import org.fdroid.index.ASSET_PATH +import org.fdroid.index.IndexParser.parseV1 +import org.fdroid.test.TestDataEmptyV1 +import org.fdroid.test.TestDataMaxV1 +import org.fdroid.test.TestDataMidV1 +import org.fdroid.test.TestDataMinV1 internal class IndexV1Test { - @Test - fun testIndexEmptyV1() { - val indexRes = Resource("$ASSET_PATH/index-empty-v1.json") - val indexStr = indexRes.readText() - val index = parseV1(indexStr) - assertEquals(TestDataEmptyV1.index, index) - } + @Test + fun testIndexEmptyV1() { + val indexRes = Resource("$ASSET_PATH/index-empty-v1.json") + val indexStr = indexRes.readText() + val index = parseV1(indexStr) + assertEquals(TestDataEmptyV1.index, index) + } - @Test - fun testIndexMinV1() { - val indexRes = Resource("$ASSET_PATH/index-min-v1.json") - val indexStr = indexRes.readText() - val index = parseV1(indexStr) - assertEquals(TestDataMinV1.index, index) - } + @Test + fun testIndexMinV1() { + val indexRes = Resource("$ASSET_PATH/index-min-v1.json") + val indexStr = indexRes.readText() + val index = parseV1(indexStr) + assertEquals(TestDataMinV1.index, index) + } - @Test - fun testIndexMidV1() { - val indexRes = Resource("$ASSET_PATH/index-mid-v1.json") - val indexStr = indexRes.readText() - val index = parseV1(indexStr) - assertEquals(TestDataMidV1.index, index) - } + @Test + fun testIndexMidV1() { + val indexRes = Resource("$ASSET_PATH/index-mid-v1.json") + val indexStr = indexRes.readText() + val index = parseV1(indexStr) + assertEquals(TestDataMidV1.index, index) + } - @Test - fun testIndexMaxV1() { - val indexRes = Resource("$ASSET_PATH/index-max-v1.json") - val indexStr = indexRes.readText() - val index = parseV1(indexStr) - assertEquals(TestDataMaxV1.index, index) - } + @Test + fun testIndexMaxV1() { + val indexRes = Resource("$ASSET_PATH/index-max-v1.json") + val indexStr = indexRes.readText() + val index = parseV1(indexStr) + assertEquals(TestDataMaxV1.index, index) + } - @Test - fun testMalformedV1() { - // empty json dict - assertFailsWith { - parseV1("{}") - }.also { assertContains(it.message!!, "repo") } + @Test + fun testMalformedV1() { + // empty json dict + assertFailsWith { parseV1("{}") } + .also { assertContains(it.message!!, "repo") } - // garbage input - assertFailsWith { - parseV1("efoj324#FD@(DJ#@DLKWf") - } + // garbage input + assertFailsWith { parseV1("efoj324#FD@(DJ#@DLKWf") } - // empty repo dict - assertFailsWith { - parseV1("""{ - "repo": {} - }""".trimIndent() - ) - }.also { assertContains(it.message!!, "timestamp") } + // empty repo dict + assertFailsWith { + parseV1( + """ + { + "repo": {} + } + """ + .trimIndent() + ) + } + .also { assertContains(it.message!!, "timestamp") } - // timestamp not a number - assertFailsWith { - parseV1("""{ - "repo": { "timestamp": "string" } - }""".trimIndent() - ) - }.also { assertContains(it.message!!, "numeric literal") } + // timestamp not a number + assertFailsWith { + parseV1( + """ + { + "repo": { "timestamp": "string" } + } + """ + .trimIndent() + ) + } + .also { assertContains(it.message!!, "numeric literal") } - // remember valid repo for further tests - val validRepo = """ - "repo": { - "timestamp": 42, - "version": 23, - "name": "foo", - "icon": "bar", - "address": "https://example.com", - "description": "desc" - } - """.trimIndent() + // remember valid repo for further tests + val validRepo = + """ + "repo": { + "timestamp": 42, + "version": 23, + "name": "foo", + "icon": "bar", + "address": "https://example.com", + "description": "desc" + } + """ + .trimIndent() - // apps is dict - assertFailsWith { - parseV1("""{ + // apps is dict + assertFailsWith { + parseV1( + """{ $validRepo, "apps": {} - }""".trimIndent() - ) - }.also { assertContains(it.message!!, "apps") } + }""" + .trimIndent() + ) + } + .also { assertContains(it.message!!, "apps") } - // packages is list - assertFailsWith { - parseV1("""{ + // packages is list + assertFailsWith { + parseV1( + """{ $validRepo, "packages": [] - }""".trimIndent() - ) - }.also { assertContains(it.message!!, "packages") } - } + }""" + .trimIndent() + ) + } + .also { assertContains(it.message!!, "packages") } + } - @Test - fun testGuardianProjectV1() { - val indexRes = Resource("$ASSET_PATH/guardianproject_index-v1.json") - val indexStr = indexRes.readText() - parseV1(indexStr) - } - - @Test - fun testLocalizedV1() { - val indexRes = Resource("$ASSET_PATH/localized.json") - val indexStr = indexRes.readText() - parseV1(indexStr) - } + @Test + fun testGuardianProjectV1() { + val indexRes = Resource("$ASSET_PATH/guardianproject_index-v1.json") + val indexStr = indexRes.readText() + parseV1(indexStr) + } + @Test + fun testLocalizedV1() { + val indexRes = Resource("$ASSET_PATH/localized.json") + val indexStr = indexRes.readText() + parseV1(indexStr) + } } diff --git a/libs/index/src/commonTest/kotlin/org/fdroid/index/v2/EntryTest.kt b/libs/index/src/commonTest/kotlin/org/fdroid/index/v2/EntryTest.kt index a6d97f4ec..67b6764e7 100644 --- a/libs/index/src/commonTest/kotlin/org/fdroid/index/v2/EntryTest.kt +++ b/libs/index/src/commonTest/kotlin/org/fdroid/index/v2/EntryTest.kt @@ -1,108 +1,134 @@ package org.fdroid.index.v2 import com.goncalossilva.resources.Resource -import kotlinx.serialization.SerializationException -import org.fdroid.index.IndexParser -import org.fdroid.index.ASSET_PATH -import org.fdroid.test.TestDataEntry -import org.junit.Test import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlinx.serialization.SerializationException +import org.fdroid.index.ASSET_PATH +import org.fdroid.index.IndexParser +import org.fdroid.test.TestDataEntry +import org.junit.Test internal class EntryTest { - @Test - fun testEmpty() { - testEntryEquality("$ASSET_PATH/entry-empty-v2.json", TestDataEntry.empty) - } + @Test + fun testEmpty() { + testEntryEquality("$ASSET_PATH/entry-empty-v2.json", TestDataEntry.empty) + } - @Test - fun testEmptyToMin() { - testEntryEquality("$ASSET_PATH/diff-empty-min/$DATA_FILE_NAME", TestDataEntry.emptyToMin) - } + @Test + fun testEmptyToMin() { + testEntryEquality("$ASSET_PATH/diff-empty-min/$DATA_FILE_NAME", TestDataEntry.emptyToMin) + } - @Test - fun testEmptyToMid() { - testEntryEquality("$ASSET_PATH/diff-empty-mid/$DATA_FILE_NAME", TestDataEntry.emptyToMid) - } + @Test + fun testEmptyToMid() { + testEntryEquality("$ASSET_PATH/diff-empty-mid/$DATA_FILE_NAME", TestDataEntry.emptyToMid) + } - @Test - fun testEmptyToMax() { - testEntryEquality("$ASSET_PATH/diff-empty-max/$DATA_FILE_NAME", TestDataEntry.emptyToMax) - } + @Test + fun testEmptyToMax() { + testEntryEquality("$ASSET_PATH/diff-empty-max/$DATA_FILE_NAME", TestDataEntry.emptyToMax) + } - @Test - fun testMalformedEntry() { - // empty dict - assertFailsWith { - IndexParser.parseEntry("{ }") - }.also { assertContains(it.message!!, "missing") } + @Test + fun testMalformedEntry() { + // empty dict + assertFailsWith { IndexParser.parseEntry("{ }") } + .also { assertContains(it.message!!, "missing") } - // garbage input - assertFailsWith { - IndexParser.parseEntry("{ 23^^%*dfDFG568 }") - } + // garbage input + assertFailsWith { IndexParser.parseEntry("{ 23^^%*dfDFG568 }") } - // timestamp is list - assertFailsWith { - IndexParser.parseEntry("""{ - "timestamp": [1, 2] - }""".trimIndent()) - }.also { assertContains(it.message!!, "timestamp") } + // timestamp is list + assertFailsWith { + IndexParser.parseEntry( + """ + { + "timestamp": [1, 2] + } + """ + .trimIndent() + ) + } + .also { assertContains(it.message!!, "timestamp") } - // version is dict - assertFailsWith { - IndexParser.parseEntry("""{ - "timestamp": 23, - "version": {} - }""".trimIndent()) - }.also { assertContains(it.message!!, "version") } + // version is dict + assertFailsWith { + IndexParser.parseEntry( + """ + { + "timestamp": 23, + "version": {} + } + """ + .trimIndent() + ) + } + .also { assertContains(it.message!!, "version") } - // index is number - assertFailsWith { - IndexParser.parseEntry("""{ - "timestamp": 23, - "version": 43, - "index": 1337 - }""".trimIndent()) - }.also { assertContains(it.message!!, "object") } + // index is number + assertFailsWith { + IndexParser.parseEntry( + """ + { + "timestamp": 23, + "version": 43, + "index": 1337 + } + """ + .trimIndent() + ) + } + .also { assertContains(it.message!!, "object") } - // index is missing numPackages - assertFailsWith { - IndexParser.parseEntry("""{ - "timestamp": 23, - "version": 43, - "index": { - "name": "sdfsdf", - "sha256": "adfsdf", - "size": 0 - } - }""".trimIndent()) - }.also { assertContains(it.message!!, "numPackages") } + // index is missing numPackages + assertFailsWith { + IndexParser.parseEntry( + """ + { + "timestamp": 23, + "version": 43, + "index": { + "name": "sdfsdf", + "sha256": "adfsdf", + "size": 0 + } + } + """ + .trimIndent() + ) + } + .also { assertContains(it.message!!, "numPackages") } - // diffs is a list - assertFailsWith { - IndexParser.parseEntry("""{ - "timestamp": 23, - "version": 43, - "index": { - "name": "sdfsdf", - "sha256": "adfsdf", - "size": 0, - "numPackages": 0 - }, - "diffs": [] - }""".trimIndent()) - }.also { assertContains(it.message!!, "diffs") } - } + // diffs is a list + assertFailsWith { + IndexParser.parseEntry( + """ + { + "timestamp": 23, + "version": 43, + "index": { + "name": "sdfsdf", + "sha256": "adfsdf", + "size": 0, + "numPackages": 0 + }, + "diffs": [] + } + """ + .trimIndent() + ) + } + .also { assertContains(it.message!!, "diffs") } + } - private fun testEntryEquality(path: String, expectedEntry: Entry) { - val entryRes = Resource(path) - val entryStr = entryRes.readText() - val entry = IndexParser.parseEntry(entryStr) - - assertEquals(expectedEntry, entry) - } + private fun testEntryEquality(path: String, expectedEntry: Entry) { + val entryRes = Resource(path) + val entryStr = entryRes.readText() + val entry = IndexParser.parseEntry(entryStr) + assertEquals(expectedEntry, entry) + } } diff --git a/libs/index/src/commonTest/kotlin/org/fdroid/index/v2/IndexV2DiffStreamProcessorTest.kt b/libs/index/src/commonTest/kotlin/org/fdroid/index/v2/IndexV2DiffStreamProcessorTest.kt index ed39e06d8..344a55ad9 100644 --- a/libs/index/src/commonTest/kotlin/org/fdroid/index/v2/IndexV2DiffStreamProcessorTest.kt +++ b/libs/index/src/commonTest/kotlin/org/fdroid/index/v2/IndexV2DiffStreamProcessorTest.kt @@ -1,114 +1,104 @@ package org.fdroid.index.v2 -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.jsonObject -import org.fdroid.index.IndexParser -import org.fdroid.index.ASSET_PATH -import org.junit.Test import java.io.ByteArrayInputStream import java.io.File import java.io.FileInputStream import kotlin.test.assertEquals import kotlin.test.assertTrue import kotlin.test.fail +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import org.fdroid.index.ASSET_PATH +import org.fdroid.index.IndexParser +import org.junit.Test internal class IndexV2DiffStreamProcessorTest { - @Test - fun testEmptyToMin() = testDiff("$ASSET_PATH/diff-empty-min/23.json", 1) + @Test fun testEmptyToMin() = testDiff("$ASSET_PATH/diff-empty-min/23.json", 1) - @Test - fun testEmptyToMid() = testDiff("$ASSET_PATH/diff-empty-mid/23.json", 2) + @Test fun testEmptyToMid() = testDiff("$ASSET_PATH/diff-empty-mid/23.json", 2) - @Test - fun testEmptyToMax() = testDiff("$ASSET_PATH/diff-empty-max/23.json", 3) + @Test fun testEmptyToMax() = testDiff("$ASSET_PATH/diff-empty-max/23.json", 3) - @Test - fun testMinToMid() = testDiff("$ASSET_PATH/diff-empty-mid/42.json", 2) + @Test fun testMinToMid() = testDiff("$ASSET_PATH/diff-empty-mid/42.json", 2) - @Test - fun testMinToMax() = testDiff("$ASSET_PATH/diff-empty-max/42.json", 3) + @Test fun testMinToMax() = testDiff("$ASSET_PATH/diff-empty-max/42.json", 3) - @Test - fun testMidToMax() = testDiff("$ASSET_PATH/diff-empty-max/1337.json", 2) + @Test fun testMidToMax() = testDiff("$ASSET_PATH/diff-empty-max/1337.json", 2) - @Test - fun testRemovePackage() { - val diffJson = """ - { - "repo": { "timestamp": 42 }, - "packages": { "foo": null } - } - """.trimIndent() + @Test + fun testRemovePackage() { + val diffJson = + """ + { + "repo": { "timestamp": 42 }, + "packages": { "foo": null } + } + """ + .trimIndent() - val streamReceiver = TestDiffReceiver() - val streamProcessor = IndexV2DiffStreamProcessor(streamReceiver) - streamProcessor.process(42, ByteArrayInputStream(diffJson.toByteArray())) { - assertEquals(1, it) - } - - val diff = IndexParser.json.parseToJsonElement(diffJson).jsonObject - assertTrue(streamReceiver.endCalled) - assertEquals(diff, streamReceiver.index) + val streamReceiver = TestDiffReceiver() + val streamProcessor = IndexV2DiffStreamProcessor(streamReceiver) + streamProcessor.process(42, ByteArrayInputStream(diffJson.toByteArray())) { + assertEquals(1, it) } - private fun testDiff(diffPath: String, expectedNumApps: Int) { - val diffFile = File(diffPath) + val diff = IndexParser.json.parseToJsonElement(diffJson).jsonObject + assertTrue(streamReceiver.endCalled) + assertEquals(diff, streamReceiver.index) + } - val streamReceiver = TestDiffReceiver() - val streamProcessor = IndexV2DiffStreamProcessor(streamReceiver) - var totalApps = 0 - streamProcessor.process(42, FileInputStream(diffFile)) { numAppsProcessed -> - totalApps = numAppsProcessed - } + private fun testDiff(diffPath: String, expectedNumApps: Int) { + val diffFile = File(diffPath) - val diff = IndexParser.json.parseToJsonElement(diffFile.readText()).jsonObject - assertTrue(streamReceiver.endCalled) - assertEquals(diff, streamReceiver.index) - assertEquals(expectedNumApps, totalApps) + val streamReceiver = TestDiffReceiver() + val streamProcessor = IndexV2DiffStreamProcessor(streamReceiver) + var totalApps = 0 + streamProcessor.process(42, FileInputStream(diffFile)) { numAppsProcessed -> + totalApps = numAppsProcessed } - private class TestDiffReceiver : IndexV2DiffStreamReceiver { - private val packages = HashMap() - private val indexMap = HashMap().apply { - put("packages", JsonObject(packages)) - } - val index = JsonObject(indexMap) - var endCalled = false + val diff = IndexParser.json.parseToJsonElement(diffFile.readText()).jsonObject + assertTrue(streamReceiver.endCalled) + assertEquals(diff, streamReceiver.index) + assertEquals(expectedNumApps, totalApps) + } - override fun receiveRepoDiff(version: Long, repoJsonObject: JsonObject) { - indexMap["repo"] = repoJsonObject - } + private class TestDiffReceiver : IndexV2DiffStreamReceiver { + private val packages = HashMap() + private val indexMap = + HashMap().apply { put("packages", JsonObject(packages)) } + val index = JsonObject(indexMap) + var endCalled = false - override fun receivePackageMetadataDiff( - packageName: String, - packageJsonObject: JsonObject?, - ) { - if (packageJsonObject == null) { - packages[packageName] = JsonNull - } else { - val packageV2 = HashMap(2) - packageV2["metadata"] = packageJsonObject - packages[packageName] = JsonObject(packageV2) - } - } - - override fun receiveVersionsDiff( - packageName: String, - versionsDiffMap: Map?, - ) { - val packageV2 = - HashMap(packages[packageName]?.jsonObject ?: fail()) - val versions = versionsDiffMap?.mapValues { it.value ?: JsonNull } - packageV2["versions"] = versions?.let { JsonObject(it) } ?: JsonNull - packages[packageName] = JsonObject(packageV2) - } - - override fun onStreamEnded() { - endCalled = true - } + override fun receiveRepoDiff(version: Long, repoJsonObject: JsonObject) { + indexMap["repo"] = repoJsonObject } + override fun receivePackageMetadataDiff(packageName: String, packageJsonObject: JsonObject?) { + if (packageJsonObject == null) { + packages[packageName] = JsonNull + } else { + val packageV2 = HashMap(2) + packageV2["metadata"] = packageJsonObject + packages[packageName] = JsonObject(packageV2) + } + } + + override fun receiveVersionsDiff( + packageName: String, + versionsDiffMap: Map?, + ) { + val packageV2 = HashMap(packages[packageName]?.jsonObject ?: fail()) + val versions = versionsDiffMap?.mapValues { it.value ?: JsonNull } + packageV2["versions"] = versions?.let { JsonObject(it) } ?: JsonNull + packages[packageName] = JsonObject(packageV2) + } + + override fun onStreamEnded() { + endCalled = true + } + } } diff --git a/libs/index/src/commonTest/kotlin/org/fdroid/index/v2/IndexV2Test.kt b/libs/index/src/commonTest/kotlin/org/fdroid/index/v2/IndexV2Test.kt index fcdf8d8bd..6227722cf 100644 --- a/libs/index/src/commonTest/kotlin/org/fdroid/index/v2/IndexV2Test.kt +++ b/libs/index/src/commonTest/kotlin/org/fdroid/index/v2/IndexV2Test.kt @@ -1,98 +1,118 @@ package org.fdroid.index.v2 import com.goncalossilva.resources.Resource -import kotlinx.serialization.SerializationException -import org.fdroid.index.IndexParser.parseV2 -import org.fdroid.index.ASSET_PATH -import org.fdroid.test.TestDataEmptyV2 -import org.fdroid.test.TestDataMaxV2 -import org.fdroid.test.TestDataMidV2 -import org.fdroid.test.TestDataMinV2 import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlinx.serialization.SerializationException +import org.fdroid.index.ASSET_PATH +import org.fdroid.index.IndexParser.parseV2 +import org.fdroid.test.TestDataEmptyV2 +import org.fdroid.test.TestDataMaxV2 +import org.fdroid.test.TestDataMidV2 +import org.fdroid.test.TestDataMinV2 internal class IndexV2Test { - @Test - fun testEmpty() { - testIndexEquality("$ASSET_PATH/index-empty-v2.json", TestDataEmptyV2.index) - } + @Test + fun testEmpty() { + testIndexEquality("$ASSET_PATH/index-empty-v2.json", TestDataEmptyV2.index) + } - @Test - fun testMin() { - testIndexEquality("$ASSET_PATH/index-min-v2.json", TestDataMinV2.index) - } + @Test + fun testMin() { + testIndexEquality("$ASSET_PATH/index-min-v2.json", TestDataMinV2.index) + } - @Test - fun testMinReordered() { - testIndexEquality("$ASSET_PATH/index-min-reordered-v2.json", TestDataMinV2.index) - } + @Test + fun testMinReordered() { + testIndexEquality("$ASSET_PATH/index-min-reordered-v2.json", TestDataMinV2.index) + } - @Test - fun testMid() { - testIndexEquality("$ASSET_PATH/index-mid-v2.json", TestDataMidV2.index) - } + @Test + fun testMid() { + testIndexEquality("$ASSET_PATH/index-mid-v2.json", TestDataMidV2.index) + } - @Test - fun testMax() { - testIndexEquality("$ASSET_PATH/index-max-v2.json", TestDataMaxV2.index) - } + @Test + fun testMax() { + testIndexEquality("$ASSET_PATH/index-max-v2.json", TestDataMaxV2.index) + } - @Test - fun testMalformedIndex() { - // empty dict - assertFailsWith { - parseV2("{ }") - }.also { assertContains(it.message!!, "missing") } + @Test + fun testMalformedIndex() { + // empty dict + assertFailsWith { parseV2("{ }") } + .also { assertContains(it.message!!, "missing") } - // garbage input - assertFailsWith { - parseV2("{ 23^^%*dfDFG568 }") - } + // garbage input + assertFailsWith { parseV2("{ 23^^%*dfDFG568 }") } - // repo is a number - assertFailsWith { - parseV2("""{ - "repo": 1 - }""".trimIndent()) - }.also { assertContains(it.message!!, "repo") } + // repo is a number + assertFailsWith { + parseV2( + """ + { + "repo": 1 + } + """ + .trimIndent() + ) + } + .also { assertContains(it.message!!, "repo") } - // repo is empty - assertFailsWith { - parseV2("""{ - "repo": { } - }""".trimIndent()) - }.also { assertContains(it.message!!, "timestamp") } + // repo is empty + assertFailsWith { + parseV2( + """ + { + "repo": { } + } + """ + .trimIndent() + ) + } + .also { assertContains(it.message!!, "timestamp") } - // repo misses address - assertFailsWith { - parseV2("""{ - "repo": { - "timestamp": 23 - } - }""".trimIndent()) - }.also { assertContains(it.message!!, "address") } + // repo misses address + assertFailsWith { + parseV2( + """ + { + "repo": { + "timestamp": 23 + } + } + """ + .trimIndent() + ) + } + .also { assertContains(it.message!!, "address") } - // packages is list - assertFailsWith { - parseV2("""{ - "repo": { - "timestamp": 23, - "address": "http://example.com" - }, - "packages": [] - }""".trimIndent()) - }.also { assertContains(it.message!!, "packages") } - } + // packages is list + assertFailsWith { + parseV2( + """ + { + "repo": { + "timestamp": 23, + "address": "http://example.com" + }, + "packages": [] + } + """ + .trimIndent() + ) + } + .also { assertContains(it.message!!, "packages") } + } - private fun testIndexEquality(file: String, expectedIndex: IndexV2) { - val indexV2Res = Resource(file) - val indexV2Str = indexV2Res.readText() - val indexV2 = parseV2(indexV2Str) - - assertEquals(expectedIndex, indexV2) - } + private fun testIndexEquality(file: String, expectedIndex: IndexV2) { + val indexV2Res = Resource(file) + val indexV2Str = indexV2Res.readText() + val indexV2 = parseV2(indexV2Str) + assertEquals(expectedIndex, indexV2) + } } diff --git a/libs/sharedTest/build.gradle.kts b/libs/sharedTest/build.gradle.kts index 9c93e2fe7..c2535f684 100644 --- a/libs/sharedTest/build.gradle.kts +++ b/libs/sharedTest/build.gradle.kts @@ -1,32 +1,28 @@ plugins { - alias(libs.plugins.jetbrains.kotlin.android) - alias(libs.plugins.android.library) + alias(libs.plugins.jetbrains.kotlin.android) + alias(libs.plugins.android.library) + alias(libs.plugins.ktfmt) } // not really an Android library, but index is not publishing for JVM at the moment android { - namespace = "org.fdroid.test" - @Suppress("ktlint:standard:chain-method-continuation") - compileSdk = libs.versions.compileSdk.get().toInt() - defaultConfig { - minSdk = 21 - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } + namespace = "org.fdroid.test" + compileSdk = libs.versions.compileSdk.get().toInt() + defaultConfig { minSdk = 21 } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } } -kotlin { - compilerOptions { - jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 - } -} +kotlin { compilerOptions { jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 } } dependencies { - implementation(project(":libs:download")) - implementation(project(":libs:index")) + implementation(project(":libs:download")) + implementation(project(":libs:index")) - implementation(libs.kotlin.test) - implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlin.test) + implementation(libs.kotlinx.serialization.json) } + +ktfmt { googleStyle() } diff --git a/libs/sharedTest/src/main/kotlin/org/fdroid/test/DiffUtils.kt b/libs/sharedTest/src/main/kotlin/org/fdroid/test/DiffUtils.kt index 6777b25ac..96b3797da 100644 --- a/libs/sharedTest/src/main/kotlin/org/fdroid/test/DiffUtils.kt +++ b/libs/sharedTest/src/main/kotlin/org/fdroid/test/DiffUtils.kt @@ -1,88 +1,80 @@ package org.fdroid.test +import kotlin.random.Random import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.jsonObject import org.fdroid.index.v2.MetadataV2 import org.fdroid.index.v2.PackageVersionV2 import org.fdroid.index.v2.RepoV2 -import kotlin.random.Random object DiffUtils { - /** - * Create a map diff by adding or removing keys. Note that this does not change keys. - */ - fun Map.randomDiff(factory: () -> T): Map = buildMap { - if (this@randomDiff.isNotEmpty()) { - // remove random keys - while (Random.nextBoolean()) put(this@randomDiff.keys.random(), null) - // Note: we don't replace random keys, because we can't easily diff inside T - } - // add random keys - while (Random.nextBoolean()) put(TestUtils.getRandomString(), factory()) + /** Create a map diff by adding or removing keys. Note that this does not change keys. */ + fun Map.randomDiff(factory: () -> T): Map = buildMap { + if (this@randomDiff.isNotEmpty()) { + // remove random keys + while (Random.nextBoolean()) put(this@randomDiff.keys.random(), null) + // Note: we don't replace random keys, because we can't easily diff inside T } + // add random keys + while (Random.nextBoolean()) put(TestUtils.getRandomString(), factory()) + } - /** - * Removes keys from a JSON object representing a [RepoV2] which need special handling. - */ - fun JsonObject.cleanRepo(): JsonObject { - val keysToFilter = listOf("mirrors", "antiFeatures", "categories", "releaseChannels") - val newMap = filterKeys { it !in keysToFilter } - return JsonObject(newMap) - } + /** Removes keys from a JSON object representing a [RepoV2] which need special handling. */ + fun JsonObject.cleanRepo(): JsonObject { + val keysToFilter = listOf("mirrors", "antiFeatures", "categories", "releaseChannels") + val newMap = filterKeys { it !in keysToFilter } + return JsonObject(newMap) + } - fun RepoV2.clean() = copy( - mirrors = emptyList(), - antiFeatures = emptyMap(), - categories = emptyMap(), - releaseChannels = emptyMap(), + fun RepoV2.clean() = + copy( + mirrors = emptyList(), + antiFeatures = emptyMap(), + categories = emptyMap(), + releaseChannels = emptyMap(), ) - /** - * Removes keys from a JSON object representing a [MetadataV2] which need special handling. - */ - fun JsonObject.cleanMetadata(): JsonObject { - val keysToFilter = listOf( - "icon", "featureGraphic", "promoGraphic", "tvBanner", "screenshots", + /** Removes keys from a JSON object representing a [MetadataV2] which need special handling. */ + fun JsonObject.cleanMetadata(): JsonObject { + val keysToFilter = listOf("icon", "featureGraphic", "promoGraphic", "tvBanner", "screenshots") + val newMap = filterKeys { it !in keysToFilter } + return JsonObject(newMap) + } + + fun MetadataV2.clean() = + copy( + icon = null, + featureGraphic = null, + promoGraphic = null, + tvBanner = null, + screenshots = null, + ) + + /** + * Removes keys from a JSON object representing a [PackageVersionV2] which need special handling. + */ + fun JsonObject.cleanVersion(): JsonObject { + if (!containsKey("manifest")) return this + val keysToFilter = listOf("features", "usesPermission", "usesPermissionSdk23") + val newMap = toMutableMap() + val filteredManifest = newMap["manifest"]!!.jsonObject.filterKeys { it !in keysToFilter } + newMap["manifest"] = JsonObject(filteredManifest) + return JsonObject(newMap) + } + + fun PackageVersionV2.clean() = + copy( + manifest = + manifest.copy( + features = emptyList(), + usesPermission = emptyList(), + usesPermissionSdk23 = emptyList(), ) - val newMap = filterKeys { it !in keysToFilter } - return JsonObject(newMap) - } - - fun MetadataV2.clean() = copy( - icon = null, - featureGraphic = null, - promoGraphic = null, - tvBanner = null, - screenshots = null, ) - /** - * Removes keys from a JSON object representing a [PackageVersionV2] which need special handling. - */ - fun JsonObject.cleanVersion(): JsonObject { - if (!containsKey("manifest")) return this - val keysToFilter = listOf("features", "usesPermission", "usesPermissionSdk23") - val newMap = toMutableMap() - val filteredManifest = newMap["manifest"]!!.jsonObject.filterKeys { it !in keysToFilter } - newMap["manifest"] = JsonObject(filteredManifest) - return JsonObject(newMap) + fun Map.applyDiff(diff: Map): Map = + toMutableMap().apply { + diff.entries.forEach { (key, value) -> if (value == null) remove(key) else set(key, value) } } - - fun PackageVersionV2.clean() = copy( - manifest = manifest.copy( - features = emptyList(), - usesPermission = emptyList(), - usesPermissionSdk23 = emptyList(), - ), - ) - - fun Map.applyDiff(diff: Map): Map = - toMutableMap().apply { - diff.entries.forEach { (key, value) -> - if (value == null) remove(key) - else set(key, value) - } - } - } diff --git a/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestAppUtils.kt b/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestAppUtils.kt index 5f5020668..f63d2a405 100644 --- a/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestAppUtils.kt +++ b/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestAppUtils.kt @@ -1,5 +1,7 @@ package org.fdroid.test +import kotlin.random.Random +import kotlin.test.assertEquals import org.fdroid.index.v2.FileV2 import org.fdroid.index.v2.LocalizedFileListV2 import org.fdroid.index.v2.MetadataV2 @@ -10,87 +12,79 @@ import org.fdroid.test.TestRepoUtils.getRandomLocalizedTextV2 import org.fdroid.test.TestUtils.getRandomList import org.fdroid.test.TestUtils.getRandomString import org.fdroid.test.TestUtils.orNull -import kotlin.random.Random -import kotlin.test.assertEquals object TestAppUtils { - fun getRandomMetadataV2( - authorName: String? = null, - lastUpdated: Long? = null - ): MetadataV2 = MetadataV2( - added = Random.nextLong(), - lastUpdated = lastUpdated ?: Random.nextLong(), - name = getRandomLocalizedTextV2().orNull(), - summary = getRandomLocalizedTextV2().orNull(), - description = getRandomLocalizedTextV2().orNull(), - webSite = getRandomString().orNull(), - changelog = getRandomString().orNull(), - license = getRandomString().orNull(), - sourceCode = getRandomString().orNull(), - issueTracker = getRandomString().orNull(), - translation = getRandomString().orNull(), - preferredSigner = getRandomString().orNull(), - video = getRandomLocalizedTextV2().orNull(), - authorName = authorName ?: getRandomString().orNull(), - authorEmail = getRandomString().orNull(), - authorWebSite = getRandomString().orNull(), - authorPhone = getRandomString().orNull(), - donate = getRandomList(Random.nextInt(0, 3)) { getRandomString() }, - liberapay = getRandomString().orNull(), - liberapayID = getRandomString().orNull(), - openCollective = getRandomString().orNull(), - bitcoin = getRandomString().orNull(), - litecoin = getRandomString().orNull(), - flattrID = getRandomString().orNull(), - icon = getRandomLocalizedFileV2().orNull(), - featureGraphic = getRandomLocalizedFileV2().orNull(), - promoGraphic = getRandomLocalizedFileV2().orNull(), - tvBanner = getRandomLocalizedFileV2().orNull(), - categories = getRandomList { getRandomString() }.orNull() - ?: emptyList(), - screenshots = getRandomScreenshots().orNull(), + fun getRandomMetadataV2(authorName: String? = null, lastUpdated: Long? = null): MetadataV2 = + MetadataV2( + added = Random.nextLong(), + lastUpdated = lastUpdated ?: Random.nextLong(), + name = getRandomLocalizedTextV2().orNull(), + summary = getRandomLocalizedTextV2().orNull(), + description = getRandomLocalizedTextV2().orNull(), + webSite = getRandomString().orNull(), + changelog = getRandomString().orNull(), + license = getRandomString().orNull(), + sourceCode = getRandomString().orNull(), + issueTracker = getRandomString().orNull(), + translation = getRandomString().orNull(), + preferredSigner = getRandomString().orNull(), + video = getRandomLocalizedTextV2().orNull(), + authorName = authorName ?: getRandomString().orNull(), + authorEmail = getRandomString().orNull(), + authorWebSite = getRandomString().orNull(), + authorPhone = getRandomString().orNull(), + donate = getRandomList(Random.nextInt(0, 3)) { getRandomString() }, + liberapay = getRandomString().orNull(), + liberapayID = getRandomString().orNull(), + openCollective = getRandomString().orNull(), + bitcoin = getRandomString().orNull(), + litecoin = getRandomString().orNull(), + flattrID = getRandomString().orNull(), + icon = getRandomLocalizedFileV2().orNull(), + featureGraphic = getRandomLocalizedFileV2().orNull(), + promoGraphic = getRandomLocalizedFileV2().orNull(), + tvBanner = getRandomLocalizedFileV2().orNull(), + categories = getRandomList { getRandomString() }.orNull() ?: emptyList(), + screenshots = getRandomScreenshots().orNull(), ) - fun getRandomScreenshots(): Screenshots? = Screenshots( + fun getRandomScreenshots(): Screenshots? = + Screenshots( phone = getRandomLocalizedFileListV2().orNull(), sevenInch = getRandomLocalizedFileListV2().orNull(), tenInch = getRandomLocalizedFileListV2().orNull(), wear = getRandomLocalizedFileListV2().orNull(), tv = getRandomLocalizedFileListV2().orNull(), - ).takeIf { !it.isNull } + ) + .takeIf { !it.isNull } - fun getRandomLocalizedFileListV2(): Map> = - TestUtils.getRandomMap(Random.nextInt(1, 3)) { - getRandomString() to getRandomList(Random.nextInt(1, 7)) { - getRandomFileV2() - } - } - - /** - * [Screenshots] include lists which can be ordered differently, - * so we need to ignore order when comparing them. - */ - fun assertScreenshotsEqual(s1: Screenshots?, s2: Screenshots?) { - if (s1 != null && s2 != null) { - assertLocalizedFileListV2Equal(s1.phone, s2.phone) - assertLocalizedFileListV2Equal(s1.sevenInch, s2.sevenInch) - assertLocalizedFileListV2Equal(s1.tenInch, s2.tenInch) - assertLocalizedFileListV2Equal(s1.wear, s2.wear) - assertLocalizedFileListV2Equal(s1.tv, s2.tv) - } else { - assertEquals(s1, s2) - } + fun getRandomLocalizedFileListV2(): Map> = + TestUtils.getRandomMap(Random.nextInt(1, 3)) { + getRandomString() to getRandomList(Random.nextInt(1, 7)) { getRandomFileV2() } } - private fun assertLocalizedFileListV2Equal(l1: LocalizedFileListV2?, l2: LocalizedFileListV2?) { - if (l1 != null && l2 != null) { - l1.keys.forEach { key -> - assertEquals(l1[key]?.toSet(), l2[key]?.toSet()) - } - } else { - assertEquals(l1, l2) - } + /** + * [Screenshots] include lists which can be ordered differently, so we need to ignore order when + * comparing them. + */ + fun assertScreenshotsEqual(s1: Screenshots?, s2: Screenshots?) { + if (s1 != null && s2 != null) { + assertLocalizedFileListV2Equal(s1.phone, s2.phone) + assertLocalizedFileListV2Equal(s1.sevenInch, s2.sevenInch) + assertLocalizedFileListV2Equal(s1.tenInch, s2.tenInch) + assertLocalizedFileListV2Equal(s1.wear, s2.wear) + assertLocalizedFileListV2Equal(s1.tv, s2.tv) + } else { + assertEquals(s1, s2) } + } + private fun assertLocalizedFileListV2Equal(l1: LocalizedFileListV2?, l2: LocalizedFileListV2?) { + if (l1 != null && l2 != null) { + l1.keys.forEach { key -> assertEquals(l1[key]?.toSet(), l2[key]?.toSet()) } + } else { + assertEquals(l1, l2) + } + } } diff --git a/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestDataEntry.kt b/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestDataEntry.kt index c1d042bef..80b3320e2 100644 --- a/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestDataEntry.kt +++ b/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestDataEntry.kt @@ -5,92 +5,108 @@ import org.fdroid.index.v2.EntryFileV2 object TestDataEntry { - val empty = Entry( - timestamp = 23, - version = 20001, - index = EntryFileV2( - name = "index-v2.json", - sha256 = "746ecda085ed53adab25f761a9dbf4c09d59e5bff9c9d5530814d56445ae30f1", - size = 42, - numPackages = 1, + val empty = + Entry( + timestamp = 23, + version = 20001, + index = + EntryFileV2( + name = "index-v2.json", + sha256 = "746ecda085ed53adab25f761a9dbf4c09d59e5bff9c9d5530814d56445ae30f1", + size = 42, + numPackages = 1, ), ) - val emptyToMin = Entry( - timestamp = 42, - version = 20001, - maxAge = 7, - index = EntryFileV2( - name = "../index-min-v2.json", - sha256 = "851ecda085ed53adab25f761a9dbf4c09d59e5bff9c9d5530814d56445ae30f2", - size = 912, - numPackages = 1, + val emptyToMin = + Entry( + timestamp = 42, + version = 20001, + maxAge = 7, + index = + EntryFileV2( + name = "../index-min-v2.json", + sha256 = "851ecda085ed53adab25f761a9dbf4c09d59e5bff9c9d5530814d56445ae30f2", + size = 912, + numPackages = 1, ), - diffs = mapOf( - "23" to EntryFileV2( - name = "/23.json", - sha256 = "b7fc69156cbd42aef1ec3f0a5a943868ccb4b62775bce71fa8cc06cc63ad425b", - size = 911, - numPackages = 1, + diffs = + mapOf( + "23" to + EntryFileV2( + name = "/23.json", + sha256 = "b7fc69156cbd42aef1ec3f0a5a943868ccb4b62775bce71fa8cc06cc63ad425b", + size = 911, + numPackages = 1, + ) + ), + ) + + val emptyToMid = + Entry( + timestamp = 1337, + version = 20001, + index = + EntryFileV2( + name = "../index-mid-v2.json", + sha256 = "561630a90ec9bcc29bc133cbd14b2d14d94124bb043c8d48effbad9d18d482fb", + size = 22756, + numPackages = 2, + ), + diffs = + mapOf( + "23" to + EntryFileV2( + name = "/23.json", + sha256 = "1e19080fa0bdf37c7ea71106e97f0b0452da89edf37f638229582dc9a871e7c9", + size = 22732, + numPackages = 2, + ), + "42" to + EntryFileV2( + name = "/42.json", + sha256 = "26203adba4fb64bbaf39743dfb8fc8d3d9f17ed809461beb6b7215dce278f263", + size = 22594, + numPackages = 2, ), ), ) - val emptyToMid = Entry( - timestamp = 1337, - version = 20001, - index = EntryFileV2( - name = "../index-mid-v2.json", - sha256 = "561630a90ec9bcc29bc133cbd14b2d14d94124bb043c8d48effbad9d18d482fb", - size = 22756, - numPackages = 2, + val emptyToMax = + Entry( + timestamp = Long.MAX_VALUE, + version = Long.MAX_VALUE, + maxAge = Int.MAX_VALUE, + index = + EntryFileV2( + name = "../index-max-v2.json", + sha256 = "36cbdb2f3134d94a210e457332e1945a237d8b8e642ae1276ffa419a9375665a", + size = 29863, + numPackages = 3, ), - diffs = mapOf( - "23" to EntryFileV2( - name = "/23.json", - sha256 = "1e19080fa0bdf37c7ea71106e97f0b0452da89edf37f638229582dc9a871e7c9", - size = 22732, - numPackages = 2, + diffs = + mapOf( + "23" to + EntryFileV2( + name = "/23.json", + sha256 = "521fe7e85ad77d0611e71bb5b96736d614ba8e62af43921eb05f05f30673f0c0", + size = 29739, + numPackages = 3, ), - "42" to EntryFileV2( - name = "/42.json", - sha256 = "26203adba4fb64bbaf39743dfb8fc8d3d9f17ed809461beb6b7215dce278f263", - size = 22594, - numPackages = 2, + "42" to + EntryFileV2( + name = "/42.json", + sha256 = "363aaaf7289828b94b684f2b1e2b8c2b9e4f1ad1f9ea3ffb99d53a87918a5a69", + size = 29601, + numPackages = 3, + ), + "1337" to + EntryFileV2( + name = "/1337.json", + sha256 = "d0f14afad781d10b9d8f3e163ff26f99f769cb273a6a9d9757eac3f11d1a5d04", + size = 20395, + numPackages = 2, ), ), ) - - val emptyToMax = Entry( - timestamp = Long.MAX_VALUE, - version = Long.MAX_VALUE, - maxAge = Int.MAX_VALUE, - index = EntryFileV2( - name = "../index-max-v2.json", - sha256 = "36cbdb2f3134d94a210e457332e1945a237d8b8e642ae1276ffa419a9375665a", - size = 29863, - numPackages = 3, - ), - diffs = mapOf( - "23" to EntryFileV2( - name = "/23.json", - sha256 = "521fe7e85ad77d0611e71bb5b96736d614ba8e62af43921eb05f05f30673f0c0", - size = 29739, - numPackages = 3, - ), - "42" to EntryFileV2( - name = "/42.json", - sha256 = "363aaaf7289828b94b684f2b1e2b8c2b9e4f1ad1f9ea3ffb99d53a87918a5a69", - size = 29601, - numPackages = 3, - ), - "1337" to EntryFileV2( - name = "/1337.json", - sha256 = "d0f14afad781d10b9d8f3e163ff26f99f769cb273a6a9d9757eac3f11d1a5d04", - size = 20395, - numPackages = 2, - ), - ), - ) - } diff --git a/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestDataV1.kt b/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestDataV1.kt index 193837bd5..4fbfee3e7 100644 --- a/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestDataV1.kt +++ b/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestDataV1.kt @@ -9,613 +9,652 @@ import org.fdroid.index.v1.RepoV1 import org.fdroid.index.v1.Requests object TestDataEmptyV1 { - val repo = RepoV1( - timestamp = 23, - version = 23, - name = "EmptyV1", - icon = "empty-v1.png", - address = "https://empty-v1.org", - description = "This is a repo with empty data.", - ) - val index = IndexV1( - repo = repo, + val repo = + RepoV1( + timestamp = 23, + version = 23, + name = "EmptyV1", + icon = "empty-v1.png", + address = "https://empty-v1.org", + description = "This is a repo with empty data.", ) + val index = IndexV1(repo = repo) } object TestDataMinV1 { - val repo = RepoV1( - timestamp = 42, - version = 1, - name = "MinV1", - icon = "min-v1.png", - address = "https://min-v1.org/repo", - description = "This is a repo with minimal data.", + val repo = + RepoV1( + timestamp = 42, + version = 1, + name = "MinV1", + icon = "min-v1.png", + address = "https://min-v1.org/repo", + description = "This is a repo with minimal data.", ) - const val PACKAGE_NAME = "org.fdroid.min1" - val app = AppV1( - packageName = PACKAGE_NAME, - categories = emptyList(), - antiFeatures = emptyList(), - license = "", + const val PACKAGE_NAME = "org.fdroid.min1" + val app = + AppV1( + packageName = PACKAGE_NAME, + categories = emptyList(), + antiFeatures = emptyList(), + license = "", ) - val apps = listOf(app) + val apps = listOf(app) - val version = PackageV1( - packageName = PACKAGE_NAME, - apkName = "${PACKAGE_NAME}_23.apk", - hash = "824a109b2352138c3699760e1683385d0ed50ce526fc7982f8d65757743374bf", - hashType = "sha256", - size = 1337, - versionName = "0", + val version = + PackageV1( + packageName = PACKAGE_NAME, + apkName = "${PACKAGE_NAME}_23.apk", + hash = "824a109b2352138c3699760e1683385d0ed50ce526fc7982f8d65757743374bf", + hashType = "sha256", + size = 1337, + versionName = "0", ) - val versions = listOf(version) - val packages = mapOf(PACKAGE_NAME to versions) + val versions = listOf(version) + val packages = mapOf(PACKAGE_NAME to versions) - val index = IndexV1( - repo = repo, - requests = Requests(emptyList(), emptyList()), - apps = apps, - packages = packages, + val index = + IndexV1( + repo = repo, + requests = Requests(emptyList(), emptyList()), + apps = apps, + packages = packages, ) } object TestDataMidV1 { - val repo = RepoV1( - timestamp = 1337, - version = 1, - maxAge = 23, - name = "MidV1", - icon = "mid-v1.png", - address = "https://mid-v1.org/repo", - description = "This is a repo with medium data.", - mirrors = listOf("https://mid-v1.com/repo"), + val repo = + RepoV1( + timestamp = 1337, + version = 1, + maxAge = 23, + name = "MidV1", + icon = "mid-v1.png", + address = "https://mid-v1.org/repo", + description = "This is a repo with medium data.", + mirrors = listOf("https://mid-v1.com/repo"), ) - const val PACKAGE_NAME_1 = TestDataMinV1.PACKAGE_NAME - const val PACKAGE_NAME_2 = "org.fdroid.fdroid" - val categories = listOf("Cat1", "Cat2", "Cat3") - val app1 = TestDataMinV1.app.copy( - packageName = PACKAGE_NAME_1, - categories = listOf(categories[0]), - antiFeatures = listOf("AntiFeature"), - summary = "App1 summary", - description = "App1 description", - name = "App1", - authorName = "App1 author", - license = "GPLv3", - webSite = "http://min1.test.org", - added = 1234567890, - icon = "icon-min1.png", - lastUpdated = 1234567891, - localized = mapOf( - "de" to Localized( - description = "App1 beschreibung", - name = "app 1 name", - summary = "App1 Zusammenfassung", - ), + const val PACKAGE_NAME_1 = TestDataMinV1.PACKAGE_NAME + const val PACKAGE_NAME_2 = "org.fdroid.fdroid" + val categories = listOf("Cat1", "Cat2", "Cat3") + val app1 = + TestDataMinV1.app.copy( + packageName = PACKAGE_NAME_1, + categories = listOf(categories[0]), + antiFeatures = listOf("AntiFeature"), + summary = "App1 summary", + description = "App1 description", + name = "App1", + authorName = "App1 author", + license = "GPLv3", + webSite = "http://min1.test.org", + added = 1234567890, + icon = "icon-min1.png", + lastUpdated = 1234567891, + localized = + mapOf( + "de" to + Localized( + description = "App1 beschreibung", + name = "app 1 name", + summary = "App1 Zusammenfassung", + ) ), ) - val app2 = AppV1( - categories = listOf("System"), - antiFeatures = emptyList(), - changelog = "https://gitlab.com/fdroid/fdroidclient/raw/HEAD/CHANGELOG.md", - translation = "https://hosted.weblate.org/projects/f-droid/f-droid", - issueTracker = "https://gitlab.com/fdroid/fdroidclient/issues", - sourceCode = "https://gitlab.com/fdroid/fdroidclient", - donate = "https://f-droid.org/donate", - liberapayID = "27859", - openCollective = "F-Droid-Euro", - flattrID = "343053", - suggestedVersionName = "1.14", - suggestedVersionCode = "1014050", - license = "GPL-3.0-or-later", - webSite = "https://f-droid.org", - added = 1295222400000, - icon = "org.fdroid.fdroid.1014050.png", - packageName = "org.fdroid.fdroid", - lastUpdated = 1643250075000, - localized = mapOf( - "af" to Localized( - description = "F-Droid is 'n installeerbare katalogus van gratis sagteware", - name = "-درويد", - summary = "متجر التطبيقات الذي يحترم الحرية والخصوصية)", + val app2 = + AppV1( + categories = listOf("System"), + antiFeatures = emptyList(), + changelog = "https://gitlab.com/fdroid/fdroidclient/raw/HEAD/CHANGELOG.md", + translation = "https://hosted.weblate.org/projects/f-droid/f-droid", + issueTracker = "https://gitlab.com/fdroid/fdroidclient/issues", + sourceCode = "https://gitlab.com/fdroid/fdroidclient", + donate = "https://f-droid.org/donate", + liberapayID = "27859", + openCollective = "F-Droid-Euro", + flattrID = "343053", + suggestedVersionName = "1.14", + suggestedVersionCode = "1014050", + license = "GPL-3.0-or-later", + webSite = "https://f-droid.org", + added = 1295222400000, + icon = "org.fdroid.fdroid.1014050.png", + packageName = "org.fdroid.fdroid", + lastUpdated = 1643250075000, + localized = + mapOf( + "af" to + Localized( + description = "F-Droid is 'n installeerbare katalogus van gratis sagteware", + name = "-درويد", + summary = "متجر التطبيقات الذي يحترم الحرية والخصوصية)", ), - "be" to Localized( - name = "F-Droid", - summary = "Крама праграм, якая паважае свабоду і прыватнасць", + "be" to + Localized( + name = "F-Droid", + summary = "Крама праграм, якая паважае свабоду і прыватнасць", ), - "bg" to Localized( - summary = "Магазинът за приложения, който уважава независимостта и поверителността", + "bg" to + Localized( + summary = "Магазинът за приложения, който уважава независимостта и поверителността" ), - "bn" to Localized( - name = "এফ-ড্রয়েড", - summary = "যে অ্যাপ স্টোর স্বাধীনতা ও গোপনীয়তা সম্মান করে" + "bn" to + Localized( + name = "এফ-ড্রয়েড", + summary = "যে অ্যাপ স্টোর স্বাধীনতা ও গোপনীয়তা সম্মান করে", ), - "bo" to Localized( - description = "ཨེཕ་རོཌ་ནི་ཨེན་ཀྲོཌ་བབ་སྟེགས་ཀྱི་ཆེད་དུ་FOSS", - summary = "རང་དབང་དང་གསང་དོན་ལ་གུས་བརྩི་ཞུས་མཁན་གྱི་མཉེན་ཆས་ཉར་ཚགས་ཁང་།", + "bo" to + Localized( + description = "ཨེཕ་རོཌ་ནི་ཨེན་ཀྲོཌ་བབ་སྟེགས་ཀྱི་ཆེད་དུ་FOSS", + summary = "རང་དབང་དང་གསང་དོན་ལ་གུས་བརྩི་ཞུས་མཁན་གྱི་མཉེན་ཆས་ཉར་ཚགས་ཁང་།", ), - "ca" to Localized( - description = "F-Droid és un catàleg instal·lable d'aplicacions de software lliure", - name = "F-Droid", - summary = "La botiga d'aplicacions que respecta la llibertat i la privacitat", + "ca" to + Localized( + description = "F-Droid és un catàleg instal·lable d'aplicacions de software lliure", + name = "F-Droid", + summary = "La botiga d'aplicacions que respecta la llibertat i la privacitat", ), - "cs" to Localized( - description = "F-Droid je instalovatelný katalog softwarových libre", - name = "F-Droid", - summary = "Zdroj aplikací který respektuje vaši svobodu a soukromí", + "cs" to + Localized( + description = "F-Droid je instalovatelný katalog softwarových libre", + name = "F-Droid", + summary = "Zdroj aplikací který respektuje vaši svobodu a soukromí", ), - "cy" to Localized( - description = "Mae F-Droid yn gatalog y gellir ei osod o apiau meddalwedd rhydd" + - "ar gyfer Android.", - name = "F-Droid", - summary = "Yr ystorfa apiau sy'n parchu rhyddid a phreifatrwydd", + "cy" to + Localized( + description = + "Mae F-Droid yn gatalog y gellir ei osod o apiau meddalwedd rhydd" + + "ar gyfer Android.", + name = "F-Droid", + summary = "Yr ystorfa apiau sy'n parchu rhyddid a phreifatrwydd", ), - "de" to Localized( - description = "F-Droid ist ein installierbarer Katalog mit Libre Software" + - "Android-Apps.", - summary = "Der App-Store, der Freiheit und Privatsphäre respektiert", + "de" to + Localized( + description = + "F-Droid ist ein installierbarer Katalog mit Libre Software" + "Android-Apps.", + summary = "Der App-Store, der Freiheit und Privatsphäre respektiert", ), - "el" to Localized( - description = "Το F-Droid είναι ένας κατάλογος εφαρμογών ελεύθερου λογισμικού", - name = "F-Droid", - summary = "Το κατάστημα εφαρμογών που σέβεται την ελευθερία και την ιδιωτικότητα", + "el" to + Localized( + description = "Το F-Droid είναι ένας κατάλογος εφαρμογών ελεύθερου λογισμικού", + name = "F-Droid", + summary = "Το κατάστημα εφαρμογών που σέβεται την ελευθερία και την ιδιωτικότητα", ), - "en-US" to Localized( - description = "F-Droid is an installable catalogue of libre software", - name = "F-Droid", - whatsNew = "* Overhaul Share menu to use built-in options like Nearby", - phoneScreenshots = listOf( - "screenshot-app-details.png", - "screenshot-dark-details.png", - "screenshot-dark-home.png", - "screenshot-dark-knownvuln.png", - "screenshot-knownvuln.png", - "screenshot-search.png", - "screenshot-updates.png", + "en-US" to + Localized( + description = "F-Droid is an installable catalogue of libre software", + name = "F-Droid", + whatsNew = "* Overhaul Share menu to use built-in options like Nearby", + phoneScreenshots = + listOf( + "screenshot-app-details.png", + "screenshot-dark-details.png", + "screenshot-dark-home.png", + "screenshot-dark-knownvuln.png", + "screenshot-knownvuln.png", + "screenshot-search.png", + "screenshot-updates.png", ), - featureGraphic = "featureGraphic_PTun9TO4cMFOeiqbvQSrkdcxNUcOFQCymMIaj9UJOAY=.jpg", - summary = "The app store that respects freedom and privacy", + featureGraphic = "featureGraphic_PTun9TO4cMFOeiqbvQSrkdcxNUcOFQCymMIaj9UJOAY=.jpg", + summary = "The app store that respects freedom and privacy", ), - "eo" to Localized( - description = "F-Droid estas instalebla katalogo de liberaj aplikaĵoj por Android.", - name = "F-Droid", - whatsNew = "• rekonstruita menuo “Kunhavigi” por uzi enkonstruitajn eblojn, ekz.", - summary = "Aplikaĵa vendejo respektanta liberecon kaj privatecon", + "eo" to + Localized( + description = "F-Droid estas instalebla katalogo de liberaj aplikaĵoj por Android.", + name = "F-Droid", + whatsNew = "• rekonstruita menuo “Kunhavigi” por uzi enkonstruitajn eblojn, ekz.", + summary = "Aplikaĵa vendejo respektanta liberecon kaj privatecon", ), ), ) - val apps = listOf(app1, app2) + val apps = listOf(app1, app2) - val version1_1 = TestDataMinV1.version.copy( - added = 2342, - apkName = "${PACKAGE_NAME_1}_23_2.apk", - size = 1338, - srcName = "${PACKAGE_NAME_1}_23_2.zip", - usesPermission = listOf(PermissionV1("perm")), - usesPermission23 = emptyList(), - versionCode = 1, - versionName = "1", - nativeCode = listOf("x86"), - features = listOf("feature"), - antiFeatures = listOf("anti-feature"), + val version1_1 = + TestDataMinV1.version.copy( + added = 2342, + apkName = "${PACKAGE_NAME_1}_23_2.apk", + size = 1338, + srcName = "${PACKAGE_NAME_1}_23_2.zip", + usesPermission = listOf(PermissionV1("perm")), + usesPermission23 = emptyList(), + versionCode = 1, + versionName = "1", + nativeCode = listOf("x86"), + features = listOf("feature"), + antiFeatures = listOf("anti-feature"), ) - val version1_2 = PackageV1( - packageName = PACKAGE_NAME_1, - apkName = "${PACKAGE_NAME_1}_42.apk", - hash = "824a109b2352138c3699760e1683385d0ed50ce526fc7982f8d65757743374bf", - hashType = "sha256", - minSdkVersion = 21, - maxSdkVersion = 4568, - targetSdkVersion = 32, - sig = "old", - signer = "824a109b2352138c3699760e1683385d0ed50ce526fc7982f8d65757743374bf", - features = listOf("new feature"), - size = 1337, - srcName = "${PACKAGE_NAME_1}_42.zip", - versionCode = 24, - versionName = "24", + val version1_2 = + PackageV1( + packageName = PACKAGE_NAME_1, + apkName = "${PACKAGE_NAME_1}_42.apk", + hash = "824a109b2352138c3699760e1683385d0ed50ce526fc7982f8d65757743374bf", + hashType = "sha256", + minSdkVersion = 21, + maxSdkVersion = 4568, + targetSdkVersion = 32, + sig = "old", + signer = "824a109b2352138c3699760e1683385d0ed50ce526fc7982f8d65757743374bf", + features = listOf("new feature"), + size = 1337, + srcName = "${PACKAGE_NAME_1}_42.zip", + versionCode = 24, + versionName = "24", ) - val version2_1 = PackageV1( - added = 1643250075000, - apkName = "org.fdroid.fdroid_1014050.apk", - hash = "8c89ce2f42f4a89af8ca6e1ea220f9dfdee220724d8a9cc067d510ac6f3e0d06", - hashType = "sha256", - minSdkVersion = 22, - targetSdkVersion = 25, - packageName = "org.fdroid.fdroid", - sig = "9063aaadfff9cfd811a9c72fb5012f28", - signer = "43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab", - size = 8165518, - srcName = "org.fdroid.fdroid_1014050_src.tar.gz", - usesPermission = listOf( - PermissionV1(name = "android.permission.INTERNET"), - PermissionV1(name = "android.permission.ACCESS_NETWORK_STATE"), - PermissionV1(name = "android.permission.ACCESS_WIFI_STATE"), - PermissionV1(name = "android.permission.CHANGE_WIFI_MULTICAST_STATE"), - PermissionV1(name = "android.permission.CHANGE_NETWORK_STATE"), - PermissionV1(name = "android.permission.CHANGE_WIFI_STATE"), - PermissionV1(name = "android.permission.BLUETOOTH"), - PermissionV1(name = "android.permission.BLUETOOTH_ADMIN"), - PermissionV1(name = "android.permission.RECEIVE_BOOT_COMPLETED"), - PermissionV1(name = "android.permission.READ_EXTERNAL_STORAGE"), - PermissionV1(name = "android.permission.WRITE_EXTERNAL_STORAGE"), - PermissionV1(name = "android.permission.WRITE_SETTINGS"), - PermissionV1(name = "android.permission.NFC"), - PermissionV1(name = "android.permission.USB_PERMISSION", maxSdk = 22), - PermissionV1(name = "android.permission.WAKE_LOCK"), - PermissionV1(name = "android.permission.FOREGROUND_SERVICE") + val version2_1 = + PackageV1( + added = 1643250075000, + apkName = "org.fdroid.fdroid_1014050.apk", + hash = "8c89ce2f42f4a89af8ca6e1ea220f9dfdee220724d8a9cc067d510ac6f3e0d06", + hashType = "sha256", + minSdkVersion = 22, + targetSdkVersion = 25, + packageName = "org.fdroid.fdroid", + sig = "9063aaadfff9cfd811a9c72fb5012f28", + signer = "43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab", + size = 8165518, + srcName = "org.fdroid.fdroid_1014050_src.tar.gz", + usesPermission = + listOf( + PermissionV1(name = "android.permission.INTERNET"), + PermissionV1(name = "android.permission.ACCESS_NETWORK_STATE"), + PermissionV1(name = "android.permission.ACCESS_WIFI_STATE"), + PermissionV1(name = "android.permission.CHANGE_WIFI_MULTICAST_STATE"), + PermissionV1(name = "android.permission.CHANGE_NETWORK_STATE"), + PermissionV1(name = "android.permission.CHANGE_WIFI_STATE"), + PermissionV1(name = "android.permission.BLUETOOTH"), + PermissionV1(name = "android.permission.BLUETOOTH_ADMIN"), + PermissionV1(name = "android.permission.RECEIVE_BOOT_COMPLETED"), + PermissionV1(name = "android.permission.READ_EXTERNAL_STORAGE"), + PermissionV1(name = "android.permission.WRITE_EXTERNAL_STORAGE"), + PermissionV1(name = "android.permission.WRITE_SETTINGS"), + PermissionV1(name = "android.permission.NFC"), + PermissionV1(name = "android.permission.USB_PERMISSION", maxSdk = 22), + PermissionV1(name = "android.permission.WAKE_LOCK"), + PermissionV1(name = "android.permission.FOREGROUND_SERVICE"), ), - usesPermission23 = listOf(PermissionV1(name = "android.permission.ACCESS_COARSE_LOCATION")), - versionCode = 1014050, - versionName = "1.14", + usesPermission23 = listOf(PermissionV1(name = "android.permission.ACCESS_COARSE_LOCATION")), + versionCode = 1014050, + versionName = "1.14", ) - val version2_2 = PackageV1( - added = 1642785071000, - apkName = "org.fdroid.fdroid_1014005.apk", - hash = "b4282febf5558d43c7c51a00478961f6df1b6d59e0a6674974cdacb792683e5d", - hashType = "sha256", - minSdkVersion = 22, - targetSdkVersion = 25, - packageName = "org.fdroid.fdroid", - sig = "9063aaadfff9cfd811a9c72fb5012f28", - signer = "43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab", - size = 8382606, - srcName = "org.fdroid.fdroid_1014005_src.tar.gz", - usesPermission = listOf( - PermissionV1(name = "android.permission.INTERNET"), - PermissionV1(name = "android.permission.ACCESS_NETWORK_STATE"), - PermissionV1(name = "android.permission.ACCESS_WIFI_STATE"), - PermissionV1(name = "android.permission.CHANGE_WIFI_MULTICAST_STATE"), - PermissionV1(name = "android.permission.CHANGE_NETWORK_STATE"), - PermissionV1(name = "android.permission.CHANGE_WIFI_STATE"), - PermissionV1(name = "android.permission.BLUETOOTH"), - PermissionV1(name = "android.permission.BLUETOOTH_ADMIN"), - PermissionV1(name = "android.permission.RECEIVE_BOOT_COMPLETED"), - PermissionV1(name = "android.permission.READ_EXTERNAL_STORAGE"), - PermissionV1(name = "android.permission.WRITE_EXTERNAL_STORAGE"), - PermissionV1(name = "android.permission.WRITE_SETTINGS"), - PermissionV1(name = "android.permission.NFC"), - PermissionV1(name = "android.permission.USB_PERMISSION", maxSdk = 22), - PermissionV1(name = "android.permission.WAKE_LOCK"), - PermissionV1(name = "android.permission.FOREGROUND_SERVICE") + val version2_2 = + PackageV1( + added = 1642785071000, + apkName = "org.fdroid.fdroid_1014005.apk", + hash = "b4282febf5558d43c7c51a00478961f6df1b6d59e0a6674974cdacb792683e5d", + hashType = "sha256", + minSdkVersion = 22, + targetSdkVersion = 25, + packageName = "org.fdroid.fdroid", + sig = "9063aaadfff9cfd811a9c72fb5012f28", + signer = "43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab", + size = 8382606, + srcName = "org.fdroid.fdroid_1014005_src.tar.gz", + usesPermission = + listOf( + PermissionV1(name = "android.permission.INTERNET"), + PermissionV1(name = "android.permission.ACCESS_NETWORK_STATE"), + PermissionV1(name = "android.permission.ACCESS_WIFI_STATE"), + PermissionV1(name = "android.permission.CHANGE_WIFI_MULTICAST_STATE"), + PermissionV1(name = "android.permission.CHANGE_NETWORK_STATE"), + PermissionV1(name = "android.permission.CHANGE_WIFI_STATE"), + PermissionV1(name = "android.permission.BLUETOOTH"), + PermissionV1(name = "android.permission.BLUETOOTH_ADMIN"), + PermissionV1(name = "android.permission.RECEIVE_BOOT_COMPLETED"), + PermissionV1(name = "android.permission.READ_EXTERNAL_STORAGE"), + PermissionV1(name = "android.permission.WRITE_EXTERNAL_STORAGE"), + PermissionV1(name = "android.permission.WRITE_SETTINGS"), + PermissionV1(name = "android.permission.NFC"), + PermissionV1(name = "android.permission.USB_PERMISSION", maxSdk = 22), + PermissionV1(name = "android.permission.WAKE_LOCK"), + PermissionV1(name = "android.permission.FOREGROUND_SERVICE"), ), - usesPermission23 = listOf(PermissionV1(name = "android.permission.ACCESS_COARSE_LOCATION")), - versionCode = 1014005, - versionName = "1.14-alpha5", - nativeCode = listOf("fakeNativeCode"), - features = listOf("fake feature"), - antiFeatures = listOf("FakeAntiFeature") + usesPermission23 = listOf(PermissionV1(name = "android.permission.ACCESS_COARSE_LOCATION")), + versionCode = 1014005, + versionName = "1.14-alpha5", + nativeCode = listOf("fakeNativeCode"), + features = listOf("fake feature"), + antiFeatures = listOf("FakeAntiFeature"), ) - val version2_3 = PackageV1( - added = 1635169849000, - apkName = "org.fdroid.fdroid_1014003.apk", - hash = "c062a9642fde08aacabbfa4cab1ab5773c83f4e6b81551ffd92027d2b20f37d3", - hashType = "sha256", - minSdkVersion = 22, - targetSdkVersion = 25, - packageName = "org.fdroid.fdroid", - sig = "9063aaadfff9cfd811a9c72fb5012f28", - signer = "43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab", - size = 8276110, - srcName = "org.fdroid.fdroid_1014003_src.tar.gz", - usesPermission = listOf( - PermissionV1(name = "android.permission.INTERNET"), - PermissionV1(name = "android.permission.ACCESS_NETWORK_STATE"), - PermissionV1(name = "android.permission.ACCESS_WIFI_STATE"), - PermissionV1(name = "android.permission.CHANGE_WIFI_MULTICAST_STATE"), - PermissionV1(name = "android.permission.CHANGE_NETWORK_STATE"), - PermissionV1(name = "android.permission.CHANGE_WIFI_STATE"), - PermissionV1(name = "android.permission.BLUETOOTH"), - PermissionV1(name = "android.permission.BLUETOOTH_ADMIN"), - PermissionV1(name = "android.permission.RECEIVE_BOOT_COMPLETED"), - PermissionV1(name = "android.permission.READ_EXTERNAL_STORAGE"), - PermissionV1(name = "android.permission.WRITE_EXTERNAL_STORAGE"), - PermissionV1(name = "android.permission.WRITE_SETTINGS"), - PermissionV1(name = "android.permission.NFC"), - PermissionV1(name = "android.permission.USB_PERMISSION", maxSdk = 22), - PermissionV1(name = "android.permission.WAKE_LOCK"), - PermissionV1(name = "android.permission.FOREGROUND_SERVICE"), + val version2_3 = + PackageV1( + added = 1635169849000, + apkName = "org.fdroid.fdroid_1014003.apk", + hash = "c062a9642fde08aacabbfa4cab1ab5773c83f4e6b81551ffd92027d2b20f37d3", + hashType = "sha256", + minSdkVersion = 22, + targetSdkVersion = 25, + packageName = "org.fdroid.fdroid", + sig = "9063aaadfff9cfd811a9c72fb5012f28", + signer = "43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab", + size = 8276110, + srcName = "org.fdroid.fdroid_1014003_src.tar.gz", + usesPermission = + listOf( + PermissionV1(name = "android.permission.INTERNET"), + PermissionV1(name = "android.permission.ACCESS_NETWORK_STATE"), + PermissionV1(name = "android.permission.ACCESS_WIFI_STATE"), + PermissionV1(name = "android.permission.CHANGE_WIFI_MULTICAST_STATE"), + PermissionV1(name = "android.permission.CHANGE_NETWORK_STATE"), + PermissionV1(name = "android.permission.CHANGE_WIFI_STATE"), + PermissionV1(name = "android.permission.BLUETOOTH"), + PermissionV1(name = "android.permission.BLUETOOTH_ADMIN"), + PermissionV1(name = "android.permission.RECEIVE_BOOT_COMPLETED"), + PermissionV1(name = "android.permission.READ_EXTERNAL_STORAGE"), + PermissionV1(name = "android.permission.WRITE_EXTERNAL_STORAGE"), + PermissionV1(name = "android.permission.WRITE_SETTINGS"), + PermissionV1(name = "android.permission.NFC"), + PermissionV1(name = "android.permission.USB_PERMISSION", maxSdk = 22), + PermissionV1(name = "android.permission.WAKE_LOCK"), + PermissionV1(name = "android.permission.FOREGROUND_SERVICE"), ), - usesPermission23 = listOf(PermissionV1(name = "android.permission.ACCESS_COARSE_LOCATION")), - versionCode = 1014003, - versionName = "1.14-alpha3", + usesPermission23 = listOf(PermissionV1(name = "android.permission.ACCESS_COARSE_LOCATION")), + versionCode = 1014003, + versionName = "1.14-alpha3", ) - val version2_4 = PackageV1( - added = 1632281731000, - apkName = "org.fdroid.fdroid_1014002.apk", - hash = "3243c24ee95be0fce0830d72e7d2605e3e24f6ccf4ee72a7c8e720fccd7621a1", - hashType = "sha256", - minSdkVersion = 22, - targetSdkVersion = 25, - packageName = "org.fdroid.fdroid", - sig = "9063aaadfff9cfd811a9c72fb5012f28", - signer = "43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab", - size = 8284386, - srcName = "org.fdroid.fdroid_1014002_src.tar.gz", - usesPermission = listOf( - PermissionV1(name = "android.permission.INTERNET"), - PermissionV1(name = "android.permission.ACCESS_NETWORK_STATE"), - PermissionV1(name = "android.permission.ACCESS_WIFI_STATE"), - PermissionV1(name = "android.permission.CHANGE_WIFI_MULTICAST_STATE"), - PermissionV1(name = "android.permission.CHANGE_NETWORK_STATE"), - PermissionV1(name = "android.permission.CHANGE_WIFI_STATE"), - PermissionV1(name = "android.permission.BLUETOOTH"), - PermissionV1(name = "android.permission.BLUETOOTH_ADMIN"), - PermissionV1(name = "android.permission.RECEIVE_BOOT_COMPLETED"), - PermissionV1(name = "android.permission.READ_EXTERNAL_STORAGE"), - PermissionV1(name = "android.permission.WRITE_EXTERNAL_STORAGE"), - PermissionV1(name = "android.permission.WRITE_SETTINGS"), - PermissionV1(name = "android.permission.NFC"), - PermissionV1(name = "android.permission.USB_PERMISSION", maxSdk = 22), - PermissionV1(name = "android.permission.WAKE_LOCK"), - PermissionV1(name = "android.permission.FOREGROUND_SERVICE"), + val version2_4 = + PackageV1( + added = 1632281731000, + apkName = "org.fdroid.fdroid_1014002.apk", + hash = "3243c24ee95be0fce0830d72e7d2605e3e24f6ccf4ee72a7c8e720fccd7621a1", + hashType = "sha256", + minSdkVersion = 22, + targetSdkVersion = 25, + packageName = "org.fdroid.fdroid", + sig = "9063aaadfff9cfd811a9c72fb5012f28", + signer = "43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab", + size = 8284386, + srcName = "org.fdroid.fdroid_1014002_src.tar.gz", + usesPermission = + listOf( + PermissionV1(name = "android.permission.INTERNET"), + PermissionV1(name = "android.permission.ACCESS_NETWORK_STATE"), + PermissionV1(name = "android.permission.ACCESS_WIFI_STATE"), + PermissionV1(name = "android.permission.CHANGE_WIFI_MULTICAST_STATE"), + PermissionV1(name = "android.permission.CHANGE_NETWORK_STATE"), + PermissionV1(name = "android.permission.CHANGE_WIFI_STATE"), + PermissionV1(name = "android.permission.BLUETOOTH"), + PermissionV1(name = "android.permission.BLUETOOTH_ADMIN"), + PermissionV1(name = "android.permission.RECEIVE_BOOT_COMPLETED"), + PermissionV1(name = "android.permission.READ_EXTERNAL_STORAGE"), + PermissionV1(name = "android.permission.WRITE_EXTERNAL_STORAGE"), + PermissionV1(name = "android.permission.WRITE_SETTINGS"), + PermissionV1(name = "android.permission.NFC"), + PermissionV1(name = "android.permission.USB_PERMISSION", maxSdk = 22), + PermissionV1(name = "android.permission.WAKE_LOCK"), + PermissionV1(name = "android.permission.FOREGROUND_SERVICE"), ), - usesPermission23 = listOf(PermissionV1(name = "android.permission.ACCESS_COARSE_LOCATION")), - versionCode = 1014002, - versionName = "1.14-alpha2", + usesPermission23 = listOf(PermissionV1(name = "android.permission.ACCESS_COARSE_LOCATION")), + versionCode = 1014002, + versionName = "1.14-alpha2", ) - val version2_5 = PackageV1( - added = 1632281729000, - apkName = "org.fdroid.fdroid_1014001.apk", - hash = "7ebfd5eb76f9ec95ba955e549260fe930dc38fb99ed3532f92c93b879aca5610", - hashType = "sha256", - minSdkVersion = 22, - targetSdkVersion = 25, - packageName = "org.fdroid.fdroid", - sig = "9063aaadfff9cfd811a9c72fb5012f28", - signer = "43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab", - size = 8272166, - srcName = "org.fdroid.fdroid_1014001_src.tar.gz", - usesPermission = listOf( - PermissionV1(name = "android.permission.INTERNET"), - PermissionV1(name = "android.permission.ACCESS_NETWORK_STATE"), - PermissionV1(name = "android.permission.ACCESS_WIFI_STATE"), - PermissionV1(name = "android.permission.CHANGE_WIFI_MULTICAST_STATE"), - PermissionV1(name = "android.permission.CHANGE_NETWORK_STATE"), - PermissionV1(name = "android.permission.CHANGE_WIFI_STATE"), - PermissionV1(name = "android.permission.BLUETOOTH"), - PermissionV1(name = "android.permission.BLUETOOTH_ADMIN"), - PermissionV1(name = "android.permission.RECEIVE_BOOT_COMPLETED"), - PermissionV1(name = "android.permission.READ_EXTERNAL_STORAGE"), - PermissionV1(name = "android.permission.WRITE_EXTERNAL_STORAGE"), - PermissionV1(name = "android.permission.WRITE_SETTINGS"), - PermissionV1(name = "android.permission.NFC"), - PermissionV1(name = "android.permission.USB_PERMISSION", maxSdk = 22), - PermissionV1(name = "android.permission.WAKE_LOCK"), - PermissionV1(name = "android.permission.FOREGROUND_SERVICE"), + val version2_5 = + PackageV1( + added = 1632281729000, + apkName = "org.fdroid.fdroid_1014001.apk", + hash = "7ebfd5eb76f9ec95ba955e549260fe930dc38fb99ed3532f92c93b879aca5610", + hashType = "sha256", + minSdkVersion = 22, + targetSdkVersion = 25, + packageName = "org.fdroid.fdroid", + sig = "9063aaadfff9cfd811a9c72fb5012f28", + signer = "43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab", + size = 8272166, + srcName = "org.fdroid.fdroid_1014001_src.tar.gz", + usesPermission = + listOf( + PermissionV1(name = "android.permission.INTERNET"), + PermissionV1(name = "android.permission.ACCESS_NETWORK_STATE"), + PermissionV1(name = "android.permission.ACCESS_WIFI_STATE"), + PermissionV1(name = "android.permission.CHANGE_WIFI_MULTICAST_STATE"), + PermissionV1(name = "android.permission.CHANGE_NETWORK_STATE"), + PermissionV1(name = "android.permission.CHANGE_WIFI_STATE"), + PermissionV1(name = "android.permission.BLUETOOTH"), + PermissionV1(name = "android.permission.BLUETOOTH_ADMIN"), + PermissionV1(name = "android.permission.RECEIVE_BOOT_COMPLETED"), + PermissionV1(name = "android.permission.READ_EXTERNAL_STORAGE"), + PermissionV1(name = "android.permission.WRITE_EXTERNAL_STORAGE"), + PermissionV1(name = "android.permission.WRITE_SETTINGS"), + PermissionV1(name = "android.permission.NFC"), + PermissionV1(name = "android.permission.USB_PERMISSION", maxSdk = 22), + PermissionV1(name = "android.permission.WAKE_LOCK"), + PermissionV1(name = "android.permission.FOREGROUND_SERVICE"), ), - usesPermission23 = listOf(PermissionV1(name = "android.permission.ACCESS_COARSE_LOCATION")), - versionCode = 1014001, - versionName = "1.14-alpha1", - ) - val versions1 = listOf(version1_1, version1_2) - val versions2 = listOf(version2_1, version2_2, version2_3, version2_4, version2_5) - val packages = mapOf( - PACKAGE_NAME_1 to versions1, - PACKAGE_NAME_2 to versions2, + usesPermission23 = listOf(PermissionV1(name = "android.permission.ACCESS_COARSE_LOCATION")), + versionCode = 1014001, + versionName = "1.14-alpha1", ) + val versions1 = listOf(version1_1, version1_2) + val versions2 = listOf(version2_1, version2_2, version2_3, version2_4, version2_5) + val packages = mapOf(PACKAGE_NAME_1 to versions1, PACKAGE_NAME_2 to versions2) - val index = IndexV1( - repo = repo, - requests = Requests(listOf("installThis"), listOf("uninstallThis")), - apps = apps, - packages = packages, + val index = + IndexV1( + repo = repo, + requests = Requests(listOf("installThis"), listOf("uninstallThis")), + apps = apps, + packages = packages, ) - } object TestDataMaxV1 { - val repo = RepoV1( - timestamp = Long.MAX_VALUE, - version = Int.MAX_VALUE, - maxAge = Int.MAX_VALUE, - name = "MaxV1", - icon = "max-v1.png", - address = "https://max-v1.org/repo", - description = "This is a repo with maximum data.", - mirrors = listOf("https://max-v1.com", "https://max-v1.org/repo"), + val repo = + RepoV1( + timestamp = Long.MAX_VALUE, + version = Int.MAX_VALUE, + maxAge = Int.MAX_VALUE, + name = "MaxV1", + icon = "max-v1.png", + address = "https://max-v1.org/repo", + description = "This is a repo with maximum data.", + mirrors = listOf("https://max-v1.com", "https://max-v1.org/repo"), ) - const val PACKAGE_NAME_1 = TestDataMidV1.PACKAGE_NAME_1 - const val PACKAGE_NAME_2 = TestDataMidV1.PACKAGE_NAME_2 - const val PACKAGE_NAME_3 = "Haoheiseeshai2que2Che0ooSa6aikeemoo2ap9Aequoh4ju5chooYuPhiev8moo" + - "dahlonu2oht5Eikahvushapeum5aefo6xig4aghahyaaNuezoo4eexee1Goo5Ung" + - "ohGha6quaeghe8uCh9iex9Oowa9aiyohzoo2ij5miifiegaeth8nie9jae6raeph" + - "oowishoor1Ien5vahGhahm7eidaiy2AeCaej9iexahyooshu2ic9tea1ool8tu4Y" - val categories = listOf("Cat1", "Cat2", "Cat3") + const val PACKAGE_NAME_1 = TestDataMidV1.PACKAGE_NAME_1 + const val PACKAGE_NAME_2 = TestDataMidV1.PACKAGE_NAME_2 + const val PACKAGE_NAME_3 = + "Haoheiseeshai2que2Che0ooSa6aikeemoo2ap9Aequoh4ju5chooYuPhiev8moo" + + "dahlonu2oht5Eikahvushapeum5aefo6xig4aghahyaaNuezoo4eexee1Goo5Ung" + + "ohGha6quaeghe8uCh9iex9Oowa9aiyohzoo2ij5miifiegaeth8nie9jae6raeph" + + "oowishoor1Ien5vahGhahm7eidaiy2AeCaej9iexahyooshu2ic9tea1ool8tu4Y" + val categories = listOf("Cat1", "Cat2", "Cat3") - val app2 = TestDataMidV1.app2.copy( - categories = listOf("NoMoreSystem", "OneMore"), - antiFeatures = listOf("AddOne"), - summary = "new summary", - description = "new description", - webSite = "https://fdroid.org", - binaries = "https://fdroid.org/binaries", - name = "F-DroidX", - suggestedVersionCode = "1014003", - localized = mapOf( - "ch" to Localized( - description = "new desc", - name = "new name", - summary = "new summary", - tenInchScreenshots = listOf("new screenshots"), - whatsNew = "This is new!" + val app2 = + TestDataMidV1.app2.copy( + categories = listOf("NoMoreSystem", "OneMore"), + antiFeatures = listOf("AddOne"), + summary = "new summary", + description = "new description", + webSite = "https://fdroid.org", + binaries = "https://fdroid.org/binaries", + name = "F-DroidX", + suggestedVersionCode = "1014003", + localized = + mapOf( + "ch" to + Localized( + description = "new desc", + name = "new name", + summary = "new summary", + tenInchScreenshots = listOf("new screenshots"), + whatsNew = "This is new!", ), - "de" to Localized( - summary = "Der App-Store, der Freiheit und Privatsphäre respektiert", - whatsNew = "das ist neu", + "de" to + Localized( + summary = "Der App-Store, der Freiheit und Privatsphäre respektiert", + whatsNew = "das ist neu", ), - "en-US" to Localized( - description = "F-Droid is an installable catalogue of libre software", - summary = "new summary in en-US", - phoneScreenshots = listOf( - "screenshot-app-details.png", - "screenshot-dark-details.png", - "screenshot-dark-home.png", - "screenshot-search.png", - "screenshot-updates.png", + "en-US" to + Localized( + description = "F-Droid is an installable catalogue of libre software", + summary = "new summary in en-US", + phoneScreenshots = + listOf( + "screenshot-app-details.png", + "screenshot-dark-details.png", + "screenshot-dark-home.png", + "screenshot-search.png", + "screenshot-updates.png", ), - featureGraphic = "featureGraphic_PTun9TO4cMFOeiqbvQSrkdcxNUcOFQCymMIaj9UJOAY=.jpg", - icon = "new icon", - whatsNew = "this is new", + featureGraphic = "featureGraphic_PTun9TO4cMFOeiqbvQSrkdcxNUcOFQCymMIaj9UJOAY=.jpg", + icon = "new icon", + whatsNew = "this is new", ), ), ) - val app3 = AppV1( - packageName = PACKAGE_NAME_3, - categories = categories, - antiFeatures = listOf("AntiFeature", "NonFreeNet", "NotNice", "VeryBad", "Dont,Show,This"), - summary = "App3 summary", - description = "App3 description", - changelog = "changeLog3", - translation = "translation3", - issueTracker = "tracker3", - sourceCode = "source code3", - binaries = "binaries3", - name = "App3", - authorName = "App3 author", - authorEmail = "email", - authorWebSite = "website", - authorPhone = "phone", - donate = "donate", - liberapayID = "liberapayID", - liberapay = "liberapay", - openCollective = "openCollective", - bitcoin = "bitcoin", - litecoin = "litecoin", - flattrID = "flattrID", - suggestedVersionName = "1.0", - suggestedVersionCode = Long.MIN_VALUE.toString(), - license = "GPLv3", - webSite = "http://min1.test.org", - added = 1234567890, - icon = "icon-max1.png", - lastUpdated = Long.MAX_VALUE, - localized = mapOf( - LOCALE to Localized( - whatsNew = "this is new", + val app3 = + AppV1( + packageName = PACKAGE_NAME_3, + categories = categories, + antiFeatures = listOf("AntiFeature", "NonFreeNet", "NotNice", "VeryBad", "Dont,Show,This"), + summary = "App3 summary", + description = "App3 description", + changelog = "changeLog3", + translation = "translation3", + issueTracker = "tracker3", + sourceCode = "source code3", + binaries = "binaries3", + name = "App3", + authorName = "App3 author", + authorEmail = "email", + authorWebSite = "website", + authorPhone = "phone", + donate = "donate", + liberapayID = "liberapayID", + liberapay = "liberapay", + openCollective = "openCollective", + bitcoin = "bitcoin", + litecoin = "litecoin", + flattrID = "flattrID", + suggestedVersionName = "1.0", + suggestedVersionCode = Long.MIN_VALUE.toString(), + license = "GPLv3", + webSite = "http://min1.test.org", + added = 1234567890, + icon = "icon-max1.png", + lastUpdated = Long.MAX_VALUE, + localized = + mapOf( + LOCALE to Localized(whatsNew = "this is new"), + "de" to Localized(whatsNew = "das ist neu"), + "en" to + Localized( + description = "en ", + name = "en ", + icon = "en ", + video = "en ", + phoneScreenshots = listOf("en phoneScreenshots", "en phoneScreenshots2"), + sevenInchScreenshots = listOf("en sevenInchScreenshots", "en sevenInchScreenshots2"), + tenInchScreenshots = listOf("en tenInchScreenshots", "en tenInchScreenshots2"), + wearScreenshots = listOf("en wearScreenshots", "en wearScreenshots2"), + tvScreenshots = listOf("en tvScreenshots", "en tvScreenshots2"), + featureGraphic = "en ", + promoGraphic = "en ", + tvBanner = "en ", + summary = "en ", ), - "de" to Localized( - whatsNew = "das ist neu", - ), - "en" to Localized( - description = "en ", - name = "en ", - icon = "en ", - video = "en ", - phoneScreenshots = listOf("en phoneScreenshots", "en phoneScreenshots2"), - sevenInchScreenshots = listOf( - "en sevenInchScreenshots", - "en sevenInchScreenshots2", - ), - tenInchScreenshots = listOf("en tenInchScreenshots", "en tenInchScreenshots2"), - wearScreenshots = listOf("en wearScreenshots", "en wearScreenshots2"), - tvScreenshots = listOf("en tvScreenshots", "en tvScreenshots2"), - featureGraphic = "en ", - promoGraphic = "en ", - tvBanner = "en ", - summary = "en ", - ) ), - allowedAPKSigningKeys = listOf("key1, key2"), + allowedAPKSigningKeys = listOf("key1, key2"), ) - val apps = listOf(TestDataMidV1.app1, app2, app3) + val apps = listOf(TestDataMidV1.app1, app2, app3) - val version2_2 = TestDataMidV1.version2_2.copy( - usesPermission = emptyList(), - usesPermission23 = emptyList(), - nativeCode = emptyList(), - features = emptyList(), - antiFeatures = emptyList(), + val version2_2 = + TestDataMidV1.version2_2.copy( + usesPermission = emptyList(), + usesPermission23 = emptyList(), + nativeCode = emptyList(), + features = emptyList(), + antiFeatures = emptyList(), ) - val version2_3 = TestDataMidV1.version2_3.copy( - minSdkVersion = 22, - targetSdkVersion = 25, - packageName = "org.fdroid.fdroid", - sig = "9063aaadfff9cfd811a9c72fb5012f28", - signer = "43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab", - size = 8276110, - srcName = "org.fdroid.fdroid_1014003_src.tar.gz", - usesPermission = listOf( - PermissionV1(name = "android.permission.ACCESS_MEDIA"), - PermissionV1(name = "android.permission.CHANGE_WIFI_STATE", maxSdk = 32), - PermissionV1(name = "android.permission.READ_EXTERNAL_STORAGE"), - PermissionV1(name = "android.permission.WRITE_EXTERNAL_STORAGE"), - PermissionV1(name = "android.permission.NFC"), - PermissionV1(name = "android.permission.USB_PERMISSION", maxSdk = 22), - PermissionV1(name = "android.permission.WAKE_LOCK"), - PermissionV1(name = "android.permission.READ_MY_ASS"), - PermissionV1(name = "android.permission.FOREGROUND_SERVICE"), + val version2_3 = + TestDataMidV1.version2_3.copy( + minSdkVersion = 22, + targetSdkVersion = 25, + packageName = "org.fdroid.fdroid", + sig = "9063aaadfff9cfd811a9c72fb5012f28", + signer = "43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab", + size = 8276110, + srcName = "org.fdroid.fdroid_1014003_src.tar.gz", + usesPermission = + listOf( + PermissionV1(name = "android.permission.ACCESS_MEDIA"), + PermissionV1(name = "android.permission.CHANGE_WIFI_STATE", maxSdk = 32), + PermissionV1(name = "android.permission.READ_EXTERNAL_STORAGE"), + PermissionV1(name = "android.permission.WRITE_EXTERNAL_STORAGE"), + PermissionV1(name = "android.permission.NFC"), + PermissionV1(name = "android.permission.USB_PERMISSION", maxSdk = 22), + PermissionV1(name = "android.permission.WAKE_LOCK"), + PermissionV1(name = "android.permission.READ_MY_ASS"), + PermissionV1(name = "android.permission.FOREGROUND_SERVICE"), ), - usesPermission23 = listOf( - PermissionV1(name = "android.permission.ACCESS_FINE_LOCATION"), - PermissionV1(name = "android.permission.ACCESS_COARSE_LOCATION", maxSdk = 3), + usesPermission23 = + listOf( + PermissionV1(name = "android.permission.ACCESS_FINE_LOCATION"), + PermissionV1(name = "android.permission.ACCESS_COARSE_LOCATION", maxSdk = 3), ), - versionCode = 1014003, - versionName = "1.14-alpha3", + versionCode = 1014003, + versionName = "1.14-alpha3", ) - val version3_1 = PackageV1( - added = 1643250075000, - apkName = "org.fdroid.fdroid_1014050.apk", - hash = "8c89ce2f42f4a89af8ca6e1ea220f9dfdee220724d8a9cc067d510ac6f3e0d06", - hashType = "sha256", - minSdkVersion = 22, - maxSdkVersion = Int.MAX_VALUE, - targetSdkVersion = 25, - packageName = "org.fdroid.fdroid", - sig = "9063aaadfff9cfd811a9c72fb5012f28", - signer = "43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab", - size = 8165518, - srcName = "org.fdroid.fdroid_1014050_src.tar.gz", - usesPermission = listOf( - PermissionV1(name = "android.permission.INTERNET"), - PermissionV1(name = "android.permission.ACCESS_NETWORK_STATE"), - PermissionV1(name = "android.permission.ACCESS_WIFI_STATE"), - PermissionV1(name = "android.permission.CHANGE_WIFI_MULTICAST_STATE"), - PermissionV1(name = "android.permission.CHANGE_NETWORK_STATE"), - PermissionV1(name = "android.permission.CHANGE_WIFI_STATE"), - PermissionV1(name = "android.permission.BLUETOOTH"), - PermissionV1(name = "android.permission.BLUETOOTH_ADMIN"), - PermissionV1(name = "android.permission.RECEIVE_BOOT_COMPLETED"), - PermissionV1(name = "android.permission.READ_EXTERNAL_STORAGE"), - PermissionV1(name = "android.permission.WRITE_EXTERNAL_STORAGE"), - PermissionV1(name = "android.permission.WRITE_SETTINGS"), - PermissionV1(name = "android.permission.NFC"), - PermissionV1(name = "android.permission.USB_PERMISSION", maxSdk = 22), - PermissionV1(name = "android.permission.WAKE_LOCK"), - PermissionV1(name = "android.permission.FOREGROUND_SERVICE") + val version3_1 = + PackageV1( + added = 1643250075000, + apkName = "org.fdroid.fdroid_1014050.apk", + hash = "8c89ce2f42f4a89af8ca6e1ea220f9dfdee220724d8a9cc067d510ac6f3e0d06", + hashType = "sha256", + minSdkVersion = 22, + maxSdkVersion = Int.MAX_VALUE, + targetSdkVersion = 25, + packageName = "org.fdroid.fdroid", + sig = "9063aaadfff9cfd811a9c72fb5012f28", + signer = "43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab", + size = 8165518, + srcName = "org.fdroid.fdroid_1014050_src.tar.gz", + usesPermission = + listOf( + PermissionV1(name = "android.permission.INTERNET"), + PermissionV1(name = "android.permission.ACCESS_NETWORK_STATE"), + PermissionV1(name = "android.permission.ACCESS_WIFI_STATE"), + PermissionV1(name = "android.permission.CHANGE_WIFI_MULTICAST_STATE"), + PermissionV1(name = "android.permission.CHANGE_NETWORK_STATE"), + PermissionV1(name = "android.permission.CHANGE_WIFI_STATE"), + PermissionV1(name = "android.permission.BLUETOOTH"), + PermissionV1(name = "android.permission.BLUETOOTH_ADMIN"), + PermissionV1(name = "android.permission.RECEIVE_BOOT_COMPLETED"), + PermissionV1(name = "android.permission.READ_EXTERNAL_STORAGE"), + PermissionV1(name = "android.permission.WRITE_EXTERNAL_STORAGE"), + PermissionV1(name = "android.permission.WRITE_SETTINGS"), + PermissionV1(name = "android.permission.NFC"), + PermissionV1(name = "android.permission.USB_PERMISSION", maxSdk = 22), + PermissionV1(name = "android.permission.WAKE_LOCK"), + PermissionV1(name = "android.permission.FOREGROUND_SERVICE"), ), - usesPermission23 = listOf( - PermissionV1(name = "android.permission.ACCESS_COARSE_LOCATION"), - PermissionV1(name = "android.permission.USB_PERMISSION", maxSdk = Int.MAX_VALUE), + usesPermission23 = + listOf( + PermissionV1(name = "android.permission.ACCESS_COARSE_LOCATION"), + PermissionV1(name = "android.permission.USB_PERMISSION", maxSdk = Int.MAX_VALUE), ), - versionCode = 1014050, - versionName = "1.14", - nativeCode = listOf("x86", "x86_64"), - features = listOf("feature", "feature2"), - antiFeatures = listOf("anti-feature", "anti-feature2"), - ) - val versions1 = TestDataMidV1.versions1 - val versions2 = listOf( - version2_2, version2_3, TestDataMidV1.version2_4, TestDataMidV1.version2_5 - ) - val versions3 = listOf(version3_1) - val packages = mapOf( - PACKAGE_NAME_1 to versions1, - PACKAGE_NAME_2 to versions2, - PACKAGE_NAME_3 to versions3, + versionCode = 1014050, + versionName = "1.14", + nativeCode = listOf("x86", "x86_64"), + features = listOf("feature", "feature2"), + antiFeatures = listOf("anti-feature", "anti-feature2"), ) + val versions1 = TestDataMidV1.versions1 + val versions2 = listOf(version2_2, version2_3, TestDataMidV1.version2_4, TestDataMidV1.version2_5) + val versions3 = listOf(version3_1) + val packages = + mapOf(PACKAGE_NAME_1 to versions1, PACKAGE_NAME_2 to versions2, PACKAGE_NAME_3 to versions3) - val index = IndexV1( - repo = repo, - requests = Requests(listOf("installThis", "installThat"), listOf("uninstallThis")), - apps = apps, - packages = packages, + val index = + IndexV1( + repo = repo, + requests = Requests(listOf("installThis", "installThat"), listOf("uninstallThis")), + apps = apps, + packages = packages, ) } diff --git a/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestDataV2.kt b/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestDataV2.kt index de53c1b8a..550cbd4da 100644 --- a/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestDataV2.kt +++ b/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestDataV2.kt @@ -22,11 +22,10 @@ import org.fdroid.index.v2.UsesSdkV2 const val LOCALE = "en-US" -fun IndexV2.v1compat() = copy( - repo = repo.v1compat(), -) +fun IndexV2.v1compat() = copy(repo = repo.v1compat()) -fun RepoV2.v1compat() = copy( +fun RepoV2.v1compat() = + copy( name = name.filterKeys { it == LOCALE }, description = description.filterKeys { it == LOCALE }, icon = icon.filterKeys { it == LOCALE }.mapValues { it.value.v1compat() }, @@ -35,1246 +34,1328 @@ fun RepoV2.v1compat() = copy( categories = categories.mapValues { CategoryV2(name = mapOf(LOCALE to it.key)) }, releaseChannels = getV1ReleaseChannels(), antiFeatures = antiFeatures.mapValues { AntiFeatureV2(name = mapOf(LOCALE to it.key)) }, -) + ) -fun PackageV2.v1compat(overrideLocale: Boolean = false) = copy( - metadata = metadata.copy( +fun PackageV2.v1compat(overrideLocale: Boolean = false) = + copy( + metadata = + metadata.copy( name = if (overrideLocale) metadata.name?.filterKeys { it == LOCALE } else metadata.name, - summary = if (overrideLocale) metadata.summary?.filterKeys { it == LOCALE } - else metadata.summary, - description = if (overrideLocale) metadata.description?.filterKeys { it == LOCALE } - else metadata.description, + summary = + if (overrideLocale) metadata.summary?.filterKeys { it == LOCALE } else metadata.summary, + description = + if (overrideLocale) metadata.description?.filterKeys { it == LOCALE } + else metadata.description, icon = metadata.icon?.mapValues { it.value.v1compat() }, featureGraphic = metadata.featureGraphic?.mapValues { it.value.v1compat() }, promoGraphic = metadata.promoGraphic?.mapValues { it.value.v1compat() }, tvBanner = metadata.tvBanner?.mapValues { it.value.v1compat() }, - screenshots = metadata.screenshots?.copy( - phone = metadata.screenshots?.phone?.mapValues { list -> + screenshots = + metadata.screenshots?.copy( + phone = + metadata.screenshots?.phone?.mapValues { list -> list.value.map { it.v1compat() } }, + sevenInch = + metadata.screenshots?.sevenInch?.mapValues { list -> list.value.map { it.v1compat() } - }, - sevenInch = metadata.screenshots?.sevenInch?.mapValues { list -> - list.value.map { it.v1compat() } - }, - tenInch = metadata.screenshots?.tenInch?.mapValues { list -> - list.value.map { it.v1compat() } - }, - wear = metadata.screenshots?.wear?.mapValues { list -> - list.value.map { it.v1compat() } - }, - tv = metadata.screenshots?.tv?.mapValues { list -> - list.value.map { it.v1compat() } - }, - ) - ) -) + }, + tenInch = + metadata.screenshots?.tenInch?.mapValues { list -> list.value.map { it.v1compat() } }, + wear = + metadata.screenshots?.wear?.mapValues { list -> list.value.map { it.v1compat() } }, + tv = metadata.screenshots?.tv?.mapValues { list -> list.value.map { it.v1compat() } }, + ), + ) + ) -fun PackageVersionV2.v1compat() = copy( +fun PackageVersionV2.v1compat() = + copy( src = src?.v1compat(), - manifest = manifest.copy( - signer = if ((manifest.signer?.sha256?.size ?: 0) <= 1) manifest.signer else { + manifest = + manifest.copy( + signer = + if ((manifest.signer?.sha256?.size ?: 0) <= 1) manifest.signer + else { SignerV2(manifest.signer?.sha256?.subList(0, 1) ?: error("")) - } - ), + } + ), releaseChannels = releaseChannels.filter { it == RELEASE_CHANNEL_BETA }, - antiFeatures = antiFeatures.mapValues { emptyMap() } -) + antiFeatures = antiFeatures.mapValues { emptyMap() }, + ) -fun FileV2.v1compat() = copy( - sha256 = null, - size = null, -) +fun FileV2.v1compat() = copy(sha256 = null, size = null) object TestDataEmptyV2 { - val repo = RepoV2( - timestamp = 23, - address = "https://empty-v1.org", - name = mapOf(LOCALE to "EmptyV1"), - icon = mapOf( - LOCALE to FileV2( - name = "/icons/empty-v1.png", - sha256 = "824a109b2352138c3699760e1683385d0ed50ce526fc7982f8d65757743374bf", - size = 32492, - ), + val repo = + RepoV2( + timestamp = 23, + address = "https://empty-v1.org", + name = mapOf(LOCALE to "EmptyV1"), + icon = + mapOf( + LOCALE to + FileV2( + name = "/icons/empty-v1.png", + sha256 = "824a109b2352138c3699760e1683385d0ed50ce526fc7982f8d65757743374bf", + size = 32492, + ) ), - webBaseUrl = null, - description = mapOf(LOCALE to "This is a repo with empty data."), - mirrors = emptyList(), - antiFeatures = emptyMap(), - categories = emptyMap(), - releaseChannels = emptyMap(), - ) - val index = IndexV2( - repo = repo, - packages = emptyMap(), + webBaseUrl = null, + description = mapOf(LOCALE to "This is a repo with empty data."), + mirrors = emptyList(), + antiFeatures = emptyMap(), + categories = emptyMap(), + releaseChannels = emptyMap(), ) + val index = IndexV2(repo = repo, packages = emptyMap()) } object TestDataMinV2 { - val repo = RepoV2( - timestamp = 42, - name = mapOf(LOCALE to "MinV1"), - icon = mapOf( - LOCALE to - FileV2( - name = "/icons/min-v1.png", - sha256 = "74758e480ae76297c8947f107db9ea03d2933c9d5c110d02046977cf78d43def", - size = 0, - ), + val repo = + RepoV2( + timestamp = 42, + name = mapOf(LOCALE to "MinV1"), + icon = + mapOf( + LOCALE to + FileV2( + name = "/icons/min-v1.png", + sha256 = "74758e480ae76297c8947f107db9ea03d2933c9d5c110d02046977cf78d43def", + size = 0, + ) ), - address = "https://min-v1.org/repo", - description = mapOf(LOCALE to "This is a repo with minimal data."), + address = "https://min-v1.org/repo", + description = mapOf(LOCALE to "This is a repo with minimal data."), ) - const val PACKAGE_NAME = "org.fdroid.min1" - val version = PackageVersionV2( - added = 0, - file = FileV1( - name = "/${PACKAGE_NAME}_23.apk", - sha256 = "824a109b2352138c3699760e1683385d0ed50ce526fc7982f8d65757743374bf", - size = 1337, - ), - manifest = ManifestV2( - versionCode = 1, - versionName = "0", + const val PACKAGE_NAME = "org.fdroid.min1" + val version = + PackageVersionV2( + added = 0, + file = + FileV1( + name = "/${PACKAGE_NAME}_23.apk", + sha256 = "824a109b2352138c3699760e1683385d0ed50ce526fc7982f8d65757743374bf", + size = 1337, ), + manifest = ManifestV2(versionCode = 1, versionName = "0"), ) - val app = PackageV2( - metadata = MetadataV2( - added = 0, - lastUpdated = 0, - license = "", // not really needed, but easier for v1 conversion testing - ), - versions = mapOf( - version.file.sha256 to version, + val app = + PackageV2( + metadata = + MetadataV2( + added = 0, + lastUpdated = 0, + license = "", // not really needed, but easier for v1 conversion testing ), + versions = mapOf(version.file.sha256 to version), ) - val packages = mapOf(PACKAGE_NAME to app) + val packages = mapOf(PACKAGE_NAME to app) - val index = IndexV2( - repo = repo, - packages = packages, - ) + val index = IndexV2(repo = repo, packages = packages) } object TestDataMidV2 { - val repo = RepoV2( - timestamp = 1337, - name = mapOf( - LOCALE to "MidV1", - "de" to "MitteV1", - ), - icon = mapOf( - LOCALE to FileV2( - name = "/icons/mid-v1.png", - sha256 = "74758e480ae76297c7947f107db9ea03d2933c9d5c110d02046977cf78d43def", - size = 234232352235, + val repo = + RepoV2( + timestamp = 1337, + name = mapOf(LOCALE to "MidV1", "de" to "MitteV1"), + icon = + mapOf( + LOCALE to + FileV2( + name = "/icons/mid-v1.png", + sha256 = "74758e480ae76297c7947f107db9ea03d2933c9d5c110d02046977cf78d43def", + size = 234232352235, ), - "de" to FileV2( - name = "/icons/mitte-v1.png", - sha256 = "34758e480ae76297c7947f107db9ea03d2933c9d5c110d02046977cf78d43def", - size = 132352235, + "de" to + FileV2( + name = "/icons/mitte-v1.png", + sha256 = "34758e480ae76297c7947f107db9ea03d2933c9d5c110d02046977cf78d43def", + size = 132352235, ), ), - address = "https://mid-v1.org/repo", - description = mapOf( - LOCALE to "This is a repo with medium data.", - "de" to "Dies ist ein Repo mit mittlerer Datendichte.", + address = "https://mid-v1.org/repo", + description = + mapOf( + LOCALE to "This is a repo with medium data.", + "de" to "Dies ist ein Repo mit mittlerer Datendichte.", ), - mirrors = listOf(MirrorV2("https://mid-v1.com/repo")), - categories = mapOf( - "Cat1" to CategoryV2( - name = mapOf(LOCALE to "Cat1"), - icon = mapOf(LOCALE to FileV2( - name = "/icons/cat2.png", - sha256 = "54758e480ae76297c7947f107db9ea03d2933c9d5c110d02046977cf78d43def", - size = Long.MAX_VALUE, - )), - ), - "System" to CategoryV2( - name = emptyMap(), - ), - ), - antiFeatures = mapOf( - "AntiFeature" to AntiFeatureV2( - name = mapOf(LOCALE to "AntiFeature"), - description = mapOf(LOCALE to "A bad anti-feature, we can't show to users."), - ) - ) - ) - val repoCompat = repo.v1compat() - - const val PACKAGE_NAME_1 = TestDataMinV1.PACKAGE_NAME - const val PACKAGE_NAME_2 = "org.fdroid.fdroid" - val version1_1 = PackageVersionV2( - added = 2342, - file = FileV1( - name = "/${PACKAGE_NAME_1}_23_2.apk", - sha256 = "824a109b2352138c3699760e1683385d0ed50ce526fc7982f8d65757743374bf", - size = 1338, - ), - src = FileV2( - name = "/${PACKAGE_NAME_1}_23_2.zip", - sha256 = "824a109b2352138c3699760e1683385d0ed50ce526fc7982f8d65757743374bf", - size = 1338, - ), - manifest = ManifestV2( - versionCode = 1, - versionName = "1", - usesPermission = listOf(PermissionV2("perm")), - nativecode = listOf("x86"), - features = listOf(FeatureV2("feature")), - ), - antiFeatures = mapOf("AntiFeature" to mapOf(LOCALE to "reason")), - ) - val version1_1Compat = version1_1.v1compat() - val version1_2 = PackageVersionV2( - added = 0, - file = FileV1( - name = "/${PACKAGE_NAME_1}_42.apk", - sha256 = "824a109b2352138c3699760e1683385d0ed50ce526fc7982f8d65757743374bf", - size = 1337, - ), - manifest = ManifestV2( - versionCode = 24, - versionName = "24", - usesSdk = UsesSdkV2( - minSdkVersion = 21, - targetSdkVersion = 32, - ), - maxSdkVersion = 4568, - signer = SignerV2( - sha256 = listOf("824a109b2352138c3699760e1683385d0ed50ce526fc7982f8d65757743374bf"), - ), - features = listOf( - FeatureV2("new feature") - ), - ), - src = FileV2( - name = "/${PACKAGE_NAME_1}_42.zip", - sha256 = "824a109b2352138c3699760e1683385d0ed50ce526fc7982f8d65757743374bf", - size = 1338, - ), - antiFeatures = mapOf("AntiFeature" to mapOf(LOCALE to "reason")), - releaseChannels = listOf("Beta") - ) - val version1_2Compat = version1_2.v1compat().copy( - antiFeatures = mapOf("AntiFeature" to emptyMap()), - ) - - val app1 = PackageV2( - metadata = MetadataV2( - added = 1234567890, - categories = listOf(TestDataMidV1.categories[0]), - summary = mapOf( - LOCALE to "App1 summary", - "de" to "App1 Zusammenfassung", - ), - description = mapOf( - LOCALE to "App1 description", - "de" to "App1 beschreibung", - ), - name = mapOf( - LOCALE to "App1", - "de" to "app 1 name", - ), - authorName = "App1 author", - license = "GPLv3", - webSite = "http://min1.test.org", - icon = mapOf( - LOCALE to FileV2( - name = "/icons/icon-min1.png", - sha256 = "824a109b2352138c3699760e1683385d0ed50ce526fc7982f8d65757743374bf", - size = 1337, + mirrors = listOf(MirrorV2("https://mid-v1.com/repo")), + categories = + mapOf( + "Cat1" to + CategoryV2( + name = mapOf(LOCALE to "Cat1"), + icon = + mapOf( + LOCALE to + FileV2( + name = "/icons/cat2.png", + sha256 = "54758e480ae76297c7947f107db9ea03d2933c9d5c110d02046977cf78d43def", + size = Long.MAX_VALUE, + ) ), ), - lastUpdated = 1234567891, + "System" to CategoryV2(name = emptyMap()), ), - versions = mapOf( - version1_1.file.sha256 to version1_1, - version1_2.file.sha256 to version1_2, + antiFeatures = + mapOf( + "AntiFeature" to + AntiFeatureV2( + name = mapOf(LOCALE to "AntiFeature"), + description = mapOf(LOCALE to "A bad anti-feature, we can't show to users."), + ) ), ) - val app1Compat = app1.v1compat(true).copy( - versions = mapOf( + val repoCompat = repo.v1compat() + + const val PACKAGE_NAME_1 = TestDataMinV1.PACKAGE_NAME + const val PACKAGE_NAME_2 = "org.fdroid.fdroid" + val version1_1 = + PackageVersionV2( + added = 2342, + file = + FileV1( + name = "/${PACKAGE_NAME_1}_23_2.apk", + sha256 = "824a109b2352138c3699760e1683385d0ed50ce526fc7982f8d65757743374bf", + size = 1338, + ), + src = + FileV2( + name = "/${PACKAGE_NAME_1}_23_2.zip", + sha256 = "824a109b2352138c3699760e1683385d0ed50ce526fc7982f8d65757743374bf", + size = 1338, + ), + manifest = + ManifestV2( + versionCode = 1, + versionName = "1", + usesPermission = listOf(PermissionV2("perm")), + nativecode = listOf("x86"), + features = listOf(FeatureV2("feature")), + ), + antiFeatures = mapOf("AntiFeature" to mapOf(LOCALE to "reason")), + ) + val version1_1Compat = version1_1.v1compat() + val version1_2 = + PackageVersionV2( + added = 0, + file = + FileV1( + name = "/${PACKAGE_NAME_1}_42.apk", + sha256 = "824a109b2352138c3699760e1683385d0ed50ce526fc7982f8d65757743374bf", + size = 1337, + ), + manifest = + ManifestV2( + versionCode = 24, + versionName = "24", + usesSdk = UsesSdkV2(minSdkVersion = 21, targetSdkVersion = 32), + maxSdkVersion = 4568, + signer = + SignerV2( + sha256 = listOf("824a109b2352138c3699760e1683385d0ed50ce526fc7982f8d65757743374bf") + ), + features = listOf(FeatureV2("new feature")), + ), + src = + FileV2( + name = "/${PACKAGE_NAME_1}_42.zip", + sha256 = "824a109b2352138c3699760e1683385d0ed50ce526fc7982f8d65757743374bf", + size = 1338, + ), + antiFeatures = mapOf("AntiFeature" to mapOf(LOCALE to "reason")), + releaseChannels = listOf("Beta"), + ) + val version1_2Compat = + version1_2.v1compat().copy(antiFeatures = mapOf("AntiFeature" to emptyMap())) + + val app1 = + PackageV2( + metadata = + MetadataV2( + added = 1234567890, + categories = listOf(TestDataMidV1.categories[0]), + summary = mapOf(LOCALE to "App1 summary", "de" to "App1 Zusammenfassung"), + description = mapOf(LOCALE to "App1 description", "de" to "App1 beschreibung"), + name = mapOf(LOCALE to "App1", "de" to "app 1 name"), + authorName = "App1 author", + license = "GPLv3", + webSite = "http://min1.test.org", + icon = + mapOf( + LOCALE to + FileV2( + name = "/icons/icon-min1.png", + sha256 = "824a109b2352138c3699760e1683385d0ed50ce526fc7982f8d65757743374bf", + size = 1337, + ) + ), + lastUpdated = 1234567891, + ), + versions = mapOf(version1_1.file.sha256 to version1_1, version1_2.file.sha256 to version1_2), + ) + val app1Compat = + app1 + .v1compat(true) + .copy( + versions = + mapOf( version1_1.file.sha256 to version1_1Compat, version1_2.file.sha256 to version1_2Compat, - ), - ) + ) + ) - val version2_1 = PackageVersionV2( - added = 1643250075000, - file = FileV1( - name = "/org.fdroid.fdroid_1014050.apk", - sha256 = "8c89ce2f42f4a89af8ca6e1ea220f9dfdee220724d8a9cc067d510ac6f3e0d06", - size = 8165518, + val version2_1 = + PackageVersionV2( + added = 1643250075000, + file = + FileV1( + name = "/org.fdroid.fdroid_1014050.apk", + sha256 = "8c89ce2f42f4a89af8ca6e1ea220f9dfdee220724d8a9cc067d510ac6f3e0d06", + size = 8165518, ), - manifest = ManifestV2( - versionCode = 1014050, - versionName = "1.14", - usesSdk = UsesSdkV2( - minSdkVersion = 22, - targetSdkVersion = 25, + manifest = + ManifestV2( + versionCode = 1014050, + versionName = "1.14", + usesSdk = UsesSdkV2(minSdkVersion = 22, targetSdkVersion = 25), + signer = + SignerV2( + sha256 = listOf("43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab") ), - signer = SignerV2( - sha256 = listOf("43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab"), - ), - usesPermission = listOf( - PermissionV2(name = "android.permission.INTERNET"), - PermissionV2(name = "android.permission.ACCESS_NETWORK_STATE"), - PermissionV2(name = "android.permission.ACCESS_WIFI_STATE"), - PermissionV2(name = "android.permission.CHANGE_WIFI_MULTICAST_STATE"), - PermissionV2(name = "android.permission.CHANGE_NETWORK_STATE"), - PermissionV2(name = "android.permission.CHANGE_WIFI_STATE"), - PermissionV2(name = "android.permission.BLUETOOTH"), - PermissionV2(name = "android.permission.BLUETOOTH_ADMIN"), - PermissionV2(name = "android.permission.RECEIVE_BOOT_COMPLETED"), - PermissionV2(name = "android.permission.READ_EXTERNAL_STORAGE"), - PermissionV2(name = "android.permission.WRITE_EXTERNAL_STORAGE"), - PermissionV2(name = "android.permission.WRITE_SETTINGS"), - PermissionV2(name = "android.permission.NFC"), - PermissionV2(name = "android.permission.USB_PERMISSION", maxSdkVersion = 22), - PermissionV2(name = "android.permission.WAKE_LOCK"), - PermissionV2(name = "android.permission.FOREGROUND_SERVICE") - ), - usesPermissionSdk23 = listOf( - PermissionV2(name = "android.permission.ACCESS_COARSE_LOCATION") + usesPermission = + listOf( + PermissionV2(name = "android.permission.INTERNET"), + PermissionV2(name = "android.permission.ACCESS_NETWORK_STATE"), + PermissionV2(name = "android.permission.ACCESS_WIFI_STATE"), + PermissionV2(name = "android.permission.CHANGE_WIFI_MULTICAST_STATE"), + PermissionV2(name = "android.permission.CHANGE_NETWORK_STATE"), + PermissionV2(name = "android.permission.CHANGE_WIFI_STATE"), + PermissionV2(name = "android.permission.BLUETOOTH"), + PermissionV2(name = "android.permission.BLUETOOTH_ADMIN"), + PermissionV2(name = "android.permission.RECEIVE_BOOT_COMPLETED"), + PermissionV2(name = "android.permission.READ_EXTERNAL_STORAGE"), + PermissionV2(name = "android.permission.WRITE_EXTERNAL_STORAGE"), + PermissionV2(name = "android.permission.WRITE_SETTINGS"), + PermissionV2(name = "android.permission.NFC"), + PermissionV2(name = "android.permission.USB_PERMISSION", maxSdkVersion = 22), + PermissionV2(name = "android.permission.WAKE_LOCK"), + PermissionV2(name = "android.permission.FOREGROUND_SERVICE"), ), + usesPermissionSdk23 = + listOf(PermissionV2(name = "android.permission.ACCESS_COARSE_LOCATION")), ), - src = FileV2( - name = "/org.fdroid.fdroid_1014050_src.tar.gz", - sha256 = "8c89ce2f42f4a89af8ca6e1ea220f9dfdee220724d8a9cc067d510ac6f3e0d07", - size = 8165519, + src = + FileV2( + name = "/org.fdroid.fdroid_1014050_src.tar.gz", + sha256 = "8c89ce2f42f4a89af8ca6e1ea220f9dfdee220724d8a9cc067d510ac6f3e0d07", + size = 8165519, ), - whatsNew = mapOf( - LOCALE to "* Overhaul Share menu to use built-in options like Nearby", - "eo" to "• rekonstruita menuo “Kunhavigi” por uzi enkonstruitajn eblojn, ekz.", + whatsNew = + mapOf( + LOCALE to "* Overhaul Share menu to use built-in options like Nearby", + "eo" to "• rekonstruita menuo “Kunhavigi” por uzi enkonstruitajn eblojn, ekz.", ), ) - val version2_1Compat = version2_1.v1compat() + val version2_1Compat = version2_1.v1compat() - val version2_2 = PackageVersionV2( - added = 1642785071000, - file = FileV1( - name = "/org.fdroid.fdroid_1014005.apk", - sha256 = "b4282febf5558d43c7c51a00478961f6df1b6d59e0a6674974cdacb792683e5d", - size = 8382606, + val version2_2 = + PackageVersionV2( + added = 1642785071000, + file = + FileV1( + name = "/org.fdroid.fdroid_1014005.apk", + sha256 = "b4282febf5558d43c7c51a00478961f6df1b6d59e0a6674974cdacb792683e5d", + size = 8382606, ), - manifest = ManifestV2( - versionCode = 1014005, - versionName = "1.14-alpha5", - usesSdk = UsesSdkV2( - minSdkVersion = 22, - targetSdkVersion = 25, + manifest = + ManifestV2( + versionCode = 1014005, + versionName = "1.14-alpha5", + usesSdk = UsesSdkV2(minSdkVersion = 22, targetSdkVersion = 25), + signer = + SignerV2( + sha256 = listOf("43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab") ), - signer = SignerV2( - sha256 = listOf("43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab"), + usesPermission = + listOf( + PermissionV2(name = "android.permission.INTERNET"), + PermissionV2(name = "android.permission.ACCESS_NETWORK_STATE"), + PermissionV2(name = "android.permission.ACCESS_WIFI_STATE"), + PermissionV2(name = "android.permission.CHANGE_WIFI_MULTICAST_STATE"), + PermissionV2(name = "android.permission.CHANGE_NETWORK_STATE"), + PermissionV2(name = "android.permission.CHANGE_WIFI_STATE"), + PermissionV2(name = "android.permission.BLUETOOTH"), + PermissionV2(name = "android.permission.BLUETOOTH_ADMIN"), + PermissionV2(name = "android.permission.RECEIVE_BOOT_COMPLETED"), + PermissionV2(name = "android.permission.READ_EXTERNAL_STORAGE"), + PermissionV2(name = "android.permission.WRITE_EXTERNAL_STORAGE"), + PermissionV2(name = "android.permission.WRITE_SETTINGS"), + PermissionV2(name = "android.permission.NFC"), + PermissionV2(name = "android.permission.USB_PERMISSION", maxSdkVersion = 22), + PermissionV2(name = "android.permission.WAKE_LOCK"), + PermissionV2(name = "android.permission.FOREGROUND_SERVICE"), ), - usesPermission = listOf( - PermissionV2(name = "android.permission.INTERNET"), - PermissionV2(name = "android.permission.ACCESS_NETWORK_STATE"), - PermissionV2(name = "android.permission.ACCESS_WIFI_STATE"), - PermissionV2(name = "android.permission.CHANGE_WIFI_MULTICAST_STATE"), - PermissionV2(name = "android.permission.CHANGE_NETWORK_STATE"), - PermissionV2(name = "android.permission.CHANGE_WIFI_STATE"), - PermissionV2(name = "android.permission.BLUETOOTH"), - PermissionV2(name = "android.permission.BLUETOOTH_ADMIN"), - PermissionV2(name = "android.permission.RECEIVE_BOOT_COMPLETED"), - PermissionV2(name = "android.permission.READ_EXTERNAL_STORAGE"), - PermissionV2(name = "android.permission.WRITE_EXTERNAL_STORAGE"), - PermissionV2(name = "android.permission.WRITE_SETTINGS"), - PermissionV2(name = "android.permission.NFC"), - PermissionV2(name = "android.permission.USB_PERMISSION", maxSdkVersion = 22), - PermissionV2(name = "android.permission.WAKE_LOCK"), - PermissionV2(name = "android.permission.FOREGROUND_SERVICE") - ), - usesPermissionSdk23 = listOf( - PermissionV2(name = "android.permission.ACCESS_COARSE_LOCATION") - ), - nativecode = listOf("fakeNativeCode"), - features = listOf(FeatureV2("fake feature")), + usesPermissionSdk23 = + listOf(PermissionV2(name = "android.permission.ACCESS_COARSE_LOCATION")), + nativecode = listOf("fakeNativeCode"), + features = listOf(FeatureV2("fake feature")), ), - src = FileV2( - name = "/org.fdroid.fdroid_1014005_src.tar.gz", - sha256 = "8c89ce2f42f4a89af8ca6e1ea220f9dfdee220724d8a9cc067d510ac6f3e0d07", - size = 8165519, + src = + FileV2( + name = "/org.fdroid.fdroid_1014005_src.tar.gz", + sha256 = "8c89ce2f42f4a89af8ca6e1ea220f9dfdee220724d8a9cc067d510ac6f3e0d07", + size = 8165519, ), - antiFeatures = mapOf("FakeAntiFeature" to emptyMap()), + antiFeatures = mapOf("FakeAntiFeature" to emptyMap()), ) - val version2_2Compat = version2_2.v1compat() + val version2_2Compat = version2_2.v1compat() - val version2_3 = PackageVersionV2( - added = 1635169849000, - file = FileV1( - name = "/org.fdroid.fdroid_1014003.apk", - sha256 = "c062a9642fde08aacabbfa4cab1ab5773c83f4e6b81551ffd92027d2b20f37d3", - size = 8276110, + val version2_3 = + PackageVersionV2( + added = 1635169849000, + file = + FileV1( + name = "/org.fdroid.fdroid_1014003.apk", + sha256 = "c062a9642fde08aacabbfa4cab1ab5773c83f4e6b81551ffd92027d2b20f37d3", + size = 8276110, ), - manifest = ManifestV2( - versionCode = 1014003, - versionName = "1.14-alpha3", - usesSdk = UsesSdkV2( - minSdkVersion = 22, - targetSdkVersion = 25, + manifest = + ManifestV2( + versionCode = 1014003, + versionName = "1.14-alpha3", + usesSdk = UsesSdkV2(minSdkVersion = 22, targetSdkVersion = 25), + signer = + SignerV2( + sha256 = listOf("43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab") ), - signer = SignerV2( - sha256 = listOf("43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab"), - ), - usesPermission = listOf( - PermissionV2(name = "android.permission.INTERNET"), - PermissionV2(name = "android.permission.ACCESS_NETWORK_STATE"), - PermissionV2(name = "android.permission.ACCESS_WIFI_STATE"), - PermissionV2(name = "android.permission.CHANGE_WIFI_MULTICAST_STATE"), - PermissionV2(name = "android.permission.CHANGE_NETWORK_STATE"), - PermissionV2(name = "android.permission.CHANGE_WIFI_STATE"), - PermissionV2(name = "android.permission.BLUETOOTH"), - PermissionV2(name = "android.permission.BLUETOOTH_ADMIN"), - PermissionV2(name = "android.permission.RECEIVE_BOOT_COMPLETED"), - PermissionV2(name = "android.permission.READ_EXTERNAL_STORAGE"), - PermissionV2(name = "android.permission.WRITE_EXTERNAL_STORAGE"), - PermissionV2(name = "android.permission.WRITE_SETTINGS"), - PermissionV2(name = "android.permission.NFC"), - PermissionV2(name = "android.permission.USB_PERMISSION", maxSdkVersion = 22), - PermissionV2(name = "android.permission.WAKE_LOCK"), - PermissionV2(name = "android.permission.FOREGROUND_SERVICE") - ), - usesPermissionSdk23 = listOf( - PermissionV2(name = "android.permission.ACCESS_COARSE_LOCATION") + usesPermission = + listOf( + PermissionV2(name = "android.permission.INTERNET"), + PermissionV2(name = "android.permission.ACCESS_NETWORK_STATE"), + PermissionV2(name = "android.permission.ACCESS_WIFI_STATE"), + PermissionV2(name = "android.permission.CHANGE_WIFI_MULTICAST_STATE"), + PermissionV2(name = "android.permission.CHANGE_NETWORK_STATE"), + PermissionV2(name = "android.permission.CHANGE_WIFI_STATE"), + PermissionV2(name = "android.permission.BLUETOOTH"), + PermissionV2(name = "android.permission.BLUETOOTH_ADMIN"), + PermissionV2(name = "android.permission.RECEIVE_BOOT_COMPLETED"), + PermissionV2(name = "android.permission.READ_EXTERNAL_STORAGE"), + PermissionV2(name = "android.permission.WRITE_EXTERNAL_STORAGE"), + PermissionV2(name = "android.permission.WRITE_SETTINGS"), + PermissionV2(name = "android.permission.NFC"), + PermissionV2(name = "android.permission.USB_PERMISSION", maxSdkVersion = 22), + PermissionV2(name = "android.permission.WAKE_LOCK"), + PermissionV2(name = "android.permission.FOREGROUND_SERVICE"), ), + usesPermissionSdk23 = + listOf(PermissionV2(name = "android.permission.ACCESS_COARSE_LOCATION")), ), - src = FileV2( - name = "/org.fdroid.fdroid_1014003_src.tar.gz", - sha256 = "8c89ce2f42f4a89af8ca6e1ea220f9dfdee220724d8a9cb067d510ac6f3e0d07", - size = 8165519, + src = + FileV2( + name = "/org.fdroid.fdroid_1014003_src.tar.gz", + sha256 = "8c89ce2f42f4a89af8ca6e1ea220f9dfdee220724d8a9cb067d510ac6f3e0d07", + size = 8165519, ), ) - val version2_3Compat = version2_3.v1compat() + val version2_3Compat = version2_3.v1compat() - val version2_4 = PackageVersionV2( - added = 1632281731000, - file = FileV1( - name = "/org.fdroid.fdroid_1014002.apk", - sha256 = "3243c24ee95be0fce0830d72e7d2605e3e24f6ccf4ee72a7c8e720fccd7621a1", - size = 8284386, + val version2_4 = + PackageVersionV2( + added = 1632281731000, + file = + FileV1( + name = "/org.fdroid.fdroid_1014002.apk", + sha256 = "3243c24ee95be0fce0830d72e7d2605e3e24f6ccf4ee72a7c8e720fccd7621a1", + size = 8284386, ), - manifest = ManifestV2( - versionCode = 1014002, - versionName = "1.14-alpha2", - usesSdk = UsesSdkV2( - minSdkVersion = 22, - targetSdkVersion = 25, + manifest = + ManifestV2( + versionCode = 1014002, + versionName = "1.14-alpha2", + usesSdk = UsesSdkV2(minSdkVersion = 22, targetSdkVersion = 25), + signer = + SignerV2( + sha256 = listOf("43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab") ), - signer = SignerV2( - sha256 = listOf("43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab"), - ), - usesPermission = listOf( - PermissionV2(name = "android.permission.INTERNET"), - PermissionV2(name = "android.permission.ACCESS_NETWORK_STATE"), - PermissionV2(name = "android.permission.ACCESS_WIFI_STATE"), - PermissionV2(name = "android.permission.CHANGE_WIFI_MULTICAST_STATE"), - PermissionV2(name = "android.permission.CHANGE_NETWORK_STATE"), - PermissionV2(name = "android.permission.CHANGE_WIFI_STATE"), - PermissionV2(name = "android.permission.BLUETOOTH"), - PermissionV2(name = "android.permission.BLUETOOTH_ADMIN"), - PermissionV2(name = "android.permission.RECEIVE_BOOT_COMPLETED"), - PermissionV2(name = "android.permission.READ_EXTERNAL_STORAGE"), - PermissionV2(name = "android.permission.WRITE_EXTERNAL_STORAGE"), - PermissionV2(name = "android.permission.WRITE_SETTINGS"), - PermissionV2(name = "android.permission.NFC"), - PermissionV2(name = "android.permission.USB_PERMISSION", maxSdkVersion = 22), - PermissionV2(name = "android.permission.WAKE_LOCK"), - PermissionV2(name = "android.permission.FOREGROUND_SERVICE") - ), - usesPermissionSdk23 = listOf( - PermissionV2(name = "android.permission.ACCESS_COARSE_LOCATION") + usesPermission = + listOf( + PermissionV2(name = "android.permission.INTERNET"), + PermissionV2(name = "android.permission.ACCESS_NETWORK_STATE"), + PermissionV2(name = "android.permission.ACCESS_WIFI_STATE"), + PermissionV2(name = "android.permission.CHANGE_WIFI_MULTICAST_STATE"), + PermissionV2(name = "android.permission.CHANGE_NETWORK_STATE"), + PermissionV2(name = "android.permission.CHANGE_WIFI_STATE"), + PermissionV2(name = "android.permission.BLUETOOTH"), + PermissionV2(name = "android.permission.BLUETOOTH_ADMIN"), + PermissionV2(name = "android.permission.RECEIVE_BOOT_COMPLETED"), + PermissionV2(name = "android.permission.READ_EXTERNAL_STORAGE"), + PermissionV2(name = "android.permission.WRITE_EXTERNAL_STORAGE"), + PermissionV2(name = "android.permission.WRITE_SETTINGS"), + PermissionV2(name = "android.permission.NFC"), + PermissionV2(name = "android.permission.USB_PERMISSION", maxSdkVersion = 22), + PermissionV2(name = "android.permission.WAKE_LOCK"), + PermissionV2(name = "android.permission.FOREGROUND_SERVICE"), ), + usesPermissionSdk23 = + listOf(PermissionV2(name = "android.permission.ACCESS_COARSE_LOCATION")), ), - src = FileV2( - name = "/org.fdroid.fdroid_1014002_src.tar.gz", - sha256 = "7c89ce2f42f4a89af8ca6e1ea220f9dfdee220724d8a9cb067d510ac6f3e0d07", - size = 7165519, + src = + FileV2( + name = "/org.fdroid.fdroid_1014002_src.tar.gz", + sha256 = "7c89ce2f42f4a89af8ca6e1ea220f9dfdee220724d8a9cb067d510ac6f3e0d07", + size = 7165519, ), ) - val version2_4Compat = version2_4.v1compat() + val version2_4Compat = version2_4.v1compat() - val version2_5 = PackageVersionV2( - added = 1632281729000, - file = FileV1( - name = "/org.fdroid.fdroid_1014001.apk", - sha256 = "7ebfd5eb76f9ec95ba955e549260fe930dc38fb99ed3532f92c93b879aca5610", - size = 8272166, + val version2_5 = + PackageVersionV2( + added = 1632281729000, + file = + FileV1( + name = "/org.fdroid.fdroid_1014001.apk", + sha256 = "7ebfd5eb76f9ec95ba955e549260fe930dc38fb99ed3532f92c93b879aca5610", + size = 8272166, ), - manifest = ManifestV2( - versionCode = 1014001, - versionName = "1.14-alpha1", - usesSdk = UsesSdkV2( - minSdkVersion = 22, - targetSdkVersion = 25, + manifest = + ManifestV2( + versionCode = 1014001, + versionName = "1.14-alpha1", + usesSdk = UsesSdkV2(minSdkVersion = 22, targetSdkVersion = 25), + signer = + SignerV2( + sha256 = listOf("43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab") ), - signer = SignerV2( - sha256 = listOf("43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab"), - ), - usesPermission = listOf( - PermissionV2(name = "android.permission.INTERNET"), - PermissionV2(name = "android.permission.ACCESS_NETWORK_STATE"), - PermissionV2(name = "android.permission.ACCESS_WIFI_STATE"), - PermissionV2(name = "android.permission.CHANGE_WIFI_MULTICAST_STATE"), - PermissionV2(name = "android.permission.CHANGE_NETWORK_STATE"), - PermissionV2(name = "android.permission.CHANGE_WIFI_STATE"), - PermissionV2(name = "android.permission.BLUETOOTH"), - PermissionV2(name = "android.permission.BLUETOOTH_ADMIN"), - PermissionV2(name = "android.permission.RECEIVE_BOOT_COMPLETED"), - PermissionV2(name = "android.permission.READ_EXTERNAL_STORAGE"), - PermissionV2(name = "android.permission.WRITE_EXTERNAL_STORAGE"), - PermissionV2(name = "android.permission.WRITE_SETTINGS"), - PermissionV2(name = "android.permission.NFC"), - PermissionV2(name = "android.permission.USB_PERMISSION", maxSdkVersion = 22), - PermissionV2(name = "android.permission.WAKE_LOCK"), - PermissionV2(name = "android.permission.FOREGROUND_SERVICE") - ), - usesPermissionSdk23 = listOf( - PermissionV2(name = "android.permission.ACCESS_COARSE_LOCATION") + usesPermission = + listOf( + PermissionV2(name = "android.permission.INTERNET"), + PermissionV2(name = "android.permission.ACCESS_NETWORK_STATE"), + PermissionV2(name = "android.permission.ACCESS_WIFI_STATE"), + PermissionV2(name = "android.permission.CHANGE_WIFI_MULTICAST_STATE"), + PermissionV2(name = "android.permission.CHANGE_NETWORK_STATE"), + PermissionV2(name = "android.permission.CHANGE_WIFI_STATE"), + PermissionV2(name = "android.permission.BLUETOOTH"), + PermissionV2(name = "android.permission.BLUETOOTH_ADMIN"), + PermissionV2(name = "android.permission.RECEIVE_BOOT_COMPLETED"), + PermissionV2(name = "android.permission.READ_EXTERNAL_STORAGE"), + PermissionV2(name = "android.permission.WRITE_EXTERNAL_STORAGE"), + PermissionV2(name = "android.permission.WRITE_SETTINGS"), + PermissionV2(name = "android.permission.NFC"), + PermissionV2(name = "android.permission.USB_PERMISSION", maxSdkVersion = 22), + PermissionV2(name = "android.permission.WAKE_LOCK"), + PermissionV2(name = "android.permission.FOREGROUND_SERVICE"), ), + usesPermissionSdk23 = + listOf(PermissionV2(name = "android.permission.ACCESS_COARSE_LOCATION")), ), - src = FileV2( - name = "/org.fdroid.fdroid_1014001_src.tar.gz", - sha256 = "6c89ce2f42f4a89af8ca6e1ea220f9dfdee220724d8a9cb067d510ac6f3e0d07", - size = 6165519, + src = + FileV2( + name = "/org.fdroid.fdroid_1014001_src.tar.gz", + sha256 = "6c89ce2f42f4a89af8ca6e1ea220f9dfdee220724d8a9cb067d510ac6f3e0d07", + size = 6165519, ), ) - val version2_5Compat = version2_5.v1compat() + val version2_5Compat = version2_5.v1compat() - val app2 = PackageV2( - metadata = MetadataV2( - added = 1295222400000, - categories = listOf("System"), - name = mapOf( - "af" to "-درويد", - "be" to "F-Droid", - "bn" to "এফ-ড্রয়েড", - "ca" to "F-Droid", - "cs" to "F-Droid", - "cy" to "F-Droid", - "el" to "F-Droid", - "en-US" to "F-Droid", - "eo" to "F-Droid", + val app2 = + PackageV2( + metadata = + MetadataV2( + added = 1295222400000, + categories = listOf("System"), + name = + mapOf( + "af" to "-درويد", + "be" to "F-Droid", + "bn" to "এফ-ড্রয়েড", + "ca" to "F-Droid", + "cs" to "F-Droid", + "cy" to "F-Droid", + "el" to "F-Droid", + "en-US" to "F-Droid", + "eo" to "F-Droid", ), - summary = mapOf( - "af" to "متجر التطبيقات الذي يحترم الحرية والخصوصية)", - "be" to "Крама праграм, якая паважае свабоду і прыватнасць", - "bg" to "Магазинът за приложения, който уважава независимостта и поверителността", - "bn" to "যে অ্যাপ স্টোর স্বাধীনতা ও গোপনীয়তা সম্মান করে", - "bo" to "རང་དབང་དང་གསང་དོན་ལ་གུས་བརྩི་ཞུས་མཁན་གྱི་མཉེན་ཆས་ཉར་ཚགས་ཁང་།", - "ca" to "La botiga d'aplicacions que respecta la llibertat i la privacitat", - "cs" to "Zdroj aplikací který respektuje vaši svobodu a soukromí", - "cy" to "Yr ystorfa apiau sy'n parchu rhyddid a phreifatrwydd", - "de" to "Der App-Store, der Freiheit und Privatsphäre respektiert", - "el" to "Το κατάστημα εφαρμογών που σέβεται την ελευθερία και την ιδιωτικότητα", - "en-US" to "The app store that respects freedom and privacy", - "eo" to "Aplikaĵa vendejo respektanta liberecon kaj privatecon", + summary = + mapOf( + "af" to "متجر التطبيقات الذي يحترم الحرية والخصوصية)", + "be" to "Крама праграм, якая паважае свабоду і прыватнасць", + "bg" to "Магазинът за приложения, който уважава независимостта и поверителността", + "bn" to "যে অ্যাপ স্টোর স্বাধীনতা ও গোপনীয়তা সম্মান করে", + "bo" to "རང་དབང་དང་གསང་དོན་ལ་གུས་བརྩི་ཞུས་མཁན་གྱི་མཉེན་ཆས་ཉར་ཚགས་ཁང་།", + "ca" to "La botiga d'aplicacions que respecta la llibertat i la privacitat", + "cs" to "Zdroj aplikací který respektuje vaši svobodu a soukromí", + "cy" to "Yr ystorfa apiau sy'n parchu rhyddid a phreifatrwydd", + "de" to "Der App-Store, der Freiheit und Privatsphäre respektiert", + "el" to "Το κατάστημα εφαρμογών που σέβεται την ελευθερία και την ιδιωτικότητα", + "en-US" to "The app store that respects freedom and privacy", + "eo" to "Aplikaĵa vendejo respektanta liberecon kaj privatecon", ), - description = mapOf( - "af" to "F-Droid is 'n installeerbare katalogus van gratis sagteware", - "bo" to "ཨེཕ་རོཌ་ནི་ཨེན་ཀྲོཌ་བབ་སྟེགས་ཀྱི་ཆེད་དུ་FOSS", - "ca" to "F-Droid és un catàleg instal·lable d'aplicacions de software lliure", - "cs" to "F-Droid je instalovatelný katalog softwarových libre", - "cy" to "Mae F-Droid yn gatalog y gellir ei osod o apiau meddalwedd " + - "rhyddar gyfer Android.", - "de" to "F-Droid ist ein installierbarer Katalog mit Libre SoftwareAndroid-Apps.", - "el" to "Το F-Droid είναι ένας κατάλογος εφαρμογών ελεύθερου λογισμικού", - "en-US" to "F-Droid is an installable catalogue of libre software", - "eo" to "F-Droid estas instalebla katalogo de liberaj aplikaĵoj por Android." + description = + mapOf( + "af" to "F-Droid is 'n installeerbare katalogus van gratis sagteware", + "bo" to "ཨེཕ་རོཌ་ནི་ཨེན་ཀྲོཌ་བབ་སྟེགས་ཀྱི་ཆེད་དུ་FOSS", + "ca" to "F-Droid és un catàleg instal·lable d'aplicacions de software lliure", + "cs" to "F-Droid je instalovatelný katalog softwarových libre", + "cy" to + "Mae F-Droid yn gatalog y gellir ei osod o apiau meddalwedd " + + "rhyddar gyfer Android.", + "de" to "F-Droid ist ein installierbarer Katalog mit Libre SoftwareAndroid-Apps.", + "el" to "Το F-Droid είναι ένας κατάλογος εφαρμογών ελεύθερου λογισμικού", + "en-US" to "F-Droid is an installable catalogue of libre software", + "eo" to "F-Droid estas instalebla katalogo de liberaj aplikaĵoj por Android.", ), - changelog = "https://gitlab.com/fdroid/fdroidclient/raw/HEAD/CHANGELOG.md", - translation = "https://hosted.weblate.org/projects/f-droid/f-droid", - issueTracker = "https://gitlab.com/fdroid/fdroidclient/issues", - sourceCode = "https://gitlab.com/fdroid/fdroidclient", - donate = listOf("https://f-droid.org/donate"), - liberapayID = "27859", - openCollective = "F-Droid-Euro", - flattrID = "343053", - preferredSigner = "43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab", - license = "GPL-3.0-or-later", - webSite = "https://f-droid.org", - icon = mapOf( - LOCALE to FileV2( - name = "/icons/org.fdroid.fdroid.1014050.png", - sha256 = "224a109b2352138c3699760e1683385d0ed50ce526fc7982f8d65757743374bf", - size = 1237, - ), + changelog = "https://gitlab.com/fdroid/fdroidclient/raw/HEAD/CHANGELOG.md", + translation = "https://hosted.weblate.org/projects/f-droid/f-droid", + issueTracker = "https://gitlab.com/fdroid/fdroidclient/issues", + sourceCode = "https://gitlab.com/fdroid/fdroidclient", + donate = listOf("https://f-droid.org/donate"), + liberapayID = "27859", + openCollective = "F-Droid-Euro", + flattrID = "343053", + preferredSigner = "43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab", + license = "GPL-3.0-or-later", + webSite = "https://f-droid.org", + icon = + mapOf( + LOCALE to + FileV2( + name = "/icons/org.fdroid.fdroid.1014050.png", + sha256 = "224a109b2352138c3699760e1683385d0ed50ce526fc7982f8d65757743374bf", + size = 1237, + ) ), - featureGraphic = mapOf( - LOCALE to FileV2( - name = "/org.fdroid.fdroid/en-US/" + - "featureGraphic_PTun9TO4cMFOeiqbvQSrkdcxNUcOFQCymMIaj9UJOAY=.jpg", - sha256 = "424a109b2352138c3699760e1683385d0ed50ce526fc7982f8d65757743374bf", - size = 4237, - ), + featureGraphic = + mapOf( + LOCALE to + FileV2( + name = + "/org.fdroid.fdroid/en-US/" + + "featureGraphic_PTun9TO4cMFOeiqbvQSrkdcxNUcOFQCymMIaj9UJOAY=.jpg", + sha256 = "424a109b2352138c3699760e1683385d0ed50ce526fc7982f8d65757743374bf", + size = 4237, + ) ), - screenshots = Screenshots( - phone = mapOf( - LOCALE to listOf( - FileV2( - name = "/org.fdroid.fdroid/en-US/phoneScreenshots/" + - "screenshot-app-details.png", - sha256 = "424a109b2352138c3699760e1683385d" + - "0ed50ce526fc7982f8d65757743374bf", - size = 4237, - ), - FileV2( - name = "/org.fdroid.fdroid/en-US/phoneScreenshots/" + - "screenshot-dark-details.png", - sha256 = "424a109b2352138c3699760e1683385d" + - "0ed50ce526fc7982f8d65757743374bf", - size = 44287, - ), - FileV2( - name = "/org.fdroid.fdroid/en-US/phoneScreenshots/" + - "screenshot-dark-home.png", - sha256 = "424a109b2352138c3699760e1673385d" + - "0ed50ce526fc7982f8d65757743374bf", - size = 4587, - ), - FileV2( - name = "/org.fdroid.fdroid/en-US/phoneScreenshots/" + - "screenshot-dark-knownvuln.png", - sha256 = "424a109b2352138c3699760e1683385d" + - "0ed50ce526fc7982f8d65757743374bf", - size = 445837, - ), - FileV2( - name = "/org.fdroid.fdroid/en-US/phoneScreenshots/" + - "screenshot-knownvuln.png", - sha256 = "424a109b2352138c4599760e1683385d" + - "0ed50ce526fc7982f8d65757743374bf", - size = 4287, - ), - FileV2( - name = "/org.fdroid.fdroid/en-US/phoneScreenshots/" + - "screenshot-search.png", - sha256 = "424a109b2352138c3694760e1683385d" + - "0ed50ce526fc7982f8d65757743374bf", - size = 2857, - ), - FileV2( - name = "/org.fdroid.fdroid/en-US/phoneScreenshots/" + - "screenshot-updates.png", - sha256 = "424a109b2352138c3699750e1683385d" + - "0ed50ce526fc7982f8d65757743374bf", - size = 485287, - ), - ), - ), + screenshots = + Screenshots( + phone = + mapOf( + LOCALE to + listOf( + FileV2( + name = + "/org.fdroid.fdroid/en-US/phoneScreenshots/" + + "screenshot-app-details.png", + sha256 = + "424a109b2352138c3699760e1683385d" + "0ed50ce526fc7982f8d65757743374bf", + size = 4237, + ), + FileV2( + name = + "/org.fdroid.fdroid/en-US/phoneScreenshots/" + + "screenshot-dark-details.png", + sha256 = + "424a109b2352138c3699760e1683385d" + "0ed50ce526fc7982f8d65757743374bf", + size = 44287, + ), + FileV2( + name = + "/org.fdroid.fdroid/en-US/phoneScreenshots/" + "screenshot-dark-home.png", + sha256 = + "424a109b2352138c3699760e1673385d" + "0ed50ce526fc7982f8d65757743374bf", + size = 4587, + ), + FileV2( + name = + "/org.fdroid.fdroid/en-US/phoneScreenshots/" + + "screenshot-dark-knownvuln.png", + sha256 = + "424a109b2352138c3699760e1683385d" + "0ed50ce526fc7982f8d65757743374bf", + size = 445837, + ), + FileV2( + name = + "/org.fdroid.fdroid/en-US/phoneScreenshots/" + "screenshot-knownvuln.png", + sha256 = + "424a109b2352138c4599760e1683385d" + "0ed50ce526fc7982f8d65757743374bf", + size = 4287, + ), + FileV2( + name = + "/org.fdroid.fdroid/en-US/phoneScreenshots/" + "screenshot-search.png", + sha256 = + "424a109b2352138c3694760e1683385d" + "0ed50ce526fc7982f8d65757743374bf", + size = 2857, + ), + FileV2( + name = + "/org.fdroid.fdroid/en-US/phoneScreenshots/" + "screenshot-updates.png", + sha256 = + "424a109b2352138c3699750e1683385d" + "0ed50ce526fc7982f8d65757743374bf", + size = 485287, + ), + ) + ) ), - lastUpdated = 1643250075000, + lastUpdated = 1643250075000, ), - versions = mapOf( - version2_1.file.sha256 to version2_1, - version2_2.file.sha256 to version2_2, - version2_3.file.sha256 to version2_3, - version2_4.file.sha256 to version2_4, - version2_5.file.sha256 to version2_5, + versions = + mapOf( + version2_1.file.sha256 to version2_1, + version2_2.file.sha256 to version2_2, + version2_3.file.sha256 to version2_3, + version2_4.file.sha256 to version2_4, + version2_5.file.sha256 to version2_5, ), ) - val app2Compat = app2.v1compat().copy( - versions = mapOf( + val app2Compat = + app2 + .v1compat() + .copy( + versions = + mapOf( version2_1.file.sha256 to version2_1Compat, version2_2.file.sha256 to version2_2Compat, version2_3.file.sha256 to version2_3Compat, version2_4.file.sha256 to version2_4Compat, version2_5.file.sha256 to version2_5Compat, - ), - ) - val packages = mapOf(PACKAGE_NAME_1 to app1, PACKAGE_NAME_2 to app2) + ) + ) + val packages = mapOf(PACKAGE_NAME_1 to app1, PACKAGE_NAME_2 to app2) - val index = IndexV2( - repo = repo, - packages = packages, - ) - val indexCompat = index.copy( - repo = repoCompat, - packages = mapOf( - PACKAGE_NAME_1 to app1Compat, - PACKAGE_NAME_2 to app2Compat, - ), + val index = IndexV2(repo = repo, packages = packages) + val indexCompat = + index.copy( + repo = repoCompat, + packages = mapOf(PACKAGE_NAME_1 to app1Compat, PACKAGE_NAME_2 to app2Compat), ) } object TestDataMaxV2 { - val repo = RepoV2( - timestamp = Long.MAX_VALUE, - name = mapOf(LOCALE to "MaxV1", "de_DE" to "MaximumV1"), - icon = mapOf( - LOCALE to FileV2( - name = "/icons/max-v1.png", - sha256 = "14758e480ae76297c7947f107db9ea03d2933c9d5c110d02046977cf78d43def", - size = Long.MAX_VALUE, + val repo = + RepoV2( + timestamp = Long.MAX_VALUE, + name = mapOf(LOCALE to "MaxV1", "de_DE" to "MaximumV1"), + icon = + mapOf( + LOCALE to + FileV2( + name = "/icons/max-v1.png", + sha256 = "14758e480ae76297c7947f107db9ea03d2933c9d5c110d02046977cf78d43def", + size = Long.MAX_VALUE, ), - "de_DE" to FileV2( - name = "/icons/maximal-v1.png", - sha256 = "12758e480ae76297c7947f107db9ea03d2933c9d5c110d02046977cf78d43def", - size = Long.MAX_VALUE - 1, + "de_DE" to + FileV2( + name = "/icons/maximal-v1.png", + sha256 = "12758e480ae76297c7947f107db9ea03d2933c9d5c110d02046977cf78d43def", + size = Long.MAX_VALUE - 1, ), ), - address = "https://max-v1.org/repo", - webBaseUrl = "https://www.max-v1.org", - description = mapOf( - LOCALE to "This is a repo with maximum data.", - "de" to "Dies ist ein Repo mit maximaler Datendichte.", + address = "https://max-v1.org/repo", + webBaseUrl = "https://www.max-v1.org", + description = + mapOf( + LOCALE to "This is a repo with maximum data.", + "de" to "Dies ist ein Repo mit maximaler Datendichte.", ), - mirrors = listOf( - MirrorV2("https://max-v1.com", "us"), - MirrorV2("https://max-v1.org/repo", "nl"), - ), - antiFeatures = mapOf( - "VeryBad" to AntiFeatureV2( - name = emptyMap(), - ), - "Dont,Show,This" to AntiFeatureV2( - name = emptyMap(), - ), - "NotNice" to AntiFeatureV2( - name = emptyMap(), - ), - "AntiFeature" to AntiFeatureV2( - icon = mapOf(LOCALE to FileV2( - name = "/icons/antifeature.png", - sha256 = "24758e480ae66297c7947f107db9ea03d2933c9d5c110d02046977cf78d43def", - size = 254916, - )), - name = mapOf(LOCALE to "AntiFeature"), - description = mapOf(LOCALE to "A bad anti-feature, we can't show to users."), - ), - "NonFreeNet" to AntiFeatureV2( - name = mapOf(LOCALE to "NonFreeNet"), - ), - "AddOne" to AntiFeatureV2( - name = mapOf(LOCALE to "AddOne anti feature"), - description = mapOf(LOCALE to "A bad anti-feature, that was added in an update."), - ), - ), - categories = mapOf( - "Cat3" to CategoryV2( - name = mapOf(LOCALE to "Cat3"), - icon = mapOf(LOCALE to FileV2( - name = "/icons/cat3.png", - sha256 = "54758e480ae76297c7947f107db9ea03d2933c9d5c110d02046977cf78d43def", - size = Long.MAX_VALUE, - )), - description = mapOf(LOCALE to "Cat3"), - ), - "Cat2" to CategoryV2( - name = mapOf(LOCALE to "Cat3"), - icon = mapOf(LOCALE to FileV2( - name = "/icons/cat2.png", - sha256 = "54758e480ae76297c7947f107db9ea03d2933c9d5c110d02046977cf78d43def", - size = Long.MAX_VALUE, - )), - description = mapOf(LOCALE to "Cat3"), - ), - "Cat1" to CategoryV2( - name = mapOf(LOCALE to "Cat1"), - icon = mapOf(LOCALE to FileV2( - name = "/icons/cat1.png", - sha256 = "54758e480ae76297c7947f107db9ea03d2933c9d5c110d02046977cf78d43def", - size = Long.MAX_VALUE, - )), - description = mapOf(LOCALE to "Cat1"), - ), - "NoMoreSystem" to CategoryV2( - name = emptyMap(), - ), - "OneMore" to CategoryV2( - name = emptyMap(), - ), - ), - releaseChannels = mapOf( - "Alpha" to ReleaseChannelV2( - name = mapOf("de" to "channel name alpha"), - description = mapOf("de-DE" to "channel desc alpha"), - ), - "Beta" to ReleaseChannelV2( - name = mapOf(LOCALE to "channel name"), - description = mapOf(LOCALE to "channel desc"), - ), - ) - ) - val repoCompat = repo.v1compat().copy( - description = mapOf(LOCALE to "This is a repo with medium data."), - categories = mapOf( - "Cat1" to CategoryV2(name = emptyMap()), - "System" to CategoryV2(name = emptyMap()), - ), - antiFeatures = mapOf( - "AntiFeature" to AntiFeatureV2(name = emptyMap()) - ), - ) - - const val PACKAGE_NAME_1 = TestDataMidV2.PACKAGE_NAME_1 - const val PACKAGE_NAME_2 = TestDataMidV2.PACKAGE_NAME_2 - const val PACKAGE_NAME_3 = "Haoheiseeshai2que2Che0ooSa6aikeemoo2ap9Aequoh4ju5chooYuPhiev8moo" + - "dahlonu2oht5Eikahvushapeum5aefo6xig4aghahyaaNuezoo4eexee1Goo5Ung" + - "ohGha6quaeghe8uCh9iex9Oowa9aiyohzoo2ij5miifiegaeth8nie9jae6raeph" + - "oowishoor1Ien5vahGhahm7eidaiy2AeCaej9iexahyooshu2ic9tea1ool8tu4Y" - - val version2_2 = TestDataMidV2.version2_2.copy( - manifest = TestDataMidV2.version2_2.manifest.copy( - usesPermission = emptyList(), - usesPermissionSdk23 = emptyList(), - nativecode = emptyList(), - features = emptyList(), - ), - antiFeatures = mapOf( - "AddOne" to mapOf(LOCALE to "was added this update"), - ), - releaseChannels = listOf(RELEASE_CHANNEL_BETA), - ) - val version2_3 = TestDataMidV2.version2_3.copy( - manifest = TestDataMidV2.version2_3.manifest.copy( - versionCode = 1014003, - versionName = "1.14-alpha3", - usesSdk = UsesSdkV2( - minSdkVersion = 22, - targetSdkVersion = 25, - ), - signer = SignerV2( - sha256 = listOf("43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab"), - ), - usesPermission = listOf( - PermissionV2(name = "android.permission.ACCESS_MEDIA"), - PermissionV2(name = "android.permission.CHANGE_WIFI_STATE", maxSdkVersion = 32), - PermissionV2(name = "android.permission.READ_EXTERNAL_STORAGE"), - PermissionV2(name = "android.permission.WRITE_EXTERNAL_STORAGE"), - PermissionV2(name = "android.permission.NFC"), - PermissionV2(name = "android.permission.USB_PERMISSION", maxSdkVersion = 22), - PermissionV2(name = "android.permission.WAKE_LOCK"), - PermissionV2(name = "android.permission.READ_MY_ASS"), - PermissionV2(name = "android.permission.FOREGROUND_SERVICE"), - ), - usesPermissionSdk23 = listOf( - PermissionV2(name = "android.permission.ACCESS_FINE_LOCATION"), - PermissionV2(name = "android.permission.ACCESS_COARSE_LOCATION", maxSdkVersion = 3), - ), - ), - file = TestDataMidV2.version2_3.file.copy( - size = 8276110, - ), - src = TestDataMidV2.version2_3.src?.copy( - name = "/org.fdroid.fdroid_1014003_src.tar.gz", - ), - antiFeatures = mapOf( - "AddOne" to mapOf(LOCALE to "was added this update"), - ), - whatsNew = mapOf( - "ch" to "This is new!", - "de" to "das ist neu", - "en-US" to "this is new", - ), - ) - val version2_4 = TestDataMidV2.version2_4.copy( - antiFeatures = mapOf( - "AddOne" to emptyMap(), - ), - ) - val version2_5 = TestDataMidV2.version2_5.copy( - antiFeatures = mapOf( - "AddOne" to mapOf(LOCALE to "was added this update"), - ), - ) - val app2 = TestDataMidV2.app2.copy( - metadata = TestDataMidV2.app2.metadata.copy( - categories = listOf("NoMoreSystem", "OneMore"), - summary = mapOf( - LOCALE to "new summary in en-US", - "ch" to "new summary", - "de" to "Der App-Store, der Freiheit und Privatsphäre respektiert", - ), - description = mapOf( - LOCALE to "F-Droid is an installable catalogue of libre software", - "ch" to "new desc", - ), - webSite = "https://fdroid.org", - name = mapOf( - LOCALE to "F-DroidX", - "ch" to "new name", - ), - icon = mapOf( - LOCALE to FileV2( - name = "/org.fdroid.fdroid/en-US/new icon", - sha256 = "324a109b2352138c3699760e1683385d1ed50ce526fc7982f8d65757743374ba", - size = 2233, - ) - ), - screenshots = TestDataMidV2.app2.metadata.screenshots?.copy( - phone = mapOf( - LOCALE to listOf( - FileV2( - name = "/org.fdroid.fdroid/en-US/phoneScreenshots/" + - "screenshot-app-details.png", - sha256 = "424a109b2352138c3699760e1683385d" + - "0ed50ce526fc7982f8d65757743374bf", - size = 4237, - ), - FileV2( - name = "/org.fdroid.fdroid/en-US/phoneScreenshots/" + - "screenshot-dark-details.png", - sha256 = "424a109b2352138c3699760e1683385d" + - "0ed50ce526fc7982f8d65757743374bf", - size = 44287, - ), - FileV2( - name = "/org.fdroid.fdroid/en-US/phoneScreenshots/" + - "screenshot-dark-home.png", - sha256 = "424a109b2352138c3699760e1673385d" + - "0ed50ce526fc7982f8d65757743374bf", - size = 4587, - ), - FileV2( - name = "/org.fdroid.fdroid/en-US/phoneScreenshots/" + - "screenshot-search.png", - sha256 = "424a109b2352138c3694760e1683385d" + - "0ed50ce526fc7982f8d65757743374bf", - size = 2857, - ), - FileV2( - name = "/org.fdroid.fdroid/en-US/phoneScreenshots/" + - "screenshot-updates.png", - sha256 = "424a109b2352138c3699750e1683385d" + - "0ed50ce526fc7982f8d65757743374bf", - size = 485286, - ), - ), - ), - tenInch = mapOf( - "ch" to listOf( - FileV2( - name = "/org.fdroid.fdroid/ch/tenInchScreenshots/new screenshots", - sha256 = "54758e380ae76297c7947f107db9ea03" + - "d2933c9d5c110d02046977cf78d43def", - size = Long.MIN_VALUE, - ) - ), - ), - wear = null, - ), - ), - versions = mapOf( - // remove one and replace two - version2_2.file.sha256 to version2_2, - version2_3.file.sha256 to version2_3, - version2_4.file.sha256 to version2_4, - version2_5.file.sha256 to version2_5, - ), - ) - private val app2CompatPre = app2.v1compat(true) - val app2Compat = app2CompatPre.copy( - // due to locale overrides - metadata = app2CompatPre.metadata.copy( - summary = mapOf(LOCALE to "new summary"), - description = mapOf(LOCALE to "new description"), - ), - versions = mapOf( - // remove one and replace two - version2_2.file.sha256 to version2_2.v1compat(), - version2_3.file.sha256 to version2_3.v1compat(), - version2_4.file.sha256 to version2_4.v1compat(), - version2_5.file.sha256 to version2_5.v1compat(), - ), - ) - - val version3_1 = PackageVersionV2( - added = 1643250075000, - file = FileV1( - name = "/org.fdroid.fdroid_1014050.apk", - sha256 = "8c89ce2f42f4a89af8ca6e1ea220f9dfdee220724d8a9cc067d510ac6f3e0d06", - size = 8165518, - ), - src = FileV2( - name = "/org.fdroid.fdroid_1014050_src.tar.gz", - sha256 = "8c89ce2f42f4a89af8ca6e1ea220f9dfdee220724d8a9cc067d510ac6f3e0d06", - size = 8165518, - ), - manifest = ManifestV2( - versionName = "1.14", - versionCode = 1014050, - usesSdk = UsesSdkV2( - minSdkVersion = 22, - targetSdkVersion = 25, - ), - maxSdkVersion = Int.MAX_VALUE, - signer = SignerV2( - sha256 = listOf( - "43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab", - "33238d512c1e3eb2d6569f4a3bfbf5523418b22e0a3ed1552770abb9a9c9ccvb", - ), - ), - usesPermission = listOf( - PermissionV2(name = "android.permission.INTERNET"), - PermissionV2(name = "android.permission.ACCESS_NETWORK_STATE"), - PermissionV2(name = "android.permission.ACCESS_WIFI_STATE"), - PermissionV2(name = "android.permission.CHANGE_WIFI_MULTICAST_STATE"), - PermissionV2(name = "android.permission.CHANGE_NETWORK_STATE"), - PermissionV2(name = "android.permission.CHANGE_WIFI_STATE"), - PermissionV2(name = "android.permission.BLUETOOTH"), - PermissionV2(name = "android.permission.BLUETOOTH_ADMIN"), - PermissionV2(name = "android.permission.RECEIVE_BOOT_COMPLETED"), - PermissionV2(name = "android.permission.READ_EXTERNAL_STORAGE"), - PermissionV2(name = "android.permission.WRITE_EXTERNAL_STORAGE"), - PermissionV2(name = "android.permission.WRITE_SETTINGS"), - PermissionV2(name = "android.permission.NFC"), - PermissionV2(name = "android.permission.USB_PERMISSION", maxSdkVersion = 22), - PermissionV2(name = "android.permission.WAKE_LOCK"), - PermissionV2(name = "android.permission.FOREGROUND_SERVICE"), - ), - usesPermissionSdk23 = listOf( - PermissionV2(name = "android.permission.ACCESS_COARSE_LOCATION"), - PermissionV2( - name = "android.permission.USB_PERMISSION", - maxSdkVersion = Int.MAX_VALUE, - ), - ), - nativecode = listOf("x86", "x86_64"), - features = listOf(FeatureV2("feature"), FeatureV2("feature2")), - ), - releaseChannels = listOf("Beta", "Alpha"), - antiFeatures = mapOf( - "AntiFeature" to emptyMap(), - "NonFreeNet" to emptyMap(), - "NotNice" to emptyMap(), - "VeryBad" to emptyMap(), - "Dont,Show,This" to emptyMap(), - "anti-feature" to mapOf(LOCALE to "bla", "de" to "blubb"), - "anti-feature2" to mapOf("de" to "blabla"), - ), - ) - val app3 = PackageV2( - metadata = MetadataV2( - name = mapOf( - LOCALE to "App3", - "en" to "en ", - "zh-CN" to "自由软件仓库", - ), - summary = mapOf( - LOCALE to "App3 summary", - "en" to "en ", - "ja" to "这个仓库中的", - ), - description = mapOf( - LOCALE to "App3 description", - "en" to "en ", - "ko-KR" to "切始终是从", - ), - added = 1234567890, - lastUpdated = Long.MAX_VALUE, - webSite = "http://min1.test.org", - changelog = "changeLog3", - license = "GPLv3", - sourceCode = "source code3", - issueTracker = "tracker3", - translation = "translation3", - preferredSigner = "43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab", - categories = listOf("Cat1", "Cat2", "Cat3"), - authorName = "App3 author", - authorEmail = "email", - authorWebSite = "website", - authorPhone = "phone", - donate = listOf("donate"), - liberapayID = "liberapayID", - liberapay = "liberapay", - openCollective = "openCollective", - bitcoin = "bitcoin", - litecoin = "litecoin", - flattrID = "flattrID", - icon = mapOf( - "en" to FileV2( - name = "/Haoheiseeshai2que2Che0ooSa6aikeemoo2ap9Aequoh4ju5chooYuPhiev8moodahl" + - "onu2oht5Eikahvushapeum5aefo6xig4aghahyaaNuezoo4eexee1Goo5UngohGha6quaegh" + - "e8uCh9iex9Oowa9aiyohzoo2ij5miifiegaeth8nie9jae6raephoowishoor1Ien5vahGha" + - "hm7eidaiy2AeCaej9iexahyooshu2ic9tea1ool8tu4Y/en/en ", - sha256 = "32758e380aeg6297c7947f107db9ea03d2933c9d5c110d02046977cf78d43def", - size = 2253245, - ), - ), - featureGraphic = mapOf( - "en" to FileV2( - name = "/Haoheiseeshai2que2Che0ooSa6aikeemoo2ap9Aequoh4ju5chooYuPhiev8moodahl" + - "onu2oht5Eikahvushapeum5aefo6xig4aghahyaaNuezoo4eexee1Goo5UngohGha6quaegh" + - "e8uCh9iex9Oowa9aiyohzoo2ij5miifiegaeth8nie9jae6raephoowishoor1Ien5vahGha" + - "hm7eidaiy2AeCaej9iexahyooshu2ic9tea1ool8tu4Y/en/en ", - sha256 = "54758e380ae762f7c7947f107db9ea03d2933c9d5c110d02046977cf78d43def", - size = 32453245, - ), - ), - promoGraphic = mapOf( - "en" to FileV2( - name = "/Haoheiseeshai2que2Che0ooSa6aikeemoo2ap9Aequoh4ju5chooYuPhiev8moodahl" + - "onu2oht5Eikahvushapeum5aefo6xig4aghahyaaNuezoo4eexee1Goo5UngohGha6quaegh" + - "e8uCh9iex9Oowa9aiyohzoo2ij5miifiegaeth8nie9jae6raephoowishoor1Ien5vahGha" + - "hm7eidaiy2AeCaej9iexahyooshu2ic9tea1ool8tu4Y/en/en ", - sha256 = "54758e380aee6297c7947f107db9ea03d2933c9d5c110d02046977cf78d43def", - size = 4523325, - ), - ), - tvBanner = mapOf( - "en" to FileV2( - name = "/Haoheiseeshai2que2Che0ooSa6aikeemoo2ap9Aequoh4ju5chooYuPhiev8moodahl" + - "onu2oht5Eikahvushapeum5aefo6xig4aghahyaaNuezoo4eexee1Goo5UngohGha6quaegh" + - "e8uCh9iex9Oowa9aiyohzoo2ij5miifiegaeth8nie9jae6raephoowishoor1Ien5vahGha" + - "hm7eidaiy2AeCaej9iexahyooshu2ic9tea1ool8tu4Y/en/en ", - sha256 = "54758e380aeh6297c7947f107db9ea03d2933c9d5c110d02046977cf78d43def", - size = 32453245, - ), - ), - video = mapOf( - "en" to "en ", - ), - screenshots = Screenshots( - phone = mapOf( - "en" to listOf( - FileV2( - name = "/Haoheiseeshai2que2Che0ooSa6aikeemoo2ap9Aequoh4ju5chooYuPhiev" + - "8moodahlonu2oht5Eikahvushapeum5aefo6xig4aghahyaaNuezoo4eexee1Goo" + - "5UngohGha6quaeghe8uCh9iex9Oowa9aiyohzoo2ij5miifiegaeth8nie9jae6r" + - "aephoowishoor1Ien5vahGhahm7eidaiy2AeCaej9iexahyooshu2ic9tea1ool8" + - "tu4Y/en/phoneScreenshots/en phoneScreenshots", - sha256 = "54758e380aee6297c7947f107db9ea03" + - "d2933c9d5c110d02046977cf78d43def", - size = 0, - ), - FileV2( - name = "/Haoheiseeshai2que2Che0ooSa6aikeemoo2ap9Aequoh4ju5chooYuPhiev" + - "8moodahlonu2oht5Eikahvushapeum5aefo6xig4aghahyaaNuezoo4eexee1Goo" + - "5UngohGha6quaeghe8uCh9iex9Oowa9aiyohzoo2ij5miifiegaeth8nie9jae6r" + - "aephoowishoor1Ien5vahGhahm7eidaiy2AeCaej9iexahyooshu2ic9tea1ool8" + - "tu4Y/en/phoneScreenshots/en phoneScreenshots2", - sha256 = "54758e380aee6297c7947f107db9ea03" + - "d2933c9d5c110d02046977cf78d43def", - size = 0, - ), + mirrors = + listOf(MirrorV2("https://max-v1.com", "us"), MirrorV2("https://max-v1.org/repo", "nl")), + antiFeatures = + mapOf( + "VeryBad" to AntiFeatureV2(name = emptyMap()), + "Dont,Show,This" to AntiFeatureV2(name = emptyMap()), + "NotNice" to AntiFeatureV2(name = emptyMap()), + "AntiFeature" to + AntiFeatureV2( + icon = + mapOf( + LOCALE to + FileV2( + name = "/icons/antifeature.png", + sha256 = "24758e480ae66297c7947f107db9ea03d2933c9d5c110d02046977cf78d43def", + size = 254916, ) ), - sevenInch = mapOf( - "en" to listOf( - FileV2( - name = "/Haoheiseeshai2que2Che0ooSa6aikeemoo2ap9Aequoh4ju5chooYuPhiev" + - "8moodahlonu2oht5Eikahvushapeum5aefo6xig4aghahyaaNuezoo4eexee1Goo" + - "5UngohGha6quaeghe8uCh9iex9Oowa9aiyohzoo2ij5miifiegaeth8nie9jae6r" + - "aephoowishoor1Ien5vahGhahm7eidaiy2AeCaej9iexahyooshu2ic9tea1ool8" + - "tu4Y/en/sevenInchScreenshots/en sevenInchScreenshots", - sha256 = "54758e380aee6297c7947f107db9ea03" + - "d2933c9d5c110d02046977cf78d43def", - size = 0, - ), - FileV2( - name = "/Haoheiseeshai2que2Che0ooSa6aikeemoo2ap9Aequoh4ju5chooYuPhiev" + - "8moodahlonu2oht5Eikahvushapeum5aefo6xig4aghahyaaNuezoo4eexee1Goo" + - "5UngohGha6quaeghe8uCh9iex9Oowa9aiyohzoo2ij5miifiegaeth8nie9jae6r" + - "aephoowishoor1Ien5vahGhahm7eidaiy2AeCaej9iexahyooshu2ic9tea1ool8" + - "tu4Y/en/sevenInchScreenshots/en sevenInchScreenshots2", - sha256 = "54758e380aee6297c7947f107db9ea03" + - "d2933c9d5c110d02046977cf78d43def", - size = 0, - ), - ), - ), - tenInch = mapOf( - "en" to listOf( - FileV2( - name = "/Haoheiseeshai2que2Che0ooSa6aikeemoo2ap9Aequoh4ju5chooYuPhiev" + - "8moodahlonu2oht5Eikahvushapeum5aefo6xig4aghahyaaNuezoo4eexee1Goo" + - "5UngohGha6quaeghe8uCh9iex9Oowa9aiyohzoo2ij5miifiegaeth8nie9jae6r" + - "aephoowishoor1Ien5vahGhahm7eidaiy2AeCaej9iexahyooshu2ic9tea1ool8" + - "tu4Y/en/tenInchScreenshots/en tenInchScreenshots", - sha256 = "54758e380aee6297c7947f107db9ea03" + - "d2933c9d5c110d02046977cf78d43def", - size = 0, - ), - FileV2( - name = "/Haoheiseeshai2que2Che0ooSa6aikeemoo2ap9Aequoh4ju5chooYuPhiev" + - "8moodahlonu2oht5Eikahvushapeum5aefo6xig4aghahyaaNuezoo4eexee1Goo" + - "5UngohGha6quaeghe8uCh9iex9Oowa9aiyohzoo2ij5miifiegaeth8nie9jae6r" + - "aephoowishoor1Ien5vahGhahm7eidaiy2AeCaej9iexahyooshu2ic9tea1ool8" + - "tu4Y/en/tenInchScreenshots/en tenInchScreenshots2", - sha256 = "54758e380aee6297c7947f107db9ea03" + - "d2933c9d5c110d02046977cf78d43def", - size = 0, - ), - ), - ), - wear = mapOf( - "en" to listOf( - FileV2( - name = "/Haoheiseeshai2que2Che0ooSa6aikeemoo2ap9Aequoh4ju5chooYuPhiev" + - "8moodahlonu2oht5Eikahvushapeum5aefo6xig4aghahyaaNuezoo4eexee1Goo" + - "5UngohGha6quaeghe8uCh9iex9Oowa9aiyohzoo2ij5miifiegaeth8nie9jae6r" + - "aephoowishoor1Ien5vahGhahm7eidaiy2AeCaej9iexahyooshu2ic9tea1ool8" + - "tu4Y/en/wearScreenshots/en wearScreenshots", - sha256 = "54758e380aee6297c7947f107db9ea03" + - "d2933c9d5c110d02046977cf78d43def", - size = 0, - ), - FileV2( - name = "/Haoheiseeshai2que2Che0ooSa6aikeemoo2ap9Aequoh4ju5chooYuPhiev" + - "8moodahlonu2oht5Eikahvushapeum5aefo6xig4aghahyaaNuezoo4eexee1Goo" + - "5UngohGha6quaeghe8uCh9iex9Oowa9aiyohzoo2ij5miifiegaeth8nie9jae6r" + - "aephoowishoor1Ien5vahGhahm7eidaiy2AeCaej9iexahyooshu2ic9tea1ool8" + - "tu4Y/en/wearScreenshots/en wearScreenshots2", - sha256 = "54758e380aee6297c7947f107db9ea03" + - "d2933c9d5c110d02046977cf78d43def", - size = 0, - ), - ), - ), - tv = mapOf( - "en" to listOf( - FileV2( - name = "/Haoheiseeshai2que2Che0ooSa6aikeemoo2ap9Aequoh4ju5chooYuPhiev" + - "8moodahlonu2oht5Eikahvushapeum5aefo6xig4aghahyaaNuezoo4eexee1Goo" + - "5UngohGha6quaeghe8uCh9iex9Oowa9aiyohzoo2ij5miifiegaeth8nie9jae6r" + - "aephoowishoor1Ien5vahGhahm7eidaiy2AeCaej9iexahyooshu2ic9tea1ool8" + - "tu4Y/en/tvScreenshots/en tvScreenshots", - sha256 = "54758e380aee6297c7947f107db9ea03" + - "d2933c9d5c110d02046977cf78d43def", - size = 0), - FileV2( - name = "/Haoheiseeshai2que2Che0ooSa6aikeemoo2ap9Aequoh4ju5chooYuPhiev" + - "8moodahlonu2oht5Eikahvushapeum5aefo6xig4aghahyaaNuezoo4eexee1Goo" + - "5UngohGha6quaeghe8uCh9iex9Oowa9aiyohzoo2ij5miifiegaeth8nie9jae6r" + - "aephoowishoor1Ien5vahGhahm7eidaiy2AeCaej9iexahyooshu2ic9tea1ool8" + - "tu4Y/en/tvScreenshots/en tvScreenshots2", - sha256 = "54758e380aee6297c7947f107db9ea03" + - "d2933c9d5c110d02046977cf78d43def", - size = 0), - ), - ), + name = mapOf(LOCALE to "AntiFeature"), + description = mapOf(LOCALE to "A bad anti-feature, we can't show to users."), + ), + "NonFreeNet" to AntiFeatureV2(name = mapOf(LOCALE to "NonFreeNet")), + "AddOne" to + AntiFeatureV2( + name = mapOf(LOCALE to "AddOne anti feature"), + description = mapOf(LOCALE to "A bad anti-feature, that was added in an update."), ), ), - versions = mapOf( - version3_1.file.sha256 to version3_1, + categories = + mapOf( + "Cat3" to + CategoryV2( + name = mapOf(LOCALE to "Cat3"), + icon = + mapOf( + LOCALE to + FileV2( + name = "/icons/cat3.png", + sha256 = "54758e480ae76297c7947f107db9ea03d2933c9d5c110d02046977cf78d43def", + size = Long.MAX_VALUE, + ) + ), + description = mapOf(LOCALE to "Cat3"), + ), + "Cat2" to + CategoryV2( + name = mapOf(LOCALE to "Cat3"), + icon = + mapOf( + LOCALE to + FileV2( + name = "/icons/cat2.png", + sha256 = "54758e480ae76297c7947f107db9ea03d2933c9d5c110d02046977cf78d43def", + size = Long.MAX_VALUE, + ) + ), + description = mapOf(LOCALE to "Cat3"), + ), + "Cat1" to + CategoryV2( + name = mapOf(LOCALE to "Cat1"), + icon = + mapOf( + LOCALE to + FileV2( + name = "/icons/cat1.png", + sha256 = "54758e480ae76297c7947f107db9ea03d2933c9d5c110d02046977cf78d43def", + size = Long.MAX_VALUE, + ) + ), + description = mapOf(LOCALE to "Cat1"), + ), + "NoMoreSystem" to CategoryV2(name = emptyMap()), + "OneMore" to CategoryV2(name = emptyMap()), + ), + releaseChannels = + mapOf( + "Alpha" to + ReleaseChannelV2( + name = mapOf("de" to "channel name alpha"), + description = mapOf("de-DE" to "channel desc alpha"), + ), + "Beta" to + ReleaseChannelV2( + name = mapOf(LOCALE to "channel name"), + description = mapOf(LOCALE to "channel desc"), + ), ), ) - val app3Compat = app3.v1compat(true).copy( - versions = mapOf( - version3_1.file.sha256 to version3_1.v1compat(), + val repoCompat = + repo + .v1compat() + .copy( + description = mapOf(LOCALE to "This is a repo with medium data."), + categories = + mapOf("Cat1" to CategoryV2(name = emptyMap()), "System" to CategoryV2(name = emptyMap())), + antiFeatures = mapOf("AntiFeature" to AntiFeatureV2(name = emptyMap())), + ) + + const val PACKAGE_NAME_1 = TestDataMidV2.PACKAGE_NAME_1 + const val PACKAGE_NAME_2 = TestDataMidV2.PACKAGE_NAME_2 + const val PACKAGE_NAME_3 = + "Haoheiseeshai2que2Che0ooSa6aikeemoo2ap9Aequoh4ju5chooYuPhiev8moo" + + "dahlonu2oht5Eikahvushapeum5aefo6xig4aghahyaaNuezoo4eexee1Goo5Ung" + + "ohGha6quaeghe8uCh9iex9Oowa9aiyohzoo2ij5miifiegaeth8nie9jae6raeph" + + "oowishoor1Ien5vahGhahm7eidaiy2AeCaej9iexahyooshu2ic9tea1ool8tu4Y" + + val version2_2 = + TestDataMidV2.version2_2.copy( + manifest = + TestDataMidV2.version2_2.manifest.copy( + usesPermission = emptyList(), + usesPermissionSdk23 = emptyList(), + nativecode = emptyList(), + features = emptyList(), + ), + antiFeatures = mapOf("AddOne" to mapOf(LOCALE to "was added this update")), + releaseChannels = listOf(RELEASE_CHANNEL_BETA), + ) + val version2_3 = + TestDataMidV2.version2_3.copy( + manifest = + TestDataMidV2.version2_3.manifest.copy( + versionCode = 1014003, + versionName = "1.14-alpha3", + usesSdk = UsesSdkV2(minSdkVersion = 22, targetSdkVersion = 25), + signer = + SignerV2( + sha256 = listOf("43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab") + ), + usesPermission = + listOf( + PermissionV2(name = "android.permission.ACCESS_MEDIA"), + PermissionV2(name = "android.permission.CHANGE_WIFI_STATE", maxSdkVersion = 32), + PermissionV2(name = "android.permission.READ_EXTERNAL_STORAGE"), + PermissionV2(name = "android.permission.WRITE_EXTERNAL_STORAGE"), + PermissionV2(name = "android.permission.NFC"), + PermissionV2(name = "android.permission.USB_PERMISSION", maxSdkVersion = 22), + PermissionV2(name = "android.permission.WAKE_LOCK"), + PermissionV2(name = "android.permission.READ_MY_ASS"), + PermissionV2(name = "android.permission.FOREGROUND_SERVICE"), + ), + usesPermissionSdk23 = + listOf( + PermissionV2(name = "android.permission.ACCESS_FINE_LOCATION"), + PermissionV2(name = "android.permission.ACCESS_COARSE_LOCATION", maxSdkVersion = 3), + ), + ), + file = TestDataMidV2.version2_3.file.copy(size = 8276110), + src = TestDataMidV2.version2_3.src?.copy(name = "/org.fdroid.fdroid_1014003_src.tar.gz"), + antiFeatures = mapOf("AddOne" to mapOf(LOCALE to "was added this update")), + whatsNew = mapOf("ch" to "This is new!", "de" to "das ist neu", "en-US" to "this is new"), + ) + val version2_4 = TestDataMidV2.version2_4.copy(antiFeatures = mapOf("AddOne" to emptyMap())) + val version2_5 = + TestDataMidV2.version2_5.copy( + antiFeatures = mapOf("AddOne" to mapOf(LOCALE to "was added this update")) + ) + val app2 = + TestDataMidV2.app2.copy( + metadata = + TestDataMidV2.app2.metadata.copy( + categories = listOf("NoMoreSystem", "OneMore"), + summary = + mapOf( + LOCALE to "new summary in en-US", + "ch" to "new summary", + "de" to "Der App-Store, der Freiheit und Privatsphäre respektiert", + ), + description = + mapOf( + LOCALE to "F-Droid is an installable catalogue of libre software", + "ch" to "new desc", + ), + webSite = "https://fdroid.org", + name = mapOf(LOCALE to "F-DroidX", "ch" to "new name"), + icon = + mapOf( + LOCALE to + FileV2( + name = "/org.fdroid.fdroid/en-US/new icon", + sha256 = "324a109b2352138c3699760e1683385d1ed50ce526fc7982f8d65757743374ba", + size = 2233, + ) + ), + screenshots = + TestDataMidV2.app2.metadata.screenshots?.copy( + phone = + mapOf( + LOCALE to + listOf( + FileV2( + name = + "/org.fdroid.fdroid/en-US/phoneScreenshots/" + + "screenshot-app-details.png", + sha256 = + "424a109b2352138c3699760e1683385d" + "0ed50ce526fc7982f8d65757743374bf", + size = 4237, + ), + FileV2( + name = + "/org.fdroid.fdroid/en-US/phoneScreenshots/" + + "screenshot-dark-details.png", + sha256 = + "424a109b2352138c3699760e1683385d" + "0ed50ce526fc7982f8d65757743374bf", + size = 44287, + ), + FileV2( + name = + "/org.fdroid.fdroid/en-US/phoneScreenshots/" + "screenshot-dark-home.png", + sha256 = + "424a109b2352138c3699760e1673385d" + "0ed50ce526fc7982f8d65757743374bf", + size = 4587, + ), + FileV2( + name = + "/org.fdroid.fdroid/en-US/phoneScreenshots/" + "screenshot-search.png", + sha256 = + "424a109b2352138c3694760e1683385d" + "0ed50ce526fc7982f8d65757743374bf", + size = 2857, + ), + FileV2( + name = + "/org.fdroid.fdroid/en-US/phoneScreenshots/" + "screenshot-updates.png", + sha256 = + "424a109b2352138c3699750e1683385d" + "0ed50ce526fc7982f8d65757743374bf", + size = 485286, + ), + ) + ), + tenInch = + mapOf( + "ch" to + listOf( + FileV2( + name = "/org.fdroid.fdroid/ch/tenInchScreenshots/new screenshots", + sha256 = + "54758e380ae76297c7947f107db9ea03" + "d2933c9d5c110d02046977cf78d43def", + size = Long.MIN_VALUE, + ) + ) + ), + wear = null, + ), + ), + versions = + mapOf( + // remove one and replace two + version2_2.file.sha256 to version2_2, + version2_3.file.sha256 to version2_3, + version2_4.file.sha256 to version2_4, + version2_5.file.sha256 to version2_5, + ), + ) + private val app2CompatPre = app2.v1compat(true) + val app2Compat = + app2CompatPre.copy( + // due to locale overrides + metadata = + app2CompatPre.metadata.copy( + summary = mapOf(LOCALE to "new summary"), + description = mapOf(LOCALE to "new description"), + ), + versions = + mapOf( + // remove one and replace two + version2_2.file.sha256 to version2_2.v1compat(), + version2_3.file.sha256 to version2_3.v1compat(), + version2_4.file.sha256 to version2_4.v1compat(), + version2_5.file.sha256 to version2_5.v1compat(), ), ) - val index = IndexV2( - repo = repo, - packages = mapOf( - TestDataMidV2.PACKAGE_NAME_1 to TestDataMidV2.app1, - TestDataMidV2.PACKAGE_NAME_2 to app2, - PACKAGE_NAME_3 to app3, + val version3_1 = + PackageVersionV2( + added = 1643250075000, + file = + FileV1( + name = "/org.fdroid.fdroid_1014050.apk", + sha256 = "8c89ce2f42f4a89af8ca6e1ea220f9dfdee220724d8a9cc067d510ac6f3e0d06", + size = 8165518, + ), + src = + FileV2( + name = "/org.fdroid.fdroid_1014050_src.tar.gz", + sha256 = "8c89ce2f42f4a89af8ca6e1ea220f9dfdee220724d8a9cc067d510ac6f3e0d06", + size = 8165518, + ), + manifest = + ManifestV2( + versionName = "1.14", + versionCode = 1014050, + usesSdk = UsesSdkV2(minSdkVersion = 22, targetSdkVersion = 25), + maxSdkVersion = Int.MAX_VALUE, + signer = + SignerV2( + sha256 = + listOf( + "43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab", + "33238d512c1e3eb2d6569f4a3bfbf5523418b22e0a3ed1552770abb9a9c9ccvb", + ) + ), + usesPermission = + listOf( + PermissionV2(name = "android.permission.INTERNET"), + PermissionV2(name = "android.permission.ACCESS_NETWORK_STATE"), + PermissionV2(name = "android.permission.ACCESS_WIFI_STATE"), + PermissionV2(name = "android.permission.CHANGE_WIFI_MULTICAST_STATE"), + PermissionV2(name = "android.permission.CHANGE_NETWORK_STATE"), + PermissionV2(name = "android.permission.CHANGE_WIFI_STATE"), + PermissionV2(name = "android.permission.BLUETOOTH"), + PermissionV2(name = "android.permission.BLUETOOTH_ADMIN"), + PermissionV2(name = "android.permission.RECEIVE_BOOT_COMPLETED"), + PermissionV2(name = "android.permission.READ_EXTERNAL_STORAGE"), + PermissionV2(name = "android.permission.WRITE_EXTERNAL_STORAGE"), + PermissionV2(name = "android.permission.WRITE_SETTINGS"), + PermissionV2(name = "android.permission.NFC"), + PermissionV2(name = "android.permission.USB_PERMISSION", maxSdkVersion = 22), + PermissionV2(name = "android.permission.WAKE_LOCK"), + PermissionV2(name = "android.permission.FOREGROUND_SERVICE"), + ), + usesPermissionSdk23 = + listOf( + PermissionV2(name = "android.permission.ACCESS_COARSE_LOCATION"), + PermissionV2( + name = "android.permission.USB_PERMISSION", + maxSdkVersion = Int.MAX_VALUE, + ), + ), + nativecode = listOf("x86", "x86_64"), + features = listOf(FeatureV2("feature"), FeatureV2("feature2")), + ), + releaseChannels = listOf("Beta", "Alpha"), + antiFeatures = + mapOf( + "AntiFeature" to emptyMap(), + "NonFreeNet" to emptyMap(), + "NotNice" to emptyMap(), + "VeryBad" to emptyMap(), + "Dont,Show,This" to emptyMap(), + "anti-feature" to mapOf(LOCALE to "bla", "de" to "blubb"), + "anti-feature2" to mapOf("de" to "blabla"), ), ) - val indexCompat = index.v1compat().copy( - packages = mapOf( + val app3 = + PackageV2( + metadata = + MetadataV2( + name = mapOf(LOCALE to "App3", "en" to "en ", "zh-CN" to "自由软件仓库"), + summary = mapOf(LOCALE to "App3 summary", "en" to "en ", "ja" to "这个仓库中的"), + description = mapOf(LOCALE to "App3 description", "en" to "en ", "ko-KR" to "切始终是从"), + added = 1234567890, + lastUpdated = Long.MAX_VALUE, + webSite = "http://min1.test.org", + changelog = "changeLog3", + license = "GPLv3", + sourceCode = "source code3", + issueTracker = "tracker3", + translation = "translation3", + preferredSigner = "43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab", + categories = listOf("Cat1", "Cat2", "Cat3"), + authorName = "App3 author", + authorEmail = "email", + authorWebSite = "website", + authorPhone = "phone", + donate = listOf("donate"), + liberapayID = "liberapayID", + liberapay = "liberapay", + openCollective = "openCollective", + bitcoin = "bitcoin", + litecoin = "litecoin", + flattrID = "flattrID", + icon = + mapOf( + "en" to + FileV2( + name = + "/Haoheiseeshai2que2Che0ooSa6aikeemoo2ap9Aequoh4ju5chooYuPhiev8moodahl" + + "onu2oht5Eikahvushapeum5aefo6xig4aghahyaaNuezoo4eexee1Goo5UngohGha6quaegh" + + "e8uCh9iex9Oowa9aiyohzoo2ij5miifiegaeth8nie9jae6raephoowishoor1Ien5vahGha" + + "hm7eidaiy2AeCaej9iexahyooshu2ic9tea1ool8tu4Y/en/en ", + sha256 = "32758e380aeg6297c7947f107db9ea03d2933c9d5c110d02046977cf78d43def", + size = 2253245, + ) + ), + featureGraphic = + mapOf( + "en" to + FileV2( + name = + "/Haoheiseeshai2que2Che0ooSa6aikeemoo2ap9Aequoh4ju5chooYuPhiev8moodahl" + + "onu2oht5Eikahvushapeum5aefo6xig4aghahyaaNuezoo4eexee1Goo5UngohGha6quaegh" + + "e8uCh9iex9Oowa9aiyohzoo2ij5miifiegaeth8nie9jae6raephoowishoor1Ien5vahGha" + + "hm7eidaiy2AeCaej9iexahyooshu2ic9tea1ool8tu4Y/en/en ", + sha256 = "54758e380ae762f7c7947f107db9ea03d2933c9d5c110d02046977cf78d43def", + size = 32453245, + ) + ), + promoGraphic = + mapOf( + "en" to + FileV2( + name = + "/Haoheiseeshai2que2Che0ooSa6aikeemoo2ap9Aequoh4ju5chooYuPhiev8moodahl" + + "onu2oht5Eikahvushapeum5aefo6xig4aghahyaaNuezoo4eexee1Goo5UngohGha6quaegh" + + "e8uCh9iex9Oowa9aiyohzoo2ij5miifiegaeth8nie9jae6raephoowishoor1Ien5vahGha" + + "hm7eidaiy2AeCaej9iexahyooshu2ic9tea1ool8tu4Y/en/en ", + sha256 = "54758e380aee6297c7947f107db9ea03d2933c9d5c110d02046977cf78d43def", + size = 4523325, + ) + ), + tvBanner = + mapOf( + "en" to + FileV2( + name = + "/Haoheiseeshai2que2Che0ooSa6aikeemoo2ap9Aequoh4ju5chooYuPhiev8moodahl" + + "onu2oht5Eikahvushapeum5aefo6xig4aghahyaaNuezoo4eexee1Goo5UngohGha6quaegh" + + "e8uCh9iex9Oowa9aiyohzoo2ij5miifiegaeth8nie9jae6raephoowishoor1Ien5vahGha" + + "hm7eidaiy2AeCaej9iexahyooshu2ic9tea1ool8tu4Y/en/en ", + sha256 = "54758e380aeh6297c7947f107db9ea03d2933c9d5c110d02046977cf78d43def", + size = 32453245, + ) + ), + video = mapOf("en" to "en "), + screenshots = + Screenshots( + phone = + mapOf( + "en" to + listOf( + FileV2( + name = + "/Haoheiseeshai2que2Che0ooSa6aikeemoo2ap9Aequoh4ju5chooYuPhiev" + + "8moodahlonu2oht5Eikahvushapeum5aefo6xig4aghahyaaNuezoo4eexee1Goo" + + "5UngohGha6quaeghe8uCh9iex9Oowa9aiyohzoo2ij5miifiegaeth8nie9jae6r" + + "aephoowishoor1Ien5vahGhahm7eidaiy2AeCaej9iexahyooshu2ic9tea1ool8" + + "tu4Y/en/phoneScreenshots/en phoneScreenshots", + sha256 = + "54758e380aee6297c7947f107db9ea03" + "d2933c9d5c110d02046977cf78d43def", + size = 0, + ), + FileV2( + name = + "/Haoheiseeshai2que2Che0ooSa6aikeemoo2ap9Aequoh4ju5chooYuPhiev" + + "8moodahlonu2oht5Eikahvushapeum5aefo6xig4aghahyaaNuezoo4eexee1Goo" + + "5UngohGha6quaeghe8uCh9iex9Oowa9aiyohzoo2ij5miifiegaeth8nie9jae6r" + + "aephoowishoor1Ien5vahGhahm7eidaiy2AeCaej9iexahyooshu2ic9tea1ool8" + + "tu4Y/en/phoneScreenshots/en phoneScreenshots2", + sha256 = + "54758e380aee6297c7947f107db9ea03" + "d2933c9d5c110d02046977cf78d43def", + size = 0, + ), + ) + ), + sevenInch = + mapOf( + "en" to + listOf( + FileV2( + name = + "/Haoheiseeshai2que2Che0ooSa6aikeemoo2ap9Aequoh4ju5chooYuPhiev" + + "8moodahlonu2oht5Eikahvushapeum5aefo6xig4aghahyaaNuezoo4eexee1Goo" + + "5UngohGha6quaeghe8uCh9iex9Oowa9aiyohzoo2ij5miifiegaeth8nie9jae6r" + + "aephoowishoor1Ien5vahGhahm7eidaiy2AeCaej9iexahyooshu2ic9tea1ool8" + + "tu4Y/en/sevenInchScreenshots/en sevenInchScreenshots", + sha256 = + "54758e380aee6297c7947f107db9ea03" + "d2933c9d5c110d02046977cf78d43def", + size = 0, + ), + FileV2( + name = + "/Haoheiseeshai2que2Che0ooSa6aikeemoo2ap9Aequoh4ju5chooYuPhiev" + + "8moodahlonu2oht5Eikahvushapeum5aefo6xig4aghahyaaNuezoo4eexee1Goo" + + "5UngohGha6quaeghe8uCh9iex9Oowa9aiyohzoo2ij5miifiegaeth8nie9jae6r" + + "aephoowishoor1Ien5vahGhahm7eidaiy2AeCaej9iexahyooshu2ic9tea1ool8" + + "tu4Y/en/sevenInchScreenshots/en sevenInchScreenshots2", + sha256 = + "54758e380aee6297c7947f107db9ea03" + "d2933c9d5c110d02046977cf78d43def", + size = 0, + ), + ) + ), + tenInch = + mapOf( + "en" to + listOf( + FileV2( + name = + "/Haoheiseeshai2que2Che0ooSa6aikeemoo2ap9Aequoh4ju5chooYuPhiev" + + "8moodahlonu2oht5Eikahvushapeum5aefo6xig4aghahyaaNuezoo4eexee1Goo" + + "5UngohGha6quaeghe8uCh9iex9Oowa9aiyohzoo2ij5miifiegaeth8nie9jae6r" + + "aephoowishoor1Ien5vahGhahm7eidaiy2AeCaej9iexahyooshu2ic9tea1ool8" + + "tu4Y/en/tenInchScreenshots/en tenInchScreenshots", + sha256 = + "54758e380aee6297c7947f107db9ea03" + "d2933c9d5c110d02046977cf78d43def", + size = 0, + ), + FileV2( + name = + "/Haoheiseeshai2que2Che0ooSa6aikeemoo2ap9Aequoh4ju5chooYuPhiev" + + "8moodahlonu2oht5Eikahvushapeum5aefo6xig4aghahyaaNuezoo4eexee1Goo" + + "5UngohGha6quaeghe8uCh9iex9Oowa9aiyohzoo2ij5miifiegaeth8nie9jae6r" + + "aephoowishoor1Ien5vahGhahm7eidaiy2AeCaej9iexahyooshu2ic9tea1ool8" + + "tu4Y/en/tenInchScreenshots/en tenInchScreenshots2", + sha256 = + "54758e380aee6297c7947f107db9ea03" + "d2933c9d5c110d02046977cf78d43def", + size = 0, + ), + ) + ), + wear = + mapOf( + "en" to + listOf( + FileV2( + name = + "/Haoheiseeshai2que2Che0ooSa6aikeemoo2ap9Aequoh4ju5chooYuPhiev" + + "8moodahlonu2oht5Eikahvushapeum5aefo6xig4aghahyaaNuezoo4eexee1Goo" + + "5UngohGha6quaeghe8uCh9iex9Oowa9aiyohzoo2ij5miifiegaeth8nie9jae6r" + + "aephoowishoor1Ien5vahGhahm7eidaiy2AeCaej9iexahyooshu2ic9tea1ool8" + + "tu4Y/en/wearScreenshots/en wearScreenshots", + sha256 = + "54758e380aee6297c7947f107db9ea03" + "d2933c9d5c110d02046977cf78d43def", + size = 0, + ), + FileV2( + name = + "/Haoheiseeshai2que2Che0ooSa6aikeemoo2ap9Aequoh4ju5chooYuPhiev" + + "8moodahlonu2oht5Eikahvushapeum5aefo6xig4aghahyaaNuezoo4eexee1Goo" + + "5UngohGha6quaeghe8uCh9iex9Oowa9aiyohzoo2ij5miifiegaeth8nie9jae6r" + + "aephoowishoor1Ien5vahGhahm7eidaiy2AeCaej9iexahyooshu2ic9tea1ool8" + + "tu4Y/en/wearScreenshots/en wearScreenshots2", + sha256 = + "54758e380aee6297c7947f107db9ea03" + "d2933c9d5c110d02046977cf78d43def", + size = 0, + ), + ) + ), + tv = + mapOf( + "en" to + listOf( + FileV2( + name = + "/Haoheiseeshai2que2Che0ooSa6aikeemoo2ap9Aequoh4ju5chooYuPhiev" + + "8moodahlonu2oht5Eikahvushapeum5aefo6xig4aghahyaaNuezoo4eexee1Goo" + + "5UngohGha6quaeghe8uCh9iex9Oowa9aiyohzoo2ij5miifiegaeth8nie9jae6r" + + "aephoowishoor1Ien5vahGhahm7eidaiy2AeCaej9iexahyooshu2ic9tea1ool8" + + "tu4Y/en/tvScreenshots/en tvScreenshots", + sha256 = + "54758e380aee6297c7947f107db9ea03" + "d2933c9d5c110d02046977cf78d43def", + size = 0, + ), + FileV2( + name = + "/Haoheiseeshai2que2Che0ooSa6aikeemoo2ap9Aequoh4ju5chooYuPhiev" + + "8moodahlonu2oht5Eikahvushapeum5aefo6xig4aghahyaaNuezoo4eexee1Goo" + + "5UngohGha6quaeghe8uCh9iex9Oowa9aiyohzoo2ij5miifiegaeth8nie9jae6r" + + "aephoowishoor1Ien5vahGhahm7eidaiy2AeCaej9iexahyooshu2ic9tea1ool8" + + "tu4Y/en/tvScreenshots/en tvScreenshots2", + sha256 = + "54758e380aee6297c7947f107db9ea03" + "d2933c9d5c110d02046977cf78d43def", + size = 0, + ), + ) + ), + ), + ), + versions = mapOf(version3_1.file.sha256 to version3_1), + ) + val app3Compat = + app3.v1compat(true).copy(versions = mapOf(version3_1.file.sha256 to version3_1.v1compat())) + + val index = + IndexV2( + repo = repo, + packages = + mapOf( + TestDataMidV2.PACKAGE_NAME_1 to TestDataMidV2.app1, + TestDataMidV2.PACKAGE_NAME_2 to app2, + PACKAGE_NAME_3 to app3, + ), + ) + val indexCompat = + index + .v1compat() + .copy( + packages = + mapOf( TestDataMidV2.PACKAGE_NAME_1 to TestDataMidV2.app1Compat, TestDataMidV2.PACKAGE_NAME_2 to app2Compat, PACKAGE_NAME_3 to app3Compat, - ), - ) + ) + ) } diff --git a/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestRepoUtils.kt b/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestRepoUtils.kt index 8b49a9216..43f9f589f 100644 --- a/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestRepoUtils.kt +++ b/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestRepoUtils.kt @@ -1,5 +1,6 @@ package org.fdroid.test +import kotlin.random.Random import org.fdroid.index.v2.AntiFeatureV2 import org.fdroid.index.v2.CategoryV2 import org.fdroid.index.v2.FileV2 @@ -10,60 +11,59 @@ import org.fdroid.index.v2.RepoV2 import org.fdroid.test.TestUtils.getRandomList import org.fdroid.test.TestUtils.getRandomString import org.fdroid.test.TestUtils.orNull -import kotlin.random.Random object TestRepoUtils { - fun getRandomMirror(): MirrorV2 = MirrorV2( - url = getRandomString(), - countryCode = getRandomString().orNull() + fun getRandomMirror(): MirrorV2 = + MirrorV2(url = getRandomString(), countryCode = getRandomString().orNull()) + + fun getRandomLocalizedTextV2(size: Int = Random.nextInt(0, 23)): LocalizedTextV2 = buildMap { + repeat(size) { put(getRandomString(4), getRandomString()) } + } + + fun getRandomFileV2(sha256Nullable: Boolean = true): FileV2 = + FileV2( + name = getRandomString(), + sha256 = getRandomString(64).also { if (sha256Nullable) orNull() }, + size = Random.nextLong(-1, Long.MAX_VALUE), ) - fun getRandomLocalizedTextV2(size: Int = Random.nextInt(0, 23)): LocalizedTextV2 = - buildMap { - repeat(size) { - put(getRandomString(4), getRandomString()) - } - } + fun getRandomLocalizedFileV2(): Map = + TestUtils.getRandomMap(Random.nextInt(1, 8)) { getRandomString(4) to getRandomFileV2() } - fun getRandomFileV2(sha256Nullable: Boolean = true): FileV2 = FileV2( - name = getRandomString(), - sha256 = getRandomString(64).also { if (sha256Nullable) orNull() }, - size = Random.nextLong(-1, Long.MAX_VALUE) - ) - - fun getRandomLocalizedFileV2(): Map = - TestUtils.getRandomMap(Random.nextInt(1, 8)) { - getRandomString(4) to getRandomFileV2() - } - - fun getRandomRepo(): RepoV2 = RepoV2( - name = getRandomLocalizedTextV2(), - icon = getRandomLocalizedFileV2(), - address = getRandomString(), - description = getRandomLocalizedTextV2(), - mirrors = getRandomList { getRandomMirror() }, - timestamp = System.currentTimeMillis(), - antiFeatures = TestUtils.getRandomMap { - getRandomString() to AntiFeatureV2( - icon = getRandomLocalizedFileV2(), - name = getRandomLocalizedTextV2(), - description = getRandomLocalizedTextV2(), + fun getRandomRepo(): RepoV2 = + RepoV2( + name = getRandomLocalizedTextV2(), + icon = getRandomLocalizedFileV2(), + address = getRandomString(), + description = getRandomLocalizedTextV2(), + mirrors = getRandomList { getRandomMirror() }, + timestamp = System.currentTimeMillis(), + antiFeatures = + TestUtils.getRandomMap { + getRandomString() to + AntiFeatureV2( + icon = getRandomLocalizedFileV2(), + name = getRandomLocalizedTextV2(), + description = getRandomLocalizedTextV2(), ) }, - categories = TestUtils.getRandomMap { - getRandomString() to CategoryV2( - icon = getRandomLocalizedFileV2(), - name = getRandomLocalizedTextV2(), - description = getRandomLocalizedTextV2(), + categories = + TestUtils.getRandomMap { + getRandomString() to + CategoryV2( + icon = getRandomLocalizedFileV2(), + name = getRandomLocalizedTextV2(), + description = getRandomLocalizedTextV2(), ) }, - releaseChannels = TestUtils.getRandomMap { - getRandomString() to ReleaseChannelV2( - name = getRandomLocalizedTextV2(), - description = getRandomLocalizedTextV2(), + releaseChannels = + TestUtils.getRandomMap { + getRandomString() to + ReleaseChannelV2( + name = getRandomLocalizedTextV2(), + description = getRandomLocalizedTextV2(), ) }, ) - } diff --git a/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestUtils.kt b/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestUtils.kt index 3c1a24bc6..7fae843de 100644 --- a/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestUtils.kt +++ b/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestUtils.kt @@ -1,87 +1,80 @@ package org.fdroid.test +import kotlin.random.Random import org.fdroid.index.v2.IndexV2 import org.fdroid.index.v2.LocalizedFileListV2 import org.fdroid.index.v2.MetadataV2 import org.fdroid.index.v2.Screenshots -import kotlin.random.Random object TestUtils { - fun String.decodeHex(): ByteArray { - check(length % 2 == 0) { "Must have an even length" } - return chunked(2) - .map { it.toInt(16).toByte() } - .toByteArray() - } + fun String.decodeHex(): ByteArray { + check(length % 2 == 0) { "Must have an even length" } + return chunked(2).map { it.toInt(16).toByte() }.toByteArray() + } - private val charPool: List = ('a'..'z') + ('A'..'Z') + ('0'..'9') + private val charPool: List = ('a'..'z') + ('A'..'Z') + ('0'..'9') - fun getRandomString(length: Int = Random.nextInt(1, 128)): String = (1..length) - .map { Random.nextInt(0, charPool.size) } - .map(charPool::get) - .joinToString("") + fun getRandomString(length: Int = Random.nextInt(1, 128)): String = + (1..length).map { Random.nextInt(0, charPool.size) }.map(charPool::get).joinToString("") - fun getRandomList( - size: Int = Random.nextInt(0, 23), - factory: () -> T, - ): List = if (size == 0) emptyList() else buildList { + fun getRandomList(size: Int = Random.nextInt(0, 23), factory: () -> T): List = + if (size == 0) emptyList() else buildList { repeat(size) { add(factory()) } } + + fun getRandomMap(size: Int = Random.nextInt(0, 23), factory: () -> Pair): Map = + if (size == 0) emptyMap() + else + buildMap { repeat(size) { - add(factory()) + val pair = factory() + put(pair.first, pair.second) } - } + } - fun getRandomMap( - size: Int = Random.nextInt(0, 23), - factory: () -> Pair, - ): Map = if (size == 0) emptyMap() else buildMap { - repeat(size) { - val pair = factory() - put(pair.first, pair.second) - } - } + fun T.orNull(): T? { + return if (Random.nextBoolean()) null else this + } - fun T.orNull(): T? { - return if (Random.nextBoolean()) null else this - } - - fun IndexV2.sorted(): IndexV2 = copy( - packages = packages.toSortedMap().mapValues { entry -> - entry.value.copy( - metadata = entry.value.metadata.sort(), - versions = entry.value.versions.mapValues { - val pv = it.value - pv.copy( - manifest = pv.manifest.copy( - usesPermission = pv.manifest.usesPermission.sortedBy { p -> p.name }, - usesPermissionSdk23 = pv.manifest.usesPermissionSdk23.sortedBy { p -> - p.name - } - ) + fun IndexV2.sorted(): IndexV2 = + copy( + packages = + packages.toSortedMap().mapValues { entry -> + entry.value.copy( + metadata = entry.value.metadata.sort(), + versions = + entry.value.versions.mapValues { + val pv = it.value + pv.copy( + manifest = + pv.manifest.copy( + usesPermission = pv.manifest.usesPermission.sortedBy { p -> p.name }, + usesPermissionSdk23 = pv.manifest.usesPermissionSdk23.sortedBy { p -> p.name }, ) - } - ) + ) + }, + ) } ) - fun MetadataV2.sort(): MetadataV2 = copy( - name = name?.toSortedMap(), - summary = summary?.toSortedMap(), - description = description?.toSortedMap(), - icon = icon?.toSortedMap(), - screenshots = screenshots?.sort(), + fun MetadataV2.sort(): MetadataV2 = + copy( + name = name?.toSortedMap(), + summary = summary?.toSortedMap(), + description = description?.toSortedMap(), + icon = icon?.toSortedMap(), + screenshots = screenshots?.sort(), ) - fun Screenshots.sort(): Screenshots = copy( - phone = phone?.sort(), - sevenInch = sevenInch?.sort(), - tenInch = tenInch?.sort(), - wear = wear?.sort(), - tv = tv?.sort(), + fun Screenshots.sort(): Screenshots = + copy( + phone = phone?.sort(), + sevenInch = sevenInch?.sort(), + tenInch = tenInch?.sort(), + wear = wear?.sort(), + tv = tv?.sort(), ) - fun LocalizedFileListV2.sort(): LocalizedFileListV2 { - return toSortedMap().mapValues { entry -> entry.value.sortedBy { it.name } } - } - + fun LocalizedFileListV2.sort(): LocalizedFileListV2 { + return toSortedMap().mapValues { entry -> entry.value.sortedBy { it.name } } + } } diff --git a/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestVersionUtils.kt b/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestVersionUtils.kt index 2803b58f6..3c699806d 100644 --- a/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestVersionUtils.kt +++ b/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestVersionUtils.kt @@ -1,5 +1,6 @@ package org.fdroid.test +import kotlin.random.Random import org.fdroid.index.v2.FeatureV2 import org.fdroid.index.v2.FileV1 import org.fdroid.index.v2.ManifestV2 @@ -13,44 +14,35 @@ import org.fdroid.test.TestUtils.getRandomList import org.fdroid.test.TestUtils.getRandomMap import org.fdroid.test.TestUtils.getRandomString import org.fdroid.test.TestUtils.orNull -import kotlin.random.Random object TestVersionUtils { - fun getRandomPackageVersionV2( - versionCode: Long = Random.nextLong(1, Long.MAX_VALUE), - signer: SignerV2? = SignerV2(getRandomList(Random.nextInt(1, 3)) { - getRandomString(64) - }).orNull(), - ) = PackageVersionV2( - added = Random.nextLong(), - file = getRandomFileV2(false).let { - FileV1(it.name, it.sha256!!, it.size) - }, - src = getRandomFileV2().orNull(), - manifest = getRandomManifestV2(versionCode, signer), - releaseChannels = getRandomList { getRandomString() }, - antiFeatures = getRandomMap { getRandomString() to getRandomLocalizedTextV2() }, - whatsNew = getRandomLocalizedTextV2(), + fun getRandomPackageVersionV2( + versionCode: Long = Random.nextLong(1, Long.MAX_VALUE), + signer: SignerV2? = + SignerV2(getRandomList(Random.nextInt(1, 3)) { getRandomString(64) }).orNull(), + ) = + PackageVersionV2( + added = Random.nextLong(), + file = getRandomFileV2(false).let { FileV1(it.name, it.sha256!!, it.size) }, + src = getRandomFileV2().orNull(), + manifest = getRandomManifestV2(versionCode, signer), + releaseChannels = getRandomList { getRandomString() }, + antiFeatures = getRandomMap { getRandomString() to getRandomLocalizedTextV2() }, + whatsNew = getRandomLocalizedTextV2(), ) - private fun getRandomManifestV2(versionCode: Long, signer: SignerV2?) = ManifestV2( - versionName = getRandomString(), - versionCode = versionCode, - usesSdk = UsesSdkV2( - minSdkVersion = Random.nextInt(), - targetSdkVersion = Random.nextInt(), - ), - maxSdkVersion = Random.nextInt().orNull(), - signer = signer, - usesPermission = getRandomList { - PermissionV2(getRandomString(), Random.nextInt().orNull()) - }, - usesPermissionSdk23 = getRandomList { - PermissionV2(getRandomString(), Random.nextInt().orNull()) - }, - nativecode = getRandomList(Random.nextInt(0, 4)) { getRandomString() }, - features = getRandomList { FeatureV2(getRandomString()) }, + private fun getRandomManifestV2(versionCode: Long, signer: SignerV2?) = + ManifestV2( + versionName = getRandomString(), + versionCode = versionCode, + usesSdk = UsesSdkV2(minSdkVersion = Random.nextInt(), targetSdkVersion = Random.nextInt()), + maxSdkVersion = Random.nextInt().orNull(), + signer = signer, + usesPermission = getRandomList { PermissionV2(getRandomString(), Random.nextInt().orNull()) }, + usesPermissionSdk23 = + getRandomList { PermissionV2(getRandomString(), Random.nextInt().orNull()) }, + nativecode = getRandomList(Random.nextInt(0, 4)) { getRandomString() }, + features = getRandomList { FeatureV2(getRandomString()) }, ) - } diff --git a/libs/sharedTest/src/main/kotlin/org/fdroid/test/VerifierConstants.kt b/libs/sharedTest/src/main/kotlin/org/fdroid/test/VerifierConstants.kt index 273e9dcb0..f84d847d1 100644 --- a/libs/sharedTest/src/main/kotlin/org/fdroid/test/VerifierConstants.kt +++ b/libs/sharedTest/src/main/kotlin/org/fdroid/test/VerifierConstants.kt @@ -2,32 +2,30 @@ package org.fdroid.test object VerifierConstants { - const val VERIFICATION_DIR: String = "src/commonTest/resources/verification/" - const val CERTIFICATE: String = - "308202cf308201b7a0030201020204410b599a300d06092a864886f70d01010b" + - "05003018311630140603550403130d546f727374656e2047726f7465301e170d" + - "3134303631363139303332305a170d3431313130313139303332305a30183116" + - "30140603550403130d546f727374656e2047726f746530820122300d06092a86" + - "4886f70d01010105000382010f003082010a02820101009fee536211eb53d0b0" + - "54b0b2cf72fe4ba66f341b5b93730f8fe4a4a68b105a35a3a5daf5b54443d744" + - "bb19eb954456e6fb1f1fcfe9023684cddb0643be2d70a1a7a37e75badad62ba6" + - "07e238a8d88fb1601d46030824ef5e719b65f855801ee323ac68f8da7afea30d" + - "9366c1a132e1cab21dcf218d163a5aa8dcc5b31d876085414fcf0eed74bc5a02" + - "c7d297beeaa756843a0acaf31eec9969322c8695ee9f2be84e58347b47dc81e4" + - "29a6f11e5cb1415aea54b88a1911a7fc62fbd53ea7a72b1e26e7da8111510dc9" + - "8631e939760095441ca2d0a6b316527dbe146245cf279607f3c9ff7006a1adf3" + - "67b8fe55a7c3a9bdb66aebbe9b71711981e0b342dca8730203010001a321301f" + - "301d0603551d0e04160414649492d14e97d5937667ee2e555926899f9a261030" + - "0d06092a864886f70d01010b050003820101002bb228f5b31e68a9175f2a6cbb" + - "0d727991fea7b71fbb295aaa28963963b5c697d20929b57e299c9607d20ac332" + - "d86544300de7d1cf4602162d9929fbb7465be279a44a31cb06f778d66625077d" + - "615affc751a300843bad116fcee9c958b88aef0f25988dc63d7f8853517d738e" + - "fd9888e61f395597090ae7b41a5983e8d2b4bd74ee98c9a3dab91114f43b7336" + - "cc00889385567e0f717aa76526dbdae2fa34e007375b2db3d34c423b77b37774" + - "b93eff762c4b3b4fb05f8b26256570607a1400cddad2ebd4762bcf4efe703248" + - "fa5b9ab455e3a5c98cb46f10adb6979aed8f96a688fd1d2a3beab380308e2ebe" + - "0a4a880615567aafc0bfe344c5d7ef677e060f" - const val FINGERPRINT: String = - "28e14fb3b280bce8ff1e0f8e82726ff46923662cecff2a0689108ce19e8b347c" - + const val VERIFICATION_DIR: String = "src/commonTest/resources/verification/" + const val CERTIFICATE: String = + "308202cf308201b7a0030201020204410b599a300d06092a864886f70d01010b" + + "05003018311630140603550403130d546f727374656e2047726f7465301e170d" + + "3134303631363139303332305a170d3431313130313139303332305a30183116" + + "30140603550403130d546f727374656e2047726f746530820122300d06092a86" + + "4886f70d01010105000382010f003082010a02820101009fee536211eb53d0b0" + + "54b0b2cf72fe4ba66f341b5b93730f8fe4a4a68b105a35a3a5daf5b54443d744" + + "bb19eb954456e6fb1f1fcfe9023684cddb0643be2d70a1a7a37e75badad62ba6" + + "07e238a8d88fb1601d46030824ef5e719b65f855801ee323ac68f8da7afea30d" + + "9366c1a132e1cab21dcf218d163a5aa8dcc5b31d876085414fcf0eed74bc5a02" + + "c7d297beeaa756843a0acaf31eec9969322c8695ee9f2be84e58347b47dc81e4" + + "29a6f11e5cb1415aea54b88a1911a7fc62fbd53ea7a72b1e26e7da8111510dc9" + + "8631e939760095441ca2d0a6b316527dbe146245cf279607f3c9ff7006a1adf3" + + "67b8fe55a7c3a9bdb66aebbe9b71711981e0b342dca8730203010001a321301f" + + "301d0603551d0e04160414649492d14e97d5937667ee2e555926899f9a261030" + + "0d06092a864886f70d01010b050003820101002bb228f5b31e68a9175f2a6cbb" + + "0d727991fea7b71fbb295aaa28963963b5c697d20929b57e299c9607d20ac332" + + "d86544300de7d1cf4602162d9929fbb7465be279a44a31cb06f778d66625077d" + + "615affc751a300843bad116fcee9c958b88aef0f25988dc63d7f8853517d738e" + + "fd9888e61f395597090ae7b41a5983e8d2b4bd74ee98c9a3dab91114f43b7336" + + "cc00889385567e0f717aa76526dbdae2fa34e007375b2db3d34c423b77b37774" + + "b93eff762c4b3b4fb05f8b26256570607a1400cddad2ebd4762bcf4efe703248" + + "fa5b9ab455e3a5c98cb46f10adb6979aed8f96a688fd1d2a3beab380308e2ebe" + + "0a4a880615567aafc0bfe344c5d7ef677e060f" + const val FINGERPRINT: String = "28e14fb3b280bce8ff1e0f8e82726ff46923662cecff2a0689108ce19e8b347c" }