Merge branch '2.0' into 'master'

F-Droid Basic 2.0

See merge request fdroid/fdroidclient!1607
This commit is contained in:
Michael Pöhn
2026-01-21 10:22:53 +00:00
913 changed files with 23531 additions and 912 deletions

View File

@@ -3,7 +3,7 @@ root = true
[*]
insert_final_newline = true
[*.{kt, kts}]
[*.{kt,kts}]
ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL
# Disable wildcard imports entirely
ij_kotlin_name_count_to_use_star_import = 2147483647

View File

@@ -35,7 +35,7 @@ workflow:
- export ANDROID_COMPILE_SDK=`sed -n 's,.*compileSdk = "\([0-9][0-9]*\)".*,\1,p' gradle/libs.versions.toml`
- echo y | sdkmanager "platforms;android-${ANDROID_COMPILE_SDK}" > /dev/null
# index-v1.jar tests need SHA1 support still, TODO use apksig to validate JAR sigs
- sed -i 's,SHA1 denyAfter 20[0-9][0-9],SHA1 denyAfter 2026,'
- sed -i 's,SHA1 denyAfter 20[0-9][0-9],SHA1 denyAfter 2027,'
/usr/lib/jvm/java-17-openjdk-amd64/conf/security/java.security
after_script:
# this file changes every time but should not be cached
@@ -61,11 +61,17 @@ workflow:
- app/build/reports
- app/build/outputs/*ml
- app/build/outputs/apk
- legacy/core*
- legacy/*.log
- legacy/build/reports
- legacy/build/outputs/*ml
- legacy/build/outputs/apk
- libs/*/build/reports
- build/reports
reports:
junit:
- app/build/**/TEST-*.xml
- legacy/build/**/TEST-*.xml
- libs/*/build/**/TEST-*.xml
expire_in: 1 week
when: on_failure
@@ -89,17 +95,21 @@ app assembleRelease test:
- changes:
- app/**/*
- libs/**/*
- legacy/**/*
script:
- ./gradlew :app:assembleRelease :app:assembleDebug :app:testFullDebugUnitTest
- ./gradlew :app:assemble :app:testFullDebugUnitTest :legacy:assemble :legacy:testFullDebugUnitTest
artifacts:
name: "${CI_PROJECT_PATH}_${CI_JOB_STAGE}_${CI_COMMIT_REF_NAME}_${CI_COMMIT_SHA}"
paths:
- app/build/reports
- app/build/outputs/apk
- libs/*/build/reports
- legacy/build/reports
- legacy/build/outputs/apk
reports:
junit:
- app/build/test-results/*/TEST-*.xml
- legacy/build/test-results/*/TEST-*.xml
expire_in: 1 week
when: always
@@ -137,22 +147,23 @@ app lint:
- <<: *always-on-these-changes
- changes:
- app/**/*
- legacy/**/*
script:
# always report on lint errors to the build log
- sed -i -e 's,textReport .*,textReport true,' app/build.gradle
- 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
- ./gradlew :app:lint :app:ktlintCheck :legacy:lint :legacy:ktlintCheck
app checkstyle:
legacy checkstyle:
<<: *test-template
stage: lint
rules:
- <<: *always-on-these-changes
- changes:
- app/**/*
- legacy/**/*
script:
- ./gradlew :app:checkstyle
- python3 tools/checkstyle-to-codeclimate.py --input app/build/reports/checkstyle/checkstyle.xml --output gl-checkstyle.json
- ./gradlew :legacy:checkstyle
- python3 tools/checkstyle-to-codeclimate.py --input legacy/build/reports/checkstyle/checkstyle.xml --output gl-checkstyle.json
artifacts:
reports:
codequality: gl-checkstyle.json
@@ -165,12 +176,10 @@ libs lint ktlintCheck:
- changes:
- libs/**/*
script:
# always report on lint errors to the build log
- sed -i -e 's,textReport .*,textReport true,' app/build.gradle
- ./gradlew :libs:database:lint :libs:download:lint :libs:index:lint ktlintCheck checkLegacyAbi
- ./gradlew :libs:database:lint :libs:download:lint :libs:index:lint :libs:ktlintCheck checkLegacyAbi
# Reference: https://gitlab.com/components/code-quality-oss/codequality-os-scanners-integration/-/blob/4121970daed111dda84cab4547e1f2951684653c/templates/pmd.yml#L52-92
app lint pmd:
legacy lint pmd:
stage: lint
image:
name: registry.gitlab.com/gitlab-ci-utils/gitlab-pmd-cpd:latest
@@ -178,18 +187,18 @@ app lint pmd:
rules:
- <<: *always-on-these-changes
- changes:
- app/**/*
- legacy/**/*
parallel:
matrix:
- PMD_VARIANT: main
PMD_RULESETS: "config/pmd/rules.xml,config/pmd/rules-main.xml"
PMD_FILE_PATHS:
- "app/src/main/java"
- "legacy/src/main/java"
- PMD_VARIANT: test
PMD_RULESETS: "config/pmd/rules.xml,config/pmd/rules-test.xml"
PMD_FILE_PATHS:
- "app/src/test/java"
- "app/src/androidTest/java"
- "legacy/src/test/java"
- "legacy/src/androidTest/java"
before_script:
- apt-get update
- apt-get -qy install --no-install-recommends jq
@@ -259,17 +268,17 @@ app weblate merge conflict:
- git diff --exit-code
- exit $EXITVALUE
app errorprone:
legacy errorprone:
extends: .base
stage: lint
rules:
- <<: *always-on-these-changes
- changes:
- app/**/*
- legacy/**/*
script:
- sed -i "s@plugins {@plugins{\nid 'net.ltgt.errorprone' version '3.1.0'@" app/build.gradle
- cat config/errorprone.gradle >> app/build.gradle
- ./gradlew -Dorg.gradle.dependency.verification=lenient assembleDebug
- sed -i "s@plugins {@plugins{\nid 'net.ltgt.errorprone' version '3.1.0'@" legacy/build.gradle
- cat config/errorprone.gradle >> legacy/build.gradle
- ./gradlew -Dorg.gradle.dependency.verification=lenient :legacy:assembleDebug
libs database schema:
stage: lint
@@ -318,12 +327,12 @@ libs database schema:
- echo no | $ANDROID_HOME/cmdline-tools/latest/bin/avdmanager --verbose create avd --name "$NAME_AVD" --package "$AVD" --device "pixel"
- df -h
- start-emulator.sh
- ./gradlew installFullDebug
- ./gradlew :app:installBasicDebug :legacy:installFullDebug
- adb shell am start -n org.fdroid.fdroid.debug/org.fdroid.fdroid.views.main.MainActivity
- export FLAG="-Pandroid.testInstrumentationRunnerArguments.notAnnotation=androidx.test.filters.LargeTest,androidx.test.filters.FlakyTest"
- ./gradlew $FLAG :app:connectedFullDebugAndroidTest :libs:database:connectedCheck :libs:download:connectedCheck :libs:index:connectedCheck
- ./gradlew $FLAG :app:connectedBasicDebugAndroidTest :legacy:connectedFullDebugAndroidTest :libs:database:connectedCheck :libs:download:connectedCheck :libs:index:connectedCheck
- export FLAG="-Pandroid.testInstrumentationRunnerArguments.annotation=androidx.test.filters.FlakyTest"
- for i in {1..5}; do echo "$i" && ./gradlew $FLAG :app:connectedFullDebugAndroidTest :libs:database:connectedCheck :libs:download:connectedCheck :libs:index:connectedCheck && break; done || exit 137
- for i in {1..5}; do echo "$i" && ./gradlew $FLAG :app:connectedBasicDebugAndroidTest :legacy:connectedFullDebugAndroidTest :libs:database:connectedCheck :libs:download:connectedCheck :libs:index:connectedCheck && break; done || exit 137
allow_failure:
exit_codes: 137
@@ -356,20 +365,26 @@ deploy_nightly:
JAVA_HOME: /usr/lib/jvm/java-17-openjdk-amd64
script:
- test -z "$DEBUG_KEYSTORE" && exit 0
- apt-get install -t bookworm-backports androguard fdroidserver
- apt-get install -t bookworm-backports androguard fdroidserver jq moreutils
- sed -i
's,<string name="app_name">.*</string>,<string name="app_name">F-Nightly</string>,'
's,<string name="app_name_basic">.*</string>,<string name="app_name_basic">F-Nightly Basic</string>,'
app/src/main/res/values*/strings.xml
- sed -i
's,<string name="app_name_full">.*</string>,<string name="app_name_full">F-Nightly</string>,'
app/src/main/res/values*/strings.xml
# add this nightly repo as a enabled repo
- sed -i -e '/<\/string-array>/d' -e '/<\/resources>/d' app/src/main/res/values/default_repos.xml
- echo "<item>${CI_PROJECT_PATH}-nightly</item>" >> app/src/main/res/values/default_repos.xml
- echo "<item>${CI_PROJECT_URL}-nightly/-/raw/master/fdroid/repo</item>" >> app/src/main/res/values/default_repos.xml
- cat config/nightly-repo/repo.xml >> app/src/main/res/values/default_repos.xml
- jq --slurpfile new_dict config/nightly-repo/repo.json "[(\$new_dict[0] | .address = \"${CI_PROJECT_URL}-nightly/-/raw/master/fdroid/repo\")] + ." app/src/main/assets/default_repos.json | sponge app/src/main/assets/default_repos.json
- sed -i -e '/<\/string-array>/d' -e '/<\/resources>/d' legacy/src/main/res/values/default_repos.xml
- echo "<item>${CI_PROJECT_PATH}-nightly</item>" >> legacy/src/main/res/values/default_repos.xml
- echo "<item>${CI_PROJECT_URL}-nightly/-/raw/master/fdroid/repo</item>" >> legacy/src/main/res/values/default_repos.xml
- cat config/nightly-repo/repo.xml >> legacy/src/main/res/values/default_repos.xml
- export DB=`sed -n 's,.*version *= *\([0-9][0-9]*\).*,\1,p' libs/database/src/main/java/org/fdroid/database/FDroidDatabase.kt`
- export versionCode=`printf '%d%05d' $DB $(date '+%s'| cut -b1-8)`
- sed -i "s,^\(\s*versionCode\) *[0-9].*,\1 $versionCode," app/build.gradle
# build the APKs!
- ./gradlew assembleDebug
- rm -rf app/build/outputs/apk
- ./gradlew :app:assembleBasicRelease :legacy:assembleFullDebug
- mv app/build/outputs/apk/basic/release/app-basic-release-unsigned.apk app/build/outputs/apk/basic/release/app-debug.apk
# taken from fdroiddata/.gitlab-ci.yml as a tmp workaround until this is released:
# https://gitlab.com/fdroid/fdroidserver/-/merge_requests/1666

1
app/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

138
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,138 @@
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)
}
android {
namespace = "org.fdroid"
compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
applicationId = "org.fdroid"
minSdk = 24
targetSdk = 36
versionCode = 2000000
versionName = "2.0-alpha0"
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
}
}
flavorDimensions += "base"
productFlavors {
create("basic") {
dimension = "base"
applicationIdSuffix = ".basic"
}
create("full") {
dimension = "base"
applicationIdSuffix = ".fdroid"
}
}
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")
}
}
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(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.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.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)
testImplementation(libs.junit)
testImplementation(kotlin("test"))
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)
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_17
}
}

View File

@@ -1,86 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<lint>
<!-- TODO bump our targetSdkVersion when we are ready for it -->
<issue id="ExpiredTargetSdkVersion" severity="ignore" />
<!-- TODO This should be handled as part of an overhaul of Bluetooth swap -->
<issue id="MissingPermission" severity="">
<ignore path="src/full/java/org/fdroid/fdroid/nearby/BluetoothManager.java" />
<ignore path="src/full/java/org/fdroid/fdroid/nearby/SwapWorkflowActivity.java" />
</issue>
<!-- Our translations are crowd-sourced -->
<issue id="MissingTranslation" severity="ignore" />
<issue id="ExtraTranslation" severity="warning" />
<!-- to make CI fail on errors until this is fixed
https://github.com/rtyley/spongycastle/issues/7 -->
<issue id="InvalidPackage" severity="warning" />
<issue id="ImpliedQuantity" severity="error" />
<issue id="DefaultLocale" severity="error" />
<issue id="SimpleDateFormat" severity="error" />
<issue id="NewApi" severity="error" />
<issue id="InlinedApi" severity="error" />
<!-- These are important to us, so promote from warning to error -->
<issue id="UnusedResources" severity="error">
<ignore path="src/main/res/drawable/category_**.png" />
<ignore path="src/main/res/values/dimens.xml" />
<ignore path="src/main/res/values/styles.xml" />
<ignore path="src/full/res/values/styles.xml" />
<!-- keep a single strings.xml for all build flavors -->
<ignore path="src/main/res/values**/strings.xml" />
</issue>
<issue id="AppCompatMethod" severity="error" />
<issue id="NestedScrolling" severity="error" />
<issue id="Typos" severity="error" />
<issue id="StringFormatCount" severity="error" />
<issue id="UnsafeProtectedBroadcastReceiver" severity="error" />
<issue id="GetInstance" severity="error" />
<issue id="PackageManagerGetSignatures" severity="error" />
<issue id="HardwareIds" severity="error" />
<issue id="TrustAllX509TrustManager" severity="error">
<!-- these come from included libraries -->
<ignore path="org/apache/commons/net/ftp/FTPSTrustManager.class" />
<ignore path="org/bouncycastle/est/jcajce/JcaJceUtils$1.class" />
<ignore path="org/bouncycastle/est/jcajce/JcaJceUtils$2.class" />
<ignore path="org/apache/commons/net/util/TrustManagerUtils$TrustManager.class" />
<ignore path="\*/bcpkix-jdk15to18-*.jar" />
<ignore path="\*/commons-net-*.jar" />
</issue>
<issue id="PluralsCandidate" severity="error" />
<issue id="HardcodedText" severity="error" />
<issue id="RtlCompat" severity="error" />
<issue id="RtlEnabled" severity="error" />
<!-- both the correct and deprecated locales need to be present for
them to be recognized on all devices -->
<issue id="LocaleFolder" severity="error">
<ignore path="src/main/res/values-he" />
<ignore path="src/main/res/values-id" />
</issue>
<!-- Weblate doesn't handle these yet: https://github.com/WeblateOrg/weblate/issues/7520 -->
<issue id="MissingQuantity" severity="error">
<ignore path="src/main/res/values-cs" />
<ignore path="src/main/res/values-lt" />
<ignore path="src/main/res/values-sk" />
</issue>
<issue id="SetWorldReadable" severity="error">
<ignore path="src/main/java/org/fdroid/fdroid/installer/ApkFileProvider.java" />
</issue>
<issue id="ProtectedPermissions" severity="error">
<ignore path="src/debug/AndroidManifest.xml" />
<ignore path="src/full/AndroidManifest.xml" />
</issue>
<!-- these should be fixed, but it'll be a chunk of work -->
<issue id="SetTextI18n" severity="error">
<ignore path="src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java" />
<ignore path="src/main/java/org/fdroid/fdroid/views/apps/AppListItemController.java" />
</issue>
</lint>

View File

@@ -1,58 +1,17 @@
-dontobfuscate
-dontoptimize
-keepattributes SourceFile,LineNumberTable,Exceptions
-keep class org.fdroid.fdroid.** {*;}
-dontwarn android.test.**
-dontwarn javax.naming.**
-dontnote org.apache.http.**
-dontnote android.net.http.**
-dontnote **ILicensingService
# Needed for espresso https://stackoverflow.com/a/21706087
-dontwarn org.xmlpull.v1.**
# StrongHttpsClient and its support classes are totally unused, so the
# ch.boye.httpclientandroidlib.** classes are also unneeded
-dontwarn info.guardianproject.netcipher.client.**
# These libraries are known to break if minification is enabled on them. They
# use reflection to instantiate classes, for example. If the keep flags are
# removed, proguard will strip classes which are required, which may result in
# crashes.
-keep class kellinwood.security.zipsigner.** {*;}
-keep class org.bouncycastle.** {*;}
# This keeps class members used for SystemInstaller IPC.
# Reference: https://gitlab.com/fdroid/fdroidclient/issues/79
-keepclassmembers class * implements android.os.IInterface {
public *;
}
-keepattributes *Annotation*,EnclosingMethod,Signature
-keepnames class com.fasterxml.jackson.** { *; }
-dontwarn com.fasterxml.jackson.databind.ext.**
-keep class org.codehaus.** { *; }
-keepclassmembers public final enum org.codehaus.jackson.annotate.JsonAutoDetect$Visibility {
public static final org.codehaus.jackson.annotate.JsonAutoDetect$Visibility *; }
-keep public class org.fdroid.** {
*;
}
-dontwarn org.bouncycastle.jsse.**
-dontwarn org.conscrypt.**
-dontwarn org.openjsse.**
# This is necessary so that RemoteWorkManager can be initialized (also marked with @Keep)
-keep class androidx.work.multiprocess.RemoteWorkManagerClient {
public <init>(...);
}
-keep class org.acra.config.MailSenderConfiguration {
public <init>(...);
}
# Anything less causes issues like not finding primary constructor in ReflectionDiffer
-keep class org.fdroid.** {*;}
# Logging
-keep class ch.qos.logback.classic.android.LogcatAppender
-keepclassmembers class ch.qos.logback.** { *; }
-keepclassmembers class org.slf4j.impl.** { *; }
# Needed for instrumentation tests (for some werid inexplicable reason)
-keep class kotlin.LazyKt
-keep class kotlin.collections.CollectionsKt
# for debugging (comment in when needed)
#-printconfiguration build/outputs/logs/r8-configuration.txt

View File

@@ -0,0 +1,71 @@
package org.fdroid.install
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
@RunWith(AndroidJUnit4::class)
class ApkFileProviderTest {
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}"
}
}
}
/**
* 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()
}
}
}

View File

@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="48"
android:viewportHeight="48">
<group
android:scaleX="0.52"
android:scaleY="0.52"
android:translateX="11.52"
android:translateY="11.52">
<path
android:pathData="M45.411,3.75 L41.161,9.25"
android:strokeWidth="2.5"
android:strokeColor="#aeea00"
android:strokeLineCap="round"
android:strokeLineJoin="miter" />
<path
android:pathData="M2.589,3.75 L6.839,9.25"
android:strokeWidth="2.5"
android:strokeColor="#aeea00"
android:strokeLineCap="round"
android:strokeLineJoin="miter" />
<path
android:fillColor="#aeea00"
android:pathData="M8,7.25L40,7.25A3,3 0,0 1,43 10.25L43,17.25A3,3 0,0 1,40 20.25L8,20.25A3,3 0,0 1,5 17.25L5,10.25A3,3 0,0 1,8 7.25z" />
<path
android:fillColor="#1976d2"
android:pathData="M8,21.25L40,21.25A3,3 0,0 1,43 24.25L43,44.25A3,3 0,0 1,40 47.25L8,47.25A3,3 0,0 1,5 44.25L5,24.25A3,3 0,0 1,8 21.25z" />
</group>
</vector>

View File

@@ -1,6 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<resources>
<string name="app_name">@string/app_name_basic</string>
<string name="app_name_debug" translatable="false" tools:ignore="UnusedResources">F-Droid Basic Debug</string>
<string name="about_title">@string/about_title_basic</string>
</resources>

View File

@@ -1,6 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">@string/app_name_full</string>
<string name="app_name_debug" translatable="false">F-Droid Debug</string>
<string name="about_title">@string/about_title_full</string>
</resources>

View File

@@ -1,350 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
* Copyright (C) 2010-2012 Ciaran Gultnieks
* Copyright (C) 2013-2017 Peter Serwylo
* Copyright (C) 2014-2015 Daniel Martí
* Copyright (C) 2014-2018 Hans-Christoph Steiner
* Copyright (C) 2016 Dominik Schürmann
* Copyright (C) 2018 Torsten Grote
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 3
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:installLocation="auto">
<supports-screens
android:anyDensity="true"
android:largeScreens="true"
android:normalScreens="true"
android:resizeable="true"
android:smallScreens="true"
android:xlargeScreens="true" />
<uses-feature
android:name="android.hardware.telephony"
android:required="false" />
<uses-feature
android:name="android.hardware.wifi"
android:required="false" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.NFC" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ENFORCE_UPDATE_OWNERSHIP" />
<!-- Permission used to externally trigger repository updates -->
<permission
android:name="${applicationId}.permission.UPDATE_REPOS"
android:protectionLevel="signature|privileged" />
<uses-permission android:name="${applicationId}.permission.UPDATE_REPOS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission
android:name="android.permission.FOREGROUND_SERVICE"
tools:ignore="ForegroundServicesPolicy" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission
android:name="android.permission.REQUEST_INSTALL_PACKAGES"
tools:ignore="RequestInstallPackagesPolicy" />
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
<uses-permission android:name="android.permission.ENFORCE_UPDATE_OWNERSHIP" />
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
<application
android:name=".FDroidApp"
android:name="org.fdroid.App"
android:allowBackup="true"
android:dataExtractionRules="@xml/backup_extraction_rules"
android:description="@string/app_description"
android:fullBackupContent="@xml/backup_rules"
android:hardwareAccelerated="true"
android:icon="@drawable/ic_launcher"
android:label="${applicationLabel}"
android:enableOnBackInvokedCallback="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:localeConfig="@xml/locales_config"
android:memtagMode="async"
android:networkSecurityConfig="@xml/network_security_config"
android:requestLegacyExternalStorage="true"
android:supportsRtl="true"
android:theme="@style/Theme.App">
android:theme="@style/Theme.FDroid"
tools:targetApi="33">
<activity
android:name=".privileged.views.InstallConfirmActivity"
android:configChanges="layoutDirection|locale"
android:excludeFromRecents="true"
android:label="@string/menu_install"
android:parentActivityName=".views.main.MainActivity" />
<activity
android:name=".privileged.views.UninstallDialogActivity"
android:excludeFromRecents="true" />
<activity
android:name=".views.repos.ManageReposActivity"
android:configChanges="layoutDirection|locale"
android:label="@string/menu_manage"
android:launchMode="singleTask"
android:parentActivityName=".views.main.MainActivity"/>
<activity
android:name=".views.repos.AddRepoActivity"
android:name="org.fdroid.MainActivity"
android:exported="true"
android:launchMode="singleInstance"
android:windowSoftInputMode="adjustResize"
android:parentActivityName=".views.main.MainActivity">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="fdroid.link" />
</intent-filter>
<!-- Repo URLs -->
<!--
This intent serves two purposes: Swapping apps between devices and adding a
repo from a website (e.g. https://guardianproject.info/fdroid/repo).
We intercept both of these situations in the FDroid activity, and then redirect
to the appropriate handler (swap handling, manage repos respectively) from there.
The reason for this is that the only differentiating factor is the presence
of a "swap=1" in the query string, and intent-filter is unable to deal with
query parameters. An alternative would be to do something like fdroidswap:// as
a scheme, but then we need to copy/paste all of this intent-filter stuff and
keep it up to date when it changes or a bug is found.
This filter supports HTTP and HTTPS schemes. There is an additional filter for
fdroidrepo:// and fdroidrepos://
-->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<category android:name="android.intent.category.DEFAULT" />
<!--
Android's scheme matcher is case-sensitive, so include
ALL CAPS versions to support ALL CAPS URLs in QR Codes.
QR Codes have a special ALL CAPS mode that uses a reduced
character set, making for more compact QR Codes.
-->
<data android:scheme="fdroidrepo" />
<data android:scheme="fdroidrepos" />
<data
android:scheme="FDROIDREPO"
tools:ignore="AppLinkUrlError" />
<data android:scheme="fdroidrepos" />
<data
android:scheme="FDROIDREPOS"
tools:ignore="AppLinkUrlError" />
</intent-filter>
<!--
The below intent filter most likely won't work anymore.
Using fdroid.link or fdroidrepos:// scheme is preferred.
-->
<intent-filter android:autoVerify="false">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<category android:name="android.intent.category.DEFAULT" />
<!--
Android's scheme matcher is case-sensitive, so include
ALL CAPS versions to support ALL CAPS URLs in QR Codes.
QR Codes have a special ALL CAPS mode that uses a reduced
character set, making for more compact QR Codes.
-->
<data android:scheme="http" />
<data
android:scheme="HTTP"
tools:ignore="AppLinkUrlError" />
<data android:scheme="https" />
<data
android:scheme="HTTPS"
tools:ignore="AppLinkUrlError" />
<data android:host="*" />
<!--
The pattern matcher here is poorly implemented, in particular the * is
non-greedy, so you have to do stupid tricks to match patterns that have
repeat characters in them. http://stackoverflow.com/a/8599921/306864
-->
<data android:path="/fdroid/repo" />
<data android:pathPattern="/fdroid/repo/*" />
<data android:pathPattern="/.*/fdroid/repo" />
<data android:pathPattern="/.*/fdroid/repo/*" />
<data android:pathPattern="/.*/.*/fdroid/repo" />
<data android:pathPattern="/.*/.*/fdroid/repo/*" />
<data android:pathPattern="/.*/.*/.*/fdroid/repo" />
<data android:pathPattern="/.*/.*/.*/fdroid/repo/*" />
<data android:pathPattern="/.*/.*/.*/.*/fdroid/repo" />
<data android:pathPattern="/.*/.*/.*/.*/fdroid/repo/*" />
<data android:pathPattern="/.*/.*/.*/.*/.*/fdroid/repo" />
<data android:pathPattern="/.*/.*/.*/.*/.*/fdroid/repo/*" />
<data android:pathPattern="/.*/.*/.*/.*/.*/.*/fdroid/repo" />
<data android:pathPattern="/.*/.*/.*/.*/.*/.*/fdroid/repo/*" />
<data android:path="/fdroid/archive" />
<data android:pathPattern="/fdroid/archive/*" />
<data android:pathPattern="/.*/fdroid/archive" />
<data android:pathPattern="/.*/fdroid/archive/*" />
<data android:pathPattern="/.*/.*/fdroid/archive" />
<data android:pathPattern="/.*/.*/fdroid/archive/*" />
<data android:pathPattern="/.*/.*/.*/fdroid/archive" />
<data android:pathPattern="/.*/.*/.*/fdroid/archive/*" />
<data android:pathPattern="/.*/.*/.*/.*/fdroid/archive" />
<data android:pathPattern="/.*/.*/.*/.*/fdroid/archive/*" />
<!--
Some QR Code scanners don't respect custom schemes like fdroidrepo://,
so this is a workaround, since the local repo URL is all uppercase in
the QR Code for sending the local repo to another device.
-->
<data android:path="/FDROID/REPO" />
<data android:pathPattern="/.*/FDROID/REPO" />
<data android:pathPattern="/.*/.*/FDROID/REPO" />
<data android:pathPattern="/.*/.*/.*/FDROID/REPO" />
</intent-filter>
<intent-filter android:label="@string/repo_add_new_title">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>
<activity
android:name=".views.IpfsGatewaySettingsActivity"
android:configChanges="layoutDirection|locale"
android:label="@string/ipfsgw_title"
android:launchMode="singleTask"
android:parentActivityName=".views.main.MainActivity"></activity>
<activity
android:name=".views.IpfsGatewayAddActivity"
android:launchMode="singleTask"
android:parentActivityName=".views.main.MainActivity" />
<activity
android:name="com.journeyapps.barcodescanner.CaptureActivity"
android:screenOrientation="fullSensor"
tools:replace="screenOrientation" />
<activity
android:name=".views.repos.RepoDetailsActivity"
android:configChanges="layoutDirection|locale"
android:label="@string/repo_details"
android:parentActivityName=".views.repos.ManageReposActivity"
android:windowSoftInputMode="stateHidden"/>
<activity
android:name=".views.AppDetailsActivity"
android:configChanges="layoutDirection|locale"
android:exported="true"
android:label="@string/app_details"
android:parentActivityName=".views.main.MainActivity">
<intent-filter>
<action android:name="android.intent.action.SHOW_APP_INFO" />
</intent-filter>
</activity>
<activity android:name=".views.ScreenShotsActivity" />
<activity
android:name=".data.ObbUrlActivity"
android:theme="@android:style/Theme.NoDisplay" />
<activity
android:name=".installer.DefaultInstallerActivity"
android:theme="@style/Theme.App.Transparent" />
<activity
android:name=".installer.ErrorDialogActivity"
android:theme="@style/Theme.App.Transparent" />
<activity
android:name=".views.main.MainActivity"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize"
android:exported="true">
android:theme="@style/Theme.FDroid"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.APP_MARKET" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.APP_MARKET" />
</intent-filter>
<!-- App URLs -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="fdroid.app" />
<action android:name="android.intent.action.SHOW_APP_INFO" />
</intent-filter>
<intent-filter android:autoVerify="false">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="f-droid.org" />
<data android:host="www.f-droid.org" />
<data android:host="staging.f-droid.org" />
<data android:host="cloudflare.f-droid.org" />
<data android:pathPrefix="/app/" />
<data android:pathPrefix="/packages/" />
<data android:pathPrefix="/repository/browse" />
<!-- support localized URLs -->
<data android:pathPattern="/.*/packages/.*" />
<data android:pathPattern="/.*/packages/.*/" />
</intent-filter>
<intent-filter android:autoVerify="false">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:host="f-droid.org" />
<data android:host="www.f-droid.org" />
<data android:host="staging.f-droid.org" />
<data android:host="cloudflare.f-droid.org" />
<data android:pathPrefix="/app/" />
<data android:pathPrefix="/packages/" />
<data android:pathPrefix="/repository/browse" />
<!-- support localized URLs -->
<data android:pathPattern="/.*/packages/.*" />
<data android:pathPattern="/.*/packages/.*/" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
@@ -356,192 +55,75 @@
android:scheme="market" />
</intent-filter>
<intent-filter android:autoVerify="false">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="play.google.com" /> <!-- they don't do www. -->
<data android:path="/store/apps/details" />
<data android:host="f-droid.org" />
<data android:host="staging.f-droid.org" />
<data android:host="cloudflare.f-droid.org" />
<data android:pathPrefix="/packages/" />
<data android:pathPattern="/.*/packages/.*" />
</intent-filter>
<!-- Repo URLs -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="fdroid.link" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="fdroidrepos" />
<data
android:host="apps"
android:path="/android"
android:scheme="amzn" />
android:scheme="FDROIDREPOS"
tools:ignore="AppLinkUrlError" />
</intent-filter>
<intent-filter android:autoVerify="false">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="amazon.com" />
<data android:host="www.amazon.com" />
<data android:path="/gp/mas/dl/android" />
</intent-filter>
<!-- Search URLs -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="fdroid.search" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="search"
android:scheme="market" />
</intent-filter>
<intent-filter android:autoVerify="false">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="play.google.com" /> <!-- they don't do www. -->
<data android:path="/store/search" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEARCH" />
</intent-filter>
<!-- Handle NFC tags detected from outside our application -->
<meta-data
android:name="android.app.searchable"
android:resource="@xml/searchable" />
</activity>
<activity android:name=".views.apps.AppListActivity" />
<activity
android:name=".views.installed.InstalledAppsActivity"
android:parentActivityName=".views.main.MainActivity"></activity>
android:name="org.fdroid.ui.crash.CrashActivity"
android:excludeFromRecents="true"
android:finishOnTaskLaunch="true"
android:launchMode="singleInstance"
android:process=":acra"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".views.InstallHistoryActivity"
android:parentActivityName=".views.main.MainActivity"></activity>
<activity
android:name=".installer.FileInstallerActivity"
android:theme="@style/Theme.App.Transparent" />
<provider
android:name="org.fdroid.fdroid.installer.ApkFileProvider"
android:authorities="${applicationId}.installer.ApkFileProvider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/apk_file_provider" />
</provider>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.installer"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/installer_file_provider" />
</provider>
<provider
android:name="org.fdroid.fdroid.nearby.PublicSourceDirProvider"
android:authorities="${applicationId}.nearby.PublicSourceDirProvider"
android:exported="false"
android:grantUriPermissions="true" />
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="remove"></provider>
<receiver
android:name=".receiver.StartupReceiver"
android:exported="false">
<intent-filter>
<!-- Implicit Broadcast Exception
https://developer.android.com/guide/components/broadcast-exceptions -->
<action android:name="android.intent.action.BOOT_COMPLETED" />
<category android:name="android.intent.category.HOME" />
</intent-filter>
</receiver>
<receiver
android:name=".NotificationBroadcastReceiver"
android:exported="false">
<!-- Doesn't require an intent-filter because it is explicitly invoked via Intent.setClass() -->
</receiver>
<receiver android:name=".receiver.RepoUpdateReceiver"
android:exported="true"
android:permission="${applicationId}.permission.UPDATE_REPOS">
<intent-filter>
<action android:name="org.fdroid.action.UPDATE_REPOS" />
</intent-filter>
</receiver>
<receiver
android:name=".receiver.UnarchivePackageReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.UNARCHIVE_PACKAGE" />
</intent-filter>
</receiver>
android:name="com.journeyapps.barcodescanner.CaptureActivity"
android:screenOrientation="fullSensor"
tools:ignore="DiscouragedApi"
tools:replace="screenOrientation" />
<service
android:name=".net.DownloaderService"
android:name="org.fdroid.install.AppInstallService"
android:exported="false"
android:permission="android.permission.BIND_JOB_SERVICE" />
<service
android:name=".installer.InstallerService"
android:exported="false"
android:permission="android.permission.BIND_JOB_SERVICE" />
android:foregroundServiceType="dataSync" />
<!-- needed for declaring foregroundServiceType for WorkManager -->
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />
<service
android:name=".DeleteCacheService"
<provider
android:name="org.fdroid.install.ApkFileProvider"
android:authorities="${applicationId}.install.ApkFileProvider"
android:exported="false"
android:permission="android.permission.BIND_JOB_SERVICE" />
<service
android:name=".net.ConnectivityMonitorService"
android:exported="false"
android:permission="android.permission.BIND_JOB_SERVICE" />
<service
android:name=".installer.InstallHistoryService"
android:exported="false"
android:permission="android.permission.BIND_JOB_SERVICE" />
<service
android:name=".installer.ObfInstallerService"
android:exported="false" />
android:grantUriPermissions="true" />
<!-- disable WorkManager initialization, needed for Hilt -->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
tools:node="remove" />
</application>
</manifest>

View File

@@ -0,0 +1,62 @@
[
{
"name": "F-Droid",
"address": "https://f-droid.org/repo",
"mirrors": [
"http://fdroidorg6cooksyluodepej4erfctzk7rrjpjbbr6wx24jh3lqyfwyd.onion/fdroid/repo",
"http://dotsrccccbidkzg7oc7oj4ugxrlfbt64qebyunxbrgqhxiwj3nl6vcad.onion/fdroid/repo",
"http://ftpfaudev4triw2vxiwzf4334e3mynz7osqgtozhbc77fixncqzbyoyd.onion/fdroid/repo",
"http://lysator7eknrfl47rlyxvgeamrv7ucefgrrlhk7rouv3sna25asetwid.onion/pub/fdroid/repo",
"http://mirror.ossplanetnyou5xifr6liw5vhzwc2g2fmmlohza25wwgnnaw65ytfsad.onion/fdroid/repo",
"https://fdroid.tetaneutral.net/fdroid/repo",
"https://ftp.agdsn.de/fdroid/repo",
"https://ftp.fau.de/fdroid/repo",
"https://ftp.gwdg.de/pub/android/fdroid/repo",
"https://ftp.lysator.liu.se/pub/fdroid/repo",
"https://mirror.cyberbits.eu/fdroid/repo",
"https://mirror.eu.ossplanet.net/fdroid/repo",
"https://mirror.fcix.net/fdroid/repo",
"https://mirror.kumi.systems/fdroid/repo",
"https://mirror.level66.network/fdroid/repo",
"https://mirror.ossplanet.net/fdroid/repo",
"https://mirrors.dotsrc.org/fdroid/repo",
"https://opencolo.mm.fcix.net/fdroid/repo",
"https://plug-mirror.rcac.purdue.edu/fdroid/repo",
"https://mirror.init7.net/fdroid/repo",
"https://mirror.freedif.org/fdroid/repo"
],
"description": "The official F-Droid Free Software repository. Everything in this repository is always built from the source code.",
"certificate": "3082035e30820246a00302010202044c49cd00300d06092a864886f70d01010505003071310b300906035504061302554b3110300e06035504081307556e6b6e6f776e3111300f0603550407130857657468657262793110300e060355040a1307556e6b6e6f776e3110300e060355040b1307556e6b6e6f776e311930170603550403131043696172616e2047756c746e69656b73301e170d3130303732333137313032345a170d3337313230383137313032345a3071310b300906035504061302554b3110300e06035504081307556e6b6e6f776e3111300f0603550407130857657468657262793110300e060355040a1307556e6b6e6f776e3110300e060355040b1307556e6b6e6f776e311930170603550403131043696172616e2047756c746e69656b7330820122300d06092a864886f70d01010105000382010f003082010a028201010096d075e47c014e7822c89fd67f795d23203e2a8843f53ba4e6b1bf5f2fd0e225938267cfcae7fbf4fe596346afbaf4070fdb91f66fbcdf2348a3d92430502824f80517b156fab00809bdc8e631bfa9afd42d9045ab5fd6d28d9e140afc1300917b19b7c6c4df4a494cf1f7cb4a63c80d734265d735af9e4f09455f427aa65a53563f87b336ca2c19d244fcbba617ba0b19e56ed34afe0b253ab91e2fdb1271f1b9e3c3232027ed8862a112f0706e234cf236914b939bcf959821ecb2a6c18057e070de3428046d94b175e1d89bd795e535499a091f5bc65a79d539a8d43891ec504058acb28c08393b5718b57600a211e803f4a634e5c57f25b9b8c4422c6fd90203010001300d06092a864886f70d0101050500038201010008e4ef699e9807677ff56753da73efb2390d5ae2c17e4db691d5df7a7b60fc071ae509c5414be7d5da74df2811e83d3668c4a0b1abc84b9fa7d96b4cdf30bba68517ad2a93e233b042972ac0553a4801c9ebe07bf57ebe9a3b3d6d663965260e50f3b8f46db0531761e60340a2bddc3426098397fda54044a17e5244549f9869b460ca5e6e216b6f6a2db0580b480ca2afe6ec6b46eedacfa4aa45038809ece0c5978653d6c85f678e7f5a2156d1bedd8117751e64a4b0dcd140f3040b021821a8d93aed8d01ba36db6c82372211fed714d9a32607038cdfd565bd529ffc637212aaa2c224ef22b603eccefb5bf1e085c191d4b24fe742b17ab3f55d4e6f05ef",
"enabled": true
},
{
"name": "F-Droid Archive",
"address": "https://f-droid.org/archive",
"mirrors": [
"http://fdroidorg6cooksyluodepej4erfctzk7rrjpjbbr6wx24jh3lqyfwyd.onion/fdroid/archive",
"http://dotsrccccbidkzg7oc7oj4ugxrlfbt64qebyunxbrgqhxiwj3nl6vcad.onion/fdroid/archive",
"http://ftpfaudev4triw2vxiwzf4334e3mynz7osqgtozhbc77fixncqzbyoyd.onion/fdroid/archive",
"http://lysator7eknrfl47rlyxvgeamrv7ucefgrrlhk7rouv3sna25asetwid.onion/pub/fdroid/archive",
"http://mirror.ossplanetnyou5xifr6liw5vhzwc2g2fmmlohza25wwgnnaw65ytfsad.onion/fdroid/archive",
"https://fdroid.tetaneutral.net/fdroid/archive",
"https://ftp.agdsn.de/fdroid/archive",
"https://ftp.fau.de/fdroid/archive",
"https://ftp.gwdg.de/pub/android/fdroid/archive",
"https://ftp.lysator.liu.se/pub/fdroid/archive",
"https://mirror.cyberbits.eu/fdroid/archive",
"https://mirror.eu.ossplanet.net/fdroid/archive",
"https://mirror.fcix.net/fdroid/archive",
"https://mirror.kumi.systems/fdroid/archive",
"https://mirror.level66.network/fdroid/archive",
"https://mirror.ossplanet.net/fdroid/archive",
"https://mirrors.dotsrc.org/fdroid/archive",
"https://opencolo.mm.fcix.net/fdroid/archive",
"https://plug-mirror.rcac.purdue.edu/fdroid/archive",
"https://mirror.init7.net/fdroid/archive",
"https://mirror.freedif.org/fdroid/archive"
],
"description": "The archive repository of the F-Droid client. This contains older versions of applications from the main repository.",
"certificate": "3082035e30820246a00302010202044c49cd00300d06092a864886f70d01010505003071310b300906035504061302554b3110300e06035504081307556e6b6e6f776e3111300f0603550407130857657468657262793110300e060355040a1307556e6b6e6f776e3110300e060355040b1307556e6b6e6f776e311930170603550403131043696172616e2047756c746e69656b73301e170d3130303732333137313032345a170d3337313230383137313032345a3071310b300906035504061302554b3110300e06035504081307556e6b6e6f776e3111300f0603550407130857657468657262793110300e060355040a1307556e6b6e6f776e3110300e060355040b1307556e6b6e6f776e311930170603550403131043696172616e2047756c746e69656b7330820122300d06092a864886f70d01010105000382010f003082010a028201010096d075e47c014e7822c89fd67f795d23203e2a8843f53ba4e6b1bf5f2fd0e225938267cfcae7fbf4fe596346afbaf4070fdb91f66fbcdf2348a3d92430502824f80517b156fab00809bdc8e631bfa9afd42d9045ab5fd6d28d9e140afc1300917b19b7c6c4df4a494cf1f7cb4a63c80d734265d735af9e4f09455f427aa65a53563f87b336ca2c19d244fcbba617ba0b19e56ed34afe0b253ab91e2fdb1271f1b9e3c3232027ed8862a112f0706e234cf236914b939bcf959821ecb2a6c18057e070de3428046d94b175e1d89bd795e535499a091f5bc65a79d539a8d43891ec504058acb28c08393b5718b57600a211e803f4a634e5c57f25b9b8c4422c6fd90203010001300d06092a864886f70d0101050500038201010008e4ef699e9807677ff56753da73efb2390d5ae2c17e4db691d5df7a7b60fc071ae509c5414be7d5da74df2811e83d3668c4a0b1abc84b9fa7d96b4cdf30bba68517ad2a93e233b042972ac0553a4801c9ebe07bf57ebe9a3b3d6d663965260e50f3b8f46db0531761e60340a2bddc3426098397fda54044a17e5244549f9869b460ca5e6e216b6f6a2db0580b480ca2afe6ec6b46eedacfa4aa45038809ece0c5978653d6c85f678e7f5a2156d1bedd8117751e64a4b0dcd140f3040b021821a8d93aed8d01ba36db6c82372211fed714d9a32607038cdfd565bd529ffc637212aaa2c224ef22b603eccefb5bf1e085c191d4b24fe742b17ab3f55d4e6f05ef",
"enabled": false
}
]

