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