mirror of
https://github.com/f-droid/fdroidclient.git
synced 2026-02-08 14:13:16 -05:00
Merge branch '2.0' into 'master'
F-Droid Basic 2.0 See merge request fdroid/fdroidclient!1607
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
1
app/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
138
app/build.gradle.kts
Normal file
138
app/build.gradle.kts
Normal 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
|
||||
}
|
||||
}
|
||||
73
app/lint.xml
73
app/lint.xml
@@ -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>
|
||||
|
||||
59
app/proguard-rules.pro
vendored
59
app/proguard-rules.pro
vendored
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
30
app/src/basic/res/drawable/ic_launcher_foreground.xml
Normal file
30
app/src/basic/res/drawable/ic_launcher_foreground.xml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
62
app/src/main/assets/default_repos.json
Normal file
62
app/src/main/assets/default_repos.json
Normal 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
|
||||
}
|
||||
]
|
||||
52
app/src/main/assets/most_downloaded_apps.json
Normal file
52
app/src/main/assets/most_downloaded_apps.json
Normal 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"
|
||||
]
|
||||
132
app/src/main/kotlin/org/fdroid/App.kt
Normal file
132
app/src/main/kotlin/org/fdroid/App.kt
Normal 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)
|
||||
39
app/src/main/kotlin/org/fdroid/MainActivity.kt
Normal file
39
app/src/main/kotlin/org/fdroid/MainActivity.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
199
app/src/main/kotlin/org/fdroid/NotificationManager.kt
Normal file
199
app/src/main/kotlin/org/fdroid/NotificationManager.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
24
app/src/main/kotlin/org/fdroid/db/DatabaseModule.kt
Normal file
24
app/src/main/kotlin/org/fdroid/db/DatabaseModule.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
21
app/src/main/kotlin/org/fdroid/db/InitialData.kt
Normal file
21
app/src/main/kotlin/org/fdroid/db/InitialData.kt
Normal 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
|
||||
}
|
||||
}
|
||||
28
app/src/main/kotlin/org/fdroid/download/DownloadModule.kt
Normal file
28
app/src/main/kotlin/org/fdroid/download/DownloadModule.kt
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
43
app/src/main/kotlin/org/fdroid/download/ImageModel.kt
Normal file
43
app/src/main/kotlin/org/fdroid/download/ImageModel.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
69
app/src/main/kotlin/org/fdroid/download/LocalIconFetcher.kt
Normal file
69
app/src/main/kotlin/org/fdroid/download/LocalIconFetcher.kt
Normal 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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
56
app/src/main/kotlin/org/fdroid/download/NetworkMonitor.kt
Normal file
56
app/src/main/kotlin/org/fdroid/download/NetworkMonitor.kt
Normal 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),
|
||||
)
|
||||
}
|
||||
113
app/src/main/kotlin/org/fdroid/install/ApkFileProvider.kt
Normal file
113
app/src/main/kotlin/org/fdroid/install/ApkFileProvider.kt
Normal 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
|
||||
}
|
||||
10
app/src/main/kotlin/org/fdroid/install/AppInstallListener.kt
Normal file
10
app/src/main/kotlin/org/fdroid/install/AppInstallListener.kt
Normal 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?)
|
||||
}
|
||||
516
app/src/main/kotlin/org/fdroid/install/AppInstallManager.kt
Normal file
516
app/src/main/kotlin/org/fdroid/install/AppInstallManager.kt
Normal 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
|
||||
}
|
||||
|
||||
}
|
||||
60
app/src/main/kotlin/org/fdroid/install/AppInstallService.kt
Normal file
60
app/src/main/kotlin/org/fdroid/install/AppInstallService.kt
Normal 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
|
||||
}
|
||||
}
|
||||
39
app/src/main/kotlin/org/fdroid/install/CacheCleaner.kt
Normal file
39
app/src/main/kotlin/org/fdroid/install/CacheCleaner.kt
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
157
app/src/main/kotlin/org/fdroid/install/InstallState.kt
Normal file
157
app/src/main/kotlin/org/fdroid/install/InstallState.kt
Normal 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
|
||||
}
|
||||
113
app/src/main/kotlin/org/fdroid/install/InstalledAppsCache.kt
Normal file
113
app/src/main/kotlin/org/fdroid/install/InstalledAppsCache.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
app/src/main/kotlin/org/fdroid/install/PreApprovalResult.kt
Normal file
15
app/src/main/kotlin/org/fdroid/install/PreApprovalResult.kt
Normal 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
|
||||
}
|
||||
417
app/src/main/kotlin/org/fdroid/install/SessionInstallManager.kt
Normal file
417
app/src/main/kotlin/org/fdroid/install/SessionInstallManager.kt
Normal 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
|
||||
}
|
||||
}
|
||||
76
app/src/main/kotlin/org/fdroid/repo/RepoPreLoader.kt
Normal file
76
app/src/main/kotlin/org/fdroid/repo/RepoPreLoader.kt
Normal 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,
|
||||
)
|
||||
295
app/src/main/kotlin/org/fdroid/repo/RepoUpdateManager.kt
Normal file
295
app/src/main/kotlin/org/fdroid/repo/RepoUpdateManager.kt
Normal 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
|
||||
159
app/src/main/kotlin/org/fdroid/repo/RepoUpdateWorker.kt
Normal file
159
app/src/main/kotlin/org/fdroid/repo/RepoUpdateWorker.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
34
app/src/main/kotlin/org/fdroid/repo/RepositoryModule.kt
Normal file
34
app/src/main/kotlin/org/fdroid/repo/RepositoryModule.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
67
app/src/main/kotlin/org/fdroid/settings/OnboardingManager.kt
Normal file
67
app/src/main/kotlin/org/fdroid/settings/OnboardingManager.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
51
app/src/main/kotlin/org/fdroid/settings/SettingsConstants.kt
Normal file
51
app/src/main/kotlin/org/fdroid/settings/SettingsConstants.kt
Normal 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
|
||||
}
|
||||
141
app/src/main/kotlin/org/fdroid/settings/SettingsManager.kt
Normal file
141
app/src/main/kotlin/org/fdroid/settings/SettingsManager.kt
Normal 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
|
||||
}
|
||||
}
|
||||
213
app/src/main/kotlin/org/fdroid/ui/About.kt
Normal file
213
app/src/main/kotlin/org/fdroid/ui/About.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
78
app/src/main/kotlin/org/fdroid/ui/Color.kt
Normal file
78
app/src/main/kotlin/org/fdroid/ui/Color.kt
Normal 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)
|
||||
158
app/src/main/kotlin/org/fdroid/ui/Main.kt
Normal file
158
app/src/main/kotlin/org/fdroid/ui/Main.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
18
app/src/main/kotlin/org/fdroid/ui/MainViewModel.kt
Normal file
18
app/src/main/kotlin/org/fdroid/ui/MainViewModel.kt
Normal 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() }
|
||||
}
|
||||
115
app/src/main/kotlin/org/fdroid/ui/Theme.kt
Normal file
115
app/src/main/kotlin/org/fdroid/ui/Theme.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
35
app/src/main/kotlin/org/fdroid/ui/apps/IgnoreIssueDialog.kt
Normal file
35
app/src/main/kotlin/org/fdroid/ui/apps/IgnoreIssueDialog.kt
Normal 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))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
89
app/src/main/kotlin/org/fdroid/ui/apps/InstalledAppRow.kt
Normal file
89
app/src/main/kotlin/org/fdroid/ui/apps/InstalledAppRow.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
134
app/src/main/kotlin/org/fdroid/ui/apps/InstallingAppRow.kt
Normal file
134
app/src/main/kotlin/org/fdroid/ui/apps/InstallingAppRow.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
56
app/src/main/kotlin/org/fdroid/ui/apps/MyAppItem.kt
Normal file
56
app/src/main/kotlin/org/fdroid/ui/apps/MyAppItem.kt
Normal 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
|
||||
}
|
||||
212
app/src/main/kotlin/org/fdroid/ui/apps/MyApps.kt
Normal file
212
app/src/main/kotlin/org/fdroid/ui/apps/MyApps.kt
Normal 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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
54
app/src/main/kotlin/org/fdroid/ui/apps/MyAppsEntry.kt
Normal file
54
app/src/main/kotlin/org/fdroid/ui/apps/MyAppsEntry.kt
Normal 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)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
25
app/src/main/kotlin/org/fdroid/ui/apps/MyAppsInfo.kt
Normal file
25
app/src/main/kotlin/org/fdroid/ui/apps/MyAppsInfo.kt
Normal 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,
|
||||
)
|
||||
263
app/src/main/kotlin/org/fdroid/ui/apps/MyAppsList.kt
Normal file
263
app/src/main/kotlin/org/fdroid/ui/apps/MyAppsList.kt
Normal 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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
115
app/src/main/kotlin/org/fdroid/ui/apps/MyAppsPresenter.kt
Normal file
115
app/src/main/kotlin/org/fdroid/ui/apps/MyAppsPresenter.kt
Normal 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 }
|
||||
}
|
||||
}
|
||||
125
app/src/main/kotlin/org/fdroid/ui/apps/MyAppsViewModel.kt
Normal file
125
app/src/main/kotlin/org/fdroid/ui/apps/MyAppsViewModel.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
63
app/src/main/kotlin/org/fdroid/ui/apps/NotAvailableDialog.kt
Normal file
63
app/src/main/kotlin/org/fdroid/ui/apps/NotAvailableDialog.kt
Normal 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") {}
|
||||
}
|
||||
}
|
||||
}
|
||||
137
app/src/main/kotlin/org/fdroid/ui/apps/UpdatableAppRow.kt
Normal file
137
app/src/main/kotlin/org/fdroid/ui/apps/UpdatableAppRow.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
103
app/src/main/kotlin/org/fdroid/ui/categories/CategoryChip.kt
Normal file
103
app/src/main/kotlin/org/fdroid/ui/categories/CategoryChip.kt
Normal 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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
221
app/src/main/kotlin/org/fdroid/ui/categories/CategoryItem.kt
Normal file
221
app/src/main/kotlin/org/fdroid/ui/categories/CategoryItem.kt
Normal 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
|
||||
}
|
||||
}
|
||||
85
app/src/main/kotlin/org/fdroid/ui/categories/CategoryList.kt
Normal file
85
app/src/main/kotlin/org/fdroid/ui/categories/CategoryList.kt
Normal 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, {})
|
||||
}
|
||||
}
|
||||
78
app/src/main/kotlin/org/fdroid/ui/crash/Crash.kt
Normal file
78
app/src/main/kotlin/org/fdroid/ui/crash/Crash.kt
Normal 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 })
|
||||
}
|
||||
}
|
||||
56
app/src/main/kotlin/org/fdroid/ui/crash/CrashActivity.kt
Normal file
56
app/src/main/kotlin/org/fdroid/ui/crash/CrashActivity.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
102
app/src/main/kotlin/org/fdroid/ui/crash/CrashContent.kt
Normal file
102
app/src/main/kotlin/org/fdroid/ui/crash/CrashContent.kt
Normal 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 })
|
||||
}
|
||||
}
|
||||
13
app/src/main/kotlin/org/fdroid/ui/crash/NoRetryPolicy.kt
Normal file
13
app/src/main/kotlin/org/fdroid/ui/crash/NoRetryPolicy.kt
Normal 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
|
||||
}
|
||||
}
|
||||
92
app/src/main/kotlin/org/fdroid/ui/details/AntiFeatures.kt
Normal file
92
app/src/main/kotlin/org/fdroid/ui/details/AntiFeatures.kt
Normal 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!!)
|
||||
}
|
||||
}
|
||||
439
app/src/main/kotlin/org/fdroid/ui/details/AppDetails.kt
Normal file
439
app/src/main/kotlin/org/fdroid/ui/details/AppDetails.kt
Normal 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, { }, {})
|
||||
}
|
||||
}
|
||||
33
app/src/main/kotlin/org/fdroid/ui/details/AppDetailsEntry.kt
Normal file
33
app/src/main/kotlin/org/fdroid/ui/details/AppDetailsEntry.kt
Normal 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() }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
343
app/src/main/kotlin/org/fdroid/ui/details/AppDetailsHeader.kt
Normal file
343
app/src/main/kotlin/org/fdroid/ui/details/AppDetailsHeader.kt
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
286
app/src/main/kotlin/org/fdroid/ui/details/AppDetailsItem.kt
Normal file
286
app/src/main/kotlin/org/fdroid/ui/details/AppDetailsItem.kt
Normal 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")
|
||||
}
|
||||
54
app/src/main/kotlin/org/fdroid/ui/details/AppDetailsLink.kt
Normal file
54
app/src/main/kotlin/org/fdroid/ui/details/AppDetailsLink.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
129
app/src/main/kotlin/org/fdroid/ui/details/AppDetailsMenu.kt
Normal file
129
app/src/main/kotlin/org/fdroid/ui/details/AppDetailsMenu.kt
Normal 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) {}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
223
app/src/main/kotlin/org/fdroid/ui/details/AppDetailsViewModel.kt
Normal file
223
app/src/main/kotlin/org/fdroid/ui/details/AppDetailsViewModel.kt
Normal 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,
|
||||
)
|
||||
145
app/src/main/kotlin/org/fdroid/ui/details/AppDetailsWarnings.kt
Normal file
145
app/src/main/kotlin/org/fdroid/ui/details/AppDetailsWarnings.kt
Normal 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),
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
266
app/src/main/kotlin/org/fdroid/ui/details/DetailsPresenter.kt
Normal file
266
app/src/main/kotlin/org/fdroid/ui/details/DetailsPresenter.kt
Normal 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)
|
||||
}
|
||||
41
app/src/main/kotlin/org/fdroid/ui/details/NoAppSelected.kt
Normal file
41
app/src/main/kotlin/org/fdroid/ui/details/NoAppSelected.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
203
app/src/main/kotlin/org/fdroid/ui/details/RepoChooser.kt
Normal file
203
app/src/main/kotlin/org/fdroid/ui/details/RepoChooser.kt
Normal 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, {}, {})
|
||||
}
|
||||
}
|
||||
151
app/src/main/kotlin/org/fdroid/ui/details/Screenshots.kt
Normal file
151
app/src/main/kotlin/org/fdroid/ui/details/Screenshots.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
56
app/src/main/kotlin/org/fdroid/ui/details/TechnicalInfo.kt
Normal file
56
app/src/main/kotlin/org/fdroid/ui/details/TechnicalInfo.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
245
app/src/main/kotlin/org/fdroid/ui/details/Versions.kt
Normal file
245
app/src/main/kotlin/org/fdroid/ui/details/Versions.kt
Normal 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) {}
|
||||
}
|
||||
}
|
||||
117
app/src/main/kotlin/org/fdroid/ui/discover/AppCarousel.kt
Normal file
117
app/src/main/kotlin/org/fdroid/ui/discover/AppCarousel.kt
Normal 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 = {}) {}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
283
app/src/main/kotlin/org/fdroid/ui/discover/AppsSearch.kt
Normal file
283
app/src/main/kotlin/org/fdroid/ui/discover/AppsSearch.kt
Normal 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), {}, {}, {})
|
||||
}
|
||||
}
|
||||
}
|
||||
187
app/src/main/kotlin/org/fdroid/ui/discover/Discover.kt
Normal file
187
app/src/main/kotlin/org/fdroid/ui/discover/Discover.kt
Normal 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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
41
app/src/main/kotlin/org/fdroid/ui/discover/DiscoverEntry.kt
Normal file
41
app/src/main/kotlin/org/fdroid/ui/discover/DiscoverEntry.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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() },
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
232
app/src/main/kotlin/org/fdroid/ui/discover/DiscoverViewModel.kt
Normal file
232
app/src/main/kotlin/org/fdroid/ui/discover/DiscoverViewModel.kt
Normal 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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
160
app/src/main/kotlin/org/fdroid/ui/discover/FirstStart.kt
Normal file
160
app/src/main/kotlin/org/fdroid/ui/discover/FirstStart.kt
Normal 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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
103
app/src/main/kotlin/org/fdroid/ui/icons/License.kt
Normal file
103
app/src/main/kotlin/org/fdroid/ui/icons/License.kt
Normal 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 = "")
|
||||
}
|
||||
}
|
||||
}
|
||||
65
app/src/main/kotlin/org/fdroid/ui/icons/Litecoin.kt
Normal file
65
app/src/main/kotlin/org/fdroid/ui/icons/Litecoin.kt
Normal 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
|
||||
98
app/src/main/kotlin/org/fdroid/ui/icons/PackageVariant.kt
Normal file
98
app/src/main/kotlin/org/fdroid/ui/icons/PackageVariant.kt
Normal 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 = "")
|
||||
}
|
||||
}
|
||||
}
|
||||
273
app/src/main/kotlin/org/fdroid/ui/lists/AppList.kt
Normal file
273
app/src/main/kotlin/org/fdroid/ui/lists/AppList.kt
Normal 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 = {})
|
||||
}
|
||||
}
|
||||
49
app/src/main/kotlin/org/fdroid/ui/lists/AppListEntry.kt
Normal file
49
app/src/main/kotlin/org/fdroid/ui/lists/AppListEntry.kt
Normal 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
Reference in New Issue
Block a user