View File

@@ -0,0 +1,52 @@
[
"com.termux",
"org.fdroid.fdroid",
"org.woheller69.arity",
"com.aurora.store",
"org.mozilla.fennec_fdroid",
"at.bitfire.davdroid",
"com.junkfood.seal",
"app.organicmaps",
"net.osmand.plus",
"ch.protonvpn.android",
"org.videolan.vlc",
"org.fossify.gallery",
"com.duckduckgo.mobile.android",
"com.fsck.k9",
"dev.imranr.obtainium.fdroid",
"com.nextcloud.client",
"com.machiav3lli.fdroid",
"InfinityLoop1309.NewPipeEnhanced",
"org.schabi.newpipe",
"net.thunderbird.android",
"de.tutao.tutanota",
"org.documentfoundation.libreoffice",
"com.artifex.mupdf.viewer.app",
"com.nononsenseapps.feeder",
"org.tasks",
"eu.faircode.email",
"org.kde.kdeconnect_tp",
"com.maxrave.simpmusic",
"com.kunzisoft.keepass.libre",
"de.danoeh.antennapod",
"com.looker.droidify",
"com.termux.api",
"deckers.thibault.aves.libre",
"org.samo_lego.canta",
"org.fossify.calendar",
"com.github.andreyasadchy.xtra",
"com.foobnix.pro.pdf.reader",
"org.fossify.messages",
"net.cozic.joplin",
"oss.krtirtho.spotube",
"im.vector.app",
"com.unciv.app",
"org.adaway",
"org.koitharu.kotatsu",
"io.github.muntashirakon.AppManager",
"com.artifex.mupdf.mini.app",
"com.anandnet.harmonymusic",
"com.beemdevelopment.aegis",
"com.github.libretube",
"org.breezyweather"
]

View File

@@ -0,0 +1,132 @@
package org.fdroid
import android.app.Application
import android.content.Context
import androidx.compose.runtime.Composer
import androidx.compose.runtime.ExperimentalComposeRuntimeApi
import androidx.compose.runtime.tooling.ComposeStackTraceMode
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import coil3.ImageLoader
import coil3.SingletonImageLoader
import coil3.disk.DiskCache
import coil3.disk.directory
import coil3.key.Keyer
import coil3.memory.MemoryCache
import coil3.request.crossfade
import coil3.util.DebugLogger
import dagger.hilt.android.HiltAndroidApp
import org.acra.ACRA
import org.acra.ReportField
import org.acra.config.dialog
import org.acra.config.mailSender
import org.acra.data.StringFormat.JSON
import org.acra.ktx.initAcra
import org.fdroid.BuildConfig.APPLICATION_ID
import org.fdroid.BuildConfig.VERSION_NAME
import org.fdroid.download.DownloadRequest
import org.fdroid.download.LocalIconFetcher
import org.fdroid.download.PackageName
import org.fdroid.download.coil.DownloadRequestFetcher
import org.fdroid.repo.RepoUpdateWorker
import org.fdroid.settings.SettingsManager
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 workerFactory: HiltWorkerFactory
@Inject
lateinit var downloadRequestFetcherFactory: DownloadRequestFetcher.Factory
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"
}
}
}
@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 (ACRA.isACRASenderServiceProcess()) return
RepoUpdateWorker.scheduleOrCancel(applicationContext, settingsManager.repoUpdates)
AppUpdateWorker.scheduleOrCancel(applicationContext, settingsManager.autoUpdateApps)
}
override fun newImageLoader(context: Context): ImageLoader {
return ImageLoader.Builder(context)
.crossfade(true)
.components {
val downloadRequestKeyer = Keyer<DownloadRequest> { data, _ -> data.getCacheKey() }
add(downloadRequestKeyer)
add(downloadRequestFetcherFactory)
val packageNameKeyer = Keyer<PackageName> { 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)

View File

@@ -0,0 +1,39 @@
package org.fdroid
import android.Manifest.permission.POST_NOTIFICATIONS
import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.os.Build.VERSION.SDK_INT
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts.RequestPermission
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import dagger.hilt.android.AndroidEntryPoint
import org.fdroid.ui.Main
// 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()) {}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
Main {
// inform OnNewIntentListeners about the initial intent (otherwise would be missed)
if (savedInstanceState == null && intent != null) {
onNewIntent(intent)
}
}
}
if (SDK_INT >= 33 &&
ContextCompat.checkSelfPermission(this, POST_NOTIFICATIONS) != PERMISSION_GRANTED
) {
requestPermissionLauncher.launch(POST_NOTIFICATIONS)
}
}
}

View File

@@ -0,0 +1,199 @@
package org.fdroid
import android.Manifest.permission.POST_NOTIFICATIONS
import android.app.PendingIntent
import android.app.PendingIntent.FLAG_IMMUTABLE
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.content.Context
import android.content.Intent
import android.content.Intent.ACTION_MAIN
import android.content.pm.PackageManager.PERMISSION_GRANTED
import androidx.annotation.StringRes
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationCompat.BigTextStyle
import androidx.core.app.NotificationCompat.CATEGORY_SERVICE
import androidx.core.app.NotificationCompat.PRIORITY_HIGH
import androidx.core.app.NotificationManagerCompat
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 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
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
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)
}
}
}
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 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 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
}
fun cancelAppInstallNotification() {
log.debug { "Cancel app install notification" }
nm.cancel(NOTIFICATION_ID_APP_INSTALLS)
}
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)
}
}

View File

@@ -0,0 +1,24 @@
package org.fdroid.db
import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
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)
}
}

View File

@@ -0,0 +1,21 @@
package org.fdroid.db
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
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(
@param:ApplicationContext private val context: Context,
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
}
}

View File

@@ -0,0 +1,28 @@
package org.fdroid.download
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
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}"
@Provides
@Singleton
fun provideHttpManager(settingsManager: SettingsManager): HttpManager {
return HttpManager(userAgent = USER_AGENT, proxyConfig = settingsManager.proxyConfig)
}
@Provides
@Singleton
fun provideDownloaderFactory(
downloaderFactoryImpl: DownloaderFactoryImpl,
): DownloaderFactory = downloaderFactoryImpl
}

View File

@@ -0,0 +1,53 @@
package org.fdroid.download
import android.content.ContentResolver.SCHEME_FILE
import android.net.Uri
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,
) : 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,
mirrors: List<Mirror>,
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, request, destFile)
}
}
}

View File

@@ -0,0 +1,43 @@
package org.fdroid.download
import android.net.Uri
import androidx.core.net.toUri
import io.ktor.client.engine.ProxyConfig
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,
)
}
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()
}
}

View File

@@ -0,0 +1,49 @@
package org.fdroid.download
import android.net.Uri
import org.fdroid.IndexFile
import java.io.File
import java.io.FileNotFoundException
import java.io.InputStream
/**
* "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"))
override fun getInputStream(resumable: Boolean): InputStream = sourceFile.inputStream()
override fun close() {}
@Deprecated("Only for v1 repos")
override fun hasChanged(): Boolean = true
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)
}
}

View File

@@ -0,0 +1,69 @@
package org.fdroid.download
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build.VERSION.SDK_INT
import coil3.ImageLoader
import coil3.asImage
import coil3.decode.DataSource
import coil3.fetch.FetchResult
import coil3.fetch.Fetcher
import coil3.fetch.ImageFetchResult
import coil3.request.Options
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,
)
class LocalIconFetcher(
private val packageManager: PackageManager,
private val data: PackageName,
private val downloadRequestFetcher: Fetcher?,
) : Fetcher {
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()
}
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<PackageName> {
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)
},
)
}
}

View File

@@ -0,0 +1,56 @@
package org.fdroid.download
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
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 kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NetworkMonitor @Inject constructor(
@param:ApplicationContext private val context: Context,
) : ConnectivityManager.NetworkCallback() {
private val connectivityManager =
context.getSystemService(ConnectivityManager::class.java) as ConnectivityManager
private val _networkState = MutableStateFlow(
connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)?.let {
NetworkState(it)
} ?: NetworkState(isOnline = false, isMetered = false)
)
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)
}
override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
_networkState.update { NetworkState(networkCapabilities) }
}
override fun onLost(network: Network) {
_networkState.update { NetworkState(isOnline = false, isMetered = false) }
}
}
data class NetworkState(
val isOnline: Boolean,
val isMetered: Boolean,
) {
constructor(networkCapabilities: NetworkCapabilities) : this(
isOnline = networkCapabilities.hasCapability(NET_CAPABILITY_INTERNET),
isMetered = !networkCapabilities.hasCapability(NET_CAPABILITY_NOT_METERED),
)
}

View File

@@ -0,0 +1,113 @@
package org.fdroid.install
import android.content.ContentProvider
import android.content.ContentValues
import android.content.Intent
import android.content.Intent.ACTION_SEND
import android.content.Intent.EXTRA_STREAM
import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.database.Cursor
import android.database.MatrixCursor
import android.net.Uri
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
class ApkFileProvider : ContentProvider() {
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 val log = KotlinLogging.logger {}
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<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
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<Any?>(
packageName,
MIME_TYPE,
applicationInfo.publicSourceDir,
File(applicationInfo.publicSourceDir).length(),
)
)
} 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
}
}
override fun onCreate(): Boolean = true
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): 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<out String>?,
): Int = 0
}

View File

@@ -0,0 +1,10 @@
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?)
}

View File

@@ -0,0 +1,516 @@
package org.fdroid.install
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.Intent.ACTION_DELETE
import android.graphics.Bitmap
import androidx.activity.result.ActivityResult
import androidx.annotation.UiThread
import androidx.annotation.WorkerThread
import androidx.core.os.LocaleListCompat
import coil3.SingletonImageLoader
import coil3.memory.MemoryCache
import coil3.request.ImageRequest
import coil3.size.Size
import coil3.toBitmap
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.updateAndGet
import mu.KotlinLogging
import org.fdroid.LocaleChooser.getBestLocale
import org.fdroid.NotificationManager
import org.fdroid.database.AppMetadata
import org.fdroid.database.AppVersion
import org.fdroid.database.Repository
import org.fdroid.download.DownloadRequest
import org.fdroid.download.DownloaderFactory
import org.fdroid.download.getUri
import org.fdroid.getCacheKey
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,
@param:IoDispatcher private val scope: CoroutineScope,
) {
private val log = KotlinLogging.logger { }
private val apps = MutableStateFlow<Map<String, InstallState>>(emptyMap())
private val jobs = ConcurrentHashMap<String, Job>()
val appInstallStates = apps.asStateFlow()
val installNotificationState: InstallNotificationState
get() {
val appStates = mutableListOf<AppState>()
var numBytesDownloaded = 0L
var numTotalBytes = 0L
// go throw all apps that have active state
apps.value.toMap().forEach { (packageName, state) ->
// assign a category to each in progress state
val appStateCategory = when (state) {
is InstallState.Installing, is InstallState.PreApproved,
is InstallState.Waiting, is InstallState.Starting -> AppStateCategory.INSTALLING
is InstallState.Downloading -> {
numBytesDownloaded += state.downloadedBytes
numTotalBytes += state.totalBytes
AppStateCategory.INSTALLING
}
is InstallState.Installed -> AppStateCategory.INSTALLED
is InstallState.UserConfirmationNeeded -> AppStateCategory.NEEDS_CONFIRMATION
else -> null
}
// track app state for in progress apps
val appState = appStateCategory?.let {
// all states that get a category above must be InstallStateWithInfo
state as InstallStateWithInfo
AppState(
packageName = packageName,
category = it,
name = state.name,
installVersionName = state.versionName,
currentVersionName = state.currentVersionName,
)
}
if (appState != null) appStates.add(appState)
}
return InstallNotificationState(
apps = appStates,
numBytesDownloaded = numBytesDownloaded,
numTotalBytes = numTotalBytes,
)
}
fun getAppFlow(packageName: String): Flow<InstallState> {
return apps.map { it[packageName] ?: InstallState.Unknown }
}
/**
* Installs the given [version].
*
* @param canAskPreApprovalNow true if there will be only one approval dialog
* and the app is currently in the foreground.
* Reasoning:
* The system will swallow the second or third dialog we pop up
* before the user could respond to the first.
* Also we are not allowed anymore to start other activities while in the background.
*/
@UiThread
suspend fun install(
appMetadata: AppMetadata,
version: AppVersion,
currentVersionName: String?,
repo: Repository,
iconModel: Any?,
canAskPreApprovalNow: Boolean,
): InstallState {
val packageName = appMetadata.packageName
val currentState = apps.value[packageName]
if (currentState?.showProgress == true && currentState !is InstallState.Waiting) {
log.warn { "Attempted to install $packageName with install in progress: $currentState" }
return currentState
}
val iconDownloadRequest = iconModel as? DownloadRequest
currentCoroutineContext().ensureActive()
val job = scope.async {
startInstall(
appMetadata = appMetadata,
version = version,
currentVersionName = currentVersionName,
repo = repo,
iconDownloadRequest = iconDownloadRequest,
canAskPreApprovalNow = canAskPreApprovalNow,
)
}
// keep track of this job, in case we want to cancel it
return trackJob(packageName, job)
}
private suspend fun trackJob(packageName: String, job: Deferred<InstallState>): InstallState {
jobs[packageName] = job
// wait for job to return
val result = try {
job.await()
} catch (_: CancellationException) {
InstallState.UserAborted
} finally {
// remove job as it has completed
jobs.remove(packageName)
}
apps.updateApp(packageName) { result }
onStatesUpdated()
return result
}
fun setWaitingState(
packageName: String,
name: String,
versionName: String,
currentVersionName: String,
lastUpdated: Long,
) {
apps.updateApp(packageName) {
InstallState.Waiting(name, versionName, currentVersionName, lastUpdated)
}
onStatesUpdated()
}
@WorkerThread
private suspend fun startInstall(
appMetadata: AppMetadata,
version: AppVersion,
currentVersionName: String?,
repo: Repository,
iconDownloadRequest: DownloadRequest?,
canAskPreApprovalNow: Boolean,
): InstallState {
val startingState = InstallState.Starting(
name = appMetadata.name.getBestLocale(LocaleListCompat.getDefault()) ?: "Unknown",
versionName = version.versionName,
currentVersionName = currentVersionName,
lastUpdated = version.added,
iconDownloadRequest = iconDownloadRequest,
)
apps.updateApp(appMetadata.packageName) { startingState }
log.info { "Started install of ${appMetadata.packageName}" }
onStatesUpdated()
val coroutineContext = currentCoroutineContext()
// get the icon for pre-approval (usually in memory cache, so should be quick)
coroutineContext.ensureActive()
val icon = getIcon(iconDownloadRequest)
// request pre-approval from user (if available)
coroutineContext.ensureActive()
val preApprovalResult = sessionInstallManager.requestPreapproval(
app = appMetadata,
icon = icon,
isUpdate = currentVersionName != null,
version = version,
canRequestUserConfirmationNow = canAskPreApprovalNow,
)
log.info { "Got pre-approval result $preApprovalResult for ${appMetadata.packageName}" }
// continue depending on result, abort early if no approval was given
return when (preApprovalResult) {
is PreApprovalResult.UserAborted -> InstallState.UserAborted
is PreApprovalResult.Success, PreApprovalResult.NotSupported -> {
val newState = apps.checkAndUpdateApp(appMetadata.packageName) {
InstallState.PreApproved(
name = it.name,
versionName = it.versionName,
currentVersionName = it.currentVersionName,
lastUpdated = it.lastUpdated,
iconDownloadRequest = it.iconDownloadRequest,
result = preApprovalResult,
)
} as InstallState.PreApproved
downloadAndInstall(newState, version, currentVersionName, repo, iconDownloadRequest)
}
is PreApprovalResult.UserConfirmationRequired -> {
InstallState.PreApprovalConfirmationNeeded(
state = startingState,
version = version,
repo = repo,
sessionId = preApprovalResult.sessionId,
intent = preApprovalResult.intent,
)
}
is PreApprovalResult.Error -> InstallState.Error(
msg = preApprovalResult.errorMsg,
s = startingState,
)
}
}
/**
* Request user confirmation for pre-approval and suspend until we get a result.
*/
@UiThread
suspend fun requestPreApprovalConfirmation(
packageName: String,
installState: InstallState.PreApprovalConfirmationNeeded,
): InstallState? {
val state = apps.value[packageName] ?: error("No state for $packageName $installState")
if (state !is InstallState.PreApprovalConfirmationNeeded) {
log.error { "Unexpected state: $state" }
return null
}
log.info { "Requesting pre-approval confirmation for $packageName" }
val result = sessionInstallManager.requestUserConfirmation(installState)
log.info { "Pre-approval confirmation for $packageName $result" }
apps.updateApp(packageName) { result }
onStatesUpdated()
return if (result is InstallState.PreApproved) {
// move us off the UiThread, so we can download/install this app now
val job = scope.async {
downloadAndInstall(
state = result,
version = installState.version,
currentVersionName = installState.currentVersionName,
repo = installState.repo,
iconDownloadRequest = installState.iconDownloadRequest,
)
}
// suspend/wait for this job and track it in case we want to cancel it
return trackJob(packageName, job)
} else result
}
@WorkerThread
private suspend fun downloadAndInstall(
state: InstallState.PreApproved,
version: AppVersion,
currentVersionName: String?,
repo: Repository,
iconDownloadRequest: DownloadRequest?,
): InstallState {
val sessionId = (state.result as? PreApprovalResult.Success)?.sessionId
val coroutineContext = currentCoroutineContext()
coroutineContext.ensureActive()
// download file
val file = File(context.cacheDir, version.file.sha256)
val uri = getUri(repo.address, version.file)
val downloader = downloaderFactory.create(repo, uri, version.file, file)
val now = System.currentTimeMillis()
downloader.setListener { bytesRead, totalBytes ->
coroutineContext.ensureActive()
apps.checkAndUpdateApp(version.packageName) {
InstallState.Downloading(
name = it.name,
versionName = it.versionName,
currentVersionName = it.currentVersionName,
lastUpdated = it.lastUpdated,
iconDownloadRequest = it.iconDownloadRequest,
downloadedBytes = bytesRead,
totalBytes = totalBytes,
startMillis = now,
)
}
onStatesUpdated()
}
try {
downloader.download()
log.debug { "Download completed" }
} catch (e: Exception) {
if (e is CancellationException) throw e
log.error(e) { "Error downloading ${version.file}" }
val msg = "Download failed: ${e::class.java.simpleName} ${e.message}"
return InstallState.Error(
msg = msg,
name = state.name,
versionName = version.versionName,
currentVersionName = currentVersionName,
lastUpdated = version.added,
iconDownloadRequest = iconDownloadRequest,
)
}
currentCoroutineContext().ensureActive()
val newState = apps.checkAndUpdateApp(version.packageName) {
InstallState.Installing(
name = it.name,
versionName = it.versionName,
currentVersionName = it.currentVersionName,
lastUpdated = it.lastUpdated,
iconDownloadRequest = it.iconDownloadRequest,
)
}
val result =
sessionInstallManager.install(sessionId, version.packageName, newState, file)
log.debug { "Install result: $result" }
return if (result is InstallState.PreApproved &&
result.result is PreApprovalResult.Error
) {
// if pre-approval failed (e.g. due to app label mismatch),
// then try to install again, this time not using the pre-approved session
sessionInstallManager.install(null, version.packageName, newState, file)
} else {
result
}
}
/**
* Request user confirmation for installation and suspend until we get a result.
*/
@UiThread
suspend fun requestUserConfirmation(
packageName: String,
installState: InstallState.UserConfirmationNeeded,
): InstallState? {
val state = apps.value[packageName] ?: error("No state for $packageName $installState")
if (state !is InstallState.UserConfirmationNeeded) {
log.error { "Unexpected state: $state" }
return null
}
log.info { "Requesting user confirmation for $packageName" }
val job = scope.async {
sessionInstallManager.requestUserConfirmation(installState)
}
// keep track of this job, in case we need to cancel it
val result = trackJob(packageName, job)
log.info { "User confirmation for $packageName $result" }
apps.updateApp(packageName) { result }
onStatesUpdated()
return result
}
/**
* A workaround for Android 10, 11, 12 and 13 where tapping outside the confirmation dialog
* dismisses it without any feedback for us.
* So when our activity resumes while we are in state [InstallState.UserConfirmationNeeded]
* we need to call this method, so we can manually check if our session progressed or not.
* If it didn't progress and the state hasn't changed, we fire up the confirmation intent again.
*/
@UiThread
fun checkUserConfirmation(
packageName: String,
installState: InstallState.UserConfirmationNeeded,
) {
val state = apps.value[packageName] ?: error("No state for $packageName $installState")
if (state !is InstallState.UserConfirmationNeeded) {
log.debug { "State has changed. Now: $state" }
return
}
val sessionInfo =
context.packageManager.packageInstaller.getSessionInfo(installState.sessionId)
?: run {
log.error { "Session ${installState.sessionId} does not exist anymore" }
return
}
if (sessionInfo.progress <= installState.progress) {
log.info {
"Session did not progress: ${sessionInfo.progress} <= ${installState.progress}"
}
// we fire up intent again to force the user to do a proper yes/no decision,
// so our session and our coroutine above don't get stuck
installState.intent.send()
} else {
log.debug { "Session has progressed, doing nothing" }
}
}
fun cancel(packageName: String) {
val job = jobs[packageName]
log.debug { "Canceling job for $packageName $job" }
job?.cancel()
}
/**
* Must be called after receiving the result from the [ACTION_DELETE] uninstall Intent.
*
* Note: We are not using [android.content.pm.PackageInstaller.uninstall],
* because on Android 10 to 13 (at least) we don't get feedback
* when the user taps outside the confirmation dialog.
* Using this non-deprecated API ([ACTION_DELETE]) seems to work
* without issues everywhere.
*/
@UiThread
fun onUninstallResult(packageName: String, activityResult: ActivityResult): InstallState {
val result = when (activityResult.resultCode) {
Activity.RESULT_OK -> InstallState.Uninstalled
Activity.RESULT_FIRST_USER -> InstallState.UserAborted
else -> InstallState.UserAborted
}
val code = activityResult.data?.getIntExtra("android.intent.extra.INSTALL_RESULT", -1)
log.info { "Uninstall result received: ${activityResult.resultCode} => $result ($code)" }
apps.updateApp(packageName) { result }
return result
}
@UiThread
fun cleanUp(packageName: String) {
val state = apps.value[packageName] ?: return
if (!state.showProgress) {
log.info { "Cleaning up state for $packageName $state" }
jobs.remove(packageName)?.cancel()
apps.update { oldApps ->
oldApps.toMutableMap().apply {
remove(packageName)
}
}
}
}
private fun onStatesUpdated() {
val notificationState = installNotificationState
val serviceIntent = Intent(context, AppInstallService::class.java)
// stop foreground service, if no app is installing and it is still running
if (!notificationState.isInstallingSomeApp && AppInstallService.isServiceRunning) {
context.stopService(serviceIntent)
}
if (notificationState.isInProgress) {
// start foreground service if at least one app is installing and not already running
if (notificationState.isInstallingSomeApp && !AppInstallService.isServiceRunning) {
try {
context.startService(serviceIntent)
} catch (e: Exception) {
log.error { "Couldn't start service: $e ${e.message}" }
}
}
notificationManager.showAppInstallNotification(notificationState)
} else {
// cancel notification if no more apps are in progress
notificationManager.cancelAppInstallNotification()
}
}
/**
* Gets icon for preapproval from memory cache.
* In the unlikely event, that the icon isn't in the cache,
* we we download it with the given [iconDownloadRequest].
*/
private suspend fun getIcon(iconDownloadRequest: DownloadRequest?): Bitmap? {
return iconDownloadRequest?.let { downloadRequest ->
// try memory cache first and download, if not found
val memoryCache = SingletonImageLoader.get(context).memoryCache
val key = downloadRequest.getCacheKey()
memoryCache?.get(MemoryCache.Key(key))?.image?.toBitmap() ?: run {
// not found in cache, download icon
val request = ImageRequest.Builder(context)
.data(downloadRequest)
.size(Size.ORIGINAL)
.build()
SingletonImageLoader.get(context).execute(request).image?.toBitmap()
}
}
}
private fun MutableStateFlow<Map<String, InstallState>>.updateApp(
packageName: String,
function: (InstallState) -> InstallState,
) = update { oldMap ->
val newMap = oldMap.toMutableMap()
newMap[packageName] = function(newMap[packageName] ?: InstallState.Unknown)
newMap
}
private fun MutableStateFlow<Map<String, InstallState>>.checkAndUpdateApp(
packageName: String,
function: (InstallStateWithInfo) -> InstallStateWithInfo,
): InstallStateWithInfo {
return updateAndGet { oldMap ->
val oldState = oldMap[packageName]
check(oldState is InstallStateWithInfo) {
"State for $packageName was $oldState"
}
val newMap = oldMap.toMutableMap()
newMap[packageName] = function(oldState)
newMap
}[packageName] as InstallStateWithInfo
}
}

View File

@@ -0,0 +1,60 @@
package org.fdroid.install
import android.app.Service
import android.content.Intent
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST
import android.os.Build.VERSION.SDK_INT
import android.os.IBinder
import androidx.core.app.ServiceCompat
import dagger.hilt.android.AndroidEntryPoint
import mu.KotlinLogging
import org.fdroid.NotificationManager
import org.fdroid.NotificationManager.Companion.NOTIFICATION_ID_APP_INSTALLS
import javax.inject.Inject
@AndroidEntryPoint
class AppInstallService : Service() {
companion object {
var isServiceRunning = false
private set
}
private val log = KotlinLogging.logger { }
@Inject
lateinit var notificationManager: NotificationManager
override fun onCreate() {
log.info { "onCreate" }
isServiceRunning = true
super.onCreate() // apparently importing for injection
}
override fun onStartCommand(
intent: Intent?,
flags: Int,
startId: Int
): Int {
log.info { "onStartCommand $intent" }
val notificationState = InstallNotificationState()
try {
ServiceCompat.startForeground(
this,
NOTIFICATION_ID_APP_INSTALLS,
notificationManager.getAppInstallNotification(notificationState).build(),
if (SDK_INT >= 29) FOREGROUND_SERVICE_TYPE_MANIFEST else 0,
)
} catch (e: Exception) {
log.error(e) { "Error starting foreground service: " }
}
return super.onStartCommand(intent, flags, startId)
}
override fun onBind(intent: Intent): IBinder? = null
override fun onDestroy() {
log.info { "onDestroy" }
isServiceRunning = false
}
}

View File

@@ -0,0 +1,39 @@
package org.fdroid.install
import android.content.Context
import androidx.annotation.WorkerThread
import dagger.hilt.android.qualifiers.ApplicationContext
import mu.KotlinLogging
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
private const val DELETE_OLDER_THAN_MILLIS = 8_640_000 // 24h
@Singleton
class CacheCleaner @Inject constructor(
@param:ApplicationContext private val context: Context,
) {
private val log = KotlinLogging.logger { }
private val shaRegex = "^[a-zA-Z0-9]{64}$".toRegex()
@WorkerThread
fun clean() {
log.info { "Cleaning up old files..," }
try {
context.cacheDir.listFiles()?.forEach { file ->
if (file.isFile && shaRegex.matches(file.name) && file.isTooOld()) {
log.debug { "Deleting ${file.name}..." }
file.delete()
}
} ?: throw NullPointerException("listFiles() returned null")
} catch (e: Exception) {
log.error(e) { "Error deleting old cached files: " }
}
}
private fun File.isTooOld(): Boolean {
val age = System.currentTimeMillis() - lastModified()
return age >= DELETE_OLDER_THAN_MILLIS
}
}

View File

@@ -0,0 +1,48 @@
package org.fdroid.install
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.Intent.EXTRA_INTENT
import android.content.pm.PackageInstaller.EXTRA_PACKAGE_NAME
import android.content.pm.PackageInstaller.EXTRA_SESSION_ID
import android.content.pm.PackageInstaller.EXTRA_STATUS
import android.content.pm.PackageInstaller.EXTRA_STATUS_MESSAGE
import androidx.core.content.IntentCompat.getParcelableExtra
import mu.KotlinLogging
class InstallBroadcastReceiver(
private val sessionId: Int,
private val listener: InstallBroadcastReceiver.(
status: Int,
confirmIntent: Intent?,
msg: String?,
) -> Unit,
) : BroadcastReceiver() {
private val log = KotlinLogging.logger { }
override fun onReceive(context: Context, intent: Intent) {
val receivedSessionId = intent.getIntExtra(EXTRA_SESSION_ID, -1)
if (receivedSessionId != sessionId) {
log.warn {
"Received intent for session $receivedSessionId, but expected $sessionId"
}
return
}
val confirmIntent = getParcelableExtra(intent, EXTRA_INTENT, Intent::class.java)
val packageName = intent.getStringExtra(EXTRA_PACKAGE_NAME)
val status = intent.getIntExtra(EXTRA_STATUS, Int.MIN_VALUE)
val msg = intent.getStringExtra(EXTRA_STATUS_MESSAGE)
val warnings = intent.getStringArrayListExtra("android.content.pm.extra.WARNINGS")
log.info {
"Received broadcast for $packageName ($sessionId) $status: $msg"
}
if (warnings != null && warnings.isNotEmpty()) {
warnings.forEach {
log.warn { it }
}
}
listener(status, confirmIntent, msg)
}
}

View File

@@ -0,0 +1,140 @@
package org.fdroid.install
import android.content.Context
import androidx.annotation.StringRes
import org.fdroid.R
import kotlin.math.roundToInt
data class InstallNotificationState(
val apps: List<AppState>,
val numBytesDownloaded: Long,
val numTotalBytes: Long,
) {
constructor() : this(emptyList(), 0, 0)
val percent: Int? = if (numTotalBytes > 0) {
((numBytesDownloaded.toFloat() / numTotalBytes) * 100).roundToInt()
} else {
null
}
/**
* Returns true if there are apps that have an installation in progress which could be
* waiting for user confirmation or downloading, or waiting for system installer.
*/
val isInProgress: Boolean = apps.any { it.category != AppStateCategory.INSTALLED }
/**
* Returns true if there is at least one app either downloading for actually installing.
* If there are only apps that have been installed already or are waiting for user confirmation,
* this will return false.
*/
val isInstallingSomeApp: Boolean = apps.any { it.category == AppStateCategory.INSTALLING }
/**
* Returns true if *all* apps being installed are updates to existing apps.
*/
private val isUpdatingApps: Boolean = apps.all { it.currentVersionName != null }
val numInstalled: Int get() = apps.count { it.category == AppStateCategory.INSTALLED }
fun getTitle(context: Context): String {
// can briefly show as foreground service notification, before we update real state
if (apps.isEmpty()) return context.getString(R.string.installing)
val titleRes = if (isUpdatingApps) {
R.plurals.notification_updating_title
} else {
R.plurals.notification_installing_title
}
val numActiveApps: Int = apps.count { it.category != AppStateCategory.INSTALLED }
val installTitle = context.resources.getQuantityString(
titleRes,
numActiveApps,
numActiveApps,
)
val needsUserConfirmation =
apps.find { it.category == AppStateCategory.NEEDS_CONFIRMATION } != null
return if (needsUserConfirmation) {
val s = context.getString(R.string.notification_installing_confirmation)
"$s $installTitle"
} else {
installTitle
}
}
fun getBigText(context: Context): String {
// split app apps into their categories
val installing = mutableListOf<AppState>()
val toConfirm = mutableListOf<AppState>()
val installed = mutableListOf<AppState>()
apps.forEach { appState ->
when (appState.category) {
AppStateCategory.INSTALLING -> installing.add(appState)
AppStateCategory.NEEDS_CONFIRMATION -> toConfirm.add(appState)
AppStateCategory.INSTALLED -> installed.add(appState)
}
}
val sb = StringBuilder()
fun printApps(@StringRes titleRes: Int, list: List<AppState>, showTitle: Boolean = true) {
if (list.isEmpty()) return
if (showTitle) {
if (sb.isNotEmpty()) sb.append("\n\n")
sb.append(context.getString(titleRes))
}
sb.append("\n")
list.forEach { appState ->
sb.append("").append(appState.displayStr).append("\n")
}
}
val showInstallTitle = toConfirm.isNotEmpty() || installed.isNotEmpty()
printApps(R.string.notification_installing_section_confirmation, toConfirm)
printApps(R.string.notification_installing_section_installing, installing, showInstallTitle)
printApps(R.string.notification_installing_section_installed, installed)
return sb.toString()
}
fun getSuccessTitle(context: Context): String {
return context.resources.getQuantityString(
R.plurals.notification_update_success_title,
numInstalled,
numInstalled,
)
}
fun getSuccessBigText(): String {
val sb = StringBuilder()
apps.forEach { appState ->
if (appState.category == AppStateCategory.INSTALLED) {
sb.append(appState.displayStr).append("\n")
}
}
return sb.toString()
}
}
data class AppState(
val packageName: String,
val category: AppStateCategory,
val name: String,
val installVersionName: String,
val currentVersionName: String?,
) {
val displayStr: String
get() {
val versionStr = if (currentVersionName == null) {
installVersionName
} else {
"$currentVersionName$installVersionName"
}
return "$name $versionStr"
}
}
enum class AppStateCategory {
INSTALLING,
NEEDS_CONFIRMATION,
INSTALLED
}

View File

@@ -0,0 +1,157 @@
package org.fdroid.install
import android.app.PendingIntent
import org.fdroid.database.AppVersion
import org.fdroid.database.Repository
import org.fdroid.download.DownloadRequest
sealed class InstallState(val showProgress: Boolean) {
data object Unknown : InstallState(false)
/**
* Used for our own app which will be updated last,
* so this is waiting for all other updates to complete.
*/
data class Waiting(
override val name: String,
override val versionName: String,
override val currentVersionName: String? = null,
override val lastUpdated: Long,
) : InstallStateWithInfo(true) {
override val iconDownloadRequest: DownloadRequest? = null
}
data class Starting(
override val name: String,
override val versionName: String,
override val currentVersionName: String? = null,
override val lastUpdated: Long,
override val iconDownloadRequest: DownloadRequest? = null,
) : InstallStateWithInfo(true)
data class PreApprovalConfirmationNeeded(
private val state: InstallStateWithInfo,
val version: AppVersion,
val repo: Repository,
override val sessionId: Int,
override val creationTimeMillis: Long = System.currentTimeMillis(),
override val intent: PendingIntent,
) : InstallConfirmationState() {
override val name: String = state.name
override val versionName: String = state.versionName
override val currentVersionName: String? = state.currentVersionName
override val lastUpdated: Long = state.lastUpdated
override val iconDownloadRequest: DownloadRequest? = state.iconDownloadRequest
}
data class PreApproved(
override val name: String,
override val versionName: String,
override val currentVersionName: String?,
override val lastUpdated: Long,
override val iconDownloadRequest: DownloadRequest?,
val result: PreApprovalResult,
) : InstallStateWithInfo(true)
data class Downloading(
override val name: String,
override val versionName: String,
override val currentVersionName: String?,
override val lastUpdated: Long,
override val iconDownloadRequest: DownloadRequest?,
val downloadedBytes: Long,
val totalBytes: Long,
val startMillis: Long,
) : InstallStateWithInfo(true) {
val progress: Float get() = downloadedBytes / totalBytes.toFloat()
}
data class Installing(
override val name: String,
override val versionName: String,
override val currentVersionName: String?,
override val lastUpdated: Long,
override val iconDownloadRequest: DownloadRequest?,
) : InstallStateWithInfo(true)
data class UserConfirmationNeeded(
override val name: String,
override val versionName: String,
override val currentVersionName: String?,
override val lastUpdated: Long,
override val iconDownloadRequest: DownloadRequest?,
override val sessionId: Int,
override val intent: PendingIntent,
override val creationTimeMillis: Long,
val progress: Float,
) : InstallConfirmationState() {
constructor(
state: InstallStateWithInfo,
sessionId: Int,
intent: PendingIntent,
progress: Float
) : this(
name = state.name,
versionName = state.versionName,
currentVersionName = state.currentVersionName,
lastUpdated = state.lastUpdated,
iconDownloadRequest = state.iconDownloadRequest,
sessionId = sessionId,
intent = intent,
creationTimeMillis = System.currentTimeMillis(),
progress = progress
)
}
data class Installed(
override val name: String,
override val versionName: String,
override val currentVersionName: String?,
override val lastUpdated: Long,
override val iconDownloadRequest: DownloadRequest?,
) : InstallStateWithInfo(false)
data object UserAborted : InstallState(false)
data class Error(
val msg: String?,
override val name: String,
override val versionName: String,
override val currentVersionName: String?,
override val lastUpdated: Long,
override val iconDownloadRequest: DownloadRequest?,
) : InstallStateWithInfo(false) {
constructor(msg: String?, s: InstallStateWithInfo) : this(
msg = msg,
name = s.name,
versionName = s.versionName,
currentVersionName = s.currentVersionName,
lastUpdated = s.lastUpdated,
iconDownloadRequest = s.iconDownloadRequest,
)
}
data object Uninstalled : InstallState(false)
}
sealed class InstallStateWithInfo(showProgress: Boolean) : InstallState(showProgress) {
abstract val name: String
abstract val versionName: String
abstract val currentVersionName: String?
abstract val lastUpdated: Long
abstract val iconDownloadRequest: DownloadRequest?
}
sealed class InstallConfirmationState() : InstallStateWithInfo(true) {
abstract val sessionId: Int
/**
* The epoch time in milliseconds when this state was created.
* This is used to get a stable ordering on apps that require user confirmation.
* The reason this is needed is that we can only show a single confirmation dialog at a time.
* If we show more than one, the second one gets silently swallowed by the system
* and we don't receive any feedback, so installation process of several apps gets stuck.
*/
abstract val creationTimeMillis: Long
abstract val intent: PendingIntent
}

View File

@@ -0,0 +1,113 @@
package org.fdroid.install
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.Intent.EXTRA_REPLACING
import android.content.IntentFilter
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.content.pm.PackageManager.GET_SIGNATURES
import androidx.annotation.UiThread
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import mu.KotlinLogging
import org.fdroid.utils.IoDispatcher
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class InstalledAppsCache @Inject constructor(
@param:ApplicationContext private val context: Context,
@param:IoDispatcher private val ioScope: CoroutineScope,
) : BroadcastReceiver() {
private val log = KotlinLogging.logger { }
private val packageManager = context.packageManager
private val _installedApps = MutableStateFlow<Map<String, PackageInfo>>(emptyMap())
val installedApps = _installedApps.asStateFlow()
private var loadJob: Job? = null
init {
val intentFilter = IntentFilter().apply {
addAction(Intent.ACTION_PACKAGE_ADDED)
addAction(Intent.ACTION_PACKAGE_REMOVED)
addDataScheme("package")
}
context.registerReceiver(this, intentFilter)
loadInstalledApps()
}
fun isInstalled(packageName: String): Boolean {
// TODO on first start this may have to wait for installed apps to load
return _installedApps.value.contains(packageName)
}
@UiThread
private fun loadInstalledApps() {
if (loadJob?.isActive == true) {
// TODO this may give us a stale cache if an app was changed
// while the system had already assembled the data, but we didn't return yet
log.warn { "Already loading apps, not loading again." }
return
}
loadJob = ioScope.launch {
log.info { "Loading installed apps..." }
@Suppress("DEPRECATION") // we'll use this as long as it works, new one was broken
val installedPackages = packageManager.getInstalledPackages(GET_SIGNATURES)
_installedApps.update { installedPackages.associateBy { it.packageName } }
}
}
override fun onReceive(context: Context, intent: Intent) {
if (intent.`package` != null) {
// we have seen duplicate intents on Android 15, need to check other versions
log.warn { "Ignoring intent with package: $intent" }
return
}
when (intent.action) {
Intent.ACTION_PACKAGE_ADDED -> onPackageAdded(intent)
Intent.ACTION_PACKAGE_REMOVED -> onPackageRemoved(intent)
else -> log.error { "Unknown broadcast received: $intent" }
}
}
private fun onPackageAdded(intent: Intent) {
val replacing = intent.getBooleanExtra(EXTRA_REPLACING, false)
log.info { "onPackageAdded($intent) ${intent.data} replacing: $replacing" }
val packageName = intent.data?.schemeSpecificPart
?: error("No package name in ACTION_PACKAGE_ADDED")
try {
@Suppress("DEPRECATION") // we'll use this as long as it works, new one was broken
val packageInfo = packageManager.getPackageInfo(packageName, GET_SIGNATURES)
// even if the app got replaced, we need to update packageInfo for new version code
_installedApps.update {
it.toMutableMap().apply {
put(packageName, packageInfo)
}
}
} catch (e: PackageManager.NameNotFoundException) {
// Broadcasts don't always get delivered on time. So when this broadcast arrives,
// the user may already have uninstalled the app.
log.warn(e) { "Maybe broadcast was late? App not installed anymore: " }
}
}
private fun onPackageRemoved(intent: Intent) {
val replacing = intent.getBooleanExtra(EXTRA_REPLACING, false)
log.info { "onPackageRemoved($intent) ${intent.data} replacing: $replacing" }
val packageName = intent.data?.schemeSpecificPart
?: error("No package name in ACTION_PACKAGE_REMOVED")
if (!replacing) _installedApps.update { apps ->
apps.toMutableMap().apply {
remove(packageName)
}
}
}
}

View File

@@ -0,0 +1,15 @@
package org.fdroid.install
import android.app.PendingIntent
sealed interface PreApprovalResult {
data object NotSupported : PreApprovalResult
data object UserAborted : PreApprovalResult
data class UserConfirmationRequired(
val sessionId: Int,
val intent: PendingIntent,
) : PreApprovalResult
data class Success(val sessionId: Int) : PreApprovalResult
data class Error(val errorMsg: String?) : PreApprovalResult
}

View File

@@ -0,0 +1,417 @@
package org.fdroid.install
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.app.PendingIntent.FLAG_IMMUTABLE
import android.app.PendingIntent.FLAG_MUTABLE
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.IntentSender
import android.content.pm.PackageInfo
import android.content.pm.PackageInstaller
import android.content.pm.PackageInstaller.EXTRA_PACKAGE_NAME
import android.content.pm.PackageInstaller.EXTRA_SESSION_ID
import android.content.pm.PackageInstaller.SessionParams
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.icu.util.ULocale
import android.os.Build.VERSION.SDK_INT
import androidx.annotation.RequiresApi
import androidx.annotation.WorkerThread
import androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED
import androidx.core.content.ContextCompat.registerReceiver
import androidx.core.os.LocaleListCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import mu.KotlinLogging
import org.fdroid.LocaleChooser.getBestLocale
import org.fdroid.database.AppMetadata
import org.fdroid.database.AppVersion
import org.fdroid.ui.utils.isAppInForeground
import org.fdroid.utils.IoDispatcher
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
@Singleton
class SessionInstallManager @Inject constructor(
@param:ApplicationContext private val context: Context,
@param:IoDispatcher private val coroutineScope: CoroutineScope,
) {
private val log = KotlinLogging.logger { }
private val installer = context.packageManager.packageInstaller
companion object {
private const val ACTION_INSTALL = "org.fdroid.install.SessionInstallManager.install"
/**
* If this returns true, we can use
* [SessionParams.setRequireUserAction] with false,
* thus updating the app with the given targetSdk without user action.
*/
fun isAutoUpdateSupported(targetSdk: Int): Boolean {
if (SDK_INT < 31) return false // not supported below Android 12
if (SDK_INT == 31 && targetSdk >= 29) return true
if (SDK_INT == 32 && targetSdk >= 29) return true
if (SDK_INT == 33 && targetSdk >= 30) return true
if (SDK_INT == 34 && targetSdk >= 31) return true
if (SDK_INT == 35 && targetSdk >= 33) return true
// This needs to be adjusted as new Android versions are released
// https://developer.android.com/reference/android/content/pm/PackageInstaller.SessionParams#setRequireUserAction(int)
// https://cs.android.com/android/platform/superproject/+/android-16.0.0_r2:frameworks/base/services/core/java/com/android/server/pm/PackageInstallerSession.java;l=329;drc=73caa0299d9196ddeefe4f659f557fb880f6536d
// current code requires targetSdk 34 on SDK 36+
return SDK_INT >= 36 && targetSdk >= 34
}
}
init {
// abandon old sessions, because there's a limit
// that will throw IllegalStateException when we try to open new sessions
coroutineScope.launch {
for (session in installer.mySessions) {
log.debug { "Abandon session ${session.sessionId} for ${session.appPackageName}" }
try {
installer.abandonSession(session.sessionId)
} catch (e: SecurityException) {
log.error(e) { "Error abandoning session: " }
}
}
}
}
/**
* Requests installation pre-approval (if available on this device).
*/
suspend fun requestPreapproval(
app: AppMetadata,
icon: Bitmap?,
isUpdate: Boolean,
version: AppVersion,
canRequestUserConfirmationNow: Boolean,
): PreApprovalResult {
return if (!context.isAppInForeground()) {
log.info { "App not in foreground, pre-approval for ${app.packageName} not supported." }
PreApprovalResult.NotSupported
} else if (isUpdate && canDoAutoUpdate(version)) {
// should not be needed, so we say not supported
log.info { "Can do auto-update pre-approval for ${app.packageName} not needed." }
PreApprovalResult.NotSupported
} else if (SDK_INT >= 34) {
log.info { "Requesting pre-approval for ${app.packageName}..." }
try {
preapproval(app, icon, canRequestUserConfirmationNow)
} catch (e: Exception) {
log.error(e) { "Error requesting pre-approval for ${app.packageName}: " }
PreApprovalResult.Error("${e::class.java.simpleName} ${e.message}")
}
} else {
PreApprovalResult.NotSupported
}
}
@RequiresApi(34)
private suspend fun preapproval(
app: AppMetadata,
icon: Bitmap?,
canRequestUserConfirmationNow: Boolean,
): PreApprovalResult = suspendCancellableCoroutine { cont ->
val params = getSessionParams(app.packageName)
val sessionId = installer.createSession(params)
log.info { "Opened session $sessionId for ${app.packageName}" }
val name = app.name.getBestLocale(LocaleListCompat.getDefault()) ?: ""
val receiver = InstallBroadcastReceiver(sessionId) { status, intent, msg ->
when (status) {
PackageInstaller.STATUS_SUCCESS -> {
cont.resume(PreApprovalResult.Success(sessionId))
context.unregisterReceiver(this)
}
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
val flags = FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE
val pendingIntent =
PendingIntent.getActivity(context, sessionId, intent, flags)
// There should be no bugs on Android versions where this is supported
// and we should be in the foreground right now,
// so fire up intent here and now.
if (canRequestUserConfirmationNow) {
log.info { "Sending pre-approval intent for ${app.packageName}: $intent" }
pendingIntent.send()
} else {
log.info { "Can not ask pre-approval for ${app.packageName}: $intent" }
val s = PreApprovalResult.UserConfirmationRequired(sessionId, pendingIntent)
cont.resume(s)
context.unregisterReceiver(this)
}
}
else -> {
val result = when (status) {
PackageInstaller.STATUS_FAILURE_ABORTED -> PreApprovalResult.UserAborted
PackageInstaller.STATUS_FAILURE_BLOCKED -> PreApprovalResult.NotSupported
else -> PreApprovalResult.Error(msg)
}
cont.resume(result)
context.unregisterReceiver(this)
}
}
}
registerReceiver(
context,
receiver,
IntentFilter(ACTION_INSTALL),
RECEIVER_NOT_EXPORTED
)
cont.invokeOnCancellation {
log.info { "Pre-approval for ${app.packageName} cancelled." }
context.unregisterReceiver(receiver)
}
installer.openSession(sessionId).use { session ->
log.info { "app name locales: ${app.name} using: ${ULocale.getDefault()}" }
val details = PackageInstaller.PreapprovalDetails.Builder()
.setPackageName(app.packageName)
.setLabel(name)
.setLocale(ULocale.getDefault()) // TODO get the real one used for label
.apply { if (icon != null) setIcon(icon) }
.build()
val sender = getInstallIntentSender(sessionId, app.packageName)
session.requestUserPreapproval(details, sender)
}
}
@WorkerThread
@SuppressLint("RequestInstallPackagesPolicy")
suspend fun install(
sessionId: Int?,
packageName: String,
state: InstallStateWithInfo,
apkFile: File,
): InstallState = suspendCancellableCoroutine { cont ->
val size = apkFile.length()
log.info { "Installing ${apkFile.name} with size $size bytes" }
val sessionId = try {
if (sessionId == null) {
val params = getSessionParams(packageName, size)
installer.createSession(params)
} else {
sessionId
}
} catch (e: Exception) {
log.error(e) { "Error when creating session: " }
val s = InstallState.Error("${e::class.java.simpleName} ${e.message}", state)
cont.resume(s)
return@suspendCancellableCoroutine
}
// set-up receiver for install result
val receiver = InstallBroadcastReceiver(sessionId) { status, intent, msg ->
context.unregisterReceiver(this)
when (status) {
PackageInstaller.STATUS_SUCCESS -> {
val newState = InstallState.Installed(
name = state.name,
versionName = state.versionName,
currentVersionName = state.currentVersionName,
lastUpdated = state.lastUpdated,
iconDownloadRequest = state.iconDownloadRequest,
)
cont.resume(newState)
}
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
val flags = if (SDK_INT >= 31) {
FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE
} else {
FLAG_UPDATE_CURRENT
}
val pendingIntent =
PendingIntent.getActivity(context, sessionId, intent, flags)
val progress = installer.getSessionInfo(sessionId)?.progress
?: error("No session info for $sessionId")
cont.resume(
InstallState.UserConfirmationNeeded(
state = state,
sessionId = sessionId,
intent = pendingIntent,
progress = progress,
)
)
}
else -> {
if (status == PackageInstaller.STATUS_FAILURE_ABORTED) {
cont.resume(InstallState.UserAborted)
} else if (status == PackageInstaller.STATUS_FAILURE &&
msg != null &&
msg.contains("PreapprovalDetails")
) {
val newState = InstallState.PreApproved(
name = state.name,
versionName = state.versionName,
currentVersionName = state.currentVersionName,
lastUpdated = state.lastUpdated,
iconDownloadRequest = state.iconDownloadRequest,
result = PreApprovalResult.Error(msg),
)
cont.resume(newState)
} else {
cont.resume(InstallState.Error(msg, state))
}
}
}
}
registerReceiver(
context,
receiver,
IntentFilter(ACTION_INSTALL),
RECEIVER_NOT_EXPORTED
)
cont.invokeOnCancellation {
log.info { "App installation was cancelled, unregistering broadcast receiver..." }
context.unregisterReceiver(receiver)
try {
installer.abandonSession(sessionId)
} catch (e: SecurityException) {
// this can happen if the cancellation came too late and session already concluded
log.warn(e) { "Error while abandoning session: " }
}
}
// do the actual installation
try {
installer.openSession(sessionId).use { session ->
apkFile.inputStream().use { inputStream ->
session.openWrite(packageName, 0, size).use { outputStream ->
inputStream.copyTo(outputStream)
session.fsync(outputStream)
}
}
val sender = getInstallIntentSender(sessionId, packageName)
log.info { "Committing session..." }
session.commit(sender)
}
} catch (e: Exception) {
log.error(e) { "Error during install session: " }
cont.resume(InstallState.Error("${e::class.java.simpleName} ${e.message}", state))
}
}
suspend fun requestUserConfirmation(
state: InstallConfirmationState,
): InstallState = suspendCancellableCoroutine { cont ->
val isPreApproval = state is InstallState.PreApprovalConfirmationNeeded
val receiver = InstallBroadcastReceiver(state.sessionId) { status, _, msg ->
context.unregisterReceiver(this)
when (status) {
PackageInstaller.STATUS_SUCCESS -> {
val newState = if (isPreApproval) InstallState.PreApproved(
name = state.name,
versionName = state.versionName,
currentVersionName = state.currentVersionName,
lastUpdated = state.lastUpdated,
iconDownloadRequest = state.iconDownloadRequest,
result = PreApprovalResult.Success(state.sessionId),
) else InstallState.Installed(
name = state.name,
versionName = state.versionName,
currentVersionName = state.currentVersionName,
lastUpdated = state.lastUpdated,
iconDownloadRequest = state.iconDownloadRequest,
)
cont.resume(newState)
}
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
error("Got STATUS_PENDING_USER_ACTION again")
}
else -> {
if (status == PackageInstaller.STATUS_FAILURE_ABORTED) {
cont.resume(InstallState.UserAborted)
} else {
cont.resume(InstallState.Error(msg, state))
}
}
}
}
registerReceiver(
context,
receiver,
IntentFilter(ACTION_INSTALL),
RECEIVER_NOT_EXPORTED,
)
cont.invokeOnCancellation {
context.unregisterReceiver(receiver)
}
state.intent.send()
}
private fun getSessionParams(packageName: String, size: Long? = null): SessionParams {
val params = SessionParams(SessionParams.MODE_FULL_INSTALL)
params.setAppPackageName(packageName)
size?.let { params.setSize(it) }
params.setInstallLocation(PackageInfo.INSTALL_LOCATION_AUTO)
if (SDK_INT >= 26) {
params.setInstallReason(PackageManager.INSTALL_REASON_USER)
}
if (SDK_INT >= 31) {
params.setRequireUserAction(SessionParams.USER_ACTION_NOT_REQUIRED)
}
if (SDK_INT >= 33) {
params.setPackageSource(PackageInstaller.PACKAGE_SOURCE_STORE)
}
if (SDK_INT >= 34) {
// Once the update ownership enforcement is enabled,
// the other installers will need the user action to update the package
// even if the installers have been granted the INSTALL_PACKAGES permission.
// The update ownership enforcement can only be enabled on initial installation.
// Set this to true on package update is a no-op.
params.setRequestUpdateOwnership(true)
}
return params
}
private fun canDoAutoUpdate(version: AppVersion): Boolean {
if (SDK_INT < 31) return false
val targetSdkVersion = version.manifest.targetSdkVersion ?: return false
// docs: https://developer.android.com/reference/android/content/pm/PackageInstaller.SessionParams#setRequireUserAction(int)
return if (isAutoUpdateSupported(targetSdkVersion)) {
val ourPackageName = context.packageName
if (ourPackageName == version.packageName) return true
val sourceInfo = try {
context.packageManager.getInstallSourceInfo(version.packageName)
} catch (e: Exception) {
log.error(e) { "Could not get package info: " }
return false
}
if (SDK_INT >= 34 && sourceInfo.updateOwnerPackageName == ourPackageName) {
true
} else if (sourceInfo.installingPackageName == ourPackageName) {
true
} else {
false
}
} else {
false
}
}
private fun getInstallIntentSender(
sessionId: Int,
packageName: String,
): IntentSender {
// Don't use a different action for preapproval and installation,
// because Android sometimes sends installation broadcasts to preapproval intent.
val broadcastIntent = Intent(ACTION_INSTALL).apply {
setPackage(context.packageName)
putExtra(EXTRA_SESSION_ID, sessionId)
putExtra(EXTRA_PACKAGE_NAME, packageName)
addFlags(Intent.FLAG_RECEIVER_FOREGROUND)
}
// intent flag needs to be mutable, otherwise the intent has no extras
val flags = if (SDK_INT >= 31) FLAG_UPDATE_CURRENT or FLAG_MUTABLE else FLAG_UPDATE_CURRENT
val pendingIntent = PendingIntent.getBroadcast(context, sessionId, broadcastIntent, flags)
return pendingIntent.intentSender
}
}

View File

@@ -0,0 +1,76 @@
package org.fdroid.repo
import android.content.Context
import androidx.annotation.WorkerThread
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import org.fdroid.database.FDroidDatabase
import org.fdroid.database.InitialRepository
import org.fdroid.database.RepositoryDao
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class RepoPreLoader @Inject constructor(
@param:ApplicationContext private val context: Context,
) {
@get:WorkerThread
val defaultRepoAddresses: Set<String> by lazy {
getDefaultRepos().map { it.address }.toSet()
}
@WorkerThread
@OptIn(ExperimentalSerializationApi::class)
fun addPreloadedRepositories(db: FDroidDatabase) {
addRepositories(db.getRepositoryDao(), getDefaultRepos())
// "system" can be removed when minSdk is 28
for (root in listOf("/system", "/system_ext", "/product", "/vendor")) {
val romReposFile = File("$root/etc/${context.packageName}/additional_repos.json")
if (romReposFile.isFile) {
val romRepos = romReposFile.inputStream().use { inputStream ->
Json.decodeFromStream<List<DefaultRepository>>(inputStream)
}
addRepositories(db.getRepositoryDao(), romRepos)
}
}
}
@WorkerThread
@OptIn(ExperimentalSerializationApi::class)
private fun getDefaultRepos() = context.assets.open("default_repos.json").use { inputStream ->
Json.decodeFromStream<List<DefaultRepository>>(inputStream)
}
private fun addRepositories(
repositoryDao: RepositoryDao,
repositories: List<DefaultRepository>
) {
repositories.forEach { repository ->
val initialRepository = InitialRepository(
name = repository.name,
address = repository.address,
mirrors = repository.mirrors,
description = repository.description,
certificate = repository.certificate,
version = 1,
enabled = repository.enabled,
)
repositoryDao.insert(initialRepository)
}
}
}
@Serializable
data class DefaultRepository(
val name: String,
val address: String,
val mirrors: List<String> = emptyList(),
val description: String,
val certificate: String,
val enabled: Boolean,
)

View File

@@ -0,0 +1,295 @@
package org.fdroid.repo
import android.content.Context
import android.text.format.Formatter
import androidx.annotation.FloatRange
import androidx.annotation.IntRange
import androidx.annotation.VisibleForTesting
import androidx.annotation.WorkerThread
import androidx.core.os.LocaleListCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import mu.KotlinLogging
import org.fdroid.CompatibilityChecker
import org.fdroid.CompatibilityCheckerImpl
import org.fdroid.NotificationManager
import org.fdroid.R
import org.fdroid.database.FDroidDatabase
import org.fdroid.database.Repository
import org.fdroid.download.DownloaderFactory
import org.fdroid.index.IndexUpdateListener
import org.fdroid.index.IndexUpdateResult
import org.fdroid.index.RepoManager
import org.fdroid.index.RepoUpdater
import org.fdroid.settings.SettingsManager
import org.fdroid.ui.utils.addressForUi
import org.fdroid.updates.UpdatesManager
import javax.inject.Inject
import javax.inject.Singleton
private const val MIN_UPDATE_INTERVAL_MILLIS = 15_000
@Singleton
class RepoUpdateManager @VisibleForTesting internal constructor(
private val context: Context,
private val db: FDroidDatabase,
private val repoManager: RepoManager,
private val updatesManager: UpdatesManager,
private val settingsManager: SettingsManager,
private val downloaderFactory: DownloaderFactory,
private val notificationManager: NotificationManager,
private val compatibilityChecker: CompatibilityChecker = CompatibilityCheckerImpl(
packageManager = context.packageManager,
forceTouchApps = false,
),
private val repoUpdateListener: RepoUpdateListener =
RepoUpdateListener(context, notificationManager),
private val repoUpdater: RepoUpdater = RepoUpdater(
tempDir = context.cacheDir,
db = db,
downloaderFactory = downloaderFactory,
compatibilityChecker = compatibilityChecker,
listener = repoUpdateListener,
),
) {
@Inject
constructor(
@ApplicationContext context: Context,
db: FDroidDatabase,
repositoryManager: RepoManager,
updatesManager: UpdatesManager,
settingsManager: SettingsManager,
downloaderFactory: DownloaderFactory,
notificationManager: NotificationManager,
) : this(
context = context,
db = db,
repoManager = repositoryManager,
updatesManager = updatesManager,
settingsManager = settingsManager,
downloaderFactory = downloaderFactory,
notificationManager = notificationManager,
)
private val log = KotlinLogging.logger { }
private val _isUpdating = MutableStateFlow(false)
val isUpdating = _isUpdating.asStateFlow()
val repoUpdateState = repoUpdateListener.updateState
/**
* The time in milliseconds of the (earliest!) next automatic repo update check.
* This is [Long.MAX_VALUE], if no time is known.
*/
val nextUpdateFlow = RepoUpdateWorker.getAutoUpdateWorkInfo(context).map { workInfo ->
workInfo?.nextScheduleTimeMillis ?: Long.MAX_VALUE
}
@WorkerThread
suspend fun updateRepos() {
currentCoroutineContext().ensureActive()
if (isUpdating.value) {
// This is a workaround for what looks like a WorkManager bug.
// Sometimes it goes through scheduling/cancellation loops
// and then ends up running the same worker more than once.
log.warn { "Already updating repositories in updateRepos() not doing it again." }
return
}
val timeSinceLastCheck = System.currentTimeMillis() - settingsManager.lastRepoUpdate
if (timeSinceLastCheck < MIN_UPDATE_INTERVAL_MILLIS) {
// This is a workaround for a similar issue as above.
// We've seen WorkManager tell our worker to run in what looks like an endless loop.
log.info { "Not updating, only $timeSinceLastCheck ms since last check." }
return
}
_isUpdating.value = true
try {
currentCoroutineContext().ensureActive()
var reposUpdated = false
// always get repos fresh from DB, because
// * when an update is requested early at app start,
// the repos above might not be available, yet
// * when an update is requested when adding a new repo,
// it might not be in the FDroidApp list, yet
db.getRepositoryDao().getRepositories().forEach { repo ->
if (!repo.enabled) return@forEach
currentCoroutineContext().ensureActive()
repoUpdateListener.onUpdateStarted(repo.repoId)
// show notification
val repoName = repo.getName(LocaleListCompat.getDefault())
val msg = context.getString(R.string.notification_repo_update_default, repoName)
notificationManager.showUpdateRepoNotification(msg, throttle = false)
// update repo
val result = repoUpdater.update(repo)
log.info { "Update repo result: $result" }
repoUpdateListener.onUpdateFinished(repo.repoId, result)
if (result is IndexUpdateResult.Processed) reposUpdated = true
else if (result is IndexUpdateResult.Error) {
log.error(result.e) { "Error updating repository ${repo.address} " }
}
}
db.getRepositoryDao().walCheckpoint()
// don't update time on first start when repos failed to update
if (!settingsManager.isFirstStart || reposUpdated) {
settingsManager.lastRepoUpdate = System.currentTimeMillis()
}
if (reposUpdated) {
updatesManager.loadUpdates().join()
val numUpdates = updatesManager.numUpdates.value
if (numUpdates > 0) {
val states = updatesManager.notificationStates
notificationManager.showAppUpdatesAvailableNotification(states)
}
}
} finally {
notificationManager.cancelUpdateRepoNotification()
_isUpdating.value = false
}
}
@WorkerThread
fun updateRepo(repoId: Long): IndexUpdateResult {
if (isUpdating.value) log.warn { "Already updating repositories: updateRepo($repoId)" }
val repo = repoManager.getRepository(repoId) ?: return IndexUpdateResult.NotFound
_isUpdating.value = true
return try {
repoUpdateListener.onUpdateStarted(repo.repoId)
// show notification
val repoName = repo.getName(LocaleListCompat.getDefault())
val msg = context.getString(R.string.notification_repo_update_default, repoName)
notificationManager.showUpdateRepoNotification(msg, throttle = false)
// update repo
val result = repoUpdater.update(repo)
log.info { "Update repo result: $result" }
repoUpdateListener.onUpdateFinished(repo.repoId, result)
if (result is IndexUpdateResult.Processed) {
updatesManager.loadUpdates()
} else if (result is IndexUpdateResult.Error) {
log.error(result.e) { "Error updating ${repo.address}: " }
}
result
} finally {
notificationManager.cancelUpdateRepoNotification()
_isUpdating.value = false
db.getRepositoryDao().walCheckpoint()
}
}
}
@VisibleForTesting
internal class RepoUpdateListener(
private val context: Context,
private val notificationManager: NotificationManager,
) : IndexUpdateListener {
private val log = KotlinLogging.logger { }
private val _updateState = MutableStateFlow<RepoUpdateState?>(null)
val updateState = _updateState.asStateFlow()
private var lastUpdateProgress = 0L
fun onUpdateStarted(repoId: Long) {
_updateState.update { RepoUpdateProgress(repoId, true, 0) }
}
override fun onDownloadProgress(repo: Repository, bytesRead: Long, totalBytes: Long) {
log.debug { "Downloading ${repo.address} ($bytesRead/$totalBytes)" }
val percent = getPercent(bytesRead, totalBytes)
val size = Formatter.formatFileSize(context, bytesRead)
notificationManager.showUpdateRepoNotification(
msg = context.getString(
R.string.notification_repo_update_downloading,
size, repo.addressForUi
),
throttle = bytesRead != totalBytes,
progress = percent,
)
_updateState.update { RepoUpdateProgress(repo.repoId, true, percent) }
}
/**
* If an updater is unable to know how many apps it has to process (i.e. it
* is streaming apps to the database or performing a large database query
* which touches all apps, but is unable to report progress), then it call
* this listener with [totalApps] = 0. Doing so will result in a message of
* "Saving app details" sent to the user. If you know how many apps you have
* processed, then a message of "Saving app details (x/total)" is displayed.
*/
override fun onUpdateProgress(repo: Repository, appsProcessed: Int, totalApps: Int) {
// Don't update progress, if we already have updated once within the last second
if (System.currentTimeMillis() - lastUpdateProgress < 1000 && appsProcessed != totalApps) {
return
}
log.debug { "Committing ${repo.address} ($appsProcessed/$totalApps)" }
val repoName = repo.getName(LocaleListCompat.getDefault())
val msg = context.resources.getQuantityString(
R.plurals.notification_repo_update_saving,
appsProcessed,
appsProcessed, repoName,
)
if (totalApps > 0) {
val percent = getPercent(appsProcessed.toLong(), totalApps.toLong())
notificationManager.showUpdateRepoNotification(
msg = msg,
throttle = appsProcessed != totalApps,
progress = percent,
)
_updateState.update { RepoUpdateProgress(repo.repoId, false, percent) }
} else {
notificationManager.showUpdateRepoNotification(msg)
_updateState.update { RepoUpdateProgress(repo.repoId, false, 0f) }
}
lastUpdateProgress = System.currentTimeMillis()
}
fun onUpdateFinished(repoId: Long, result: IndexUpdateResult) {
_updateState.update { RepoUpdateFinished(repoId, result) }
}
private fun getPercent(current: Long, total: Long): Int {
if (total <= 0) return 0
return (100L * current / total).toInt()
}
}
sealed interface RepoUpdateState {
val repoId: Long
}
/**
* There's two types of progress. First, there's the download, so [isDownloading] is true.
* Then there's inserting the repo data into the DB, there [isDownloading] is false.
* The [stepProgress] gets re-used for both.
*
* An external unified view on that is given as [progress].
*/
data class RepoUpdateProgress(
override val repoId: Long,
private val isDownloading: Boolean,
@param:FloatRange(from = 0.0, to = 1.0) private val stepProgress: Float,
) : RepoUpdateState {
constructor(
repoId: Long,
isDownloading: Boolean,
@IntRange(from = 0, to = 100) percent: Int,
) : this(
repoId = repoId,
isDownloading = isDownloading,
stepProgress = percent.toFloat() / 100,
)
val progress: Float = if (isDownloading) stepProgress / 2 else 0.5f + stepProgress / 2
}
data class RepoUpdateFinished(
override val repoId: Long,
val result: IndexUpdateResult,
) : RepoUpdateState

View File

@@ -0,0 +1,159 @@
package org.fdroid.repo
import android.content.Context
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST
import android.os.Build.VERSION.SDK_INT
import android.util.Log
import androidx.annotation.UiThread
import androidx.hilt.work.HiltWorker
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy.UPDATE
import androidx.work.ForegroundInfo
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import mu.KotlinLogging
import org.fdroid.NotificationManager
import org.fdroid.NotificationManager.Companion.NOTIFICATION_ID_REPO_UPDATE
import org.fdroid.install.CacheCleaner
import org.fdroid.settings.SettingsConstants.AutoUpdateValues
import org.fdroid.ui.utils.canStartForegroundService
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeUnit.MINUTES
private val TAG = RepoUpdateWorker::class.java.simpleName
@HiltWorker
class RepoUpdateWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted workerParams: WorkerParameters,
private val repoUpdateManager: RepoUpdateManager,
private val cacheCleaner: CacheCleaner,
private val nm: NotificationManager,
) : CoroutineWorker(appContext, workerParams) {
companion object {
private const val UNIQUE_WORK_NAME_REPO_AUTO_UPDATE = "repoAutoUpdate"
/**
* Use this to trigger a manual repo update if the app is currently in the foreground.
*
* @param repoId The optional ID of the repo to update.
* If no ID is given, all (enabled) repos will be updated.
* Also triggers a clean cache job if no ID is given
*/
@UiThread
@JvmStatic
@JvmOverloads
fun updateNow(context: Context, repoId: Long = -1) {
val request = OneTimeWorkRequestBuilder<RepoUpdateWorker>()
.setExpedited(RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.apply {
if (repoId >= 0) setInputData(workDataOf("repoId" to repoId))
}
.build()
WorkManager.getInstance(context)
.enqueue(request)
}
@JvmStatic
fun scheduleOrCancel(context: Context, autoUpdate: AutoUpdateValues) {
val workManager = WorkManager.getInstance(context)
if (autoUpdate != AutoUpdateValues.Never) {
Log.i(TAG, "scheduleOrCancel: enqueueUniquePeriodicWork")
val networkType = if (autoUpdate == AutoUpdateValues.Always) {
NetworkType.CONNECTED
} else {
NetworkType.UNMETERED
}
val constraints = Constraints.Builder()
.setRequiresBatteryNotLow(true)
.setRequiresStorageNotLow(true)
.setRequiredNetworkType(networkType)
.build()
val workRequest = PeriodicWorkRequestBuilder<RepoUpdateWorker>(
repeatInterval = 4,
repeatIntervalTimeUnit = TimeUnit.HOURS,
flexTimeInterval = 15,
flexTimeIntervalUnit = MINUTES,
)
.setConstraints(constraints)
.build()
workManager.enqueueUniquePeriodicWork(
UNIQUE_WORK_NAME_REPO_AUTO_UPDATE,
UPDATE,
workRequest,
)
} else {
Log.w(TAG, "Cancelling job due to settings!")
workManager.cancelUniqueWork(UNIQUE_WORK_NAME_REPO_AUTO_UPDATE)
}
}
fun getAutoUpdateWorkInfo(context: Context): Flow<WorkInfo?> {
return WorkManager.getInstance(context).getWorkInfosForUniqueWorkFlow(
UNIQUE_WORK_NAME_REPO_AUTO_UPDATE
).map { it.getOrNull(0) }
}
}
private val log = KotlinLogging.logger { }
override suspend fun doWork(): Result {
log.info {
if (SDK_INT >= 31) {
"Starting RepoUpdateWorker... $this stopReason: ${this.stopReason} $runAttemptCount"
} else {
"Starting RepoUpdateWorker... $this $runAttemptCount"
}
}
try {
if (canStartForegroundService(applicationContext)) setForeground(getForegroundInfo())
} catch (e: Exception) {
log.error(e) { "Error while running setForeground: " }
}
val repoId = inputData.getLong("repoId", -1)
return try {
currentCoroutineContext().ensureActive()
if (repoId >= 0) repoUpdateManager.updateRepo(repoId)
else repoUpdateManager.updateRepos()
// use opportunity to clean up cached APKs
cacheCleaner.clean()
// return result
Result.success()
} catch (e: Exception) {
log.error(e) { "Error updating repos" }
if (runAttemptCount <= 3) {
Result.retry()
} else {
log.warn { "Not retrying, already tried $runAttemptCount times." }
Result.failure()
}
} finally {
log.info {
if (SDK_INT >= 31) "finished doWork $this (stopReason: ${this.stopReason})"
else "finished doWork $this"
}
}
}
override suspend fun getForegroundInfo(): ForegroundInfo {
return ForegroundInfo(
NOTIFICATION_ID_REPO_UPDATE,
nm.getRepoUpdateNotification().build(),
if (SDK_INT >= 29) FOREGROUND_SERVICE_TYPE_MANIFEST else 0
)
}
}

View File

@@ -0,0 +1,34 @@
package org.fdroid.repo
import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import org.fdroid.CompatibilityChecker
import org.fdroid.database.FDroidDatabase
import org.fdroid.download.DownloaderFactory
import org.fdroid.download.HttpManager
import org.fdroid.index.RepoManager
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object RepositoryModule {
@Provides
@Singleton
fun provideRepoManager(
@ApplicationContext context: Context,
db: FDroidDatabase,
downloaderFactory: DownloaderFactory,
httpManager: HttpManager,
compatibilityChecker: CompatibilityChecker,
): RepoManager = RepoManager(
context = context,
db = db,
downloaderFactory = downloaderFactory,
httpManager = httpManager,
compatibilityChecker = compatibilityChecker,
)
}

View File

@@ -0,0 +1,67 @@
package org.fdroid.settings
import android.content.Context
import android.content.Context.MODE_PRIVATE
import android.content.SharedPreferences
import androidx.core.content.edit
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class OnboardingManager @Inject constructor(
@param:ApplicationContext private val context: Context,
) {
private companion object {
const val KEY_FILTER = "appFilter"
const val KEY_REPO_LIST = "repoList"
const val KEY_REPO_DETAILS = "repoDetails"
}
private val prefs = context.getSharedPreferences("onboarding", MODE_PRIVATE)
private val _showFilterOnboarding = Onboarding(KEY_FILTER, prefs)
val showFilterOnboarding = _showFilterOnboarding.flow
private val _showRepositoriesOnboarding = Onboarding(KEY_REPO_LIST, prefs)
val showRepositoriesOnboarding = _showRepositoriesOnboarding.flow
private val _showRepoDetailsOnboarding = Onboarding(KEY_REPO_DETAILS, prefs)
val showRepoDetailsOnboarding = _showRepoDetailsOnboarding.flow
fun onFilterOnboardingSeen() {
_showFilterOnboarding.onSeen(prefs)
}
fun onRepositoriesOnboardingSeen() {
_showRepositoriesOnboarding.onSeen(prefs)
}
fun onRepoDetailsOnboardingSeen() {
_showRepoDetailsOnboarding.onSeen(prefs)
}
}
private data class Onboarding(
val key: String,
private val _flow: MutableStateFlow<Boolean>,
) {
constructor(key: String, prefs: SharedPreferences) : this(
key = key,
_flow = MutableStateFlow(prefs.getBoolean(key, true)),
)
val flow: StateFlow<Boolean> = _flow.asStateFlow()
fun onSeen(prefs: SharedPreferences) {
_flow.update { false }
prefs.edit {
putBoolean(key, false)
}
}
}

View File

@@ -0,0 +1,51 @@
package org.fdroid.settings
import org.fdroid.database.AppListSortOrder
import org.fdroid.settings.SettingsConstants.AutoUpdateValues
object SettingsConstants {
const val PREF_KEY_LAST_UPDATE_CHECK = "lastRepoUpdateCheck"
const val PREF_DEFAULT_LAST_UPDATE_CHECK = -1L
const val PREF_KEY_THEME = "theme"
const val PREF_DEFAULT_THEME = "followSystem"
const val PREF_KEY_DYNAMIC_COLORS = "dynamicColors"
const val PREF_DEFAULT_DYNAMIC_COLORS = false
enum class AutoUpdateValues { OnlyWifi, Always, Never }
const val PREF_KEY_REPO_UPDATES = "repoAutoUpdates"
val PREF_DEFAULT_REPO_UPDATES = AutoUpdateValues.OnlyWifi.name
const val PREF_KEY_AUTO_UPDATES = "appAutoUpdates"
val PREF_DEFAULT_AUTO_UPDATES = AutoUpdateValues.OnlyWifi.name
const val PREF_KEY_PROXY = "proxy"
const val PREF_DEFAULT_PROXY = ""
const val PREF_KEY_SHOW_INCOMPATIBLE = "incompatibleVersions"
const val PREF_DEFAULT_SHOW_INCOMPATIBLE = true
const val PREF_KEY_APP_LIST_SORT_ORDER = "appListSortOrder"
const val PREF_DEFAULT_APP_LIST_SORT_ORDER = "lastUpdated"
fun getAppListSortOrder(s: String?) = when (s) {
"name" -> AppListSortOrder.NAME
else -> AppListSortOrder.LAST_UPDATED
}
fun AppListSortOrder.toSettings() = when (this) {
AppListSortOrder.LAST_UPDATED -> "lastUpdated"
AppListSortOrder.NAME -> "name"
}
const val PREF_KEY_IGNORED_APP_ISSUES = "ignoredAppIssues"
}
fun String?.toAutoUpdateValue() = try {
if (this == null) AutoUpdateValues.OnlyWifi
else AutoUpdateValues.valueOf(this)
} catch (_: IllegalArgumentException) {
AutoUpdateValues.OnlyWifi
}

View File

@@ -0,0 +1,141 @@
package org.fdroid.settings
import android.content.Context
import android.content.Context.MODE_PRIVATE
import androidx.core.content.edit
import dagger.hilt.android.qualifiers.ApplicationContext
import io.ktor.client.engine.ProxyBuilder
import io.ktor.client.engine.ProxyConfig
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import me.zhanghai.compose.preference.createPreferenceFlow
import me.zhanghai.compose.preference.isDefaultPreferenceFlowLongSupportEnabled
import mu.KotlinLogging
import org.fdroid.database.AppListSortOrder
import org.fdroid.settings.SettingsConstants.PREF_DEFAULT_APP_LIST_SORT_ORDER
import org.fdroid.settings.SettingsConstants.PREF_DEFAULT_AUTO_UPDATES
import org.fdroid.settings.SettingsConstants.PREF_DEFAULT_DYNAMIC_COLORS
import org.fdroid.settings.SettingsConstants.PREF_DEFAULT_LAST_UPDATE_CHECK
import org.fdroid.settings.SettingsConstants.PREF_DEFAULT_PROXY
import org.fdroid.settings.SettingsConstants.PREF_DEFAULT_REPO_UPDATES
import org.fdroid.settings.SettingsConstants.PREF_DEFAULT_SHOW_INCOMPATIBLE
import org.fdroid.settings.SettingsConstants.PREF_DEFAULT_THEME
import org.fdroid.settings.SettingsConstants.PREF_KEY_APP_LIST_SORT_ORDER
import org.fdroid.settings.SettingsConstants.PREF_KEY_AUTO_UPDATES
import org.fdroid.settings.SettingsConstants.PREF_KEY_DYNAMIC_COLORS
import org.fdroid.settings.SettingsConstants.PREF_KEY_IGNORED_APP_ISSUES
import org.fdroid.settings.SettingsConstants.PREF_KEY_LAST_UPDATE_CHECK
import org.fdroid.settings.SettingsConstants.PREF_KEY_PROXY
import org.fdroid.settings.SettingsConstants.PREF_KEY_REPO_UPDATES
import org.fdroid.settings.SettingsConstants.PREF_KEY_SHOW_INCOMPATIBLE
import org.fdroid.settings.SettingsConstants.PREF_KEY_THEME
import org.fdroid.settings.SettingsConstants.getAppListSortOrder
import org.fdroid.settings.SettingsConstants.toSettings
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SettingsManager @Inject constructor(
@param:ApplicationContext private val context: Context,
) {
private val log = KotlinLogging.logger {}
private val prefs by lazy {
context.getSharedPreferences("${context.packageName}_preferences", MODE_PRIVATE)
}
/**
* This is mutable, so the settings UI can make changes to it.
*/
val prefsFlow by lazy {
isDefaultPreferenceFlowLongSupportEnabled = true
createPreferenceFlow(prefs)
}
val theme get() = prefs.getString(PREF_KEY_THEME, PREF_DEFAULT_THEME)!!
val themeFlow = prefsFlow.map { it.get<String>(PREF_KEY_THEME) }.distinctUntilChanged()
val dynamicColorFlow: Flow<Boolean> = prefsFlow.map {
it.get<Boolean>(PREF_KEY_DYNAMIC_COLORS) ?: PREF_DEFAULT_DYNAMIC_COLORS
}.distinctUntilChanged()
val repoUpdates
get() = prefs.getString(PREF_KEY_REPO_UPDATES, PREF_DEFAULT_REPO_UPDATES)
.toAutoUpdateValue()
val repoUpdatesFlow
get() = prefsFlow.map {
it.get<String>(PREF_KEY_REPO_UPDATES).toAutoUpdateValue()
}.distinctUntilChanged()
val autoUpdateApps
get() = prefs.getString(PREF_KEY_AUTO_UPDATES, PREF_DEFAULT_AUTO_UPDATES)
.toAutoUpdateValue()
val autoUpdateAppsFlow
get() = prefsFlow.map {
it.get<String>(PREF_KEY_AUTO_UPDATES).toAutoUpdateValue()
}.distinctUntilChanged()
var lastRepoUpdate: Long
get() = try {
prefs.getLong(PREF_KEY_LAST_UPDATE_CHECK, PREF_DEFAULT_LAST_UPDATE_CHECK)
} catch (_: Exception) {
PREF_DEFAULT_LAST_UPDATE_CHECK
}
set(value) {
prefs.edit { putLong(PREF_KEY_LAST_UPDATE_CHECK, value) }
_lastRepoUpdateFlow.update { value }
}
private val _lastRepoUpdateFlow = MutableStateFlow(lastRepoUpdate)
val lastRepoUpdateFlow = _lastRepoUpdateFlow.asStateFlow()
val isFirstStart get() = lastRepoUpdate <= PREF_DEFAULT_LAST_UPDATE_CHECK.toLong()
/**
* A set of package name for which we should not show app issues.
*/
var ignoredAppIssues: Map<String, Long>
get() = try {
prefs.getStringSet(PREF_KEY_IGNORED_APP_ISSUES, emptySet<String>())?.associate {
val (packageName, versionCode) = it.split('|')
Pair(packageName, versionCode.toLong())
} ?: emptyMap()
} catch (e: Exception) {
log.error(e) { "Error parsing ignored app issues: " }
emptyMap()
}
private set(value) {
val newValue = value.map { (packageName, versionCode) -> "$packageName|$versionCode" }
prefs.edit { putStringSet(PREF_KEY_IGNORED_APP_ISSUES, newValue.toSet()) }
}
val proxyConfig: ProxyConfig?
get() {
val proxyStr = prefs.getString(PREF_KEY_PROXY, PREF_DEFAULT_PROXY)
return if (proxyStr.isNullOrBlank()) null
else {
val (host, port) = proxyStr.split(':')
ProxyBuilder.socks(host, port.toInt())
}
}
val filterIncompatible: Boolean
get() = !prefs.getBoolean(PREF_KEY_SHOW_INCOMPATIBLE, PREF_DEFAULT_SHOW_INCOMPATIBLE)
val appListSortOrder: AppListSortOrder
get() {
val s = prefs.getString(PREF_KEY_APP_LIST_SORT_ORDER, PREF_DEFAULT_APP_LIST_SORT_ORDER)
return getAppListSortOrder(s)
}
fun saveAppListFilter(sortOrder: AppListSortOrder, filterIncompatible: Boolean) {
prefs.edit {
putBoolean(PREF_KEY_SHOW_INCOMPATIBLE, !filterIncompatible)
putString(PREF_KEY_APP_LIST_SORT_ORDER, sortOrder.toSettings())
}
}
fun ignoreAppIssue(packageName: String, versionCode: Long) {
val newMap = ignoredAppIssues.toMutableMap().apply {
put(packageName, versionCode)
}
ignoredAppIssues = newMap
}
}

View File

@@ -0,0 +1,213 @@
package org.fdroid.ui
import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.Forum
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.MonetizationOn
import androidx.compose.material.icons.filled.Translate
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults.enterAlwaysScrollBehavior
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.Hyphens
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.fdroid.BuildConfig.VERSION_NAME
import org.fdroid.R
import org.fdroid.ui.utils.openUriSafe
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun About(onBackClicked: (() -> Unit)?) {
val scrollBehavior = enterAlwaysScrollBehavior(rememberTopAppBarState())
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
if (onBackClicked != null) IconButton(onClick = onBackClicked) {
Icon(
imageVector = Icons.AutoMirrored.Default.ArrowBack,
contentDescription = stringResource(R.string.back),
)
}
},
title = {
Text(stringResource(R.string.about_title_full))
},
scrollBehavior = scrollBehavior,
)
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
) { paddingValues ->
AboutContent(Modifier.fillMaxSize(), paddingValues)
}
}
@Composable
fun AboutContent(modifier: Modifier = Modifier, paddingValues: PaddingValues = PaddingValues()) {
val scrollableState = rememberScrollState()
Box(
modifier = modifier.verticalScroll(scrollableState)
) {
Column(
verticalArrangement = spacedBy(8.dp),
modifier = Modifier
.padding(paddingValues)
.padding(16.dp)
) {
AboutHeader()
AboutText()
}
}
}
@Composable
private fun AboutHeader(modifier: Modifier = Modifier) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier.fillMaxWidth()
) {
Image(
painter = painterResource(id = R.drawable.ic_launcher_foreground),
contentDescription = null, // decorative element
modifier = Modifier
.fillMaxWidth(0.25f)
.aspectRatio(1f)
.semantics { hideFromAccessibility() }
)
SelectionContainer {
Text(
text = "${stringResource(R.string.about_version)} $VERSION_NAME",
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.padding(top = 16.dp)
.alpha(0.75f)
)
}
}
}
@Composable
private fun AboutText() {
SelectionContainer {
Text(
text = stringResource(R.string.about_text),
textAlign = TextAlign.Justify,
style = MaterialTheme.typography.bodyLarge.copy(hyphens = Hyphens.Auto),
modifier = Modifier.padding(top = 16.dp),
)
}
val uriHandler = LocalUriHandler.current
Text(
text = stringResource(R.string.links),
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Justify,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(top = 8.dp)
)
AboutLink(
text = stringResource(R.string.menu_website),
icon = Icons.Default.Home,
onClick = { uriHandler.openUriSafe("https://f-droid.org") },
)
AboutLink(
text = stringResource(R.string.about_forum),
icon = Icons.Default.Forum,
onClick = { uriHandler.openUriSafe("https://forum.f-droid.org") },
)
AboutLink(
text = stringResource(R.string.menu_translation),
icon = Icons.Default.Translate,
onClick = {
uriHandler.openUriSafe("https://f-droid.org/en/docs/Translation_and_Localization/")
},
)
AboutLink(
text = stringResource(R.string.donate_title),
icon = Icons.Default.MonetizationOn,
onClick = { uriHandler.openUriSafe("https://f-droid.org/donate/") },
)
AboutLink(
text = stringResource(R.string.about_source),
icon = Icons.Default.Code,
onClick = { uriHandler.openUriSafe("https://gitlab.com/fdroid/fdroidclient") },
)
Text(
text = stringResource(R.string.about_license),
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.bodyLarge,
)
SelectionContainer {
Text(
text = stringResource(R.string.about_license_text),
textAlign = TextAlign.Justify,
style = MaterialTheme.typography.bodyLarge.copy(hyphens = Hyphens.Auto),
)
}
}
@Composable
private fun AboutLink(text: String, icon: ImageVector, onClick: () -> Unit) {
TextButton(onClick = onClick) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.semantics { hideFromAccessibility() }
)
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
Text(text = text)
}
}
@Preview(showBackground = true)
@Composable
private fun AboutPreview() {
FDroidContent {
About {}
}
}
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun AboutPreviewDark() {
FDroidContent {
About(null)
}
}

View File

@@ -0,0 +1,78 @@
package org.fdroid.ui
import androidx.compose.ui.graphics.Color
// Generated by the Material Theme Builder from fdroid_blue and fdroid_green
// https://www.figma.com/community/plugin/1034969338659738588
val primaryLight = Color(0xFF005197)
val onPrimaryLight = Color(0xFFFFFFFF)
val primaryContainerLight = Color(0xFF1976D2)
val onPrimaryContainerLight = Color(0xFFFFFFFF)
val secondaryLight = Color(0xFF4F6600)
val onSecondaryLight = Color(0xFFFFFFFF)
val secondaryContainerLight = Color(0xFF95BC18)
val onSecondaryContainerLight = Color(0xFF1C2700)
val tertiaryLight = Color(0xFF763192)
val onTertiaryLight = Color(0xFFFFFFFF)
val tertiaryContainerLight = Color(0xFF9F58BA)
val onTertiaryContainerLight = Color(0xFFFFFFFF)
val errorLight = Color(0xFFBA1A1A)
val onErrorLight = Color(0xFFFFFFFF)
val errorContainerLight = Color(0xFFFFDAD6)
val onErrorContainerLight = Color(0xFF410002)
val backgroundLight = Color(0xFFF9F9FF)
val onBackgroundLight = Color(0xFF181C21)
val surfaceLight = Color(0xFFF9F9FF)
val onSurfaceLight = Color(0xFF181C21)
val surfaceVariantLight = Color(0xFFDDE2F0)
val onSurfaceVariantLight = Color(0xFF414752)
val outlineLight = Color(0xFF717783)
val outlineVariantLight = Color(0xFFC1C6D4)
val scrimLight = Color(0xFF000000)
val inverseSurfaceLight = Color(0xFF2D3037)
val inverseOnSurfaceLight = Color(0xFFEFF0F9)
val inversePrimaryLight = Color(0xFFA5C8FF)
val surfaceDimLight = Color(0xFFD8DAE2)
val surfaceBrightLight = Color(0xFFF9F9FF)
val surfaceContainerLowestLight = Color(0xFFFFFFFF)
val surfaceContainerLowLight = Color(0xFFF2F3FC)
val surfaceContainerLight = Color(0xFFECEDF6)
val surfaceContainerHighLight = Color(0xFFE6E8F0)
val surfaceContainerHighestLight = Color(0xFFE0E2EA)
val primaryDark = Color(0xFFA5C8FF)
val onPrimaryDark = Color(0xFF00315F)
val primaryContainerDark = Color(0xFF006DC7)
val onPrimaryContainerDark = Color(0xFFFFFFFF)
val secondaryDark = Color(0xFFADD535)
val onSecondaryDark = Color(0xFF283500)
val secondaryContainerDark = Color(0xFF83A800)
val onSecondaryContainerDark = Color(0xFF080D00)
val tertiaryDark = Color(0xFFEDB1FF)
val onTertiaryDark = Color(0xFF52046E)
val tertiaryContainerDark = Color(0xFF954FB0)
val onTertiaryContainerDark = Color(0xFFFFFFFF)
val errorDark = Color(0xFFFFB4AB)
val onErrorDark = Color(0xFF690005)
val errorContainerDark = Color(0xFF93000A)
val onErrorContainerDark = Color(0xFFFFDAD6)
val backgroundDark = Color.Black // changed
val onBackgroundDark = Color(0xFFE0E2EA)
val surfaceDark = Color(0xff1e1e1e) // changed
val onSurfaceDark = Color(0xFFE0E2EA)
val surfaceVariantDark = Color(0xFF414752)
val onSurfaceVariantDark = Color(0xFFC1C6D4)
val outlineDark = Color(0xFF8B919E)
val outlineVariantDark = Color(0xFF414752)
val scrimDark = Color(0xFF000000)
val inverseSurfaceDark = Color(0xFFE0E2EA)
val inverseOnSurfaceDark = Color(0xFF2D3037)
val inversePrimaryDark = Color(0xFF005FAF)
val surfaceDimDark = Color(0xFF101319)
val surfaceBrightDark = Color(0xFF363940)
val surfaceContainerLowestDark = Color(0xFF0B0E14)
val surfaceContainerLowDark = Color(0xFF181C21)
val surfaceContainerDark = Color(0xFF1C2026)
val surfaceContainerHighDark = Color(0xFF272A30)
val surfaceContainerHighestDark = Color(0xFF32353B)

View File

@@ -0,0 +1,158 @@
package org.fdroid.ui
import androidx.activity.ComponentActivity
import androidx.activity.compose.LocalActivity
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective
import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy
import androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneStrategy
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.ui.NavDisplay
import com.viktormykhailiv.compose.hints.HintHost
import org.fdroid.settings.SettingsConstants.PREF_DEFAULT_DYNAMIC_COLORS
import org.fdroid.ui.apps.myAppsEntry
import org.fdroid.ui.details.appDetailsEntry
import org.fdroid.ui.discover.discoverEntry
import org.fdroid.ui.lists.appListEntry
import org.fdroid.ui.navigation.BottomBar
import org.fdroid.ui.navigation.IntentRouter
import org.fdroid.ui.navigation.MainNavKey
import org.fdroid.ui.navigation.NavigationKey
import org.fdroid.ui.navigation.NavigationRail
import org.fdroid.ui.navigation.Navigator
import org.fdroid.ui.navigation.rememberNavigationState
import org.fdroid.ui.navigation.toEntries
import org.fdroid.ui.navigation.topLevelRoutes
import org.fdroid.ui.repositories.repoEntry
import org.fdroid.ui.settings.Settings
import org.fdroid.ui.settings.SettingsViewModel
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun Main(onListeningForIntent: () -> Unit = {}) {
val navigationState = rememberNavigationState(
startRoute = NavigationKey.Discover,
topLevelRoutes = topLevelRoutes,
)
val navigator = remember { Navigator(navigationState) }
// set up intent routing by listening to new intents from activity
val activity = (LocalActivity.current as ComponentActivity)
DisposableEffect(navigator) {
val intentListener = IntentRouter(navigator)
activity.addOnNewIntentListener(intentListener)
onListeningForIntent() // call this to get informed about initial intents we have missed
onDispose { activity.removeOnNewIntentListener(intentListener) }
}
// Override the defaults so that there isn't a horizontal space between the panes.
val windowAdaptiveInfo = currentWindowAdaptiveInfo()
val directive = remember(windowAdaptiveInfo) {
calculatePaneScaffoldDirective(windowAdaptiveInfo)
.copy(horizontalPartitionSpacerSize = 2.dp)
}
val isBigScreen = directive.maxHorizontalPartitions > 1
val listDetailStrategy = rememberListDetailSceneStrategy<NavKey>(directive = directive)
val entryProvider: (NavKey) -> NavEntry<NavKey> = entryProvider {
discoverEntry(navigator)
myAppsEntry(navigator, isBigScreen)
appDetailsEntry(navigator, isBigScreen)
appListEntry(navigator, isBigScreen)
repoEntry(navigator, isBigScreen)
entry(NavigationKey.Settings) {
val viewModel = hiltViewModel<SettingsViewModel>()
Settings(
model = viewModel.model,
onSaveLogcat = {
viewModel.onSaveLogcat(it)
navigator.goBack()
},
onBackClicked = { navigator.goBack() },
)
}
entry(
key = NavigationKey.About,
metadata = ListDetailSceneStrategy.detailPane("appdetails"),
) {
About(
onBackClicked = if (isBigScreen) null else {
{ navigator.goBack() }
},
)
}
}
val showBottomBar = !isBigScreen && navigator.last is MainNavKey
val viewModel = hiltViewModel<MainViewModel>()
val dynamicColors =
viewModel.dynamicColors.collectAsStateWithLifecycle(PREF_DEFAULT_DYNAMIC_COLORS).value
val numUpdates = viewModel.numUpdates.collectAsStateWithLifecycle().value
val hasAppIssues = viewModel.hasAppIssues.collectAsStateWithLifecycle(false).value
FDroidContent(dynamicColors = dynamicColors) {
HintHost {
Scaffold(
bottomBar = if (showBottomBar) {
{
BottomBar(
numUpdates = numUpdates,
hasIssues = hasAppIssues,
currentNavKey = navigationState.topLevelRoute,
onNav = { navKey -> navigator.navigate(navKey) },
)
}
} else {
{}
},
) { paddingValues ->
Row {
// show nav rail only on big screen (at least two partitions)
if (isBigScreen) NavigationRail(
numUpdates = numUpdates,
hasIssues = hasAppIssues,
currentNavKey = navigationState.topLevelRoute,
onNav = { navKey -> navigator.navigate(navKey) },
)
val modifier = if (isBigScreen) {
// need to consume start insets or some phones leave a lot of space there
Modifier.consumeWindowInsets(PaddingValues(start = 64.dp))
} else if (showBottomBar) {
// we only apply the bottom padding here, so content stays above bottom bar
// but we need to consume the navigation bar height manually
val bottom = with(LocalDensity.current) {
WindowInsets.navigationBars.getBottom(this).toDp()
}
Modifier
.consumeWindowInsets(PaddingValues(bottom = bottom))
.padding(bottom = paddingValues.calculateBottomPadding())
} else {
Modifier
}
// this needs to a have a fixed place or state saving breaks,
// so all moving pieces with conditionals are above
NavDisplay(
entries = navigationState.toEntries(entryProvider),
sceneStrategy = listDetailStrategy,
onBack = { navigator.goBack() },
modifier = modifier,
)
}
}
}
}
}

View File

@@ -0,0 +1,18 @@
package org.fdroid.ui
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.map
import org.fdroid.settings.SettingsManager
import org.fdroid.updates.UpdatesManager
import javax.inject.Inject
@HiltViewModel
class MainViewModel @Inject constructor(
settingsManager: SettingsManager,
updatesManager: UpdatesManager,
) : ViewModel() {
val dynamicColors = settingsManager.dynamicColorFlow
val numUpdates = updatesManager.numUpdates
val hasAppIssues = updatesManager.appsWithIssues.map { !it.isNullOrEmpty() }
}

View File

@@ -0,0 +1,115 @@
package org.fdroid.ui
import android.os.Build.VERSION.SDK_INT
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
// The followings are generated by the Material Theme Builder with modifications
// https://www.figma.com/community/plugin/1034969338659738588
// Unused code are and themes with contrast are removed
private val lightScheme = lightColorScheme(
primary = primaryLight,
onPrimary = onPrimaryLight,
primaryContainer = primaryContainerLight,
onPrimaryContainer = onPrimaryContainerLight,
secondary = secondaryLight,
onSecondary = onSecondaryLight,
secondaryContainer = secondaryContainerLight,
onSecondaryContainer = onSecondaryContainerLight,
tertiary = tertiaryLight,
onTertiary = onTertiaryLight,
tertiaryContainer = tertiaryContainerLight,
onTertiaryContainer = onTertiaryContainerLight,
error = errorLight,
onError = onErrorLight,
errorContainer = errorContainerLight,
onErrorContainer = onErrorContainerLight,
background = backgroundLight,
onBackground = onBackgroundLight,
surface = surfaceLight,
onSurface = onSurfaceLight,
surfaceVariant = surfaceVariantLight,
onSurfaceVariant = onSurfaceVariantLight,
outline = outlineLight,
outlineVariant = outlineVariantLight,
scrim = scrimLight,
inverseSurface = inverseSurfaceLight,
inverseOnSurface = inverseOnSurfaceLight,
inversePrimary = inversePrimaryLight,
surfaceDim = surfaceDimLight,
surfaceBright = surfaceBrightLight,
surfaceContainerLowest = surfaceContainerLowestLight,
surfaceContainerLow = surfaceContainerLowLight,
surfaceContainer = surfaceContainerLight,
surfaceContainerHigh = surfaceContainerHighLight,
surfaceContainerHighest = surfaceContainerHighestLight,
)
private val darkScheme = darkColorScheme(
primary = primaryDark,
onPrimary = onPrimaryDark,
primaryContainer = primaryContainerDark,
onPrimaryContainer = onPrimaryContainerDark,
secondary = secondaryDark,
onSecondary = onSecondaryDark,
secondaryContainer = secondaryContainerDark,
onSecondaryContainer = onSecondaryContainerDark,
tertiary = tertiaryDark,
onTertiary = onTertiaryDark,
tertiaryContainer = tertiaryContainerDark,
onTertiaryContainer = onTertiaryContainerDark,
error = errorDark,
onError = onErrorDark,
errorContainer = errorContainerDark,
onErrorContainer = onErrorContainerDark,
background = backgroundDark,
onBackground = onBackgroundDark,
surface = surfaceDark,
onSurface = onSurfaceDark,
surfaceVariant = surfaceVariantDark,
onSurfaceVariant = onSurfaceVariantDark,
outline = outlineDark,
outlineVariant = outlineVariantDark,
scrim = scrimDark,
inverseSurface = inverseSurfaceDark,
inverseOnSurface = inverseOnSurfaceDark,
inversePrimary = inversePrimaryDark,
surfaceDim = surfaceDimDark,
surfaceBright = surfaceBrightDark,
surfaceContainerLowest = surfaceContainerLowestDark,
surfaceContainerLow = surfaceContainerLowDark,
surfaceContainer = surfaceContainerDark,
surfaceContainerHigh = surfaceContainerHighDark,
surfaceContainerHighest = surfaceContainerHighestDark,
)
@Composable
fun FDroidContent(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColors: Boolean = false,
content: @Composable () -> Unit
) {
val colorScheme = when {
SDK_INT >= 31 && dynamicColors && darkTheme -> {
dynamicDarkColorScheme(LocalContext.current)
}
SDK_INT >= 31 && dynamicColors && !darkTheme -> {
dynamicLightColorScheme(LocalContext.current)
}
darkTheme -> darkScheme
else -> lightScheme
}
MaterialTheme(
colorScheme = colorScheme,
) {
Surface(content = content)
}
}

View File

@@ -0,0 +1,35 @@
package org.fdroid.ui.apps
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import org.fdroid.R
@Composable
fun IgnoreIssueDialog(appName: String, onIgnore: () -> Unit, onDismiss: () -> Unit) {
AlertDialog(
title = {
Text(text = stringResource(R.string.my_apps_ignore_dialog_title))
},
text = {
Text(text = stringResource(R.string.my_apps_ignore_dialog_text, appName))
},
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = onIgnore) {
Text(
text = stringResource(R.string.my_apps_ignore_dialog_button),
color = MaterialTheme.colorScheme.error,
)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(android.R.string.cancel))
}
}
)
}

View File

@@ -0,0 +1,89 @@
package org.fdroid.ui.apps
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Error
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.fdroid.R
import org.fdroid.ui.FDroidContent
import org.fdroid.ui.utils.AsyncShimmerImage
import org.fdroid.ui.utils.BadgeIcon
import org.fdroid.ui.utils.Names
@Composable
fun InstalledAppRow(
app: MyInstalledAppItem,
isSelected: Boolean,
modifier: Modifier = Modifier,
hasIssue: Boolean = false,
) {
Column(modifier = modifier) {
ListItem(
leadingContent = {
BadgedBox(badge = {
if (hasIssue) BadgeIcon(
icon = Icons.Filled.Error,
color = MaterialTheme.colorScheme.error,
contentDescription =
stringResource(R.string.my_apps_header_apps_with_issue),
)
}) {
AsyncShimmerImage(
model = app.iconModel,
error = painterResource(R.drawable.ic_repo_app_default),
contentDescription = null,
modifier = Modifier
.size(48.dp)
.semantics { hideFromAccessibility() },
)
}
},
headlineContent = {
Text(app.name)
},
supportingContent = {
Text(app.installedVersionName)
},
colors = ListItemDefaults.colors(
containerColor = if (isSelected) {
MaterialTheme.colorScheme.surfaceVariant
} else {
Color.Transparent
}
),
modifier = modifier,
)
}
}
@Preview
@Composable
fun InstalledAppRowPreview() {
val app = InstalledAppItem(
packageName = "",
name = Names.randomName,
installedVersionName = "1.0.1",
lastUpdated = System.currentTimeMillis() - 5000,
)
FDroidContent {
Column {
InstalledAppRow(app, false)
InstalledAppRow(app, true)
InstalledAppRow(app, false, hasIssue = true)
}
}
}

View File

@@ -0,0 +1,134 @@
package org.fdroid.ui.apps
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.fdroid.R
import org.fdroid.install.InstallState
import org.fdroid.ui.FDroidContent
import org.fdroid.ui.utils.AsyncShimmerImage
@Composable
fun InstallingAppRow(
app: InstallingAppItem,
isSelected: Boolean,
modifier: Modifier = Modifier
) {
Column(modifier = modifier) {
ListItem(
leadingContent = {
AsyncShimmerImage(
model = app.iconModel,
error = painterResource(R.drawable.ic_repo_app_default),
contentDescription = null,
modifier = Modifier.size(48.dp),
)
},
headlineContent = {
Text(app.name)
},
supportingContent = {
val currentVersionName = app.installState.currentVersionName
if (currentVersionName == null) {
Text(app.installState.versionName)
} else {
Text("$currentVersionName${app.installState.versionName}")
}
},
trailingContent = {
if (app.installState is InstallState.Installed) {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = stringResource(R.string.app_installed),
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier.padding(8.dp)
)
} else if (app.installState is InstallState.Error) {
val desc = stringResource(R.string.notification_title_summary_install_error)
Icon(
imageVector = Icons.Default.ErrorOutline,
contentDescription = desc,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(8.dp)
)
} else {
if (app.installState is InstallState.Downloading) {
CircularProgressIndicator(progress = { app.installState.progress })
} else {
CircularProgressIndicator()
}
}
},
colors = ListItemDefaults.colors(
containerColor = if (isSelected) {
MaterialTheme.colorScheme.surfaceVariant
} else {
Color.Transparent
}
),
modifier = modifier,
)
}
}
@Preview
@Composable
private fun Preview() {
val installingApp1 = InstallingAppItem(
packageName = "A1",
installState = InstallState.Downloading(
name = "Installing App 1",
versionName = "1.0.4",
currentVersionName = null,
lastUpdated = 23,
iconDownloadRequest = null,
downloadedBytes = 25,
totalBytes = 100,
startMillis = System.currentTimeMillis(),
)
)
val installingApp2 = InstallingAppItem(
packageName = "A2",
installState = InstallState.Installed(
name = "Installing App 2",
versionName = "2.0.1",
currentVersionName = null,
lastUpdated = 13,
iconDownloadRequest = null,
)
)
val installingApp3 = InstallingAppItem(
packageName = "A3",
installState = InstallState.Error(
msg = "error msg",
name = "Installing App 2",
versionName = "0.0.4",
currentVersionName = null,
lastUpdated = 13,
iconDownloadRequest = null,
)
)
FDroidContent {
Column {
InstallingAppRow(installingApp1, false)
InstallingAppRow(installingApp2, true)
InstallingAppRow(installingApp3, false)
}
}
}

View File

@@ -0,0 +1,56 @@
package org.fdroid.ui.apps
import org.fdroid.database.AppIssue
import org.fdroid.download.PackageName
import org.fdroid.index.v2.PackageVersion
import org.fdroid.install.InstallStateWithInfo
sealed class MyAppItem {
abstract val packageName: String
abstract val name: String
abstract val lastUpdated: Long
abstract val iconModel: Any?
}
data class InstallingAppItem(
override val packageName: String,
val installState: InstallStateWithInfo,
) : MyAppItem() {
override val name: String = installState.name
override val lastUpdated: Long = installState.lastUpdated
override val iconModel: Any = PackageName(packageName, installState.iconDownloadRequest)
}
data class AppUpdateItem(
val repoId: Long,
override val packageName: String,
override val name: String,
val installedVersionName: String,
val update: PackageVersion,
val whatsNew: String?,
override val iconModel: Any? = null,
) : MyAppItem() {
override val lastUpdated: Long = update.added
}
data class AppWithIssueItem(
override val packageName: String,
override val name: String,
override val installedVersionName: String,
val installedVersionCode: Long,
val issue: AppIssue,
override val lastUpdated: Long,
override val iconModel: Any? = null,
) : MyInstalledAppItem()
data class InstalledAppItem(
override val packageName: String,
override val name: String,
override val installedVersionName: String,
override val lastUpdated: Long,
override val iconModel: Any? = null,
) : MyInstalledAppItem()
abstract class MyInstalledAppItem : MyAppItem() {
abstract val installedVersionName: String
}

View File

@@ -0,0 +1,212 @@
package org.fdroid.ui.apps
import androidx.activity.compose.BackHandler
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.annotation.RestrictTo
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Sort
import androidx.compose.material.icons.filled.AccessTime
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SortByAlpha
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults.enterAlwaysScrollBehavior
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle.State.STARTED
import androidx.lifecycle.compose.LocalLifecycleOwner
import org.fdroid.R
import org.fdroid.database.AppListSortOrder
import org.fdroid.database.AppListSortOrder.LAST_UPDATED
import org.fdroid.download.NetworkState
import org.fdroid.install.InstallConfirmationState
import org.fdroid.ui.FDroidContent
import org.fdroid.ui.lists.TopSearchBar
import org.fdroid.ui.utils.BigLoadingIndicator
import org.fdroid.ui.utils.getMyAppsInfo
import org.fdroid.ui.utils.myAppsModel
@Composable
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
fun MyApps(
myAppsInfo: MyAppsInfo,
currentPackageName: String?,
onAppItemClick: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val myAppsModel = myAppsInfo.model
// Ask user to confirm appToConfirm whenever it changes and we are in STARTED state.
// In tests, waiting for RESUME didn't work, because the LaunchedEffect ran before.
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(myAppsModel.appToConfirm) {
val app = myAppsModel.appToConfirm
if (app != null && lifecycleOwner.lifecycle.currentState.isAtLeast(STARTED)) {
val state = app.installState as InstallConfirmationState
myAppsInfo.confirmAppInstall(app.packageName, state)
}
}
val installingApps = myAppsModel.installingApps
val updatableApps = myAppsModel.appUpdates
val appsWithIssue = myAppsModel.appsWithIssue
val installedApps = myAppsModel.installedApps
val scrollBehavior = enterAlwaysScrollBehavior(rememberTopAppBarState())
var searchActive by rememberSaveable { mutableStateOf(false) }
val onSearchCleared = { myAppsInfo.search("") }
// when search bar is shown, back button closes it again
BackHandler(enabled = searchActive) {
searchActive = false
onSearchCleared()
}
val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
Scaffold(
topBar = {
if (searchActive) {
TopSearchBar(onSearch = myAppsInfo::search, onSearchCleared) {
onBackPressedDispatcher?.onBackPressed()
}
} else TopAppBar(
title = {
Text(stringResource(R.string.menu_apps_my))
},
actions = {
IconButton(onClick = { searchActive = true }) {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = stringResource(R.string.menu_search),
)
}
var sortByMenuExpanded by remember { mutableStateOf(false) }
IconButton(onClick = { sortByMenuExpanded = !sortByMenuExpanded }) {
Icon(
imageVector = Icons.AutoMirrored.Default.Sort,
contentDescription = stringResource(R.string.more),
)
}
DropdownMenu(
expanded = sortByMenuExpanded,
onDismissRequest = { sortByMenuExpanded = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.sort_by_name)) },
leadingIcon = {
Icon(Icons.Filled.SortByAlpha, null)
},
trailingIcon = {
RadioButton(
selected = myAppsModel.sortOrder == AppListSortOrder.NAME,
onClick = null,
)
},
onClick = {
myAppsInfo.changeSortOrder(AppListSortOrder.NAME)
sortByMenuExpanded = false
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.sort_by_latest)) },
leadingIcon = {
Icon(Icons.Filled.AccessTime, null)
},
trailingIcon = {
RadioButton(
selected = myAppsModel.sortOrder == LAST_UPDATED,
onClick = null,
)
},
onClick = {
myAppsInfo.changeSortOrder(LAST_UPDATED)
sortByMenuExpanded = false
},
)
}
},
scrollBehavior = scrollBehavior,
)
},
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
) { paddingValues ->
val lazyListState = rememberLazyListState()
if (updatableApps == null && installedApps == null) BigLoadingIndicator()
else if (installingApps.isEmpty() &&
updatableApps.isNullOrEmpty() &&
appsWithIssue.isNullOrEmpty() &&
installedApps.isNullOrEmpty()
) {
Text(
text = if (searchActive) {
stringResource(R.string.search_my_apps_no_results)
} else {
stringResource(R.string.my_apps_empty)
},
textAlign = TextAlign.Center,
modifier = Modifier
.padding(paddingValues)
.fillMaxSize()
.padding(16.dp),
)
} else {
MyAppsList(
myAppsInfo = myAppsInfo,
currentPackageName = currentPackageName,
lazyListState = lazyListState,
onAppItemClick = onAppItemClick,
paddingValues = paddingValues,
)
}
}
}
@Preview
@Composable
fun MyAppsLoadingPreview() {
val model = MyAppsModel(
installingApps = emptyList(),
appUpdates = null,
installedApps = null,
sortOrder = AppListSortOrder.NAME,
networkState = NetworkState(isOnline = false, isMetered = false),
)
FDroidContent {
MyApps(
myAppsInfo = getMyAppsInfo(model),
currentPackageName = null,
onAppItemClick = {},
)
}
}
@Preview
@Composable
@RestrictTo(RestrictTo.Scope.TESTS)
fun MyAppsPreview() {
FDroidContent {
MyApps(
myAppsInfo = getMyAppsInfo(myAppsModel),
currentPackageName = null,
onAppItemClick = {},
)
}
}

View File

@@ -0,0 +1,54 @@
package org.fdroid.ui.apps
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import org.fdroid.database.AppListSortOrder
import org.fdroid.install.InstallConfirmationState
import org.fdroid.ui.navigation.NavigationKey
import org.fdroid.ui.navigation.Navigator
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
fun EntryProviderScope<NavKey>.myAppsEntry(
navigator: Navigator,
isBigScreen: Boolean,
) {
entry<NavigationKey.MyApps>(
metadata = ListDetailSceneStrategy.listPane("appdetails"),
) {
val myAppsViewModel = hiltViewModel<MyAppsViewModel>()
val myAppsInfo = object : MyAppsInfo {
override val model = myAppsViewModel.myAppsModel.collectAsStateWithLifecycle().value
override fun updateAll() = myAppsViewModel.updateAll()
override fun changeSortOrder(sort: AppListSortOrder) =
myAppsViewModel.changeSortOrder(sort)
override fun search(query: String) = myAppsViewModel.search(query)
override fun confirmAppInstall(
packageName: String,
state: InstallConfirmationState,
) = myAppsViewModel.confirmAppInstall(packageName, state)
override fun ignoreAppIssue(item: AppWithIssueItem) =
myAppsViewModel.ignoreAppIssue(item)
}
MyApps(
myAppsInfo = myAppsInfo,
currentPackageName = if (isBigScreen) {
(navigator.last as? NavigationKey.AppDetails)?.packageName
} else null,
onAppItemClick = {
val new = NavigationKey.AppDetails(it)
if (navigator.last is NavigationKey.AppDetails) {
navigator.replaceLast(new)
} else {
navigator.navigate(new)
}
},
)
}
}

View File

@@ -0,0 +1,25 @@
package org.fdroid.ui.apps
import org.fdroid.database.AppListSortOrder
import org.fdroid.download.NetworkState
import org.fdroid.install.InstallConfirmationState
interface MyAppsInfo {
val model: MyAppsModel
fun updateAll()
fun changeSortOrder(sort: AppListSortOrder)
fun search(query: String)
fun confirmAppInstall(packageName: String, state: InstallConfirmationState)
fun ignoreAppIssue(item: AppWithIssueItem)
}
data class MyAppsModel(
val appToConfirm: InstallingAppItem? = null,
val appUpdates: List<AppUpdateItem>? = null,
val installingApps: List<InstallingAppItem>,
val appsWithIssue: List<AppWithIssueItem>? = null,
val installedApps: List<InstalledAppItem>? = null,
val sortOrder: AppListSortOrder = AppListSortOrder.NAME,
val networkState: NetworkState,
val appUpdatesBytes: Long? = null,
)

View File

@@ -0,0 +1,263 @@
package org.fdroid.ui.apps
import androidx.annotation.RestrictTo
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.fdroid.R
import org.fdroid.database.NotAvailable
import org.fdroid.ui.FDroidContent
import org.fdroid.ui.utils.MeteredConnectionDialog
import org.fdroid.ui.utils.OfflineBar
import org.fdroid.ui.utils.getMyAppsInfo
import org.fdroid.ui.utils.myAppsModel
@Composable
fun MyAppsList(
myAppsInfo: MyAppsInfo,
currentPackageName: String?,
lazyListState: LazyListState,
onAppItemClick: (String) -> Unit,
paddingValues: PaddingValues,
modifier: Modifier = Modifier,
) {
val updatableApps = myAppsInfo.model.appUpdates
val installingApps = myAppsInfo.model.installingApps
val appsWithIssue = myAppsInfo.model.appsWithIssue
val installedApps = myAppsInfo.model.installedApps
// allow us to hide "update all" button to avoid user pressing it twice
var showUpdateAllButton by remember(updatableApps) {
mutableStateOf(true)
}
var showMeteredDialog by remember { mutableStateOf<(() -> Unit)?>(null) }
var showIssueIgnoreDialog by remember { mutableStateOf<AppWithIssueItem?>(null) }
LazyColumn(
state = lazyListState,
contentPadding = paddingValues,
modifier = modifier
.then(
if (currentPackageName == null) Modifier
else Modifier.selectableGroup()
),
) {
// Updates header with Update all button
if (!updatableApps.isNullOrEmpty()) {
if (!myAppsInfo.model.networkState.isOnline) {
item(key = "OfflineBar", contentType = "offlineBar") {
OfflineBar()
}
}
item(key = "A", contentType = "header") {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResource(R.string.updates),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.padding(16.dp)
.weight(1f),
)
if (showUpdateAllButton) Button(
onClick = {
val installLambda = {
myAppsInfo.updateAll()
showUpdateAllButton = false
}
if (myAppsInfo.model.networkState.isMetered) {
showMeteredDialog = installLambda
} else {
installLambda()
}
},
modifier = Modifier.padding(end = 16.dp),
) {
Text(stringResource(R.string.update_all))
}
}
}
// List of updatable apps
items(
items = updatableApps,
key = { it.packageName },
contentType = { "A" },
) { app ->
val isSelected = app.packageName == currentPackageName
val interactionModifier = if (currentPackageName == null) {
Modifier.clickable(
onClick = { onAppItemClick(app.packageName) }
)
} else {
Modifier.selectable(
selected = isSelected,
onClick = { onAppItemClick(app.packageName) }
)
}
val modifier = Modifier.Companion
.animateItem()
.then(interactionModifier)
UpdatableAppRow(app, isSelected, modifier)
}
}
// Apps currently installing header
if (installingApps.isNotEmpty()) {
item(key = "B", contentType = "header") {
Text(
text = stringResource(R.string.notification_title_summary_installing),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.padding(16.dp)
)
}
// List of currently installing apps
items(
items = installingApps,
key = { it.packageName },
contentType = { "B" },
) { app ->
val isSelected = app.packageName == currentPackageName
val interactionModifier = if (currentPackageName == null) {
Modifier.clickable(
onClick = { onAppItemClick(app.packageName) }
)
} else {
Modifier.selectable(
selected = isSelected,
onClick = { onAppItemClick(app.packageName) }
)
}
val modifier = Modifier.Companion
.animateItem()
.then(interactionModifier)
InstallingAppRow(app, isSelected, modifier)
}
}
// Apps with issues
if (!appsWithIssue.isNullOrEmpty()) {
// header
item(key = "C", contentType = "header") {
Text(
text = stringResource(R.string.my_apps_header_apps_with_issue),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp),
)
}
// list of apps with issues
items(
items = appsWithIssue,
key = { it.packageName },
contentType = { "C" },
) { app ->
val isSelected = app.packageName == currentPackageName
var showNotAvailableDialog by remember { mutableStateOf(false) }
val onClick = {
if (app.issue is NotAvailable) {
showNotAvailableDialog = true
} else {
onAppItemClick(app.packageName)
}
}
val interactionModifier = if (currentPackageName == null) {
Modifier.combinedClickable(
onClick = onClick,
onLongClick = { showIssueIgnoreDialog = app },
onLongClickLabel = stringResource(R.string.my_apps_ignore_dialog_title),
)
} else {
Modifier.selectable(
selected = isSelected,
onClick = onClick,
)
}
val modifier = Modifier
.animateItem()
.then(interactionModifier)
InstalledAppRow(app, isSelected, modifier, hasIssue = true)
// Dialogs
val appToIgnore = showIssueIgnoreDialog
if (appToIgnore != null) IgnoreIssueDialog(
appName = appToIgnore.name,
onIgnore = {
myAppsInfo.ignoreAppIssue(appToIgnore)
showIssueIgnoreDialog = null
},
onDismiss = { showIssueIgnoreDialog = null },
) else if (showNotAvailableDialog) NotAvailableDialog(app.packageName) {
showNotAvailableDialog = false
}
}
}
// Installed apps header (only show when we have non-empty lists above)
val aboveNonEmpty = installingApps.isNotEmpty() ||
!updatableApps.isNullOrEmpty() ||
!appsWithIssue.isNullOrEmpty()
if (aboveNonEmpty && !installedApps.isNullOrEmpty()) {
item(key = "D", contentType = "header") {
Text(
text = stringResource(R.string.installed_apps__activity_title),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp),
)
}
}
// List of installed apps
if (installedApps != null) items(
items = installedApps,
key = { it.packageName },
contentType = { "D" },
) { app ->
val isSelected = app.packageName == currentPackageName
val interactionModifier = if (currentPackageName == null) {
Modifier.clickable(
onClick = { onAppItemClick(app.packageName) }
)
} else {
Modifier.selectable(
selected = isSelected,
onClick = { onAppItemClick(app.packageName) }
)
}
val modifier = Modifier
.animateItem()
.then(interactionModifier)
InstalledAppRow(app, isSelected, modifier)
}
}
val meteredLambda = showMeteredDialog
if (meteredLambda != null) MeteredConnectionDialog(
numBytes = myAppsInfo.model.appUpdatesBytes,
onConfirm = { meteredLambda() },
onDismiss = { showMeteredDialog = null },
)
}
@Preview
@Composable
@RestrictTo(RestrictTo.Scope.TESTS)
private fun MyAppsListPreview() {
FDroidContent {
MyApps(
myAppsInfo = getMyAppsInfo(myAppsModel),
currentPackageName = null,
onAppItemClick = {},
)
}
}

View File

@@ -0,0 +1,115 @@
@file:Suppress("ktlint:standard:filename")
package org.fdroid.ui.apps
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import org.fdroid.database.AppListSortOrder
import org.fdroid.download.NetworkState
import org.fdroid.install.InstallConfirmationState
import org.fdroid.install.InstallState
import org.fdroid.install.InstallStateWithInfo
import org.fdroid.ui.utils.normalize
import java.text.Collator
import java.util.Locale
// TODO add tests for this, similar to DetailsPresenter
@Composable
fun MyAppsPresenter(
appUpdatesFlow: StateFlow<List<AppUpdateItem>?>,
appInstallStatesFlow: StateFlow<Map<String, InstallState>>,
appsWithIssuesFlow: StateFlow<List<AppWithIssueItem>?>,
installedAppsFlow: Flow<List<InstalledAppItem>>,
searchQueryFlow: StateFlow<String>,
sortOrderFlow: StateFlow<AppListSortOrder>,
networkStateFlow: StateFlow<NetworkState>,
): MyAppsModel {
val appUpdates = appUpdatesFlow.collectAsState().value
val appInstallStates = appInstallStatesFlow.collectAsState().value
val appsWithIssues = appsWithIssuesFlow.collectAsState().value
val installedApps = installedAppsFlow.collectAsState(null).value
val searchQuery = searchQueryFlow.collectAsState().value.normalize()
val sortOrder = sortOrderFlow.collectAsState().value
val processedPackageNames = mutableSetOf<String>()
// we want to show apps currently installing/updating even if they have updates available,
// so we need to handle those first
val installingApps = appInstallStates.mapNotNull { (packageName, state) ->
if (state is InstallStateWithInfo) {
val keep = searchQuery.isBlank() ||
state.name.normalize().contains(searchQuery, ignoreCase = true)
if (keep) {
processedPackageNames.add(packageName)
InstallingAppItem(packageName, state)
} else null
} else {
null
}
}
val updates = appUpdates?.filter {
val keep = if (searchQuery.isBlank()) {
it.packageName !in processedPackageNames
} else {
it.packageName !in processedPackageNames &&
it.name.normalize().contains(searchQuery, ignoreCase = true)
}
if (keep) processedPackageNames.add(it.packageName)
keep
}
val withIssues = appsWithIssues?.filter {
val keep = if (searchQuery.isBlank()) {
it.packageName !in processedPackageNames
} else {
it.packageName !in processedPackageNames &&
it.name.normalize().contains(searchQuery, ignoreCase = true)
}
if (keep) processedPackageNames.add(it.packageName)
keep
}
val installed = installedApps?.filter {
if (searchQuery.isBlank()) {
it.packageName !in processedPackageNames
} else {
it.packageName !in processedPackageNames &&
it.name.normalize().contains(searchQuery, ignoreCase = true)
}
}
var updateBytes: Long? = 0L
updates?.forEach {
val size = it.update.size
if (size == null) {
// when we don't know the size of one update, we can't provide a total, so say null
updateBytes = null
return@forEach
} else {
updateBytes = updateBytes?.plus(size)
}
} ?: run { updateBytes = null }
return MyAppsModel(
appToConfirm = installingApps.filter {
it.installState is InstallConfirmationState
}.minByOrNull {
(it.installState as InstallConfirmationState).creationTimeMillis
},
installingApps = installingApps.sort(sortOrder),
appUpdates = updates?.sort(sortOrder),
appsWithIssue = withIssues?.sort(sortOrder),
installedApps = installed?.sort(sortOrder),
sortOrder = sortOrder,
networkState = networkStateFlow.collectAsState().value,
appUpdatesBytes = updateBytes,
)
}
private fun <T : MyAppItem> List<T>.sort(sortOrder: AppListSortOrder): List<T> {
val collator = Collator.getInstance(Locale.getDefault())
return when (sortOrder) {
AppListSortOrder.NAME -> sortedWith { a1, a2 ->
// storing collator.getCollationKey() and using that could be an optimization
collator.compare(a1.name, a2.name)
}
AppListSortOrder.LAST_UPDATED -> sortedByDescending { it.lastUpdated }
}
}

View File

@@ -0,0 +1,125 @@
package org.fdroid.ui.apps
import android.app.Application
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import app.cash.molecule.AndroidUiDispatcher
import app.cash.molecule.RecompositionMode.ContextClock
import app.cash.molecule.launchMolecule
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import mu.KotlinLogging
import org.fdroid.database.AppListSortOrder
import org.fdroid.database.FDroidDatabase
import org.fdroid.download.DownloadRequest
import org.fdroid.download.NetworkMonitor
import org.fdroid.download.PackageName
import org.fdroid.download.getImageModel
import org.fdroid.index.RepoManager
import org.fdroid.install.AppInstallManager
import org.fdroid.install.InstallConfirmationState
import org.fdroid.install.InstallState
import org.fdroid.install.InstalledAppsCache
import org.fdroid.settings.SettingsManager
import org.fdroid.updates.UpdatesManager
import org.fdroid.utils.IoDispatcher
import javax.inject.Inject
@HiltViewModel
class MyAppsViewModel @Inject constructor(
app: Application,
@param:IoDispatcher private val scope: CoroutineScope,
savedStateHandle: SavedStateHandle,
private val db: FDroidDatabase,
private val settingsManager: SettingsManager,
private val installedAppsCache: InstalledAppsCache,
private val appInstallManager: AppInstallManager,
private val networkMonitor: NetworkMonitor,
private val updatesManager: UpdatesManager,
private val repoManager: RepoManager,
) : AndroidViewModel(app) {
private val log = KotlinLogging.logger { }
private val localeList = LocaleListCompat.getDefault()
private val moleculeScope =
CoroutineScope(viewModelScope.coroutineContext + AndroidUiDispatcher.Main)
private val updates = updatesManager.updates
@OptIn(ExperimentalCoroutinesApi::class)
private val installedAppItems =
installedAppsCache.installedApps.flatMapLatest { installedApps ->
val proxyConfig = settingsManager.proxyConfig
db.getAppDao().getInstalledAppListItems(installedApps).map { list ->
list.map { app ->
val backupModel = repoManager.getRepository(app.repoId)?.let { repo ->
app.getIcon(localeList)?.getImageModel(repo, proxyConfig)
} as? DownloadRequest
InstalledAppItem(
packageName = app.packageName,
name = app.name ?: "Unknown app",
installedVersionName = app.installedVersionName ?: "???",
lastUpdated = app.lastUpdated,
iconModel = PackageName(app.packageName, backupModel),
)
}
}
}
private val searchQuery = savedStateHandle.getMutableStateFlow("query", "")
private val sortOrder = savedStateHandle.getMutableStateFlow("sort", AppListSortOrder.NAME)
val myAppsModel: StateFlow<MyAppsModel> by lazy(LazyThreadSafetyMode.NONE) {
moleculeScope.launchMolecule(mode = ContextClock) {
MyAppsPresenter(
appUpdatesFlow = updates,
appInstallStatesFlow = appInstallManager.appInstallStates,
appsWithIssuesFlow = updatesManager.appsWithIssues,
installedAppsFlow = installedAppItems,
searchQueryFlow = searchQuery,
sortOrderFlow = sortOrder,
networkStateFlow = networkMonitor.networkState,
)
}
}
fun updateAll() {
scope.launch {
updatesManager.updateAll(true)
}
}
fun search(query: String) {
searchQuery.value = query
}
fun changeSortOrder(sort: AppListSortOrder) {
sortOrder.value = sort
}
fun confirmAppInstall(packageName: String, state: InstallConfirmationState) {
log.info { "Asking user to confirm install of $packageName..." }
scope.launch(Dispatchers.Main) {
when (state) {
is InstallState.PreApprovalConfirmationNeeded -> {
appInstallManager.requestPreApprovalConfirmation(packageName, state)
}
is InstallState.UserConfirmationNeeded -> {
appInstallManager.requestUserConfirmation(packageName, state)
}
}
}
}
fun ignoreAppIssue(item: AppWithIssueItem) {
settingsManager.ignoreAppIssue(item.packageName, item.installedVersionCode)
updatesManager.loadUpdates()
}
}

View File

@@ -0,0 +1,63 @@
package org.fdroid.ui.apps
import android.content.Intent
import android.net.Uri
import android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.fdroid.R
import org.fdroid.ui.FDroidContent
import org.fdroid.ui.utils.startActivitySafe
@Composable
fun NotAvailableDialog(packageName: String, onDismiss: () -> Unit) {
val context = LocalContext.current
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = stringResource(R.string.app_issue_not_available_title)) },
text = {
Column(verticalArrangement = spacedBy(8.dp)) {
Text(text = stringResource(R.string.app_issue_not_available_text))
OutlinedButton(
onClick = {
val intent: Intent = Intent(ACTION_APPLICATION_DETAILS_SETTINGS).apply {
setData(Uri.fromParts("package", packageName, null))
}
context.startActivitySafe(intent)
},
modifier = Modifier.align(CenterHorizontally)
) {
Text(stringResource(R.string.app_issue_not_available_button))
}
}
},
confirmButton = {
TextButton(
onClick = onDismiss,
) { Text(stringResource(R.string.ok)) }
},
)
}
@Preview
@Composable
private fun Preview() {
FDroidContent {
Box(modifier = Modifier.fillMaxSize()) {
NotAvailableDialog("foo.bar") {}
}
}
}

View File

@@ -0,0 +1,137 @@
package org.fdroid.ui.apps
import android.text.format.Formatter
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.NewReleases
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.Card
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.fdroid.R
import org.fdroid.ui.FDroidContent
import org.fdroid.ui.utils.AsyncShimmerImage
import org.fdroid.ui.utils.BadgeIcon
import org.fdroid.ui.utils.ExpandIconArrow
import org.fdroid.ui.utils.getPreviewVersion
@Composable
fun UpdatableAppRow(
app: AppUpdateItem,
isSelected: Boolean,
modifier: Modifier = Modifier
) {
var isExpanded by remember { mutableStateOf(false) }
Column(modifier = modifier) {
ListItem(
leadingContent = {
BadgedBox(
badge = {
BadgeIcon(
icon = Icons.Filled.NewReleases,
color = MaterialTheme.colorScheme.secondary,
contentDescription =
stringResource(R.string.notification_title_single_update_available),
)
},
) {
AsyncShimmerImage(
model = app.iconModel,
error = painterResource(R.drawable.ic_repo_app_default),
contentDescription = null,
modifier = Modifier
.size(48.dp)
.semantics { hideFromAccessibility() },
)
}
},
headlineContent = {
Text(app.name)
},
supportingContent = {
val size = app.update.size?.let {
Formatter.formatFileSize(LocalContext.current, it)
}
Text("${app.installedVersionName}${app.update.versionName}$size")
},
trailingContent = {
if (app.whatsNew != null) IconButton(onClick = { isExpanded = !isExpanded }) {
ExpandIconArrow(isExpanded)
}
},
colors = ListItemDefaults.colors(
containerColor = if (isSelected) {
MaterialTheme.colorScheme.surfaceVariant
} else {
Color.Transparent
}
),
modifier = modifier,
)
AnimatedVisibility(
visible = isExpanded,
modifier = Modifier
.padding(8.dp)
.semantics { liveRegion = LiveRegionMode.Polite }
) {
Card(modifier = Modifier.fillMaxWidth()) {
Text(
text = app.whatsNew ?: "",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.padding(8.dp)
)
}
}
}
}
@Preview
@Composable
fun UpdatableAppRowPreview() {
val app1 = AppUpdateItem(
repoId = 1,
packageName = "A",
name = "App Update 123",
installedVersionName = "1.0.1",
update = getPreviewVersion("1.1.0", 123456789),
whatsNew = "This is new, all is new, nothing old.",
)
val app2 = AppUpdateItem(
repoId = 2,
packageName = "B",
name = "App Update 456",
installedVersionName = "1.0.1",
update = getPreviewVersion("1.1.0", 123456789),
whatsNew = "This is new, all is new, nothing old.",
)
FDroidContent {
Column {
UpdatableAppRow(app1, false)
UpdatableAppRow(app2, true)
}
}
}

View File

@@ -0,0 +1,103 @@
package org.fdroid.ui.categories
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.AssistChip
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.fdroid.R
import org.fdroid.ui.FDroidContent
@Composable
fun CategoryChip(
categoryItem: CategoryItem,
onSelected: () -> Unit,
modifier: Modifier = Modifier,
selected: Boolean = false,
) {
FilterChip(
onClick = onSelected,
leadingIcon = {
if (selected) Icon(
imageVector = Icons.Default.Check,
contentDescription = stringResource(R.string.filter_selected),
) else Icon(
imageVector = categoryItem.imageVector,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.semantics { hideFromAccessibility() },
)
},
label = {
Text(
categoryItem.name,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
},
selected = selected,
modifier = modifier.padding(horizontal = 4.dp)
)
}
@Composable
fun CategoryChip(
categoryItem: CategoryItem,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
AssistChip(
onClick = onClick,
leadingIcon = {
Icon(
imageVector = categoryItem.imageVector,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.semantics { hideFromAccessibility() },
)
},
label = {
Text(
text = categoryItem.name,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
},
modifier = modifier.padding(horizontal = 4.dp)
)
}
@Preview
@Composable
fun CategoryCardPreview() {
FDroidContent {
Column {
CategoryChip(
CategoryItem("VPN & Proxy", "VPN & Proxy"),
selected = true,
onSelected = {},
)
CategoryChip(
CategoryItem("VPN & Proxy", "VPN & Proxy"),
selected = false,
onSelected = {},
)
CategoryChip(
CategoryItem("VPN & Proxy", "VPN & Proxy"),
onClick = {},
)
}
}
}

View File

@@ -0,0 +1,23 @@
package org.fdroid.ui.categories
import androidx.annotation.StringRes
import org.fdroid.R
data class CategoryGroup(
val id: String,
@get:StringRes
val name: Int,
)
object CategoryGroups {
val productivity = CategoryGroup("productivity", R.string.category_group_productivity)
val tools = CategoryGroup("tools", R.string.category_group_tools)
val wallets = CategoryGroup("wallets", R.string.category_group_wallets)
val media = CategoryGroup("media", R.string.category_group_media)
val communication = CategoryGroup("communication", R.string.category_group_communication)
val device = CategoryGroup("device", R.string.category_group_device)
val network = CategoryGroup("network", R.string.category_group_network)
val storage = CategoryGroup("storage", R.string.category_group_storage)
val interests = CategoryGroup("interests", R.string.category_group_interests)
val misc = CategoryGroup("misc", R.string.category_group_misc)
}

View File

@@ -0,0 +1,221 @@
package org.fdroid.ui.categories
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.Icons.AutoMirrored
import androidx.compose.material.icons.automirrored.filled.MenuBook
import androidx.compose.material.icons.automirrored.filled.Message
import androidx.compose.material.icons.filled.AccessTime
import androidx.compose.material.icons.filled.AccountBalanceWallet
import androidx.compose.material.icons.filled.Airplay
import androidx.compose.material.icons.filled.AllInbox
import androidx.compose.material.icons.filled.AlternateEmail
import androidx.compose.material.icons.filled.AppBlocking
import androidx.compose.material.icons.filled.Apps
import androidx.compose.material.icons.filled.BatteryChargingFull
import androidx.compose.material.icons.filled.Bookmarks
import androidx.compose.material.icons.filled.Brush
import androidx.compose.material.icons.filled.Calculate
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Category
import androidx.compose.material.icons.filled.Church
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Collections
import androidx.compose.material.icons.filled.CurrencyExchange
import androidx.compose.material.icons.filled.DeveloperMode
import androidx.compose.material.icons.filled.DirectionsBus
import androidx.compose.material.icons.filled.Dns
import androidx.compose.material.icons.filled.Draw
import androidx.compose.material.icons.filled.EditNote
import androidx.compose.material.icons.filled.EnhancedEncryption
import androidx.compose.material.icons.filled.FitnessCenter
import androidx.compose.material.icons.filled.FlashlightOn
import androidx.compose.material.icons.filled.Games
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.HealthAndSafety
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Keyboard
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.LocalPlay
import androidx.compose.material.icons.filled.MonetizationOn
import androidx.compose.material.icons.filled.Money
import androidx.compose.material.icons.filled.MusicNote
import androidx.compose.material.icons.filled.MusicVideo
import androidx.compose.material.icons.filled.MyLocation
import androidx.compose.material.icons.filled.Navigation
import androidx.compose.material.icons.filled.NetworkCheck
import androidx.compose.material.icons.filled.Newspaper
import androidx.compose.material.icons.filled.NoteAlt
import androidx.compose.material.icons.filled.OpenInBrowser
import androidx.compose.material.icons.filled.Password
import androidx.compose.material.icons.filled.PermPhoneMsg
import androidx.compose.material.icons.filled.PhotoSizeSelectActual
import androidx.compose.material.icons.filled.Podcasts
import androidx.compose.material.icons.filled.RestaurantMenu
import androidx.compose.material.icons.filled.Science
import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.ShoppingCart
import androidx.compose.material.icons.filled.SignalCellularAlt
import androidx.compose.material.icons.filled.Storefront
import androidx.compose.material.icons.filled.Style
import androidx.compose.material.icons.filled.TaskAlt
import androidx.compose.material.icons.filled.TrackChanges
import androidx.compose.material.icons.filled.Translate
import androidx.compose.material.icons.filled.UploadFile
import androidx.compose.material.icons.filled.VideoChat
import androidx.compose.material.icons.filled.VoiceChat
import androidx.compose.material.icons.filled.VpnLock
import androidx.compose.material.icons.filled.Wallet
import androidx.compose.material.icons.filled.Wallpaper
import androidx.compose.material.icons.filled.WbSunny
import androidx.compose.ui.graphics.vector.ImageVector
data class CategoryItem(val id: String, val name: String) {
val imageVector: ImageVector
get() = when (id) {
"AI Chat" -> Icons.Default.VoiceChat
"App Manager" -> Icons.Default.Apps
"App Store & Updater" -> Icons.Default.Storefront
"Battery" -> Icons.Default.BatteryChargingFull
"Bookmark" -> Icons.Default.Bookmarks
"Browser" -> Icons.Default.OpenInBrowser
"Calculator" -> Icons.Default.Calculate
"Calendar & Agenda" -> Icons.Default.CalendarMonth
"Clock" -> Icons.Default.AccessTime
"Cloud Storage & File Sync" -> Icons.Default.Cloud
"Connectivity" -> Icons.Default.SignalCellularAlt
"Development" -> Icons.Default.DeveloperMode
"DNS & Hosts" -> Icons.Default.Dns
"Draw" -> Icons.Default.Draw
"Ebook Reader" -> AutoMirrored.Default.MenuBook
"Email" -> Icons.Default.AlternateEmail
"File Encryption & Vault" -> Icons.Default.EnhancedEncryption
"File Transfer" -> Icons.Default.UploadFile
"Finance Manager" -> Icons.Default.MonetizationOn
"Firewall" -> Icons.Default.AppBlocking
"Flashlight" -> Icons.Default.FlashlightOn
"Forum" -> Icons.Default.Image
"Gallery" -> Icons.Default.PhotoSizeSelectActual
"Games" -> Icons.Default.Games
"Graphics" -> Icons.Default.Brush
"Habit Tracker" -> Icons.Default.TrackChanges
"Icon Pack" -> Icons.Default.Collections
"Internet" -> Icons.Default.Language
"Inventory" -> Icons.Default.AllInbox
"Keyboard & IME" -> Icons.Default.Keyboard
"Launcher" -> Icons.Default.Home
"Local Media Player" -> Icons.Default.LocalPlay
"Location Tracker & Sharer" -> Icons.Default.MyLocation
"Messaging" -> AutoMirrored.Default.Message
"Money" -> Icons.Default.Money
"Multimedia" -> Icons.Default.MusicVideo
"Music Practice Tool" -> Icons.Default.MusicNote
"Navigation" -> Icons.Default.Navigation
"Network Analyzer" -> Icons.Default.NetworkCheck
"News" -> Icons.Default.Newspaper
"Note" -> Icons.Default.NoteAlt
"Online Media Player" -> Icons.Default.Airplay
"Pass Wallet" -> Icons.Default.AccountBalanceWallet
"Password & 2FA" -> Icons.Default.Password
"Phone & SMS" -> Icons.Default.PermPhoneMsg
"Podcast" -> Icons.Default.Podcasts
"Public Transport" -> Icons.Default.DirectionsBus
"Reading" -> AutoMirrored.Default.MenuBook
"Recipe Manager" -> Icons.Default.RestaurantMenu
"Religion" -> Icons.Default.Church
"Science & Education" -> Icons.Default.Science
"Security" -> Icons.Default.Security
"Shopping List" -> Icons.Default.ShoppingCart
"Social Network" -> Icons.Default.Groups
"Sports & Health" -> Icons.Default.HealthAndSafety
"System" -> Icons.Default.Settings
"Task" -> Icons.Default.TaskAlt
"Text Editor" -> Icons.Default.EditNote
"Theming" -> Icons.Default.Style
"Time" -> Icons.Default.AccessTime
"Translation & Dictionary" -> Icons.Default.Translate
"Voice & Video Chat" -> Icons.Default.VideoChat
"Unit Convertor" -> Icons.Default.CurrencyExchange
"VPN & Proxy" -> Icons.Default.VpnLock
"Wallet" -> Icons.Default.Wallet
"Wallpaper" -> Icons.Default.Wallpaper
"Weather" -> Icons.Default.WbSunny
"Workout" -> Icons.Default.FitnessCenter
"Writing" -> Icons.Default.EditNote
else -> Icons.Default.Category
}
val group: CategoryGroup
get() = when (id) {
"AI Chat" -> CategoryGroups.tools
"App Manager" -> CategoryGroups.device
"App Store & Updater" -> CategoryGroups.device
"Battery" -> CategoryGroups.device
"Bookmark" -> CategoryGroups.storage
"Browser" -> CategoryGroups.productivity
"Calculator" -> CategoryGroups.tools
"Calendar & Agenda" -> CategoryGroups.productivity
"Clock" -> CategoryGroups.productivity
"Cloud Storage & File Sync" -> CategoryGroups.storage
"Connectivity" -> CategoryGroups.network
"Development" -> CategoryGroups.interests
"DNS & Hosts" -> CategoryGroups.network
"Draw" -> CategoryGroups.interests
"Ebook Reader" -> CategoryGroups.media
"Email" -> CategoryGroups.communication
"File Encryption & Vault" -> CategoryGroups.storage
"File Transfer" -> CategoryGroups.storage
"Finance Manager" -> CategoryGroups.wallets
"Firewall" -> CategoryGroups.device
"Flashlight" -> CategoryGroups.tools
"Forum" -> CategoryGroups.communication
"Gallery" -> CategoryGroups.storage
"Games" -> CategoryGroups.media
"Graphics" -> CategoryGroups.interests
"Habit Tracker" -> CategoryGroups.productivity
"Icon Pack" -> CategoryGroups.device
"Internet" -> CategoryGroups.productivity
"Inventory" -> CategoryGroups.tools
"Keyboard & IME" -> CategoryGroups.device
"Launcher" -> CategoryGroups.device
"Local Media Player" -> CategoryGroups.media
"Location Tracker & Sharer" -> CategoryGroups.tools
"Messaging" -> CategoryGroups.communication
"Money" -> CategoryGroups.wallets
"Multimedia" -> CategoryGroups.media
"Music Practice Tool" -> CategoryGroups.interests
"Navigation" -> CategoryGroups.tools
"Network Analyzer" -> CategoryGroups.tools
"News" -> CategoryGroups.interests
"Note" -> CategoryGroups.storage
"Online Media Player" -> CategoryGroups.media
"Pass Wallet" -> CategoryGroups.wallets
"Password & 2FA" -> CategoryGroups.device
"Phone & SMS" -> CategoryGroups.communication
"Podcast" -> CategoryGroups.media
"Public Transport" -> CategoryGroups.tools
"Reading" -> CategoryGroups.media
"Recipe Manager" -> CategoryGroups.interests
"Religion" -> CategoryGroups.interests
"Science & Education" -> CategoryGroups.interests
"Security" -> CategoryGroups.device
"Shopping List" -> CategoryGroups.tools
"Social Network" -> CategoryGroups.communication
"Sports & Health" -> CategoryGroups.interests
"System" -> CategoryGroups.device
"Task" -> CategoryGroups.productivity
"Text Editor" -> CategoryGroups.productivity
"Theming" -> CategoryGroups.device
"Time" -> CategoryGroups.productivity
"Translation & Dictionary" -> CategoryGroups.tools
"Voice & Video Chat" -> CategoryGroups.communication
"Unit Convertor" -> CategoryGroups.tools
"VPN & Proxy" -> CategoryGroups.network
"Wallet" -> CategoryGroups.wallets
"Wallpaper" -> CategoryGroups.device
"Weather" -> CategoryGroups.tools
"Workout" -> CategoryGroups.interests
"Writing" -> CategoryGroups.productivity
else -> CategoryGroups.misc
}
}

View File

@@ -0,0 +1,85 @@
package org.fdroid.ui.categories
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation3.runtime.NavKey
import org.fdroid.R
import org.fdroid.ui.FDroidContent
import org.fdroid.ui.lists.AppListType
import org.fdroid.ui.navigation.NavigationKey
@Composable
fun CategoryList(
categoryMap: Map<CategoryGroup, List<CategoryItem>>?,
onNav: (NavKey) -> Unit,
modifier: Modifier = Modifier
) {
if (categoryMap != null && categoryMap.isNotEmpty()) Column(
modifier = modifier
) {
Text(
text = stringResource(R.string.main_menu__categories),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(bottom = 8.dp, start = 4.dp),
)
// we'll sort the groups here, because before we didn't have the context to get names
val res = LocalResources.current
val sortedMap = remember(categoryMap) {
val comparator = compareBy<CategoryGroup> { res.getString(it.name) }
categoryMap.toSortedMap(comparator)
}
sortedMap.forEach { (group, categories) ->
Text(
text = stringResource(group.name),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(4.dp),
)
FlowRow(
horizontalArrangement = Arrangement.Start,
) {
categories.forEach { category ->
CategoryChip(
categoryItem = category,
onClick = {
val type = AppListType.Category(category.name, category.id)
val navKey = NavigationKey.AppList(type)
onNav(navKey)
},
)
}
}
}
}
}
@Preview
@Composable
fun CategoryListPreview() {
FDroidContent {
val categories = mapOf(
CategoryGroups.productivity to listOf(
CategoryItem("App Store & Updater", "App Store & Updater"),
CategoryItem("Browser", "Browser"),
CategoryItem("Calendar & Agenda", "Calendar & Agenda"),
),
CategoryGroups.media to listOf(
CategoryItem("Cloud Storage & File Sync", "Cloud Storage & File Sync"),
CategoryItem("Connectivity", "Connectivity"),
CategoryItem("Development", "Development"),
CategoryItem("doesn't exist", "Foo bar"),
)
)
CategoryList(categories, {})
}
}

View File

@@ -0,0 +1,78 @@
package org.fdroid.ui.crash
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import kotlinx.coroutines.launch
import org.fdroid.R
import org.fdroid.ui.FDroidContent
import org.fdroid.utils.getLogName
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun Crash(
onCancel: () -> Unit,
onSend: (String, String) -> Unit,
onSave: (Uri, String) -> Boolean,
modifier: Modifier = Modifier
) {
val res = LocalResources.current
val coroutineScope = rememberCoroutineScope()
val textFieldState = rememberTextFieldState()
val snackbarHostState = remember { SnackbarHostState() }
val launcher = rememberLauncherForActivityResult(CreateDocument("application/json")) {
val success = it != null && onSave(it, textFieldState.text.toString())
val msg = if (success) res.getString(R.string.crash_report_saved)
else res.getString(R.string.crash_report_error_saving)
coroutineScope.launch {
snackbarHostState.showSnackbar(msg)
}
}
val context = LocalContext.current
Scaffold(
topBar = {
TopAppBar(
title = {},
actions = {
IconButton(onClick = { launcher.launch("${getLogName(context)}.json") }) {
Icon(
imageVector = Icons.Default.Save,
contentDescription = stringResource(R.string.crash_report_save),
)
}
}
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
modifier = modifier,
) { paddingValues ->
CrashContent(onCancel, onSend, textFieldState, Modifier.padding(paddingValues))
}
}
@Preview
@Composable
private fun Preview() {
FDroidContent {
Crash({}, { _, _ -> }, { _, _ -> true })
}
}

View File

@@ -0,0 +1,56 @@
package org.fdroid.ui.crash
import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import mu.KotlinLogging
import org.acra.ReportField
import org.acra.dialog.CrashReportDialogHelper
import org.fdroid.ui.FDroidContent
import java.io.IOException
class CrashActivity : ComponentActivity() {
private val log = KotlinLogging.logger {}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val helper = CrashReportDialogHelper(this, intent)
setContent {
FDroidContent {
Crash(
onCancel = {
helper.cancelReports()
finishAfterTransition()
},
onSend = { comment, userEmail ->
helper.sendCrash(comment, userEmail)
finishAfterTransition()
},
onSave = { uri, comment ->
onSave(helper, uri, comment)
},
)
}
}
}
private fun onSave(helper: CrashReportDialogHelper, uri: Uri, comment: String): Boolean {
return try {
val crashData = helper.reportData.apply {
if (comment.isNotBlank()) {
put(ReportField.USER_COMMENT, comment)
}
}
contentResolver.openOutputStream(uri, "wt")?.use { outputStream ->
outputStream.write(crashData.toJSON().encodeToByteArray())
} ?: throw IOException("Could not open $uri")
true
} catch (e: Exception) {
log.error(e) { "Error saving log: " }
false
}
}
}

View File

@@ -0,0 +1,102 @@
package org.fdroid.ui.crash
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.fdroid.R
import org.fdroid.ui.FDroidContent
@Composable
fun CrashContent(
onCancel: () -> Unit,
onSend: (String, String) -> Unit,
textFieldState: TextFieldState,
modifier: Modifier = Modifier
) {
val scrollState = rememberScrollState()
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween,
modifier = modifier
.fillMaxSize()
.imePadding()
.padding(horizontal = 16.dp)
.verticalScroll(scrollState)
) {
Image(
painter = painterResource(id = R.drawable.ic_crash),
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary),
contentDescription = null, // decorative element
modifier = Modifier
.fillMaxWidth(0.5f)
.aspectRatio(1f)
.padding(vertical = 16.dp)
.semantics { hideFromAccessibility() },
)
Column(verticalArrangement = spacedBy(16.dp)) {
Text(
text = stringResource(R.string.crash_dialog_title),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
Text(
text = stringResource(R.string.crash_report_text),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
)
TextField(
state = textFieldState,
placeholder = { Text(stringResource(R.string.crash_report_comment_hint)) },
modifier = Modifier.fillMaxWidth()
)
}
Row(
horizontalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp)
) {
OutlinedButton(onClick = onCancel) {
Text(stringResource(R.string.cancel))
}
Button(onClick = {
onSend(textFieldState.text.toString(), "")
}) {
Text(stringResource(R.string.crash_report_button_send))
}
}
}
}
@Preview
@Composable
private fun Preview() {
FDroidContent {
Crash({}, { _, _ -> }, { _, _ -> true })
}
}

View File

@@ -0,0 +1,13 @@
package org.fdroid.ui.crash
import org.acra.config.RetryPolicy
import org.acra.sender.ReportSender
class NoRetryPolicy() : RetryPolicy {
override fun shouldRetrySend(
senders: List<ReportSender>,
failedSenders: List<RetryPolicy.FailedSender>
): Boolean {
return false
}
}

View File

@@ -0,0 +1,92 @@
package org.fdroid.ui.details
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CrisisAlert
import androidx.compose.material.icons.filled.WarningAmber
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter.Companion.tint
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.fdroid.R
import org.fdroid.ui.FDroidContent
import org.fdroid.ui.utils.AsyncShimmerImage
import org.fdroid.ui.utils.ExpandableSection
import org.fdroid.ui.utils.testApp
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
fun AntiFeatures(
antiFeatures: List<AntiFeature>,
) {
ElevatedCard(
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.inverseSurface,
),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
ExpandableSection(
icon = rememberVectorPainter(Icons.Default.WarningAmber),
title = stringResource(R.string.anti_features_title),
modifier = Modifier.padding(horizontal = 16.dp),
) {
Column {
antiFeatures.forEach { antiFeature ->
ListItem(
leadingContent = {
AsyncShimmerImage(
model = antiFeature.icon,
contentDescription = "",
colorFilter = tint(MaterialTheme.colorScheme.inverseOnSurface),
error = rememberVectorPainter(Icons.Default.CrisisAlert),
modifier = Modifier.size(32.dp),
)
},
headlineContent = {
Text(
text = antiFeature.name,
color = MaterialTheme.colorScheme.inverseOnSurface,
style = MaterialTheme.typography.bodyMediumEmphasized,
)
},
supportingContent = {
antiFeature.reason?.let {
Text(
text = antiFeature.reason,
color = MaterialTheme.colorScheme.inverseOnSurface,
style = MaterialTheme.typography.labelMedium,
)
}
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = Modifier.padding(bottom = 8.dp),
)
}
}
}
}
}
@Preview
@Composable
fun AntiFeaturesPreview() {
FDroidContent {
AntiFeatures(testApp.antiFeatures!!)
}
}

View File

@@ -0,0 +1,439 @@
package org.fdroid.ui.details
import android.util.Log
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AppSettingsAlt
import androidx.compose.material.icons.filled.Category
import androidx.compose.material.icons.filled.ChangeHistory
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.CurrencyBitcoin
import androidx.compose.material.icons.filled.EditNote
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.Mail
import androidx.compose.material.icons.filled.OndemandVideo
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Translate
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.fromHtml
import androidx.compose.ui.text.style.TextAlign.Companion.Center
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withLink
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.os.LocaleListCompat
import org.fdroid.LocaleChooser.getBestLocale
import org.fdroid.R
import org.fdroid.install.InstallState
import org.fdroid.ui.FDroidContent
import org.fdroid.ui.categories.CategoryChip
import org.fdroid.ui.icons.License
import org.fdroid.ui.icons.Litecoin
import org.fdroid.ui.lists.AppListType
import org.fdroid.ui.navigation.NavigationKey
import org.fdroid.ui.utils.BigLoadingIndicator
import org.fdroid.ui.utils.ExpandableSection
import org.fdroid.ui.utils.testApp
@Composable
@OptIn(
ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class
)
fun AppDetails(
item: AppDetailsItem?,
onNav: (NavigationKey) -> Unit,
onBackNav: (() -> Unit)?,
modifier: Modifier = Modifier,
) {
val topAppBarState = rememberTopAppBarState()
var showInstallError by remember { mutableStateOf(false) }
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(topAppBarState)
if (item == null) BigLoadingIndicator()
else Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
AppDetailsTopAppBar(item, topAppBarState, scrollBehavior, onBackNav)
},
) { innerPadding ->
// react to install state changes
LaunchedEffect(item.installState) {
val state = item.installState
if (state is InstallState.UserConfirmationNeeded) {
Log.i("AppDetails", "Requesting user confirmation... $state")
item.actions.requestUserConfirmation(state)
} else if (state is InstallState.Error) {
showInstallError = true
}
}
val scrollState = rememberScrollState()
Column(
modifier = modifier
.verticalScroll(scrollState)
.fillMaxWidth()
.padding(bottom = innerPadding.calculateBottomPadding()),
) {
// Header is taking care of top innerPadding
AppDetailsHeader(item, innerPadding)
AnimatedVisibility(item.showWarnings) {
AppDetailsWarnings(item, Modifier.padding(horizontal = 16.dp))
}
// What's New
if (item.installedVersion != null &&
(item.whatsNew != null || item.app.changelog != null)
) {
ElevatedCard(
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Text(
text = stringResource(R.string.whats_new_title),
style = MaterialTheme.typography.titleMediumEmphasized,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
if (item.whatsNew != null) SelectionContainer {
Text(
text = item.whatsNew,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
} else if (item.app.changelog != null) {
Text(
text = buildAnnotatedString {
withLink(LinkAnnotation.Url(item.app.changelog!!)) {
append(item.app.changelog)
}
},
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
}
}
// Description
var descriptionExpanded by remember { mutableStateOf(false) }
item.description?.let { description ->
val htmlDescription = AnnotatedString.fromHtml(description)
AnimatedVisibility(
visible = descriptionExpanded,
modifier = Modifier
.semantics { liveRegion = LiveRegionMode.Polite },
) {
SelectionContainer {
Text(
text = htmlDescription,
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(top = 8.dp),
)
}
}
AnimatedVisibility(!descriptionExpanded) {
Text(
text = htmlDescription,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(top = 8.dp),
)
}
TextButton(onClick = { descriptionExpanded = !descriptionExpanded }) {
Text(
text = if (descriptionExpanded) {
stringResource(R.string.less)
} else {
stringResource(R.string.more)
},
textAlign = Center,
maxLines = if (descriptionExpanded) Int.MAX_VALUE else 3,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 8.dp),
)
}
}
// Anti-features
if (!item.antiFeatures.isNullOrEmpty()) {
AntiFeatures(item.antiFeatures)
}
// Screenshots
if (item.phoneScreenshots.isNotEmpty()) {
Screenshots(item.networkState.isMetered, item.phoneScreenshots)
}
// Donate card
if (item.showDonate) ElevatedCard(
colors = CardDefaults.elevatedCardColors(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Text(
text = stringResource(R.string.donate_title),
style = MaterialTheme.typography.titleMediumEmphasized,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
item.app.donate?.forEach { donation ->
AppDetailsLink(
icon = Icons.Default.Link,
title = donation,
url = donation,
modifier = modifier
.padding(horizontal = 16.dp)
.padding(bottom = 8.dp),
)
}
item.liberapayUri?.let { liberapayUri ->
AppDetailsLink(
icon = Icons.Default.ChangeHistory,
title = "LiberaPay",
url = liberapayUri,
modifier = modifier
.padding(horizontal = 16.dp)
.padding(bottom = 8.dp),
)
}
item.openCollectiveUri?.let { openCollectiveUri ->
AppDetailsLink(
icon = Icons.Default.Groups,
title = "OpenCollective",
url = openCollectiveUri,
modifier = modifier
.padding(horizontal = 16.dp)
.padding(bottom = 8.dp),
)
}
item.bitcoinUri?.let { bitcoinUri ->
AppDetailsLink(
icon = Icons.Default.CurrencyBitcoin,
title = stringResource(R.string.menu_bitcoin),
url = bitcoinUri,
modifier = modifier
.padding(horizontal = 16.dp)
.padding(bottom = 8.dp),
)
}
item.litecoinUri?.let { litecoinUri ->
AppDetailsLink(
icon = Litecoin,
title = stringResource(R.string.menu_litecoin),
url = litecoinUri,
modifier = modifier
.padding(horizontal = 16.dp)
.padding(bottom = 8.dp),
)
}
}
// Links
ExpandableSection(
icon = rememberVectorPainter(Icons.Default.Link),
title = stringResource(R.string.links),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Column(modifier = Modifier.padding(start = 16.dp)) {
item.app.webSite?.let { webSite ->
AppDetailsLink(
icon = Icons.Default.Home,
title = stringResource(R.string.menu_website),
url = webSite,
)
}
item.app.issueTracker?.let { issueTracker ->
AppDetailsLink(
icon = Icons.Default.EditNote,
title = stringResource(R.string.menu_issues),
url = issueTracker,
)
}
item.app.changelog?.let { changelog ->
AppDetailsLink(
icon = Icons.Default.ChangeHistory,
title = stringResource(R.string.menu_changelog),
url = changelog,
)
}
item.app.license?.let { license ->
AppDetailsLink(
icon = License,
title = stringResource(R.string.menu_license, license),
url = "https://spdx.org/licenses/$license",
)
}
item.app.translation?.let { translation ->
AppDetailsLink(
icon = Icons.Default.Translate,
title = stringResource(R.string.menu_translation),
url = translation,
)
}
item.app.sourceCode?.let { sourceCode ->
AppDetailsLink(
icon = Icons.Default.Code,
title = stringResource(R.string.menu_source),
url = sourceCode,
)
}
item.app.video?.getBestLocale(LocaleListCompat.getDefault())?.let { video ->
AppDetailsLink(
icon = Icons.Default.OndemandVideo,
title = stringResource(R.string.menu_video),
url = video,
)
}
}
}
// Versions
if (!item.versions.isNullOrEmpty()) {
Versions(item) { scrollState.scrollTo(0) }
}
// Developer contact
if (item.showAuthorContact) ExpandableSection(
icon = rememberVectorPainter(Icons.Default.Person),
title = stringResource(R.string.developer_contact),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Column(modifier = Modifier.padding(start = 16.dp)) {
item.app.authorWebSite?.let { authorWebSite ->
AppDetailsLink(
icon = Icons.Default.Home,
title = stringResource(R.string.menu_website),
url = authorWebSite,
)
}
item.app.authorEmail?.let { authorEmail ->
AppDetailsLink(
icon = Icons.Default.Mail,
title = stringResource(R.string.menu_email),
url = authorEmail,
)
}
}
}
if (!item.categories.isNullOrEmpty()) ExpandableSection(
icon = rememberVectorPainter(Icons.Default.Category),
title = stringResource(R.string.main_menu__categories),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
initiallyExpanded = true,
) {
FlowRow(modifier = Modifier.padding(start = 16.dp)) {
item.categories.forEach { item ->
CategoryChip(item, onClick = {
val categoryNav = AppListType.Category(item.name, item.id)
onNav(NavigationKey.AppList(categoryNav))
})
}
}
}
ExpandableSection(
icon = rememberVectorPainter(Icons.Default.AppSettingsAlt),
title = stringResource(R.string.technical_info),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
) {
TechnicalInfo(item)
}
// More apps by dev
if (item.authorHasMoreThanOneApp) {
val authorName = item.app.authorName!!
val title = stringResource(R.string.app_list_author, authorName)
Button(
onClick = {
onNav(NavigationKey.AppList(AppListType.Author(title, authorName)))
},
modifier = Modifier
.align(CenterHorizontally)
.padding(bottom = 16.dp),
) {
val s = stringResource(R.string.app_details_more_apps_by_author, authorName)
Text(s)
}
}
}
}
if (showInstallError && item != null && item.installState is InstallState.Error) AlertDialog(
onDismissRequest = { showInstallError = false },
containerColor = MaterialTheme.colorScheme.errorContainer,
title = {
Text(stringResource(R.string.install_error_notify_title, item.name))
},
text = {
if (item.installState.msg == null) {
Text(stringResource(R.string.app_details_install_error_text))
} else {
ExpandableSection(
icon = null,
title = stringResource(R.string.app_details_install_error_text)
) {
SelectionContainer {
Text(
text = item.installState.msg,
fontFamily = FontFamily.Monospace,
modifier = Modifier.padding(top = 8.dp),
)
}
}
}
},
confirmButton = {
TextButton(onClick = { showInstallError = false }) {
Text(stringResource(R.string.ok))
}
},
)
}
@Preview
@Composable
fun AppDetailsLoadingPreview() {
FDroidContent {
AppDetails(null, { }, {})
}
}
@Preview
@Composable
fun AppDetailsPreview() {
FDroidContent {
AppDetails(testApp, { }, {})
}
}

View File

@@ -0,0 +1,33 @@
package org.fdroid.ui.details
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import org.fdroid.ui.navigation.NavigationKey
import org.fdroid.ui.navigation.Navigator
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
fun EntryProviderScope<NavKey>.appDetailsEntry(
navigator: Navigator,
isBigScreen: Boolean,
) {
entry<NavigationKey.AppDetails>(
metadata = ListDetailSceneStrategy.detailPane("appdetails")
) {
val viewModel = hiltViewModel<AppDetailsViewModel, AppDetailsViewModel.Factory>(
creationCallback = { factory ->
factory.create(it.packageName)
}
)
AppDetails(
item = viewModel.appDetails.collectAsStateWithLifecycle().value,
onNav = { navKey -> navigator.navigate(navKey) },
onBackNav = if (isBigScreen) null else {
{ navigator.goBack() }
},
)
}
}

View File

@@ -0,0 +1,343 @@
package org.fdroid.ui.details
import android.text.format.Formatter
import android.util.Log
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.absoluteOffset
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearWavyProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import coil3.compose.AsyncImage
import org.fdroid.R
import org.fdroid.download.NetworkState
import org.fdroid.install.InstallState
import org.fdroid.ui.FDroidContent
import org.fdroid.ui.utils.AsyncShimmerImage
import org.fdroid.ui.utils.InstalledBadge
import org.fdroid.ui.utils.MeteredConnectionDialog
import org.fdroid.ui.utils.OfflineBar
import org.fdroid.ui.utils.asRelativeTimeString
import org.fdroid.ui.utils.startActivitySafe
import org.fdroid.ui.utils.testApp
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
fun AppDetailsHeader(
item: AppDetailsItem,
innerPadding: PaddingValues,
) {
var showTopSpacer by rememberSaveable(item.featureGraphic) { mutableStateOf(true) }
if (showTopSpacer) {
Spacer(modifier = Modifier.padding(innerPadding))
}
var showMeteredDialog by remember { mutableStateOf(false) }
item.featureGraphic?.let { featureGraphic ->
AsyncImage(
model = featureGraphic,
contentDescription = "",
contentScale = ContentScale.FillWidth,
onSuccess = {
showTopSpacer = false
},
onError = {
showTopSpacer = true
},
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 196.dp)
.graphicsLayer { alpha = 0.5f }
.drawWithContent {
val colors = listOf(
Color.Black,
Color.Transparent
)
drawContent()
drawRect(
brush = Brush.verticalGradient(colors),
blendMode = BlendMode.DstIn
)
}
.padding(bottom = 8.dp)
.semantics { hideFromAccessibility() },
)
}
// Offline bar, if no internet
if (!item.networkState.isOnline) {
OfflineBar(modifier = Modifier.absoluteOffset(y = (-16).dp))
}
// Header
val version = item.suggestedVersion ?: item.versions?.first()?.version
Row(
modifier = Modifier
.padding(horizontal = 16.dp),
horizontalArrangement = spacedBy(16.dp),
verticalAlignment = CenterVertically,
) {
BadgedBox(badge = { if (item.installedVersionCode != null) InstalledBadge() }) {
AsyncShimmerImage(
model = item.icon,
contentDescription = "",
contentScale = ContentScale.Crop,
error = painterResource(R.drawable.ic_repo_app_default),
modifier = Modifier
.size(64.dp)
.clip(MaterialTheme.shapes.large)
.semantics { hideFromAccessibility() },
)
}
Column {
SelectionContainer {
Text(
item.name,
style = MaterialTheme.typography.headlineMediumEmphasized
)
}
item.app.authorName?.let { authorName ->
SelectionContainer {
Text(
text = stringResource(R.string.author_by, authorName),
style = MaterialTheme.typography.bodyMedium,
)
}
}
val lastUpdated = item.app.lastUpdated.asRelativeTimeString()
val size = version?.size?.let { size ->
Formatter.formatFileSize(LocalContext.current, size)
}
SelectionContainer {
Text(
text = if (size == null) {
stringResource(R.string.last_updated, lastUpdated)
} else {
stringResource(R.string.last_updated_with_size, lastUpdated, size)
},
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
// Summary
item.summary?.let { summary ->
SelectionContainer {
Text(
text = summary,
style = MaterialTheme.typography.bodyLargeEmphasized,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
}
// Repo Chooser
RepoChooser(
repos = item.repositories,
currentRepoId = item.app.repoId,
preferredRepoId = item.preferredRepoId,
proxy = item.proxy,
onRepoChanged = item.actions.onRepoChanged,
onPreferredRepoChanged = item.actions.onPreferredRepoChanged,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
// check user confirmation ON_RESUME to work around Android bug
val lifecycleOwner = LocalLifecycleOwner.current
val currentInstallState by rememberUpdatedState(item.installState)
var numChecks by remember { mutableStateOf(0) }
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
val state = currentInstallState
if (state is InstallState.UserConfirmationNeeded && numChecks < 3) {
Log.i(
"AppDetailsHeader",
"Resumed ($numChecks). Checking user confirmation... $state"
)
// there's annoying installer bugs where it doesn't tell us about errors
// and we would run into infinite UI loops here, so there's a counter.
@Suppress("AssignedValueIsNeverRead")
numChecks += 1
item.actions.checkUserConfirmation(state)
} else if (state is InstallState.UserConfirmationNeeded) {
// we tried three times, so cancel install now
Log.i("AppDetailsHeader", "Cancel installation")
item.actions.cancelInstall()
}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
// Main Buttons
val buttonLineModifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
if (item.mainButtonState == MainButtonState.PROGRESS) {
Row(
modifier = buttonLineModifier,
verticalAlignment = CenterVertically,
) {
Column {
val strRes = when (item.installState) {
is InstallState.Waiting -> R.string.status_install_preparing
is InstallState.Starting -> R.string.status_install_preparing
is InstallState.PreApproved -> R.string.status_install_preparing
is InstallState.Downloading -> R.string.downloading
is InstallState.Installing -> R.string.installing
is InstallState.UserConfirmationNeeded -> R.string.installing
else -> -1
}
if (strRes >= 0) Text(
text = stringResource(strRes),
style = MaterialTheme.typography.bodyMedium,
)
Row(verticalAlignment = CenterVertically) {
if (item.installState is InstallState.Downloading) {
val animatedProgress by animateFloatAsState(
targetValue = item.installState.progress,
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
)
LinearWavyProgressIndicator(
stopSize = 0.dp,
progress = { animatedProgress },
modifier = Modifier.weight(1f),
)
} else {
LinearWavyProgressIndicator(modifier = Modifier.weight(1f))
}
var cancelled by remember { mutableStateOf(false) }
IconButton(onClick = {
if (!cancelled) item.actions.cancelInstall()
cancelled = true
}) {
AnimatedVisibility(cancelled) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
AnimatedVisibility(!cancelled) {
Icon(
imageVector = Icons.Default.Cancel,
contentDescription = stringResource(R.string.cancel),
)
}
}
}
}
}
} else if (item.showOpenButton || item.mainButtonState != MainButtonState.NONE) Row(
horizontalArrangement = spacedBy(8.dp, CenterHorizontally),
modifier = buttonLineModifier,
) {
if (item.showOpenButton) {
val context = LocalContext.current
OutlinedButton(
onClick = {
context.startActivitySafe(item.actions.launchIntent)
},
modifier = Modifier.weight(1f)
) {
Text(stringResource(R.string.menu_open))
}
}
if (item.mainButtonState != MainButtonState.NONE) {
// button is for either installing or updating
Button(
onClick = {
if (item.networkState.isMetered) {
showMeteredDialog = true
} else {
require(item.suggestedVersion != null) {
"suggestedVersion was null"
}
item.actions.installAction(item.app, item.suggestedVersion, item.icon)
}
},
modifier = Modifier.weight(1f)
) {
if (item.mainButtonState == MainButtonState.INSTALL) {
Text(stringResource(R.string.menu_install))
} else if (item.mainButtonState == MainButtonState.UPDATE) {
Text(stringResource(R.string.app__install_downloaded_update))
}
}
}
}
if (showMeteredDialog) MeteredConnectionDialog(
numBytes = version?.size,
onConfirm = {
require(item.suggestedVersion != null) { "suggestedVersion was null" }
item.actions.installAction(item.app, item.suggestedVersion, item.icon)
},
onDismiss = { showMeteredDialog = false },
)
}
@Preview
@Composable
fun AppDetailsHeaderPreview() {
FDroidContent {
Column {
AppDetailsHeader(testApp, PaddingValues(top = 16.dp))
}
}
}
@Preview
@Composable
private fun PreviewProgress() {
FDroidContent(dynamicColors = true) {
Column {
val app = testApp.copy(
installState = InstallState.Starting("", "", "", 23),
networkState = NetworkState(true, isMetered = true),
)
AppDetailsHeader(app, PaddingValues(top = 16.dp))
}
}
}

View File

@@ -0,0 +1,286 @@
package org.fdroid.ui.details
import android.content.Intent
import android.os.Build.VERSION.SDK_INT
import androidx.activity.result.ActivityResult
import androidx.annotation.VisibleForTesting
import androidx.core.os.LocaleListCompat
import io.ktor.client.engine.ProxyConfig
import org.fdroid.database.App
import org.fdroid.database.AppIssue
import org.fdroid.database.AppMetadata
import org.fdroid.database.AppPrefs
import org.fdroid.database.AppVersion
import org.fdroid.database.Repository
import org.fdroid.download.DownloadRequest
import org.fdroid.download.NetworkState
import org.fdroid.download.PackageName
import org.fdroid.download.getImageModel
import org.fdroid.index.RELEASE_CHANNEL_BETA
import org.fdroid.index.v2.PackageVersion
import org.fdroid.install.InstallState
import org.fdroid.install.SessionInstallManager
import org.fdroid.ui.categories.CategoryItem
data class AppDetailsItem(
val app: AppMetadata,
val actions: AppDetailsActions,
val installState: InstallState,
val networkState: NetworkState,
/**
* The ID of the repo that is currently set as preferred.
* Note that the repository ID of this [app] may be different.
*/
val preferredRepoId: Long = app.repoId,
/**
* A list of [Repository]s the app is in. If this is empty, we don't want to show the list.
*/
val repositories: List<Repository> = emptyList(),
val name: String,
val summary: String? = null,
val description: String? = null,
val icon: Any? = null,
val featureGraphic: Any? = null,
val phoneScreenshots: List<Any> = emptyList(),
val categories: List<CategoryItem>? = null,
val versions: List<VersionItem>? = null,
val installedVersion: PackageVersion? = null,
/**
* Needed, because the [installedVersion] may not be available, e.g. too old.
*/
val installedVersionCode: Long? = null,
val installedVersionName: String? = null,
/**
* The currently suggested version for installation.
*/
val suggestedVersion: AppVersion? = null,
/**
* Similar to [suggestedVersion], but doesn't obey [appPrefs] for ignoring versions.
* This is useful for (un-)ignoring this version.
*/
val possibleUpdate: PackageVersion? = null,
val appPrefs: AppPrefs? = null,
val whatsNew: String? = null,
val antiFeatures: List<AntiFeature>? = null,
val issue: AppIssue? = null,
val authorHasMoreThanOneApp: Boolean = false,
val proxy: ProxyConfig?,
) {
constructor(
repository: Repository,
preferredRepoId: Long,
repositories: List<Repository>,
dbApp: App,
actions: AppDetailsActions,
installState: InstallState,
networkState: NetworkState,
versions: List<VersionItem>?,
installedVersion: AppVersion?,
installedVersionCode: Long?,
installedVersionName: String?,
suggestedVersion: AppVersion?,
possibleUpdate: AppVersion?,
appPrefs: AppPrefs?,
issue: AppIssue?,
authorHasMoreThanOneApp: Boolean,
localeList: LocaleListCompat,
proxy: ProxyConfig?,
) : this(
app = dbApp.metadata,
actions = actions,
installState = installState,
networkState = networkState,
preferredRepoId = preferredRepoId,
repositories = repositories,
name = dbApp.name ?: "Unknown App",
summary = dbApp.summary,
description = getHtmlDescription(dbApp.getDescription(localeList)),
icon = if (installedVersionCode == null) {
dbApp.getIcon(localeList)?.getImageModel(repository, proxy)
} else {
val request =
dbApp.getIcon(localeList)?.getImageModel(repository, proxy) as? DownloadRequest
PackageName(dbApp.packageName, request)
},
featureGraphic = dbApp.getFeatureGraphic(localeList)?.getImageModel(repository, proxy),
phoneScreenshots = dbApp.getPhoneScreenshots(localeList).mapNotNull {
it.getImageModel(repository, proxy)
},
categories = dbApp.metadata.categories?.mapNotNull { categoryId ->
val category = repository.getCategories()[categoryId] ?: return@mapNotNull null
CategoryItem(
id = category.id,
name = category.getName(localeList) ?: "Unknown Category",
)
},
versions = versions,
installedVersion = installedVersion,
installedVersionCode = installedVersionCode,
installedVersionName = installedVersionName,
suggestedVersion = suggestedVersion,
possibleUpdate = possibleUpdate,
appPrefs = appPrefs,
whatsNew = suggestedVersion?.getWhatsNew(localeList)
?: installedVersion?.getWhatsNew(localeList),
antiFeatures = installedVersion?.getAntiFeatures(repository, localeList, proxy)
?: suggestedVersion?.getAntiFeatures(repository, localeList, proxy)
?: (versions?.first()?.version as? AppVersion).getAntiFeatures(
repository = repository,
localeList = localeList,
proxy = proxy,
),
issue = issue,
authorHasMoreThanOneApp = authorHasMoreThanOneApp,
proxy = proxy,
)
/**
* True if the app is installed (and has a launch intent)
* and thus the 'Open' button should be shown.
*/
val showOpenButton: Boolean get() = actions.launchIntent != null
val allowsBetaVersions: Boolean
get() = appPrefs?.releaseChannels?.contains(RELEASE_CHANNEL_BETA) == true
val ignoresAllUpdates: Boolean get() = appPrefs?.ignoreAllUpdates == true
/**
* True if the update from [possibleUpdate] is being ignored
* and not already ignoring all updates anyway.
*/
val ignoresCurrentUpdate: Boolean
get() {
if (ignoresAllUpdates) return false
val prefs = appPrefs ?: return false
val updateVersionCode = possibleUpdate?.versionCode ?: return false
return actions.ignoreThisUpdate != null && prefs.shouldIgnoreUpdate(updateVersionCode)
}
/**
* Specifies what main button should be shown.
*/
val mainButtonState: MainButtonState
get() {
return if (installState.showProgress) {
MainButtonState.PROGRESS
} else if (installedVersionCode == null) { // app is not installed
if (suggestedVersion == null) MainButtonState.NONE
else MainButtonState.INSTALL
} else { // app is installed
if (suggestedVersion == null ||
suggestedVersion.versionCode <= installedVersionCode
) MainButtonState.NONE
else MainButtonState.UPDATE
}
}
/**
* True if all available versions for this app are incompatible with this device.
*/
val isIncompatible: Boolean = versions?.all { !it.isCompatible } ?: false
/**
* True if this app has warnings, we need to show to the user.
*/
val showWarnings: Boolean
get() = isIncompatible || oldTargetSdk || issue != null
/**
* True if the targetSdk of the suggested version is so old
* that auto updates for this app are not available (due to system restrictions).
*/
val oldTargetSdk: Boolean
get() {
val targetSdk = suggestedVersion?.packageManifest?.targetSdkVersion
// auto-updates are only available on SDK 31 and up
return if (targetSdk != null && SDK_INT >= 31) {
!SessionInstallManager.isAutoUpdateSupported(targetSdk)
} else {
false
}
}
val showAuthorContact: Boolean get() = app.authorEmail != null || app.authorWebSite != null
val showDonate: Boolean
get() = !app.donate.isNullOrEmpty() ||
app.liberapay != null ||
app.openCollective != null ||
app.litecoin != null ||
app.bitcoin != null
val liberapayUri = app.liberapay?.let { "https://liberapay.com/$it/donate" }
val openCollectiveUri = app.openCollective?.let { "https://opencollective.com/$it/donate" }
val litecoinUri = app.litecoin?.let { "litecoin:$it" }
val bitcoinUri = app.bitcoin?.let { "bitcoin:$it" }
}
class AppDetailsActions(
val installAction: (AppMetadata, AppVersion, Any?) -> Unit,
val requestUserConfirmation: (InstallState.UserConfirmationNeeded) -> Unit,
/**
* A workaround for Android 10, 11, 12 and 13 where tapping outside the confirmation dialog
* dismisses it without any feedback for us.
* So when our activity resumes while we are in state [InstallState.UserConfirmationNeeded]
* we need to call this method, so we can manually check if our session progressed or not.
*/
val checkUserConfirmation: (InstallState.UserConfirmationNeeded) -> Unit,
val cancelInstall: () -> Unit,
val onUninstallResult: (ActivityResult) -> Unit,
val onRepoChanged: (Long) -> Unit,
val onPreferredRepoChanged: (Long) -> Unit,
val allowBetaVersions: () -> Unit,
val ignoreAllUpdates: (() -> Unit)? = null,
val ignoreThisUpdate: (() -> Unit)? = null,
val shareApk: Intent? = null,
val uninstallIntent: Intent? = null,
val launchIntent: Intent? = null,
val shareIntent: Intent? = null,
)
data class VersionItem(
val version: PackageVersion,
val isInstalled: Boolean,
val isSuggested: Boolean,
val isCompatible: Boolean,
val isSignerCompatible: Boolean,
val showInstallButton: Boolean,
)
enum class MainButtonState {
NONE,
INSTALL,
UPDATE,
PROGRESS,
}
data class AntiFeature(
val id: String,
val icon: Any? = null,
val name: String = id,
val reason: String? = null,
)
private fun AppVersion?.getAntiFeatures(
repository: Repository,
localeList: LocaleListCompat,
proxy: ProxyConfig?,
): List<AntiFeature>? {
return this?.antiFeatureKeys?.mapNotNull { key ->
val antiFeature = repository.getAntiFeatures()[key] ?: return@mapNotNull null
AntiFeature(
id = key,
icon = antiFeature.getIcon(localeList)?.getImageModel(repository, proxy),
name = antiFeature.getName(localeList) ?: key,
reason = getAntiFeatureReason(key, localeList),
)
}
}
@VisibleForTesting
internal fun getHtmlDescription(description: String?): String? {
return description?.replace("</?h[1-6]>".toRegex(), "")
?.replace("(\\s\\(?)(https://\\S+[^\\s).])([\\s\\n).]|$)".toRegex()) {
val prefix = it.groups[1]?.value ?: it.value
val url = it.groups[2]?.value ?: it.value
val suffix = it.groups[3]?.value ?: it.value
"$prefix<a href=\"$url\">$url</a>$suffix"
}?.replace("(?<!</p>|ul>|</li>)\n".toRegex(), "<br>\n")
}

View File

@@ -0,0 +1,54 @@
package org.fdroid.ui.details
import android.content.ClipData
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.fdroid.R
import org.fdroid.ui.utils.openUriSafe
@Composable
fun AppDetailsLink(icon: ImageVector, title: String, url: String, modifier: Modifier = Modifier) {
val uriHandler = LocalUriHandler.current
val haptics = LocalHapticFeedback.current
val clipboardManager = LocalClipboard.current
val coroutineScope = rememberCoroutineScope()
Row(
horizontalArrangement = spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.heightIn(min = 48.dp)
.fillMaxWidth()
.combinedClickable(
onClick = { uriHandler.openUriSafe(url) },
onLongClick = {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
val entry = ClipEntry(ClipData.newPlainText("", url))
coroutineScope.launch {
clipboardManager.setClipEntry(entry)
}
},
onLongClickLabel = stringResource(R.string.copy_link),
),
) {
Icon(icon, null)
Text(title)
}
}

View File

@@ -0,0 +1,129 @@
package org.fdroid.ui.details
import android.content.Intent
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Preview
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.UpdateDisabled
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import org.fdroid.R
import org.fdroid.ui.FDroidContent
import org.fdroid.ui.utils.startActivitySafe
import org.fdroid.ui.utils.testApp
@Composable
fun AppDetailsMenu(
item: AppDetailsItem,
expanded: Boolean,
onDismiss: () -> Unit,
) {
val res = LocalResources.current
val context = LocalContext.current
val uninstallLauncher = rememberLauncherForActivityResult(StartActivityForResult()) {
item.actions.onUninstallResult(it)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = onDismiss,
) {
if (item.appPrefs != null) DropdownMenuItem(
leadingIcon = {
Icon(Icons.Default.Preview, null)
},
text = { Text(stringResource(R.string.menu_release_channel_beta)) },
trailingIcon = {
Checkbox(
checked = item.allowsBetaVersions,
onCheckedChange = null,
enabled = !item.ignoresAllUpdates,
)
},
enabled = !item.ignoresAllUpdates,
onClick = {
item.actions.allowBetaVersions()
onDismiss()
},
)
if (item.actions.ignoreAllUpdates != null) DropdownMenuItem(
leadingIcon = {
Icon(Icons.Default.UpdateDisabled, null)
},
text = { Text(stringResource(R.string.menu_ignore_all)) },
trailingIcon = {
Checkbox(item.ignoresAllUpdates, null)
},
onClick = {
item.actions.ignoreAllUpdates()
onDismiss()
},
)
if (item.actions.ignoreThisUpdate != null) DropdownMenuItem(
leadingIcon = {
Icon(Icons.Default.UpdateDisabled, null)
},
text = { Text(stringResource(R.string.menu_ignore_this)) },
trailingIcon = {
Checkbox(
checked = item.ignoresCurrentUpdate,
onCheckedChange = null,
enabled = !item.ignoresAllUpdates,
)
},
enabled = !item.ignoresAllUpdates,
onClick = {
item.actions.ignoreThisUpdate()
onDismiss()
},
)
if (item.actions.shareApk != null) DropdownMenuItem(
leadingIcon = {
Icon(Icons.Default.Share, null)
},
text = { Text(stringResource(R.string.menu_share_apk)) },
onClick = {
val s = res.getString(R.string.menu_share_apk)
val i = Intent.createChooser(item.actions.shareApk, s)
context.startActivitySafe(i)
onDismiss()
},
)
if (item.actions.uninstallIntent != null) DropdownMenuItem(
leadingIcon = {
Icon(Icons.Default.Delete, null)
},
text = { Text(stringResource(R.string.menu_uninstall)) },
onClick = {
uninstallLauncher.launch(item.actions.uninstallIntent)
onDismiss()
},
)
}
}
@Preview
@Composable
fun AppDetailsMenuPreview() {
AppDetailsMenu(testApp, true) {}
}
@Preview(uiMode = UI_MODE_NIGHT_YES)
@Composable
fun AppDetailsMenuAllIgnoredPreview() {
val appPrefs = testApp.appPrefs!!.toggleIgnoreAllUpdates()
FDroidContent {
AppDetailsMenu(testApp.copy(appPrefs = appPrefs), true) {}
}
}

View File

@@ -0,0 +1,73 @@
package org.fdroid.ui.details
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.TopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import org.fdroid.R
import org.fdroid.ui.utils.startActivitySafe
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppDetailsTopAppBar(
item: AppDetailsItem,
topAppBarState: TopAppBarState,
scrollBehavior: TopAppBarScrollBehavior,
onBackNav: (() -> Unit)?,
) {
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
),
title = {
if (topAppBarState.overlappedFraction == 1f) {
Text(item.name, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
},
navigationIcon = {
if (onBackNav != null) IconButton(onClick = onBackNav) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back),
)
}
},
actions = {
val context = LocalContext.current
item.actions.shareIntent?.let { shareIntent ->
IconButton(onClick = { context.startActivitySafe(shareIntent) }) {
Icon(
imageVector = Icons.Filled.Share,
contentDescription = stringResource(R.string.menu_share),
)
}
}
var expanded by remember { mutableStateOf(false) }
IconButton(onClick = { expanded = !expanded }) {
Icon(
imageVector = Icons.Filled.MoreVert,
contentDescription = stringResource(R.string.more),
)
}
AppDetailsMenu(item, expanded) { expanded = false }
},
scrollBehavior = scrollBehavior,
)
}

View File

@@ -0,0 +1,223 @@
package org.fdroid.ui.details
import android.app.Application
import android.content.Intent
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.content.pm.PackageManager.GET_SIGNATURES
import androidx.activity.result.ActivityResult
import androidx.annotation.UiThread
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.application
import androidx.lifecycle.viewModelScope
import app.cash.molecule.AndroidUiDispatcher
import app.cash.molecule.RecompositionMode.ContextClock
import app.cash.molecule.launchMolecule
import coil3.SingletonImageLoader
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import mu.KotlinLogging
import org.fdroid.UpdateChecker
import org.fdroid.database.AppMetadata
import org.fdroid.database.AppVersion
import org.fdroid.database.FDroidDatabase
import org.fdroid.download.DownloadRequest
import org.fdroid.download.NetworkMonitor
import org.fdroid.getCacheKey
import org.fdroid.index.RELEASE_CHANNEL_BETA
import org.fdroid.index.RepoManager
import org.fdroid.install.AppInstallManager
import org.fdroid.install.InstallState
import org.fdroid.repo.RepoPreLoader
import org.fdroid.settings.SettingsManager
import org.fdroid.updates.UpdatesManager
import org.fdroid.utils.IoDispatcher
@HiltViewModel(assistedFactory = AppDetailsViewModel.Factory::class)
class AppDetailsViewModel @AssistedInject constructor(
private val app: Application,
@Assisted private val packageName: String,
@param:IoDispatcher private val scope: CoroutineScope,
private val db: FDroidDatabase,
private val repoManager: RepoManager,
private val repoPreLoader: RepoPreLoader,
private val updateChecker: UpdateChecker,
private val updatesManager: UpdatesManager,
private val networkMonitor: NetworkMonitor,
private val settingsManager: SettingsManager,
private val appInstallManager: AppInstallManager,
) : AndroidViewModel(app) {
private val log = KotlinLogging.logger { }
private val packageInfoFlow = MutableStateFlow<AppInfo?>(null)
private val currentRepoIdFlow = MutableStateFlow<Long?>(null)
private val moleculeScope =
CoroutineScope(viewModelScope.coroutineContext + AndroidUiDispatcher.Main)
val appDetails: StateFlow<AppDetailsItem?> by lazy(LazyThreadSafetyMode.NONE) {
moleculeScope.launchMolecule(mode = ContextClock) {
DetailsPresenter(
db = db,
scope = scope,
repoManager = repoManager,
repoPreLoader = repoPreLoader,
updateChecker = updateChecker,
settingsManager = settingsManager,
appInstallManager = appInstallManager,
viewModel = this,
packageInfoFlow = packageInfoFlow,
currentRepoIdFlow = currentRepoIdFlow,
appsWithIssuesFlow = updatesManager.appsWithIssues,
networkStateFlow = networkMonitor.networkState,
)
}
}
init {
loadPackageInfoFlow()
}
private fun loadPackageInfoFlow() {
val packageManager = app.packageManager
scope.launch {
val packageInfo = try {
@Suppress("DEPRECATION")
packageManager.getPackageInfo(packageName, GET_SIGNATURES)
} catch (_: PackageManager.NameNotFoundException) {
null
}
packageInfoFlow.value = if (packageInfo == null) {
AppInfo(packageName)
} else {
val intent = packageManager.getLaunchIntentForPackage(packageName)
AppInfo(packageName, packageInfo, intent)
}
}
}
@UiThread
fun install(appMetadata: AppMetadata, version: AppVersion, iconModel: Any?) {
scope.launch(Dispatchers.Main) {
val result = appInstallManager.install(
appMetadata = appMetadata,
version = version,
currentVersionName = packageInfoFlow.value?.packageInfo?.versionName,
repo = repoManager.getRepository(version.repoId) ?: return@launch, // TODO
iconModel = iconModel,
canAskPreApprovalNow = true,
)
if (result is InstallState.Installed) {
// to reload packageInfoFlow with fresh packageInfo
loadPackageInfoFlow()
}
}
}
@UiThread
fun requestUserConfirmation(installState: InstallState.UserConfirmationNeeded) {
scope.launch(Dispatchers.Main) {
val result = appInstallManager.requestUserConfirmation(packageName, installState)
if (result is InstallState.Installed) withContext(Dispatchers.Main) {
// to reload packageInfoFlow with fresh packageInfo
loadPackageInfoFlow()
}
}
}
@UiThread
fun checkUserConfirmation(installState: InstallState.UserConfirmationNeeded) {
scope.launch(Dispatchers.Main) {
delay(500) // wait a moment to increase chance that state got updated
appInstallManager.checkUserConfirmation(packageName, installState)
}
}
@UiThread
fun cancelInstall() {
appInstallManager.cancel(packageName)
}
@UiThread
fun onUninstallResult(activityResult: ActivityResult) {
val result = appInstallManager.onUninstallResult(packageName, activityResult)
if (result is InstallState.Uninstalled) {
// to reload packageInfoFlow with fresh packageInfo
loadPackageInfoFlow()
}
}
@UiThread
fun onRepoChanged(repoId: Long) {
currentRepoIdFlow.update { repoId }
}
@UiThread
fun onPreferredRepoChanged(repoId: Long) {
scope.launch {
repoManager.setPreferredRepoId(packageName, repoId).join()
updatesManager.loadUpdates()
}
}
override fun onCleared() {
log.info { "App details screen left: $packageName" }
appInstallManager.cleanUp(packageName)
// remove screenshots from disk cache to not fill it up quickly with large images
val diskCache = SingletonImageLoader.get(application).diskCache
if (diskCache != null) scope.launch {
appDetails.value?.phoneScreenshots?.forEach { screenshot ->
if (screenshot is DownloadRequest) {
diskCache.remove(screenshot.getCacheKey())
}
}
}
}
@UiThread
fun allowBetaUpdates() {
val appPrefs = appDetails.value?.appPrefs ?: return
scope.launch {
db.getAppPrefsDao().update(appPrefs.toggleReleaseChannel(RELEASE_CHANNEL_BETA))
updatesManager.loadUpdates()
}
}
@UiThread
fun ignoreAllUpdates() {
val appPrefs = appDetails.value?.appPrefs ?: return
scope.launch {
db.getAppPrefsDao().update(appPrefs.toggleIgnoreAllUpdates())
updatesManager.loadUpdates()
}
}
@UiThread
fun ignoreThisUpdate() {
val appPrefs = appDetails.value?.appPrefs ?: return
val versionCode = appDetails.value?.possibleUpdate?.versionCode ?: return
scope.launch {
db.getAppPrefsDao().update(appPrefs.toggleIgnoreVersionCodeUpdate(versionCode))
updatesManager.loadUpdates()
}
}
@AssistedFactory
interface Factory {
fun create(packageName: String): AppDetailsViewModel
}
}
class AppInfo(
val packageName: String,
val packageInfo: PackageInfo? = null,
val launchIntent: Intent? = null,
)

View File

@@ -0,0 +1,145 @@
package org.fdroid.ui.details
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.WarningAmber
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.os.LocaleListCompat
import org.fdroid.R
import org.fdroid.database.AppVersion
import org.fdroid.database.KnownVulnerability
import org.fdroid.database.NoCompatibleSigner
import org.fdroid.database.NotAvailable
import org.fdroid.database.UpdateInOtherRepo
import org.fdroid.index.v2.ANTI_FEATURE_KNOWN_VULNERABILITY
import org.fdroid.ui.FDroidContent
import org.fdroid.ui.utils.testApp
@Composable
fun AppDetailsWarnings(
item: AppDetailsItem,
modifier: Modifier = Modifier,
) {
val (color, string) = when {
// app issues take priority
item.issue != null -> when (item.issue) {
// apps has a known security vulnerability
is KnownVulnerability -> {
val details = item.versions?.firstNotNullOfOrNull { versionItem ->
(versionItem.version as? AppVersion)?.getAntiFeatureReason(
antiFeatureKey = ANTI_FEATURE_KNOWN_VULNERABILITY,
localeList = LocaleListCompat.getDefault(),
)
}
Pair(
MaterialTheme.colorScheme.errorContainer,
if (details.isNullOrBlank()) {
stringResource(R.string.antiknownvulnlist)
} else {
stringResource(R.string.antiknownvulnlist) + ":\n\n" + details
},
)
}
is NoCompatibleSigner -> Pair(
MaterialTheme.colorScheme.errorContainer,
if (item.issue.repoIdWithCompatibleSigner == null) {
stringResource(R.string.app_no_compatible_signer)
} else {
stringResource(R.string.app_no_compatible_signer_in_this_repo)
},
)
is UpdateInOtherRepo -> Pair(
MaterialTheme.colorScheme.inverseSurface,
stringResource(R.string.app_issue_update_other_repo),
)
NotAvailable -> Pair(
MaterialTheme.colorScheme.errorContainer,
stringResource(R.string.error),
)
}
// app is outright incompatible
item.isIncompatible -> Pair(
MaterialTheme.colorScheme.errorContainer,
stringResource(R.string.app_no_compatible_versions),
)
// app targets old targetSdk, not a deal breaker, but worth flagging, no auto-update
item.oldTargetSdk -> Pair(
MaterialTheme.colorScheme.inverseSurface,
stringResource(R.string.app_no_auto_update),
)
else -> return
}
ElevatedCard(
colors = CardDefaults.elevatedCardColors(containerColor = color),
modifier = modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
) {
WarningRow(
text = string,
)
}
}
@Composable
private fun WarningRow(text: String) {
Row(
horizontalArrangement = spacedBy(16.dp),
verticalAlignment = CenterVertically,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Icon(Icons.Default.WarningAmber, null)
Text(text, style = MaterialTheme.typography.bodyLarge)
}
}
@Preview
@Composable
fun AppDetailsWarningsPreview() {
FDroidContent {
Column {
AppDetailsWarnings(testApp)
}
}
}
@Preview
@Composable
private fun KnownVulnPreview() {
FDroidContent {
Column {
AppDetailsWarnings(testApp.copy(issue = KnownVulnerability(true)))
AppDetailsWarnings(testApp.copy(issue = KnownVulnerability(false)))
}
}
}
@Preview
@Composable
private fun IncompatiblePreview() {
FDroidContent {
Column {
AppDetailsWarnings(
testApp.copy(
versions = listOf(
testApp.versions!!.first().copy(isCompatible = false),
),
)
)
}
}
}

View File

@@ -0,0 +1,266 @@
package org.fdroid.ui.details
import android.content.Intent
import android.net.Uri
import android.util.Log
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.core.content.pm.PackageInfoCompat.getLongVersionCode
import androidx.core.net.toUri
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.asFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withContext
import org.fdroid.UpdateChecker
import org.fdroid.database.App
import org.fdroid.database.AppPrefs
import org.fdroid.database.AppVersion
import org.fdroid.database.FDroidDatabase
import org.fdroid.database.Repository
import org.fdroid.download.NetworkState
import org.fdroid.index.RepoManager
import org.fdroid.install.ApkFileProvider
import org.fdroid.install.AppInstallManager
import org.fdroid.install.InstallState
import org.fdroid.repo.RepoPreLoader
import org.fdroid.settings.SettingsManager
import org.fdroid.ui.apps.AppWithIssueItem
import org.fdroid.utils.sha256
private const val TAG = "DetailsPresenter"
// TODO write tests for this function
// see: https://github.com/cashapp/molecule?tab=readme-ov-file#testing
@Composable
fun DetailsPresenter(
db: FDroidDatabase,
scope: CoroutineScope,
repoManager: RepoManager,
repoPreLoader: RepoPreLoader,
updateChecker: UpdateChecker,
settingsManager: SettingsManager,
appInstallManager: AppInstallManager,
viewModel: AppDetailsViewModel,
packageInfoFlow: StateFlow<AppInfo?>,
currentRepoIdFlow: StateFlow<Long?>,
appsWithIssuesFlow: StateFlow<List<AppWithIssueItem>?>,
networkStateFlow: StateFlow<NetworkState>,
): AppDetailsItem? {
val packagePair = packageInfoFlow.collectAsState().value ?: return null
val packageName = packagePair.packageName
val packageInfo = packagePair.packageInfo
val currentRepoId = currentRepoIdFlow.collectAsState().value
val appsWithIssues = appsWithIssuesFlow.collectAsState().value
val appDao = db.getAppDao()
val app = produceState<App?>(null, currentRepoId) {
withContext(scope.coroutineContext) {
if (currentRepoId == null) {
val flow = appDao.getApp(packageName).asFlow()
flow.collect { value = it }
} else {
value = appDao.getApp(currentRepoId, packageName)
}
}
}.value ?: return null
val repo = produceState<Repository?>(null) {
withContext(scope.coroutineContext) {
value = repoManager.getRepository(app.repoId)
}
}.value ?: return null
val repositories = produceState(emptyList(), packageName) {
withContext(scope.coroutineContext) {
val repos = appDao.getRepositoryIdsForApp(packageName).mapNotNull { repoId ->
repoManager.getRepository(repoId)
}
// show repo chooser only if
// * app is in more than one repo, or
// * app is from a non-default repo
value = if (repos.size > 1) repos
else if (repo.address in repoPreLoader.defaultRepoAddresses) emptyList()
else repos
}
}.value
val installState =
appInstallManager.getAppFlow(packageName).collectAsState(InstallState.Unknown).value
val versions = produceState<List<AppVersion>?>(null, currentRepoId) {
withContext(scope.coroutineContext) {
if (currentRepoId == null) {
db.getVersionDao().getAppVersions(app.repoId, packageName).asFlow().collect {
value = it
}
} else {
db.getVersionDao().getAppVersions(currentRepoId, packageName).asFlow().collect {
value = it
}
}
}
}.value
val appPrefs = produceState<AppPrefs?>(null, packageName) {
withContext(scope.coroutineContext) {
db.getAppPrefsDao().getAppPrefs(packageName).asFlow().collect { value = it }
}
}.value
val preferredRepoId = remember(packageName, appPrefs) {
appPrefs?.preferredRepoId ?: app.repoId // DB loads preferred repo first, so we remember it
}
val installedSigner = remember(packageInfo?.packageName) {
@Suppress("DEPRECATION") // so far we had issues with the new way of getting sigs
packageInfo?.signatures?.get(0)?.let {
sha256(it.toByteArray())
}
}
val suggestedVersion = remember(versions, appPrefs, installedSigner) {
if (versions == null || appPrefs == null) {
null
} else {
updateChecker.getSuggestedVersion(
versions = versions,
preferredSigner = installedSigner ?: app.metadata.preferredSigner,
releaseChannels = appPrefs.releaseChannels,
preferencesGetter = { appPrefs },
)
}
}
val possibleUpdate = remember(versions, appPrefs, packageInfo) {
if (versions == null || appPrefs == null || packageInfo == null) {
null
} else {
updateChecker.getUpdate(
versions = versions,
packageInfo = packageInfo,
releaseChannels = appPrefs.releaseChannels,
// ignoring existing preferences to include ignored versions
preferencesGetter = null,
)
}
}
val installedVersionCode = packageInfo?.let {
getLongVersionCode(packageInfo)
}
val installedVersion = packageInfo?.let {
val installedVersions = versions?.filter { it.versionCode == installedVersionCode }
when (installedVersions?.size) {
null -> null
0 -> null
1 -> installedVersions.first()
// more than version with the same version code, find a matching signer
else -> installedVersions.find {
val versionSigners = it.signer?.sha256?.toSet()
// F-Droid allows versions without a signer entry, allow those
if (versionSigners != null && installedSigner != null) {
versionSigners.intersect(setOf(installedSigner)).isNotEmpty()
} else {
true
}
}
}
}
val authorName = app.authorName
val authorHasMoreThanOneApp = if (authorName == null) false else {
produceState(false) {
withContext(scope.coroutineContext) {
db.getAppDao().hasAuthorMoreThanOneApp(authorName).asFlow().collect { value = it }
}
}.value
}
val issue = remember(appsWithIssues) {
appsWithIssues?.find { it.packageName == packageName }?.issue
}
val locales = LocaleListCompat.getDefault()
Log.d(TAG, "Presenting app details:")
Log.d(TAG, " app '${app.name}' ($packageName) in ${repo.address}")
Log.d(TAG, " versions: ${versions?.size}")
Log.d(TAG, " appPrefs: $appPrefs")
Log.d(TAG, " installState: $installState")
return AppDetailsItem(
repository = repo,
preferredRepoId = preferredRepoId,
repositories = repositories,
dbApp = app,
actions = AppDetailsActions(
installAction = viewModel::install,
requestUserConfirmation = viewModel::requestUserConfirmation,
checkUserConfirmation = viewModel::checkUserConfirmation,
cancelInstall = viewModel::cancelInstall,
onUninstallResult = viewModel::onUninstallResult,
onRepoChanged = viewModel::onRepoChanged,
onPreferredRepoChanged = viewModel::onPreferredRepoChanged,
allowBetaVersions = viewModel::allowBetaUpdates,
ignoreAllUpdates = if (installedVersionCode == null) {
null
} else {
viewModel::ignoreAllUpdates
},
ignoreThisUpdate = if (installedVersionCode == null ||
possibleUpdate == null ||
possibleUpdate.versionCode <= installedVersionCode
) {
null
} else {
viewModel::ignoreThisUpdate
},
shareApk = if (installedVersionCode == null) {
null
} else {
ApkFileProvider.getIntent(packageName)
},
uninstallIntent = packageInfo?.let {
Intent(Intent.ACTION_DELETE).apply {
setData(Uri.fromParts("package", it.packageName, null))
putExtra(Intent.EXTRA_RETURN_RESULT, true)
}
},
launchIntent = packagePair.launchIntent,
shareIntent = getShareIntent(repo, packageName, app.name ?: ""),
),
installState = installState,
networkState = networkStateFlow.collectAsState().value,
versions = versions?.map { version ->
val signerCompatible = installedSigner == null ||
version.signer?.sha256?.first() == installedSigner
VersionItem(
version = version,
isInstalled = installedVersion == version,
isSuggested = suggestedVersion == version,
isCompatible = version.isCompatible,
isSignerCompatible = signerCompatible,
showInstallButton = if (!signerCompatible || installState.showProgress) {
false
} else {
(installedVersion?.versionCode ?: 0) < version.versionCode
},
)
},
installedVersion = installedVersion,
installedVersionCode = installedVersionCode,
installedVersionName = packageInfo?.versionName,
suggestedVersion = suggestedVersion,
possibleUpdate = possibleUpdate,
appPrefs = appPrefs,
issue = issue,
authorHasMoreThanOneApp = authorHasMoreThanOneApp,
localeList = locales,
proxy = settingsManager.proxyConfig,
)
}
private fun getShareIntent(
repo: Repository,
packageName: String,
appName: String,
): Intent? {
val webBaseUrl = repo.webBaseUrl ?: return null
val shareUri = webBaseUrl.toUri().buildUpon().appendPath(packageName).build()
val uriIntent = Intent(Intent.ACTION_SEND).apply {
setType("text/plain")
putExtra(Intent.EXTRA_SUBJECT, appName)
putExtra(Intent.EXTRA_TITLE, appName)
putExtra(Intent.EXTRA_TEXT, shareUri.toString())
}
return Intent.createChooser(uriIntent, appName)
}

View File

@@ -0,0 +1,41 @@
package org.fdroid.ui.details
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment.Companion.Center
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.fdroid.ui.AboutContent
import org.fdroid.ui.FDroidContent
@Composable
fun NoAppSelected() {
Box(
contentAlignment = Center,
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
) {
AboutContent(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth(fraction = 0.7f)
.padding(top = 32.dp)
)
}
}
@Preview
@Composable
private fun Preview() {
FDroidContent {
NoAppSelected()
}
}

View File

@@ -0,0 +1,203 @@
package org.fdroid.ui.details
import android.content.res.Configuration
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment.Companion.End
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight.Companion.Bold
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.os.LocaleListCompat
import io.ktor.client.engine.ProxyConfig
import org.fdroid.R
import org.fdroid.database.Repository
import org.fdroid.index.IndexFormatVersion.TWO
import org.fdroid.ui.FDroidContent
import org.fdroid.ui.repositories.RepoIcon
import org.fdroid.ui.utils.FDroidOutlineButton
@Composable
fun RepoChooser(
repos: List<Repository>,
currentRepoId: Long,
preferredRepoId: Long,
proxy: ProxyConfig?,
onRepoChanged: (Long) -> Unit,
onPreferredRepoChanged: (Long) -> Unit,
modifier: Modifier = Modifier,
) {
if (repos.isEmpty()) return
var expanded by remember { mutableStateOf(false) }
val currentRepo = remember(currentRepoId) {
repos.find { it.repoId == currentRepoId } ?: error("Current repoId not in list")
}
val isPreferred = currentRepo.repoId == preferredRepoId
Column(
modifier = modifier.fillMaxWidth(),
) {
Box {
val borderColor = if (isPreferred) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outline
}
OutlinedTextField(
value = TextFieldValue(
annotatedString = getRepoString(
repo = currentRepo,
isPreferred = repos.size > 1 && isPreferred,
),
),
textStyle = MaterialTheme.typography.bodyMedium,
onValueChange = {},
label = {
if (repos.size == 1) {
Text(stringResource(R.string.app_details_repository))
} else {
Text(stringResource(R.string.app_details_repositories))
}
},
leadingIcon = {
RepoIcon(repo = currentRepo, proxy = proxy, modifier = Modifier.size(24.dp))
},
trailingIcon = {
if (repos.size > 1) Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = stringResource(R.string.app_details_repository_expand),
tint = if (isPreferred) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
},
)
},
singleLine = false,
enabled = false,
colors = OutlinedTextFieldDefaults.colors(
// hack to enable clickable and look like enabled
disabledTextColor = MaterialTheme.colorScheme.onSurface,
disabledBorderColor = borderColor,
disabledLeadingIconColor = MaterialTheme.colorScheme.onSurface,
disabledLabelColor = borderColor,
),
modifier = Modifier
.fillMaxWidth()
.let {
if (repos.size > 1) it.clickable(onClick = { expanded = true }) else it
},
)
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
repos.iterator().forEach { repo ->
RepoMenuItem(
repo = repo,
isPreferred = repo.repoId == preferredRepoId,
proxy = proxy,
onClick = {
onRepoChanged(repo.repoId)
expanded = false
},
modifier = modifier,
)
}
}
}
if (!isPreferred) {
FDroidOutlineButton(
text = stringResource(R.string.app_details_repository_button_prefer),
onClick = { onPreferredRepoChanged(currentRepo.repoId) },
modifier = Modifier
.align(End)
.padding(top = 8.dp),
)
}
}
}
@Composable
private fun RepoMenuItem(
repo: Repository,
isPreferred: Boolean,
proxy: ProxyConfig?,
modifier: Modifier = Modifier,
onClick: () -> Unit
) {
DropdownMenuItem(
text = {
Text(
text = getRepoString(repo, isPreferred),
style = MaterialTheme.typography.bodyMedium,
)
},
modifier = modifier,
onClick = onClick,
leadingIcon = { RepoIcon(repo, proxy, Modifier.size(24.dp)) }
)
}
@Composable
private fun getRepoString(repo: Repository, isPreferred: Boolean) = buildAnnotatedString {
append(repo.getName(LocaleListCompat.getDefault()) ?: "Unknown Repository")
if (isPreferred) {
append(" ")
pushStyle(SpanStyle(fontWeight = Bold))
append(" ")
append(stringResource(R.string.app_details_repository_preferred))
}
}
@Composable
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
fun RepoChooserSingleRepoPreview() {
val repo1 = Repository(1L, "1", 1L, TWO, "null", 1L, 1, 1L)
FDroidContent {
RepoChooser(listOf(repo1), 1L, 1L, null, {}, {})
}
}
@Composable
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
fun RepoChooserPreview() {
val repo1 = Repository(1L, "1", 1L, TWO, "null", 1L, 1, 1L)
val repo2 = Repository(2L, "2", 2L, TWO, "null", 2L, 2, 2L)
val repo3 = Repository(3L, "2", 3L, TWO, "null", 3L, 3, 3L)
FDroidContent {
RepoChooser(listOf(repo1, repo2, repo3), 1L, 1L, null, {}, {})
}
}
@Composable
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
fun RepoChooserNightPreview() {
val repo1 = Repository(1L, "1", 1L, TWO, "null", 1L, 1, 1L)
val repo2 = Repository(2L, "2", 2L, TWO, "null", 2L, 2, 2L)
val repo3 = Repository(3L, "2", 3L, TWO, "null", 3L, 3, 3L)
FDroidContent {
RepoChooser(listOf(repo1, repo2, repo3), 1L, 2L, null, {}, {})
}
}

View File

@@ -0,0 +1,151 @@
package org.fdroid.ui.details
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.Image
import androidx.compose.material3.ElevatedButton
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.ModalBottomSheetProperties
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.carousel.HorizontalUncontainedCarousel
import androidx.compose.material3.carousel.rememberCarouselState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment.Companion.Center
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.fdroid.R
import org.fdroid.ui.FDroidContent
import org.fdroid.ui.utils.AsyncShimmerImage
import org.fdroid.ui.utils.testApp
@Composable
fun Screenshots(isMetered: Boolean, phoneScreenshots: List<Any>) {
var showEvenWhenMetered by remember { mutableStateOf(false) }
if (isMetered && !showEvenWhenMetered) Box(
contentAlignment = Center,
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
.padding(horizontal = 16.dp)
.clickable { showEvenWhenMetered = true }
) {
Image(
painterResource(R.drawable.screenshots_placeholder),
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.large)
.semantics { hideFromAccessibility() }
)
ElevatedButton(
onClick = { showEvenWhenMetered = true },
modifier = Modifier.padding(horizontal = 16.dp)
) {
Text(
text = stringResource(R.string.screenshots_metered),
textAlign = TextAlign.Center,
)
}
} else {
Screenshots(phoneScreenshots)
}
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun Screenshots(phoneScreenshots: List<Any>) {
val carouselState = rememberCarouselState { phoneScreenshots.size }
var showScreenshot by remember { mutableStateOf<Int?>(null) }
val screenshotIndex = showScreenshot
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
if (screenshotIndex != null) ModalBottomSheet(
onDismissRequest = { showScreenshot = null },
sheetState = sheetState,
properties = ModalBottomSheetProperties(),
) {
val pagerState = rememberPagerState(
initialPage = screenshotIndex,
pageCount = { phoneScreenshots.size },
)
Surface {
HorizontalPager(state = pagerState) { page ->
AsyncShimmerImage(
model = phoneScreenshots[page],
contentDescription = "",
contentScale = ContentScale.Fit,
placeholder = rememberVectorPainter(Icons.Default.Image),
error = rememberVectorPainter(Icons.Default.Error),
modifier = Modifier.fillMaxSize()
)
}
}
}
HorizontalUncontainedCarousel(
state = carouselState,
itemWidth = 120.dp,
itemSpacing = 2.dp,
contentPadding = PaddingValues(horizontal = 16.dp),
modifier = Modifier
.fillMaxWidth()
.height(240.dp)
.padding(vertical = 8.dp)
) { index ->
AsyncShimmerImage(
model = phoneScreenshots[index],
contentDescription = "",
contentScale = ContentScale.Fit,
placeholder = rememberVectorPainter(Icons.Default.Image),
error = rememberVectorPainter(Icons.Default.Error),
modifier = Modifier
.size(120.dp, 240.dp)
.clickable {
showScreenshot = index
}
)
}
}
@Preview
@Composable
private fun Preview() {
FDroidContent {
Screenshots(false, testApp.phoneScreenshots)
}
}
@Preview(widthDp = 300)
@Composable
private fun PreviewMetered() {
FDroidContent {
Screenshots(true, testApp.phoneScreenshots)
}
}

View File

@@ -0,0 +1,56 @@
package org.fdroid.ui.details
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.fdroid.R
import org.fdroid.ui.FDroidContent
import org.fdroid.ui.utils.testApp
@Composable
fun TechnicalInfo(item: AppDetailsItem) {
val items = mutableMapOf(
stringResource(R.string.package_name) to item.app.packageName
)
if (item.installedVersionCode != null) {
items[stringResource(R.string.installed_version)] =
"${item.installedVersionName} (${item.installedVersionCode})"
}
Column(
verticalArrangement = spacedBy(4.dp),
modifier = Modifier
.padding(start = 16.dp, bottom = 16.dp),
) {
items.forEach { (name, content) ->
Row(horizontalArrangement = spacedBy(2.dp)) {
Text(
text = name,
style = MaterialTheme.typography.bodyMedium
)
SelectionContainer {
Text(
text = content,
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
}
@Preview
@Composable
private fun Preview() {
FDroidContent {
TechnicalInfo(testApp)
}
}

View File

@@ -0,0 +1,245 @@
package org.fdroid.ui.details
import android.text.format.Formatter
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccessTime
import androidx.compose.material3.Badge
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.fdroid.R
import org.fdroid.database.AppVersion
import org.fdroid.ui.FDroidContent
import org.fdroid.ui.utils.ExpandIconChevron
import org.fdroid.ui.utils.ExpandableSection
import org.fdroid.ui.utils.FDroidOutlineButton
import org.fdroid.ui.utils.MeteredConnectionDialog
import org.fdroid.ui.utils.asRelativeTimeString
import org.fdroid.ui.utils.testApp
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
fun Versions(
item: AppDetailsItem,
scrollUp: suspend () -> Unit,
) {
ExpandableSection(
icon = rememberVectorPainter(Icons.Default.AccessTime),
title = stringResource(R.string.versions),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Column(modifier = Modifier) {
item.versions?.forEach { versionItem ->
Version(
item = versionItem,
isMetered = item.networkState.isMetered,
installAction = { version: AppVersion ->
item.actions.installAction(item.app, version, item.icon)
},
scrollUp = scrollUp,
)
}
}
}
}
@Composable
fun Version(
item: VersionItem,
isMetered: Boolean,
installAction: (AppVersion) -> Unit,
scrollUp: suspend () -> Unit,
) {
val isPreview = LocalInspectionMode.current
var expanded by rememberSaveable { mutableStateOf(isPreview) }
Column(modifier = Modifier.padding(bottom = 16.dp)) {
Row(
horizontalArrangement = spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(bottom = 8.dp)
.clickable {
expanded = !expanded
}
) {
ExpandIconChevron(expanded)
Row {
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.version.versionName,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = stringResource(
R.string.added_x_ago,
item.version.added.asRelativeTimeString(),
),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (item.isInstalled) Badge(
containerColor = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 8.dp)
) {
Text(
text = stringResource(R.string.app_installed),
modifier = Modifier.padding(2.dp)
)
}
if (item.isSuggested) Badge(
containerColor = MaterialTheme.colorScheme.secondary,
modifier = Modifier.padding(start = 8.dp)
) {
Text(
text = stringResource(R.string.app_suggested),
modifier = Modifier.padding(2.dp)
)
}
}
}
AnimatedVisibility(
visible = expanded,
modifier = Modifier
.semantics { liveRegion = LiveRegionMode.Polite }
) {
val coroutineScope = rememberCoroutineScope()
var showMeteredDialog by remember { mutableStateOf(false) }
Row(
horizontalArrangement = spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
if (!item.isCompatible || !item.isSignerCompatible) Text(
text = if (!item.isCompatible) {
stringResource(R.string.app_details_incompatible_version)
} else {
stringResource(R.string.app_details_incompatible_signer)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
item.version.size?.let { size ->
Text(
text = stringResource(
R.string.size_colon,
Formatter.formatFileSize(LocalContext.current, size)
),
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
val sdkString = buildString {
item.version.packageManifest.minSdkVersion?.let { sdk ->
append(stringResource(R.string.sdk_min_version, sdk))
}
item.version.packageManifest.targetSdkVersion?.let { sdk ->
if (isNotEmpty()) append(" ")
append(stringResource(R.string.sdk_target_version, sdk))
}
item.version.packageManifest.maxSdkVersion?.let { sdk ->
if (isNotEmpty()) append(" ")
append(stringResource(R.string.sdk_max_version, sdk))
}
}
if (sdkString.isNotEmpty()) Text(
text = stringResource(R.string.sdk_versions_colon, sdkString),
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
item.version.packageManifest.nativecode?.let { nativeCode ->
if (nativeCode.isNotEmpty()) {
Text(
text = stringResource(
R.string.architectures_colon,
nativeCode.joinToString(", ")
),
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
item.version.signer?.let { signer ->
Text(
text = stringResource(
R.string.signer_colon,
signer.sha256[0].substring(0..15)
),
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
if (item.showInstallButton) {
val coroutineScope = rememberCoroutineScope()
FDroidOutlineButton(
text = stringResource(R.string.menu_install),
onClick = {
if (isMetered) {
showMeteredDialog = true
} else {
installAction(item.version as AppVersion)
coroutineScope.launch {
scrollUp()
}
}
},
)
}
}
if (showMeteredDialog) MeteredConnectionDialog(
numBytes = item.version.size,
onConfirm = {
installAction(item.version as AppVersion)
coroutineScope.launch {
scrollUp()
}
},
onDismiss = { showMeteredDialog = false },
)
}
}
}
@Preview
@Composable
fun VersionsPreview() {
FDroidContent {
Versions(testApp) {}
}
}

View File

@@ -0,0 +1,117 @@
package org.fdroid.ui.discover
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.fdroid.ui.FDroidContent
import org.fdroid.ui.utils.AsyncShimmerImage
import org.fdroid.ui.utils.InstalledBadge
import org.fdroid.ui.utils.Names
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppCarousel(
title: String,
apps: List<AppDiscoverItem>,
modifier: Modifier = Modifier,
onTitleTap: () -> Unit,
onAppTap: (AppDiscoverItem) -> Unit,
) {
Column(
verticalArrangement = spacedBy(8.dp),
modifier = modifier
) {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onTitleTap)
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
)
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
modifier = Modifier.semantics { hideFromAccessibility() },
contentDescription = null,
)
}
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = spacedBy(16.dp),
) {
items(apps, key = { it.packageName }) { app ->
AppBox(app, onAppTap)
}
}
}
}
@Composable
fun AppBox(app: AppDiscoverItem, onAppTap: (AppDiscoverItem) -> Unit) {
Column(
verticalArrangement = spacedBy(8.dp),
modifier = Modifier
.width(80.dp)
.clickable { onAppTap(app) },
) {
BadgedBox(badge = { if (app.isInstalled) InstalledBadge() }) {
AsyncShimmerImage(
model = app.imageModel,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(76.dp)
.clip(MaterialTheme.shapes.large)
.semantics { hideFromAccessibility() },
)
}
Text(
text = app.name,
style = MaterialTheme.typography.bodySmall,
minLines = 2,
maxLines = 2,
)
}
}
@Preview
@Composable
fun AppCarouselPreview() {
val apps = listOf(
AppDiscoverItem("1", Names.randomName, false),
AppDiscoverItem("2", Names.randomName, true),
AppDiscoverItem("3", Names.randomName, false),
AppDiscoverItem("4", Names.randomName, false),
AppDiscoverItem("5", Names.randomName, false),
)
FDroidContent {
AppCarousel("Preview Apps", apps, onTitleTap = {}) {}
}
}

View File

@@ -0,0 +1,9 @@
package org.fdroid.ui.discover
class AppDiscoverItem(
val packageName: String,
val name: String,
val isInstalled: Boolean,
val imageModel: Any? = null,
val lastUpdated: Long = -1,
)

View File

@@ -0,0 +1,88 @@
package org.fdroid.ui.discover
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.placeCursorAtEnd
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.SearchBarState
import androidx.compose.material3.SearchBarValue
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import org.fdroid.R
@Composable
@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class)
fun AppSearchInputField(
searchBarState: SearchBarState,
textFieldState: TextFieldState,
onSearch: suspend (String) -> Unit,
onSearchCleared: () -> Unit,
) {
val scope = rememberCoroutineScope()
// set-up search as you type
LaunchedEffect(Unit) {
textFieldState.edit { placeCursorAtEnd() }
snapshotFlow { textFieldState.text }
.distinctUntilChanged()
.debounce(500)
.collectLatest {
if (it.isEmpty()) {
onSearchCleared()
} else if (it.length >= SEARCH_THRESHOLD) {
onSearch(textFieldState.text.toString())
}
}
}
SearchBarDefaults.InputField(
modifier = Modifier,
searchBarState = searchBarState,
textFieldState = textFieldState,
onSearch = {
scope.launch { onSearch(it) }
},
placeholder = { Text(stringResource(R.string.search_placeholder)) },
leadingIcon = {
if (searchBarState.currentValue == SearchBarValue.Expanded) {
IconButton(
onClick = { scope.launch { searchBarState.animateToCollapsed() } }
) {
Icon(
imageVector = Icons.AutoMirrored.Default.ArrowBack,
contentDescription = stringResource(R.string.back)
)
}
} else {
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.menu_search),
)
}
},
trailingIcon = {
if (textFieldState.text.isNotEmpty()) {
IconButton(onClick = onSearchCleared) {
Icon(
imageVector = Icons.Filled.Clear,
contentDescription = stringResource(R.string.clear_search),
)
}
}
}
)
}

View File

@@ -0,0 +1,283 @@
package org.fdroid.ui.discover
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ExpandedDockedSearchBar
import androidx.compose.material3.ExpandedFullScreenSearchBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.SearchBarState
import androidx.compose.material3.SearchBarValue
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.rememberSearchBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND
import org.fdroid.R
import org.fdroid.ui.FDroidContent
import org.fdroid.ui.categories.CategoryChip
import org.fdroid.ui.categories.CategoryItem
import org.fdroid.ui.lists.AppListItem
import org.fdroid.ui.lists.AppListRow
import org.fdroid.ui.lists.AppListType
import org.fdroid.ui.navigation.NavigationKey
import org.fdroid.ui.utils.BigLoadingIndicator
/**
* The minimum amount of characters we start auto-searching for.
*/
const val SEARCH_THRESHOLD = 2
@Composable
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
fun AppsSearch(
searchBarState: SearchBarState,
searchResults: SearchResults?,
onSearch: suspend (String) -> Unit,
onNav: (NavigationKey) -> Unit,
onSearchCleared: () -> Unit,
modifier: Modifier = Modifier,
) {
val textFieldState = rememberTextFieldState()
SearchBar(
state = searchBarState,
inputField = {
// InputField is different from ExpandedFullScreenSearchBar to separate onSearch()
SearchBarDefaults.InputField(
searchBarState = searchBarState,
textFieldState = textFieldState,
placeholder = {
Text(
text = stringResource(R.string.search_placeholder),
// we hide the placeholder, because TalkBack is already saying "Search"
modifier = Modifier.semantics { hideFromAccessibility() },
)
},
leadingIcon = {
Icon(
imageVector = Icons.Default.Search,
contentDescription = null,
modifier = Modifier.semantics { hideFromAccessibility() },
)
},
onSearch = { },
)
},
modifier = modifier,
)
// rememberLazyListState done differently, so it refreshes for different searchResults
val listState = rememberSaveable(searchResults, saver = LazyListState.Saver) {
LazyListState(0, 0)
}
val inputField = @Composable {
AppSearchInputField(
searchBarState = searchBarState,
textFieldState = textFieldState,
onSearch = onSearch,
onSearchCleared = {
textFieldState.setTextAndPlaceCursorAtEnd("")
onSearchCleared()
},
)
}
val results = @Composable {
if (searchResults == null) {
if (textFieldState.text.length >= SEARCH_THRESHOLD) BigLoadingIndicator()
} else if (searchResults.apps.isEmpty() && textFieldState.text.length >= SEARCH_THRESHOLD) {
if (searchResults.categories.isNotEmpty()) {
CategoriesFlowRow(searchResults.categories, onNav)
}
Text(
text = stringResource(R.string.search_no_results),
textAlign = TextAlign.Center,
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
)
} else {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize()
) {
if (searchResults.categories.isNotEmpty()) {
item(
key = "categories",
contentType = "category",
) {
CategoriesFlowRow(searchResults.categories, onNav)
}
}
if (searchResults.apps.isNotEmpty()) {
item(
key = "appsHeader",
contentType = "appsHeader",
) {
Column {
if (searchResults.categories.isNotEmpty()) {
HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp)
)
}
Text(
text = stringResource(R.string.apps),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp)
)
}
}
}
items(
searchResults.apps,
key = { it.packageName },
contentType = { "app" },
) { item ->
AppListRow(
item = item,
isSelected = false,
modifier = Modifier
.fillMaxWidth()
.animateItem()
.clickable {
onNav(NavigationKey.AppDetails(item.packageName))
}
)
}
}
}
}
val windowAdaptiveInfo = currentWindowAdaptiveInfo()
val isBigScreen =
windowAdaptiveInfo.windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)
if (isBigScreen) {
ExpandedDockedSearchBar(
state = searchBarState,
inputField = inputField,
) { results() }
} else {
ExpandedFullScreenSearchBar(
state = searchBarState,
inputField = inputField,
) { results() }
}
}
@Composable
private fun CategoriesFlowRow(categories: List<CategoryItem>, onNav: (NavigationKey) -> Unit) {
Column(modifier = Modifier.padding(horizontal = 8.dp)) {
Text(
text = stringResource(R.string.main_menu__categories),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(8.dp)
)
FlowRow {
categories.forEach { item ->
CategoryChip(categoryItem = item, onClick = {
val type = AppListType.Category(item.name, item.id)
val navKey = NavigationKey.AppList(type)
onNav(navKey)
})
}
}
}
}
@Preview
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun AppsSearchCollapsedPreview() {
FDroidContent {
Box(Modifier.fillMaxSize()) {
val state = rememberSearchBarState()
AppsSearch(state, null, {}, {}, {})
}
}
}
@Preview
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun AppsSearchLoadingPreview() {
FDroidContent {
Box(Modifier.fillMaxSize()) {
val state = rememberSearchBarState(SearchBarValue.Expanded)
AppsSearch(state, null, {}, {}, {})
}
}
}
@Preview
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun AppsSearchEmptyPreview() {
FDroidContent {
Box(Modifier.fillMaxSize()) {
val state = rememberSearchBarState(SearchBarValue.Expanded)
AppsSearch(state, SearchResults(emptyList(), emptyList()), {}, {}, {})
}
}
}
@Preview
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun AppsSearchOnlyCategoriesPreview() {
FDroidContent {
Box(Modifier.fillMaxSize()) {
val state = rememberSearchBarState(SearchBarValue.Expanded)
val categories = listOf(
CategoryItem("Bookmark", "Bookmark"),
CategoryItem("Browser", "Browser"),
CategoryItem("Calculator", "Calc"),
CategoryItem("Money", "Money"),
)
AppsSearch(state, SearchResults(emptyList(), categories), {}, {}, {})
}
}
}
@Preview
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun AppsSearchPreview() {
FDroidContent {
Box(Modifier.fillMaxSize()) {
val state = rememberSearchBarState(SearchBarValue.Expanded)
val categories = listOf(
CategoryItem("Bookmark", "Bookmark"),
CategoryItem("Browser", "Browser"),
CategoryItem("Calculator", "Calc"),
CategoryItem("Money", "Money"),
)
val apps = listOf(
AppListItem(1, "1", "This is app 1", "It has summary 2", 0, false, true, null),
AppListItem(2, "2", "This is app 2", "It has summary 2", 0, true, true, null),
)
AppsSearch(state, SearchResults(apps, categories), {}, {}, {})
}
}
}

View File

@@ -0,0 +1,187 @@
package org.fdroid.ui.discover
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults.enterAlwaysScrollBehavior
import androidx.compose.material3.rememberSearchBarState
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation3.runtime.NavKey
import org.fdroid.R
import org.fdroid.download.NetworkState
import org.fdroid.repo.RepoUpdateProgress
import org.fdroid.ui.FDroidContent
import org.fdroid.ui.lists.AppListType
import org.fdroid.ui.navigation.NavigationKey
import org.fdroid.ui.navigation.topBarMenuItems
import org.fdroid.ui.utils.BigLoadingIndicator
@Composable
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
fun Discover(
discoverModel: DiscoverModel,
onSearch: suspend (String) -> Unit,
onSearchCleared: () -> Unit,
onListTap: (AppListType) -> Unit,
onAppTap: (AppDiscoverItem) -> Unit,
onNav: (NavKey) -> Unit,
modifier: Modifier = Modifier,
) {
val searchBarState = rememberSearchBarState()
val scrollBehavior = enterAlwaysScrollBehavior(rememberTopAppBarState())
Scaffold(
topBar = {
TopAppBar(
title = {
Text(stringResource(R.string.app_name))
},
actions = {
topBarMenuItems.forEach { dest ->
BadgedBox(badge = {
val hasRepoIssues =
(discoverModel as? LoadedDiscoverModel)?.hasRepoIssues == true
if (dest.id == NavigationKey.Repos && hasRepoIssues) Badge {
Text("")
}
}) {
IconButton(onClick = { onNav(dest.id) }) {
Icon(
imageVector = dest.icon,
contentDescription = stringResource(dest.label),
)
}
}
}
var menuExpanded by remember { mutableStateOf(false) }
IconButton(onClick = { menuExpanded = !menuExpanded }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = stringResource(R.string.more),
)
}
DiscoverOverFlowMenu(menuExpanded, {
menuExpanded = false
onNav(it.id)
}) {
menuExpanded = false
}
},
scrollBehavior = scrollBehavior,
)
},
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
) { paddingValues ->
when (discoverModel) {
is FirstStartDiscoverModel -> FirstStart(
networkState = discoverModel.networkState,
repoUpdateState = discoverModel.repoUpdateState,
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
)
is LoadingDiscoverModel -> BigLoadingIndicator(Modifier.padding(paddingValues))
is LoadedDiscoverModel -> {
DiscoverContent(
discoverModel = discoverModel,
searchBarState = searchBarState,
onSearch = onSearch,
onSearchCleared = onSearchCleared,
onListTap = onListTap,
onAppTap = onAppTap,
onNav = onNav,
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(paddingValues),
)
}
NoEnabledReposDiscoverModel -> {
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.fillMaxSize()
.padding(paddingValues)
) {
Text(
text = stringResource(R.string.no_repos_enabled),
textAlign = TextAlign.Center,
modifier = Modifier.padding(16.dp)
)
}
}
}
}
}
@Preview
@Composable
fun FirstStartDiscoverPreview() {
FDroidContent {
Discover(
discoverModel = FirstStartDiscoverModel(
NetworkState(true, isMetered = false),
RepoUpdateProgress(1, true, 0.25f),
),
onSearch = {},
onSearchCleared = {},
onListTap = {},
onAppTap = {},
onNav = {},
)
}
}
@Preview
@Composable
fun LoadingDiscoverPreview() {
FDroidContent {
Discover(
discoverModel = LoadingDiscoverModel,
onSearch = {},
onSearchCleared = {},
onListTap = {},
onAppTap = {},
onNav = {},
)
}
}
@Preview
@Composable
private fun NoEnabledReposPreview() {
FDroidContent {
Discover(
discoverModel = NoEnabledReposDiscoverModel,
onSearch = {},
onSearchCleared = {},
onListTap = {},
onAppTap = {},
onNav = {},
)
}
}

View File

@@ -0,0 +1,81 @@
package org.fdroid.ui.discover
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SearchBarState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation3.runtime.NavKey
import org.fdroid.R
import org.fdroid.ui.categories.CategoryList
import org.fdroid.ui.lists.AppListType
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DiscoverContent(
discoverModel: LoadedDiscoverModel,
searchBarState: SearchBarState,
onSearch: suspend (String) -> Unit,
onSearchCleared: () -> Unit,
onListTap: (AppListType) -> Unit,
onAppTap: (AppDiscoverItem) -> Unit,
onNav: (NavKey) -> Unit,
modifier: Modifier = Modifier,
) {
// workaround for https://issuetracker.google.com/issues/445720462)
Column(modifier = modifier.focusable()) {
AppsSearch(
searchBarState = searchBarState,
searchResults = discoverModel.searchResults,
onSearch = onSearch,
onNav = onNav,
onSearchCleared = onSearchCleared,
modifier = Modifier
.padding(top = 16.dp, bottom = 4.dp)
.padding(horizontal = 16.dp)
.align(Alignment.CenterHorizontally),
)
if (discoverModel.newApps.isNotEmpty()) {
val listNew = AppListType.New(stringResource(R.string.app_list_new))
AppCarousel(
title = listNew.title,
apps = discoverModel.newApps,
onTitleTap = { onListTap(listNew) },
onAppTap = onAppTap,
)
}
val listRecentlyUpdated = AppListType.RecentlyUpdated(
stringResource(R.string.app_list_recently_updated),
)
AppCarousel(
title = listRecentlyUpdated.title,
apps = discoverModel.recentlyUpdatedApps,
onTitleTap = { onListTap(listRecentlyUpdated) },
onAppTap = onAppTap,
)
if (!discoverModel.mostDownloadedApps.isNullOrEmpty()) {
val listMostDownloaded = AppListType.MostDownloaded(
stringResource(R.string.app_list_most_downloaded),
)
AppCarousel(
title = listMostDownloaded.title,
apps = discoverModel.mostDownloadedApps,
onTitleTap = { onListTap(listMostDownloaded) },
onAppTap = onAppTap,
)
}
CategoryList(
categoryMap = discoverModel.categories,
onNav = onNav,
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
)
}
}

View File

@@ -0,0 +1,41 @@
package org.fdroid.ui.discover
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import org.fdroid.ui.details.NoAppSelected
import org.fdroid.ui.navigation.NavigationKey
import org.fdroid.ui.navigation.Navigator
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
fun EntryProviderScope<NavKey>.discoverEntry(
navigator: Navigator,
) {
entry<NavigationKey.Discover>(
metadata = ListDetailSceneStrategy.listPane("appdetails") {
NoAppSelected()
},
) {
val viewModel = hiltViewModel<DiscoverViewModel>()
Discover(
discoverModel = viewModel.discoverModel.collectAsStateWithLifecycle().value,
onListTap = {
navigator.navigate(NavigationKey.AppList(it))
},
onAppTap = {
val new = NavigationKey.AppDetails(it.packageName)
if (navigator.last is NavigationKey.AppDetails) {
navigator.replaceLast(new)
} else {
navigator.navigate(new)
}
},
onNav = { navKey -> navigator.navigate(navKey) },
onSearch = viewModel::search,
onSearchCleared = viewModel::onSearchCleared,
)
}
}

View File

@@ -0,0 +1,40 @@
package org.fdroid.ui.discover
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.semantics
import org.fdroid.ui.navigation.NavDestinations
import org.fdroid.ui.navigation.getMoreMenuItems
@Composable
fun DiscoverOverFlowMenu(
menuExpanded: Boolean,
onItemClicked: (NavDestinations) -> Unit,
onDismissRequest: () -> Unit,
) {
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = onDismissRequest
) {
getMoreMenuItems(LocalContext.current).forEach { dest ->
DropdownMenuItem(
text = { Text(stringResource(dest.label)) },
onClick = { onItemClicked(dest) },
leadingIcon = {
Icon(
imageVector = dest.icon,
contentDescription = null,
modifier = Modifier.semantics { hideFromAccessibility() },
)
}
)
}
}
}

View File

@@ -0,0 +1,72 @@
package org.fdroid.ui.discover
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.fdroid.database.Repository
import org.fdroid.download.NetworkState
import org.fdroid.repo.RepoUpdateState
import org.fdroid.ui.categories.CategoryGroup
import org.fdroid.ui.categories.CategoryItem
@Composable
fun DiscoverPresenter(
newAppsFlow: Flow<List<AppDiscoverItem>>,
recentlyUpdatedAppsFlow: Flow<List<AppDiscoverItem>>,
mostDownloadedAppsFlow: MutableStateFlow<List<AppDiscoverItem>?>,
categoriesFlow: Flow<List<CategoryItem>>,
repositoriesFlow: Flow<List<Repository>>,
searchResultsFlow: StateFlow<SearchResults?>,
isFirstStart: Boolean,
networkState: NetworkState,
repoUpdateStateFlow: StateFlow<RepoUpdateState?>,
hasRepoIssuesFlow: Flow<Boolean>,
): DiscoverModel {
val newApps = newAppsFlow.collectAsState(null).value
val recentlyUpdatedApps = recentlyUpdatedAppsFlow.collectAsState(null).value
val mostDownloadedApps = mostDownloadedAppsFlow.collectAsState().value
val categories = categoriesFlow.collectAsState(null).value
val searchResults = searchResultsFlow.collectAsState().value
// We may not have any new apps, but there should always be recently updated apps,
// because those don't have a freshness constraint.
// So if we don't have those, we are still loading, have no enabled repo, or this is first start
return if (recentlyUpdatedApps.isNullOrEmpty()) {
val repositories = repositoriesFlow.collectAsState(null).value
if (repositories?.all { !it.enabled } == true) {
NoEnabledReposDiscoverModel
} else if (isFirstStart) {
FirstStartDiscoverModel(networkState, repoUpdateStateFlow.collectAsState().value)
} else {
LoadingDiscoverModel
}
} else {
LoadedDiscoverModel(
newApps = newApps ?: emptyList(),
recentlyUpdatedApps = recentlyUpdatedApps,
mostDownloadedApps = mostDownloadedApps,
categories = categories?.groupBy { it.group },
searchResults = searchResults,
hasRepoIssues = hasRepoIssuesFlow.collectAsState(false).value,
)
}
}
sealed class DiscoverModel
data class FirstStartDiscoverModel(
val networkState: NetworkState,
val repoUpdateState: RepoUpdateState?,
) : DiscoverModel()
data object LoadingDiscoverModel : DiscoverModel()
data object NoEnabledReposDiscoverModel : DiscoverModel()
data class LoadedDiscoverModel(
val newApps: List<AppDiscoverItem>,
val recentlyUpdatedApps: List<AppDiscoverItem>,
val mostDownloadedApps: List<AppDiscoverItem>?,
val categories: Map<CategoryGroup, List<CategoryItem>>?,
val searchResults: SearchResults? = null,
val hasRepoIssues: Boolean,
) : DiscoverModel()

View File

@@ -0,0 +1,232 @@
package org.fdroid.ui.discover
import android.annotation.SuppressLint
import android.app.Application
import android.database.sqlite.SQLiteException
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.asFlow
import androidx.lifecycle.viewModelScope
import app.cash.molecule.AndroidUiDispatcher
import app.cash.molecule.RecompositionMode.ContextClock
import app.cash.molecule.launchMolecule
import dagger.hilt.android.lifecycle.HiltViewModel
import io.ktor.client.engine.ProxyConfig
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import mu.KotlinLogging
import org.fdroid.LocaleChooser.getBestLocale
import org.fdroid.database.AppOverviewItem
import org.fdroid.database.FDroidDatabase
import org.fdroid.database.Repository
import org.fdroid.download.DownloadRequest
import org.fdroid.download.NetworkMonitor
import org.fdroid.download.PackageName
import org.fdroid.download.getImageModel
import org.fdroid.index.RepoManager
import org.fdroid.install.InstalledAppsCache
import org.fdroid.repo.RepoUpdateManager
import org.fdroid.settings.SettingsManager
import org.fdroid.ui.categories.CategoryItem
import org.fdroid.ui.lists.AppListItem
import org.fdroid.ui.utils.normalize
import org.fdroid.utils.IoDispatcher
import java.text.Collator
import java.util.Locale
import javax.inject.Inject
import kotlin.time.measureTimedValue
@HiltViewModel
class DiscoverViewModel @Inject constructor(
private val app: Application,
savedStateHandle: SavedStateHandle,
private val db: FDroidDatabase,
networkMonitor: NetworkMonitor,
private val settingsManager: SettingsManager,
private val repoManager: RepoManager,
private val repoUpdateManager: RepoUpdateManager,
private val installedAppsCache: InstalledAppsCache,
@param:IoDispatcher private val ioScope: CoroutineScope,
) : AndroidViewModel(app) {
private val log = KotlinLogging.logger { }
private val moleculeScope =
CoroutineScope(viewModelScope.coroutineContext + AndroidUiDispatcher.Main)
private val collator = Collator.getInstance(Locale.getDefault())
private val newApps = db.getAppDao().getNewAppsFlow().map { list ->
val proxyConfig = settingsManager.proxyConfig
list.mapNotNull {
val repository = repoManager.getRepository(it.repoId) ?: return@mapNotNull null
it.toAppDiscoverItem(repository, proxyConfig)
}
}
private val recentlyUpdatedApps = db.getAppDao().getRecentlyUpdatedAppsFlow().map { list ->
val proxyConfig = settingsManager.proxyConfig
list.mapNotNull {
val repository = repoManager.getRepository(it.repoId) ?: return@mapNotNull null
it.toAppDiscoverItem(repository, proxyConfig)
}
}
private val mostDownloadedApps = MutableStateFlow<List<AppDiscoverItem>?>(null)
private val categories = db.getRepositoryDao().getLiveCategories().asFlow().map { categories ->
categories.map { category ->
CategoryItem(
id = category.id,
name = category.getName(localeList) ?: "Unknown Category",
)
}.sortedWith { c1, c2 -> collator.compare(c1.name, c2.name) }
}
private val searchResults = MutableStateFlow<SearchResults?>(null)
private val hasRepoIssues = repoManager.repositoriesState.map { repos ->
repos.any { it.enabled && it.errorCount >= 5 }
}
val localeList = LocaleListCompat.getDefault()
val discoverModel: StateFlow<DiscoverModel> by lazy(LazyThreadSafetyMode.NONE) {
@SuppressLint("StateFlowValueCalledInComposition") // see comment below
moleculeScope.launchMolecule(mode = ContextClock) {
DiscoverPresenter(
newAppsFlow = newApps,
recentlyUpdatedAppsFlow = recentlyUpdatedApps,
mostDownloadedAppsFlow = mostDownloadedApps,
categoriesFlow = categories,
repositoriesFlow = repoManager.repositoriesState,
searchResultsFlow = searchResults,
isFirstStart = settingsManager.isFirstStart,
// not observing the flow, but just taking the current value,
// because we kick off repo updates from the UI depending on this state
networkState = networkMonitor.networkState.value,
repoUpdateStateFlow = repoUpdateManager.repoUpdateState,
hasRepoIssuesFlow = hasRepoIssues,
)
}
}
init {
loadMostDownloadedApps()
}
private fun loadMostDownloadedApps() {
viewModelScope.launch(ioScope.coroutineContext) {
val packageNames = try {
app.assets.open("most_downloaded_apps.json").use { inputStream ->
@OptIn(ExperimentalSerializationApi::class)
Json.decodeFromStream<List<String>>(inputStream)
}
} catch (e: Exception) {
log.error(e) { "Error loading most downloaded apps: " }
return@launch
}
db.getAppDao().getAppsFlow(packageNames).collect { apps ->
val proxyConfig = settingsManager.proxyConfig
mostDownloadedApps.value = apps.mapNotNull {
val repository = repoManager.getRepository(it.repoId) ?: return@mapNotNull null
it.toAppDiscoverItem(repository, proxyConfig)
}
}
}
}
suspend fun search(term: String) = withContext(ioScope.coroutineContext) {
// we need a way to make the app crash for testing, e.g. the crash reporter
if (term == "CrashMe") error("BOOOOOOOOM!!!")
val sanitized = term.replace(Regex.fromLiteral("\""), "")
val splits = sanitized.split(' ').filter { it.isNotBlank() }
val query = splits.joinToString(" ") { word ->
var isCjk = false
// go through word and separate CJK chars (if needed)
val newString = word.toList().joinToString("") {
if (Character.isIdeographic(it.code)) {
isCjk = true
"$it* "
} else "$it"
}
// add * to enable prefix matches
if (isCjk) newString else "$newString*"
}.let { firstPassQuery ->
// if we had more than one word, make a more complex query
if (splits.size > 1) {
"$firstPassQuery " + // search* term* (implicit AND and prefix search)
"OR ${splits.joinToString("")}* " + // camel case prefix
"OR \"${splits.joinToString("* ")}*\"" // phrase query
} else firstPassQuery
}
log.info { "Searching for: $query" }
val timedApps = measureTimedValue {
try {
val proxyConfig = settingsManager.proxyConfig
db.getAppDao().getAppSearchItems(query).sortedDescending().mapNotNull {
val repository = repoManager.getRepository(it.repoId) ?: return@mapNotNull null
val iconModel = it.getIcon(localeList)?.getImageModel(repository, proxyConfig)
as? DownloadRequest
val isInstalled = installedAppsCache.isInstalled(it.packageName)
AppListItem(
repoId = it.repoId,
packageName = it.packageName,
name = it.name.getBestLocale(localeList) ?: "Unknown",
summary = it.summary.getBestLocale(localeList) ?: "",
lastUpdated = it.lastUpdated,
isInstalled = isInstalled,
isCompatible = true, // doesn't matter here, as we don't filter
iconModel = if (isInstalled) {
PackageName(it.packageName, iconModel)
} else {
iconModel
},
categoryIds = it.categories?.toSet(),
)
}
} catch (e: SQLiteException) {
log.error(e) { "Error searching for $query: " }
emptyList()
}
}
val timedCategories = measureTimedValue {
this@DiscoverViewModel.categories.first().filter {
// normalization removed diacritics, so searches without them work
it.name.normalize().contains(sanitized.normalize(), ignoreCase = true)
}
}
searchResults.value = SearchResults(timedApps.value, timedCategories.value)
log.debug {
val numResults = searchResults.value?.apps?.size ?: 0
"Search for $query had $numResults results " +
"and took ${timedApps.duration} and ${timedCategories.duration}"
}
}
fun onSearchCleared() {
searchResults.value = null
}
private fun AppOverviewItem.toAppDiscoverItem(
repository: Repository,
proxyConfig: ProxyConfig?,
): AppDiscoverItem {
val isInstalled = installedAppsCache.isInstalled(packageName)
val imageModel =
getIcon(localeList)?.getImageModel(repository, proxyConfig) as? DownloadRequest
return AppDiscoverItem(
packageName = packageName,
name = getName(localeList) ?: "Unknown App",
lastUpdated = lastUpdated,
isInstalled = isInstalled,
imageModel = if (isInstalled) {
PackageName(packageName, imageModel)
} else {
imageModel
},
)
}
}

View File

@@ -0,0 +1,160 @@
package org.fdroid.ui.discover
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.Arrangement.Center
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.fdroid.R
import org.fdroid.download.NetworkState
import org.fdroid.index.IndexUpdateResult
import org.fdroid.repo.RepoUpdateFinished
import org.fdroid.repo.RepoUpdateProgress
import org.fdroid.repo.RepoUpdateState
import org.fdroid.repo.RepoUpdateWorker
import org.fdroid.ui.FDroidContent
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
fun FirstStart(
networkState: NetworkState,
repoUpdateState: RepoUpdateState?,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
var override by rememberSaveable { mutableStateOf(false) }
// reset override on error, so user can press button again for re-try
LaunchedEffect(repoUpdateState) {
if (repoUpdateState is RepoUpdateFinished &&
repoUpdateState.result is IndexUpdateResult.Error
) override = false
// TODO it would be nice to surface normal update errors better and also let the user retry
}
Column(verticalArrangement = Center, modifier = modifier) {
if ((!networkState.isOnline || networkState.isMetered) && !override) {
// offline or metered, not overridden
val res = if (networkState.isMetered) {
stringResource(R.string.first_start_metered)
} else {
stringResource(R.string.first_start_offline)
}
Text(
text = stringResource(R.string.first_start_intro),
textAlign = TextAlign.Center,
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
)
Text(
text = res,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
)
Button(
onClick = { override = true },
modifier = Modifier.align(CenterHorizontally),
) {
Text(stringResource(R.string.first_start_button))
}
} else {
// happy path or user set override
LaunchedEffect(Unit) {
RepoUpdateWorker.updateNow(context)
}
Text(
text = stringResource(R.string.first_start_loading),
textAlign = TextAlign.Center,
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
)
// use thicker stroke for larger circle
val stroke = Stroke(
width = with(LocalDensity.current) {
6.dp.toPx()
},
cap = StrokeCap.Round,
)
val progressModifier = Modifier
.padding(16.dp)
.size(128.dp)
.align(CenterHorizontally)
// show indeterminate circle if we don't have any progress (may take a bit to start)
val progress = (repoUpdateState as? RepoUpdateProgress)?.progress ?: 0f
if (progress == 0f) CircularWavyProgressIndicator(
wavelength = 24.dp,
stroke = stroke,
trackStroke = stroke,
modifier = progressModifier,
) else {
// animate real progress (download and DB insertion)
val animatedProgress by animateFloatAsState(targetValue = progress)
CircularWavyProgressIndicator(
progress = { animatedProgress },
wavelength = 24.dp,
stroke = stroke,
trackStroke = stroke,
modifier = progressModifier,
)
}
}
}
}
@Preview
@Composable
private fun OfflinePreview() {
FDroidContent {
Column {
FirstStart(NetworkState(isOnline = false, isMetered = false), null)
}
}
}
@Preview
@Composable
private fun MeteredPreview() {
FDroidContent {
Column {
FirstStart(NetworkState(isOnline = true, isMetered = true), null)
}
}
}
@Preview
@Composable
private fun UpdatePreview() {
FDroidContent {
Column {
FirstStart(
networkState = NetworkState(isOnline = true, isMetered = false),
repoUpdateState = RepoUpdateProgress(1L, false, 0.5f),
modifier = Modifier.fillMaxSize(),
)
}
}
}

View File

@@ -0,0 +1,9 @@
package org.fdroid.ui.discover
import org.fdroid.ui.categories.CategoryItem
import org.fdroid.ui.lists.AppListItem
data class SearchResults(
val apps: List<AppListItem>,
val categories: List<CategoryItem>,
)

View File

@@ -0,0 +1,103 @@
package org.fdroid.ui.icons
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathFillType
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.fdroid.ui.FDroidContent
val License: ImageVector
get() {
if (_License != null) {
return _License!!
}
_License = ImageVector.Builder(
name = "License",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 960f,
viewportHeight = 960f
).apply {
path(
fill = SolidColor(Color.Black),
fillAlpha = 1.0f,
stroke = null,
strokeAlpha = 1.0f,
strokeLineWidth = 1.0f,
strokeLineCap = StrokeCap.Butt,
strokeLineJoin = StrokeJoin.Miter,
strokeLineMiter = 1.0f,
pathFillType = PathFillType.NonZero
) {
moveTo(480f, 520f)
quadToRelative(-50f, 0f, -85f, -35f)
reflectiveQuadToRelative(-35f, -85f)
reflectiveQuadToRelative(35f, -85f)
reflectiveQuadToRelative(85f, -35f)
reflectiveQuadToRelative(85f, 35f)
reflectiveQuadToRelative(35f, 85f)
reflectiveQuadToRelative(-35f, 85f)
reflectiveQuadToRelative(-85f, 35f)
moveToRelative(0f, 320f)
lineTo(293f, 902f)
quadToRelative(-20f, 7f, -36.5f, -5f)
reflectiveQuadTo(240f, 865f)
verticalLineToRelative(-254f)
quadToRelative(-38f, -42f, -59f, -96f)
reflectiveQuadToRelative(-21f, -115f)
quadToRelative(0f, -134f, 93f, -227f)
reflectiveQuadToRelative(227f, -93f)
reflectiveQuadToRelative(227f, 93f)
reflectiveQuadToRelative(93f, 227f)
quadToRelative(0f, 61f, -21f, 115f)
reflectiveQuadToRelative(-59f, 96f)
verticalLineToRelative(254f)
quadToRelative(0f, 20f, -16.5f, 32f)
reflectiveQuadTo(667f, 902f)
close()
moveToRelative(0f, -200f)
quadToRelative(100f, 0f, 170f, -70f)
reflectiveQuadToRelative(70f, -170f)
reflectiveQuadToRelative(-70f, -170f)
reflectiveQuadToRelative(-170f, -70f)
reflectiveQuadToRelative(-170f, 70f)
reflectiveQuadToRelative(-70f, 170f)
reflectiveQuadToRelative(70f, 170f)
reflectiveQuadToRelative(170f, 70f)
moveTo(320f, 801f)
lineToRelative(160f, -41f)
lineToRelative(160f, 41f)
verticalLineToRelative(-124f)
quadToRelative(-35f, 20f, -75.5f, 31.5f)
reflectiveQuadTo(480f, 720f)
reflectiveQuadToRelative(-84.5f, -11.5f)
reflectiveQuadTo(320f, 677f)
close()
moveToRelative(160f, -62f)
}
}.build()
return _License!!
}
@Suppress("ktlint:standard:backing-property-naming")
private var _License: ImageVector? = null
@Preview
@Composable
private fun Preview() {
FDroidContent {
Box(modifier = Modifier.padding(12.dp)) {
Image(imageVector = License, contentDescription = "")
}
}
}

View File

@@ -0,0 +1,65 @@
package org.fdroid.ui.icons
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
val Litecoin: ImageVector
get() {
if (_Litecoin != null) return _Litecoin!!
_Litecoin = ImageVector.Builder(
name = "litecoin",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).apply {
path(
fill = SolidColor(Color(0xFF000000))
) {
}
path(
fill = SolidColor(Color(0xFF000000))
) {
}
path(
fill = SolidColor(Color(0xFF000000))
) {
moveTo(12f, 1f)
arcTo(11f, 11f, 0f, true, false, 23f, 12f)
arcTo(11f, 11f, 0f, false, false, 12f, 1f)
close()
moveToRelative(0.178277f, 11.361877f)
lineToRelative(-1.14417f, 3.860909f)
horizontalLineToRelative(6.119981f)
arcToRelative(0.31398162f, 0.31398162f, 0f, false, true, 0.300677f, 0.401791f)
lineToRelative(-0.532172f, 1.833333f)
arcToRelative(0.42041606f, 0.42041606f, 0f, false, true, -0.404451f, 0.303339f)
horizontalLineTo(7.170537f)
lineTo(8.7510885f, 13.423561f)
lineTo(7.0029028f, 13.955733f)
lineTo(7.3887276f, 12.707789f)
lineTo(9.1395743f, 12.175616f)
lineTo(11.358733f, 4.6773101f)
arcToRelative(0.4177552f, 0.4177552f, 0f, false, true, 0.401789f, -0.305999f)
horizontalLineToRelative(2.368167f)
arcToRelative(0.31398162f, 0.31398162f, 0f, false, true, 0.303338f, 0.3991292f)
lineTo(12.569424f, 11.111273f)
lineTo(14.31761f, 10.5791f)
lineTo(13.942429f, 11.848331f)
close()
}
path(
fill = SolidColor(Color(0xFF000000))
) {
}
}.build()
return _Litecoin!!
}
@Suppress("ktlint:standard:backing-property-naming")
private var _Litecoin: ImageVector? = null

View File

@@ -0,0 +1,98 @@
package org.fdroid.ui.icons
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathFillType.Companion.NonZero
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap.Companion.Butt
import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.fdroid.ui.FDroidContent
val PackageVariant: ImageVector
get() {
if (_PackageVariant != null) {
return _PackageVariant!!
}
_PackageVariant = Builder(
name = "packageVariant",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f,
).apply {
path(
fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f,
strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
pathFillType = NonZero
) {
moveTo(2.0f, 10.96f)
curveTo(1.5f, 10.68f, 1.35f, 10.07f, 1.63f, 9.59f)
lineTo(3.13f, 7.0f)
curveTo(3.24f, 6.8f, 3.41f, 6.66f, 3.6f, 6.58f)
lineTo(11.43f, 2.18f)
curveTo(11.59f, 2.06f, 11.79f, 2.0f, 12.0f, 2.0f)
curveTo(12.21f, 2.0f, 12.41f, 2.06f, 12.57f, 2.18f)
lineTo(20.47f, 6.62f)
curveTo(20.66f, 6.72f, 20.82f, 6.88f, 20.91f, 7.08f)
lineTo(22.36f, 9.6f)
curveTo(22.64f, 10.08f, 22.47f, 10.69f, 22.0f, 10.96f)
lineTo(21.0f, 11.54f)
verticalLineTo(16.5f)
curveTo(21.0f, 16.88f, 20.79f, 17.21f, 20.47f, 17.38f)
lineTo(12.57f, 21.82f)
curveTo(12.41f, 21.94f, 12.21f, 22.0f, 12.0f, 22.0f)
curveTo(11.79f, 22.0f, 11.59f, 21.94f, 11.43f, 21.82f)
lineTo(3.53f, 17.38f)
curveTo(3.21f, 17.21f, 3.0f, 16.88f, 3.0f, 16.5f)
verticalLineTo(10.96f)
curveTo(2.7f, 11.13f, 2.32f, 11.14f, 2.0f, 10.96f)
moveTo(12.0f, 4.15f)
verticalLineTo(4.15f)
lineTo(12.0f, 10.85f)
verticalLineTo(10.85f)
lineTo(17.96f, 7.5f)
lineTo(12.0f, 4.15f)
moveTo(5.0f, 15.91f)
lineTo(11.0f, 19.29f)
verticalLineTo(12.58f)
lineTo(5.0f, 9.21f)
verticalLineTo(15.91f)
moveTo(19.0f, 15.91f)
verticalLineTo(12.69f)
lineTo(14.0f, 15.59f)
curveTo(13.67f, 15.77f, 13.3f, 15.76f, 13.0f, 15.6f)
verticalLineTo(19.29f)
lineTo(19.0f, 15.91f)
moveTo(13.85f, 13.36f)
lineTo(20.13f, 9.73f)
lineTo(19.55f, 8.72f)
lineTo(13.27f, 12.35f)
lineTo(13.85f, 13.36f)
close()
}
}
.build()
return _PackageVariant!!
}
@Suppress("ktlint:standard:backing-property-naming")
private var _PackageVariant: ImageVector? = null
@Preview
@Composable
private fun Preview() {
FDroidContent {
Box(modifier = Modifier.padding(12.dp)) {
Image(imageVector = PackageVariant, contentDescription = "")
}
}
}

View File

@@ -0,0 +1,273 @@
package org.fdroid.ui.lists
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.plus
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults.enterAlwaysScrollBehavior
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.viktormykhailiv.compose.hints.hintAnchor
import com.viktormykhailiv.compose.hints.rememberHint
import com.viktormykhailiv.compose.hints.rememberHintAnchorState
import com.viktormykhailiv.compose.hints.rememberHintController
import org.fdroid.R
import org.fdroid.database.AppListSortOrder
import org.fdroid.ui.FDroidContent
import org.fdroid.ui.utils.BigLoadingIndicator
import org.fdroid.ui.utils.OnboardingCard
import org.fdroid.ui.utils.getAppListInfo
import org.fdroid.ui.utils.getHintOverlayColor
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun AppList(
appListInfo: AppListInfo,
currentPackageName: String?,
modifier: Modifier = Modifier,
onBackClicked: () -> Unit,
onItemClick: (String) -> Unit,
) {
var searchActive by rememberSaveable { mutableStateOf(false) }
val scrollBehavior = enterAlwaysScrollBehavior(rememberTopAppBarState())
val hintController = rememberHintController(
overlay = getHintOverlayColor(),
)
val hint = rememberHint {
OnboardingCard(
title = stringResource(R.string.onboarding_app_list_filter_title),
message = stringResource(R.string.onboarding_app_list_filter_message),
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
onGotIt = {
appListInfo.actions.onOnboardingSeen()
hintController.dismiss()
},
)
}
val hintAnchor = rememberHintAnchorState(hint)
LaunchedEffect(appListInfo.showOnboarding) {
if (appListInfo.showOnboarding) {
hintController.show(hintAnchor)
appListInfo.actions.onOnboardingSeen()
}
}
Scaffold(
topBar = {
if (searchActive) {
val onSearchCleared = { appListInfo.actions.onSearch("") }
TopSearchBar(onSearch = appListInfo.actions::onSearch, onSearchCleared) {
searchActive = false
onSearchCleared()
}
} else TopAppBar(
title = {
Text(
text = appListInfo.list.title,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
)
},
navigationIcon = {
IconButton(onClick = {
if (searchActive) searchActive = false else onBackClicked()
}) {
Icon(
imageVector = Icons.AutoMirrored.Default.ArrowBack,
contentDescription = stringResource(R.string.back),
)
}
},
actions = {
IconButton(onClick = { searchActive = true }) {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = stringResource(R.string.menu_search),
)
}
IconButton(
onClick = { appListInfo.actions.toggleFilterVisibility() },
modifier = Modifier.hintAnchor(
state = hintAnchor,
shape = RoundedCornerShape(16.dp),
)
) {
val showFilterBadge =
appListInfo.model.filteredRepositoryIds.isNotEmpty() ||
appListInfo.model.filteredCategoryIds.isNotEmpty()
BadgedBox(badge = {
if (showFilterBadge) Badge(
containerColor = MaterialTheme.colorScheme.secondary,
)
}) {
Icon(
imageVector = Icons.Filled.FilterList,
contentDescription = stringResource(R.string.filter),
)
}
}
},
scrollBehavior = scrollBehavior,
)
},
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
) { paddingValues ->
val listState = rememberSaveable(saver = LazyListState.Saver) {
LazyListState()
}
Column(
modifier = Modifier.fillMaxSize()
) {
val apps = appListInfo.model.apps
if (apps == null) BigLoadingIndicator()
else if (apps.isEmpty()) {
Text(
text = stringResource(R.string.search_filter_no_results),
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp),
)
} else LazyColumn(
state = listState,
contentPadding = paddingValues + PaddingValues(top = 8.dp),
verticalArrangement = spacedBy(8.dp),
modifier = Modifier.then(
if (currentPackageName == null) Modifier
else Modifier.selectableGroup()
),
) {
items(apps, key = { it.packageName }, contentType = { "A" }) { navItem ->
val isSelected = currentPackageName == navItem.packageName
val interactionModifier = if (currentPackageName == null) {
Modifier.clickable(
onClick = { onItemClick(navItem.packageName) }
)
} else {
Modifier.selectable(
selected = isSelected,
onClick = { onItemClick(navItem.packageName) }
)
}
AppListRow(
item = navItem,
isSelected = isSelected,
modifier = Modifier
.fillMaxWidth()
.animateItem()
.padding(horizontal = 8.dp)
.then(interactionModifier)
)
}
}
// Bottom Sheet
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
if (appListInfo.showFilters) {
ModalBottomSheet(
modifier = Modifier.fillMaxHeight(),
sheetState = sheetState,
onDismissRequest = { appListInfo.actions.toggleFilterVisibility() },
) {
AppsFilter(info = appListInfo)
}
}
}
}
}
@Preview
@Composable
private fun Preview() {
FDroidContent {
val model = AppListModel(
apps = listOf(
AppListItem(1, "1", "This is app 1", "It has summary 2", 0, false, true, null),
AppListItem(2, "2", "This is app 2", "It has summary 2", 0, true, true, null),
),
sortBy = AppListSortOrder.NAME,
filterIncompatible = true,
categories = null,
filteredCategoryIds = emptySet(),
repositories = emptyList(),
filteredRepositoryIds = emptySet(),
)
val info = getAppListInfo(model)
AppList(appListInfo = info, currentPackageName = null, onBackClicked = {}, onItemClick = {})
}
}
@Preview
@Composable
private fun PreviewLoading() {
FDroidContent {
val model = AppListModel(
apps = null,
sortBy = AppListSortOrder.NAME,
filterIncompatible = false,
categories = null,
filteredCategoryIds = emptySet(),
repositories = emptyList(),
filteredRepositoryIds = emptySet(),
)
val info = getAppListInfo(model)
AppList(appListInfo = info, currentPackageName = null, onBackClicked = {}, onItemClick = {})
}
}
@Preview
@Composable
private fun PreviewEmpty() {
FDroidContent {
val model = AppListModel(
apps = emptyList(),
sortBy = AppListSortOrder.NAME,
filterIncompatible = false,
categories = null,
filteredCategoryIds = emptySet(),
repositories = emptyList(),
filteredRepositoryIds = emptySet(),
)
val info = getAppListInfo(model)
AppList(appListInfo = info, currentPackageName = null, onBackClicked = {}, onItemClick = {})
}
}

View File

@@ -0,0 +1,49 @@
package org.fdroid.ui.lists
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import org.fdroid.ui.navigation.NavigationKey
import org.fdroid.ui.navigation.Navigator
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
fun EntryProviderScope<NavKey>.appListEntry(
navigator: Navigator,
isBigScreen: Boolean,
) {
entry<NavigationKey.AppList>(
metadata = ListDetailSceneStrategy.listPane("appdetails"),
) {
val viewModel = hiltViewModel<AppListViewModel, AppListViewModel.Factory>(
creationCallback = { factory ->
factory.create(it.type)
}
)
val appListInfo = object : AppListInfo {
override val model = viewModel.appListModel.collectAsStateWithLifecycle().value
override val list: AppListType = it.type
override val actions: AppListActions = viewModel
override val showFilters: Boolean =
viewModel.showFilters.collectAsStateWithLifecycle().value
override val showOnboarding: Boolean =
viewModel.showOnboarding.collectAsStateWithLifecycle().value
}
AppList(
appListInfo = appListInfo,
currentPackageName = if (isBigScreen) {
(navigator.last as? NavigationKey.AppDetails)?.packageName
} else null,
onBackClicked = { navigator.goBack() },
) { packageName ->
val new = NavigationKey.AppDetails(packageName)
if (navigator.last is NavigationKey.AppDetails) {
navigator.replaceLast(new)
} else {
navigator.navigate(new)
}
}
}
}

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