diff --git a/.editorconfig b/.editorconfig index dce9126d2..a52bde9ce 100644 --- a/.editorconfig +++ b/.editorconfig @@ -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 diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 04a8972ab..e20945852 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -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,.*,F-Nightly,' + 's,.*,F-Nightly Basic,' + app/src/main/res/values*/strings.xml + - sed -i + 's,.*,F-Nightly,' 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 "${CI_PROJECT_PATH}-nightly" >> app/src/main/res/values/default_repos.xml - - echo "${CI_PROJECT_URL}-nightly/-/raw/master/fdroid/repo" >> 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 "${CI_PROJECT_PATH}-nightly" >> legacy/src/main/res/values/default_repos.xml + - echo "${CI_PROJECT_URL}-nightly/-/raw/master/fdroid/repo" >> 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 diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 000000000..833f20ecd --- /dev/null +++ b/app/build.gradle.kts @@ -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 + } +} diff --git a/app/lint.xml b/app/lint.xml index 876fcf92c..1732ebe7a 100644 --- a/app/lint.xml +++ b/app/lint.xml @@ -1,86 +1,13 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 20e22cd98..b37172aee 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -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 (...); -} - --keep class org.acra.config.MailSenderConfiguration { - public (...); -} +# 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 diff --git a/app/src/androidTest/java/org/fdroid/install/ApkFileProviderTest.kt b/app/src/androidTest/java/org/fdroid/install/ApkFileProviderTest.kt new file mode 100644 index 000000000..2247aef09 --- /dev/null +++ b/app/src/androidTest/java/org/fdroid/install/ApkFileProviderTest.kt @@ -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() + } + } +} diff --git a/app/src/basic/res/drawable/ic_launcher_foreground.xml b/app/src/basic/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 000000000..ddae19fea --- /dev/null +++ b/app/src/basic/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + diff --git a/app/src/basic/res/values/strings.xml b/app/src/basic/res/values/strings.xml index 52aeffdac..2f6657fd9 100644 --- a/app/src/basic/res/values/strings.xml +++ b/app/src/basic/res/values/strings.xml @@ -1,6 +1,4 @@ - + @string/app_name_basic - F-Droid Basic Debug - @string/about_title_basic diff --git a/app/src/full/res/values/strings.xml b/app/src/full/res/values/strings.xml index 23cd5f16e..946be2654 100644 --- a/app/src/full/res/values/strings.xml +++ b/app/src/full/res/values/strings.xml @@ -1,6 +1,4 @@ @string/app_name_full - F-Droid Debug - @string/about_title_full diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d1b189c0d..f5f7afa12 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,350 +1,49 @@ - - - - - - - - - + xmlns:tools="http://schemas.android.com/tools"> + - - - - - - - - - - - - - - + + + + + + + + - + android:theme="@style/Theme.FDroid" + tools:targetApi="33"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:theme="@style/Theme.FDroid" + android:windowSoftInputMode="adjustResize"> - - + - - - - - - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -356,192 +55,75 @@ android:scheme="market" /> - + - - - + + + + + + + + + + + + + + + + - + + + android:scheme="FDROIDREPOS" + tools:ignore="AppLinkUrlError" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:name="org.fdroid.ui.crash.CrashActivity" + android:excludeFromRecents="true" + android:finishOnTaskLaunch="true" + android:launchMode="singleInstance" + android:process=":acra" + android:windowSoftInputMode="adjustResize" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:name="com.journeyapps.barcodescanner.CaptureActivity" + android:screenOrientation="fullSensor" + tools:ignore="DiscouragedApi" + tools:replace="screenOrientation" /> - + android:foregroundServiceType="dataSync" /> - - - - - - - + android:grantUriPermissions="true" /> + + diff --git a/app/src/main/assets/default_repos.json b/app/src/main/assets/default_repos.json new file mode 100644 index 000000000..0fddb0f01 --- /dev/null +++ b/app/src/main/assets/default_repos.json @@ -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 + } +] \ No newline at end of file diff --git a/app/src/main/assets/most_downloaded_apps.json b/app/src/main/assets/most_downloaded_apps.json new file mode 100644 index 000000000..7e9c782c6 --- /dev/null +++ b/app/src/main/assets/most_downloaded_apps.json @@ -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" +] diff --git a/app/src/main/kotlin/org/fdroid/App.kt b/app/src/main/kotlin/org/fdroid/App.kt new file mode 100644 index 000000000..b8e717c88 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/App.kt @@ -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 { data, _ -> data.getCacheKey() } + add(downloadRequestKeyer) + add(downloadRequestFetcherFactory) + + val packageNameKeyer = Keyer { data, _ -> data.packageName } + add(packageNameKeyer) + add(LocalIconFetcher.Factory(context, downloadRequestFetcherFactory)) + } + .memoryCache { + MemoryCache.Builder() + .maxSizePercent(context, 0.25) + .build() + } + .diskCache { + DiskCache.Builder() + .directory(context.cacheDir.resolve("coil")) + .maxSizePercent(0.05) + .build() + } + .logger(if (BuildConfig.DEBUG) DebugLogger() else null) + .build() + } +} + +fun DownloadRequest.getCacheKey() = indexFile.sha256 ?: (mirrors[0].baseUrl + indexFile.name) diff --git a/app/src/main/kotlin/org/fdroid/MainActivity.kt b/app/src/main/kotlin/org/fdroid/MainActivity.kt new file mode 100644 index 000000000..28e5d09dd --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/MainActivity.kt @@ -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) + } + } +} diff --git a/app/src/main/kotlin/org/fdroid/NotificationManager.kt b/app/src/main/kotlin/org/fdroid/NotificationManager.kt new file mode 100644 index 000000000..272d68e28 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/NotificationManager.kt @@ -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) + } +} diff --git a/app/src/main/kotlin/org/fdroid/db/DatabaseModule.kt b/app/src/main/kotlin/org/fdroid/db/DatabaseModule.kt new file mode 100644 index 000000000..80ae0f43a --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/db/DatabaseModule.kt @@ -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) + } +} diff --git a/app/src/main/kotlin/org/fdroid/db/InitialData.kt b/app/src/main/kotlin/org/fdroid/db/InitialData.kt new file mode 100644 index 000000000..9df4e10f8 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/db/InitialData.kt @@ -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 + } +} diff --git a/app/src/main/kotlin/org/fdroid/download/DownloadModule.kt b/app/src/main/kotlin/org/fdroid/download/DownloadModule.kt new file mode 100644 index 000000000..aa4493c17 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/download/DownloadModule.kt @@ -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 +} diff --git a/app/src/main/kotlin/org/fdroid/download/DownloaderFactoryImpl.kt b/app/src/main/kotlin/org/fdroid/download/DownloaderFactoryImpl.kt new file mode 100644 index 000000000..56a139eac --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/download/DownloaderFactoryImpl.kt @@ -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, + 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) + } + } +} diff --git a/app/src/main/kotlin/org/fdroid/download/ImageModel.kt b/app/src/main/kotlin/org/fdroid/download/ImageModel.kt new file mode 100644 index 000000000..df433a0ea --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/download/ImageModel.kt @@ -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() + } +} diff --git a/app/src/main/kotlin/org/fdroid/download/LocalFileDownloader.kt b/app/src/main/kotlin/org/fdroid/download/LocalFileDownloader.kt new file mode 100644 index 000000000..1f04ca17a --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/download/LocalFileDownloader.kt @@ -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) + } +} diff --git a/app/src/main/kotlin/org/fdroid/download/LocalIconFetcher.kt b/app/src/main/kotlin/org/fdroid/download/LocalIconFetcher.kt new file mode 100644 index 000000000..2b4555c07 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/download/LocalIconFetcher.kt @@ -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 { + override fun create( + data: PackageName, + options: Options, + imageLoader: ImageLoader, + ): Fetcher = LocalIconFetcher( + packageManager = context.packageManager, + data = data, + downloadRequestFetcher = data.iconDownloadRequest?.let { + downloadRequestFetcherFactory.create(it, options, imageLoader) + }, + ) + } +} diff --git a/app/src/main/kotlin/org/fdroid/download/NetworkMonitor.kt b/app/src/main/kotlin/org/fdroid/download/NetworkMonitor.kt new file mode 100644 index 000000000..305346fc6 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/download/NetworkMonitor.kt @@ -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), + ) +} diff --git a/app/src/main/kotlin/org/fdroid/install/ApkFileProvider.kt b/app/src/main/kotlin/org/fdroid/install/ApkFileProvider.kt new file mode 100644 index 000000000..9ce95c706 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/install/ApkFileProvider.kt @@ -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?, + selection: String?, + selectionArgs: Array?, + sortOrder: String?, + ): Cursor? { + val packageName = uri.lastPathSegment ?: return null + val applicationInfo = getApplicationInfo(uri) ?: return null + // we don't care what they are asking for, just give them this + val columns = arrayOf( + MediaColumns.DISPLAY_NAME, + MediaColumns.MIME_TYPE, + MediaColumns.DATA, + MediaColumns.SIZE, + ) + return MatrixCursor(columns).apply { + try { + addRow( + arrayOf( + packageName, + MIME_TYPE, + applicationInfo.publicSourceDir, + File(applicationInfo.publicSourceDir).length(), + ) + ) + } 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?): Int = 0 + override fun getType(uri: Uri): String = MIME_TYPE + override fun getTypeAnonymous(uri: Uri): String = MIME_TYPE + override fun insert(uri: Uri, values: ContentValues?): Uri? = null + + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array?, + ): Int = 0 +} diff --git a/app/src/main/kotlin/org/fdroid/install/AppInstallListener.kt b/app/src/main/kotlin/org/fdroid/install/AppInstallListener.kt new file mode 100644 index 000000000..ed5d9f7b6 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/install/AppInstallListener.kt @@ -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?) +} diff --git a/app/src/main/kotlin/org/fdroid/install/AppInstallManager.kt b/app/src/main/kotlin/org/fdroid/install/AppInstallManager.kt new file mode 100644 index 000000000..f301d749d --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/install/AppInstallManager.kt @@ -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>(emptyMap()) + private val jobs = ConcurrentHashMap() + val appInstallStates = apps.asStateFlow() + val installNotificationState: InstallNotificationState + get() { + val appStates = mutableListOf() + 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 { + 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 { + 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>.updateApp( + packageName: String, + function: (InstallState) -> InstallState, + ) = update { oldMap -> + val newMap = oldMap.toMutableMap() + newMap[packageName] = function(newMap[packageName] ?: InstallState.Unknown) + newMap + } + + private fun MutableStateFlow>.checkAndUpdateApp( + packageName: String, + function: (InstallStateWithInfo) -> InstallStateWithInfo, + ): InstallStateWithInfo { + return updateAndGet { oldMap -> + val oldState = oldMap[packageName] + check(oldState is InstallStateWithInfo) { + "State for $packageName was $oldState" + } + val newMap = oldMap.toMutableMap() + newMap[packageName] = function(oldState) + newMap + }[packageName] as InstallStateWithInfo + } + +} diff --git a/app/src/main/kotlin/org/fdroid/install/AppInstallService.kt b/app/src/main/kotlin/org/fdroid/install/AppInstallService.kt new file mode 100644 index 000000000..34404d01d --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/install/AppInstallService.kt @@ -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 + } +} diff --git a/app/src/main/kotlin/org/fdroid/install/CacheCleaner.kt b/app/src/main/kotlin/org/fdroid/install/CacheCleaner.kt new file mode 100644 index 000000000..007650c66 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/install/CacheCleaner.kt @@ -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 + } +} diff --git a/app/src/main/kotlin/org/fdroid/install/InstallBroadcastReceiver.kt b/app/src/main/kotlin/org/fdroid/install/InstallBroadcastReceiver.kt new file mode 100644 index 000000000..e8d16bb86 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/install/InstallBroadcastReceiver.kt @@ -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) + } +} diff --git a/app/src/main/kotlin/org/fdroid/install/InstallNotificationState.kt b/app/src/main/kotlin/org/fdroid/install/InstallNotificationState.kt new file mode 100644 index 000000000..4a1d8cd61 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/install/InstallNotificationState.kt @@ -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, + 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() + val toConfirm = mutableListOf() + val installed = mutableListOf() + apps.forEach { appState -> + when (appState.category) { + AppStateCategory.INSTALLING -> installing.add(appState) + AppStateCategory.NEEDS_CONFIRMATION -> toConfirm.add(appState) + AppStateCategory.INSTALLED -> installed.add(appState) + } + } + val sb = StringBuilder() + fun printApps(@StringRes titleRes: Int, list: List, showTitle: Boolean = true) { + if (list.isEmpty()) return + if (showTitle) { + if (sb.isNotEmpty()) sb.append("\n⠀\n") + sb.append(context.getString(titleRes)) + } + sb.append("\n") + list.forEach { appState -> + sb.append("• ").append(appState.displayStr).append("\n") + } + } + + val showInstallTitle = toConfirm.isNotEmpty() || installed.isNotEmpty() + printApps(R.string.notification_installing_section_confirmation, toConfirm) + printApps(R.string.notification_installing_section_installing, installing, showInstallTitle) + printApps(R.string.notification_installing_section_installed, installed) + return sb.toString() + } + + 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 +} diff --git a/app/src/main/kotlin/org/fdroid/install/InstallState.kt b/app/src/main/kotlin/org/fdroid/install/InstallState.kt new file mode 100644 index 000000000..de4f9dc5a --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/install/InstallState.kt @@ -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 +} diff --git a/app/src/main/kotlin/org/fdroid/install/InstalledAppsCache.kt b/app/src/main/kotlin/org/fdroid/install/InstalledAppsCache.kt new file mode 100644 index 000000000..70b704a58 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/install/InstalledAppsCache.kt @@ -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>(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) + } + } + } +} diff --git a/app/src/main/kotlin/org/fdroid/install/PreApprovalResult.kt b/app/src/main/kotlin/org/fdroid/install/PreApprovalResult.kt new file mode 100644 index 000000000..3195c0a8b --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/install/PreApprovalResult.kt @@ -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 +} diff --git a/app/src/main/kotlin/org/fdroid/install/SessionInstallManager.kt b/app/src/main/kotlin/org/fdroid/install/SessionInstallManager.kt new file mode 100644 index 000000000..8b829b0ba --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/install/SessionInstallManager.kt @@ -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 + } +} diff --git a/app/src/main/kotlin/org/fdroid/repo/RepoPreLoader.kt b/app/src/main/kotlin/org/fdroid/repo/RepoPreLoader.kt new file mode 100644 index 000000000..da07783bd --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/repo/RepoPreLoader.kt @@ -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 by lazy { + getDefaultRepos().map { it.address }.toSet() + } + + @WorkerThread + @OptIn(ExperimentalSerializationApi::class) + fun addPreloadedRepositories(db: FDroidDatabase) { + addRepositories(db.getRepositoryDao(), getDefaultRepos()) + // "system" can be removed when minSdk is 28 + for (root in listOf("/system", "/system_ext", "/product", "/vendor")) { + val romReposFile = File("$root/etc/${context.packageName}/additional_repos.json") + if (romReposFile.isFile) { + val romRepos = romReposFile.inputStream().use { inputStream -> + Json.decodeFromStream>(inputStream) + } + addRepositories(db.getRepositoryDao(), romRepos) + } + } + } + + @WorkerThread + @OptIn(ExperimentalSerializationApi::class) + private fun getDefaultRepos() = context.assets.open("default_repos.json").use { inputStream -> + Json.decodeFromStream>(inputStream) + } + + private fun addRepositories( + repositoryDao: RepositoryDao, + repositories: List + ) { + repositories.forEach { repository -> + val initialRepository = InitialRepository( + name = repository.name, + address = repository.address, + mirrors = repository.mirrors, + description = repository.description, + certificate = repository.certificate, + version = 1, + enabled = repository.enabled, + ) + repositoryDao.insert(initialRepository) + } + } +} + +@Serializable +data class DefaultRepository( + val name: String, + val address: String, + val mirrors: List = emptyList(), + val description: String, + val certificate: String, + val enabled: Boolean, +) diff --git a/app/src/main/kotlin/org/fdroid/repo/RepoUpdateManager.kt b/app/src/main/kotlin/org/fdroid/repo/RepoUpdateManager.kt new file mode 100644 index 000000000..fa152135c --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/repo/RepoUpdateManager.kt @@ -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(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 diff --git a/app/src/main/kotlin/org/fdroid/repo/RepoUpdateWorker.kt b/app/src/main/kotlin/org/fdroid/repo/RepoUpdateWorker.kt new file mode 100644 index 000000000..317e8e408 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/repo/RepoUpdateWorker.kt @@ -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() + .setExpedited(RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .apply { + if (repoId >= 0) setInputData(workDataOf("repoId" to repoId)) + } + .build() + WorkManager.getInstance(context) + .enqueue(request) + } + + @JvmStatic + fun scheduleOrCancel(context: Context, autoUpdate: AutoUpdateValues) { + val workManager = WorkManager.getInstance(context) + if (autoUpdate != AutoUpdateValues.Never) { + Log.i(TAG, "scheduleOrCancel: enqueueUniquePeriodicWork") + val networkType = if (autoUpdate == AutoUpdateValues.Always) { + NetworkType.CONNECTED + } else { + NetworkType.UNMETERED + } + val constraints = Constraints.Builder() + .setRequiresBatteryNotLow(true) + .setRequiresStorageNotLow(true) + .setRequiredNetworkType(networkType) + .build() + val workRequest = PeriodicWorkRequestBuilder( + repeatInterval = 4, + repeatIntervalTimeUnit = TimeUnit.HOURS, + flexTimeInterval = 15, + flexTimeIntervalUnit = MINUTES, + ) + .setConstraints(constraints) + .build() + workManager.enqueueUniquePeriodicWork( + UNIQUE_WORK_NAME_REPO_AUTO_UPDATE, + UPDATE, + workRequest, + ) + } else { + Log.w(TAG, "Cancelling job due to settings!") + workManager.cancelUniqueWork(UNIQUE_WORK_NAME_REPO_AUTO_UPDATE) + } + } + + fun getAutoUpdateWorkInfo(context: Context): Flow { + return WorkManager.getInstance(context).getWorkInfosForUniqueWorkFlow( + UNIQUE_WORK_NAME_REPO_AUTO_UPDATE + ).map { it.getOrNull(0) } + } + } + + 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 + ) + } +} diff --git a/app/src/main/kotlin/org/fdroid/repo/RepositoryModule.kt b/app/src/main/kotlin/org/fdroid/repo/RepositoryModule.kt new file mode 100644 index 000000000..353aae642 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/repo/RepositoryModule.kt @@ -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, + ) +} diff --git a/app/src/main/kotlin/org/fdroid/settings/OnboardingManager.kt b/app/src/main/kotlin/org/fdroid/settings/OnboardingManager.kt new file mode 100644 index 000000000..4e8c5e56f --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/settings/OnboardingManager.kt @@ -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, +) { + constructor(key: String, prefs: SharedPreferences) : this( + key = key, + _flow = MutableStateFlow(prefs.getBoolean(key, true)), + ) + + val flow: StateFlow = _flow.asStateFlow() + + fun onSeen(prefs: SharedPreferences) { + _flow.update { false } + prefs.edit { + putBoolean(key, false) + } + } +} diff --git a/app/src/main/kotlin/org/fdroid/settings/SettingsConstants.kt b/app/src/main/kotlin/org/fdroid/settings/SettingsConstants.kt new file mode 100644 index 000000000..bbc7b7730 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/settings/SettingsConstants.kt @@ -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 +} diff --git a/app/src/main/kotlin/org/fdroid/settings/SettingsManager.kt b/app/src/main/kotlin/org/fdroid/settings/SettingsManager.kt new file mode 100644 index 000000000..571a538d1 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/settings/SettingsManager.kt @@ -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(PREF_KEY_THEME) }.distinctUntilChanged() + val dynamicColorFlow: Flow = prefsFlow.map { + it.get(PREF_KEY_DYNAMIC_COLORS) ?: PREF_DEFAULT_DYNAMIC_COLORS + }.distinctUntilChanged() + val repoUpdates + get() = prefs.getString(PREF_KEY_REPO_UPDATES, PREF_DEFAULT_REPO_UPDATES) + .toAutoUpdateValue() + val repoUpdatesFlow + get() = prefsFlow.map { + it.get(PREF_KEY_REPO_UPDATES).toAutoUpdateValue() + }.distinctUntilChanged() + val autoUpdateApps + get() = prefs.getString(PREF_KEY_AUTO_UPDATES, PREF_DEFAULT_AUTO_UPDATES) + .toAutoUpdateValue() + val autoUpdateAppsFlow + get() = prefsFlow.map { + it.get(PREF_KEY_AUTO_UPDATES).toAutoUpdateValue() + }.distinctUntilChanged() + var lastRepoUpdate: Long + get() = try { + 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 + get() = try { + prefs.getStringSet(PREF_KEY_IGNORED_APP_ISSUES, emptySet())?.associate { + val (packageName, versionCode) = it.split('|') + Pair(packageName, versionCode.toLong()) + } ?: emptyMap() + } catch (e: Exception) { + log.error(e) { "Error parsing ignored app issues: " } + emptyMap() + } + private set(value) { + val newValue = value.map { (packageName, versionCode) -> "$packageName|$versionCode" } + prefs.edit { putStringSet(PREF_KEY_IGNORED_APP_ISSUES, newValue.toSet()) } + } + + val 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 + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/About.kt b/app/src/main/kotlin/org/fdroid/ui/About.kt new file mode 100644 index 000000000..811c534fc --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/About.kt @@ -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) + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/Color.kt b/app/src/main/kotlin/org/fdroid/ui/Color.kt new file mode 100644 index 000000000..6a99b02da --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/Color.kt @@ -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) diff --git a/app/src/main/kotlin/org/fdroid/ui/Main.kt b/app/src/main/kotlin/org/fdroid/ui/Main.kt new file mode 100644 index 000000000..4dd73cc14 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/Main.kt @@ -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(directive = directive) + + val entryProvider: (NavKey) -> NavEntry = entryProvider { + discoverEntry(navigator) + myAppsEntry(navigator, isBigScreen) + appDetailsEntry(navigator, isBigScreen) + appListEntry(navigator, isBigScreen) + repoEntry(navigator, isBigScreen) + entry(NavigationKey.Settings) { + val viewModel = hiltViewModel() + 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() + 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, + ) + } + } + } + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/MainViewModel.kt b/app/src/main/kotlin/org/fdroid/ui/MainViewModel.kt new file mode 100644 index 000000000..2331218fc --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/MainViewModel.kt @@ -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() } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/Theme.kt b/app/src/main/kotlin/org/fdroid/ui/Theme.kt new file mode 100644 index 000000000..815c2f7ef --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/Theme.kt @@ -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) + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/apps/IgnoreIssueDialog.kt b/app/src/main/kotlin/org/fdroid/ui/apps/IgnoreIssueDialog.kt new file mode 100644 index 000000000..0cc6dbadd --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/apps/IgnoreIssueDialog.kt @@ -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)) + } + } + ) +} diff --git a/app/src/main/kotlin/org/fdroid/ui/apps/InstalledAppRow.kt b/app/src/main/kotlin/org/fdroid/ui/apps/InstalledAppRow.kt new file mode 100644 index 000000000..d89fdc115 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/apps/InstalledAppRow.kt @@ -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) + } + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/apps/InstallingAppRow.kt b/app/src/main/kotlin/org/fdroid/ui/apps/InstallingAppRow.kt new file mode 100644 index 000000000..ac6826d8f --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/apps/InstallingAppRow.kt @@ -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) + } + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/apps/MyAppItem.kt b/app/src/main/kotlin/org/fdroid/ui/apps/MyAppItem.kt new file mode 100644 index 000000000..4e03fbaf1 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/apps/MyAppItem.kt @@ -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 +} diff --git a/app/src/main/kotlin/org/fdroid/ui/apps/MyApps.kt b/app/src/main/kotlin/org/fdroid/ui/apps/MyApps.kt new file mode 100644 index 000000000..d4950cc9d --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/apps/MyApps.kt @@ -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 = {}, + ) + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/apps/MyAppsEntry.kt b/app/src/main/kotlin/org/fdroid/ui/apps/MyAppsEntry.kt new file mode 100644 index 000000000..62b0c236a --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/apps/MyAppsEntry.kt @@ -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.myAppsEntry( + navigator: Navigator, + isBigScreen: Boolean, +) { + entry( + metadata = ListDetailSceneStrategy.listPane("appdetails"), + ) { + val myAppsViewModel = hiltViewModel() + val myAppsInfo = object : MyAppsInfo { + override val model = myAppsViewModel.myAppsModel.collectAsStateWithLifecycle().value + + override 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) + } + }, + ) + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/apps/MyAppsInfo.kt b/app/src/main/kotlin/org/fdroid/ui/apps/MyAppsInfo.kt new file mode 100644 index 000000000..f3b1d54fe --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/apps/MyAppsInfo.kt @@ -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? = null, + val installingApps: List, + val appsWithIssue: List? = null, + val installedApps: List? = null, + val sortOrder: AppListSortOrder = AppListSortOrder.NAME, + val networkState: NetworkState, + val appUpdatesBytes: Long? = null, +) diff --git a/app/src/main/kotlin/org/fdroid/ui/apps/MyAppsList.kt b/app/src/main/kotlin/org/fdroid/ui/apps/MyAppsList.kt new file mode 100644 index 000000000..e3b077482 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/apps/MyAppsList.kt @@ -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(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 = {}, + ) + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/apps/MyAppsPresenter.kt b/app/src/main/kotlin/org/fdroid/ui/apps/MyAppsPresenter.kt new file mode 100644 index 000000000..b7e9f9d05 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/apps/MyAppsPresenter.kt @@ -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?>, + appInstallStatesFlow: StateFlow>, + appsWithIssuesFlow: StateFlow?>, + installedAppsFlow: Flow>, + searchQueryFlow: StateFlow, + sortOrderFlow: StateFlow, + networkStateFlow: StateFlow, +): MyAppsModel { + val appUpdates = appUpdatesFlow.collectAsState().value + val appInstallStates = appInstallStatesFlow.collectAsState().value + val appsWithIssues = appsWithIssuesFlow.collectAsState().value + val installedApps = installedAppsFlow.collectAsState(null).value + val searchQuery = searchQueryFlow.collectAsState().value.normalize() + val sortOrder = sortOrderFlow.collectAsState().value + val processedPackageNames = mutableSetOf() + + // 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 List.sort(sortOrder: AppListSortOrder): List { + val collator = Collator.getInstance(Locale.getDefault()) + return when (sortOrder) { + AppListSortOrder.NAME -> sortedWith { a1, a2 -> + // storing collator.getCollationKey() and using that could be an optimization + collator.compare(a1.name, a2.name) + } + AppListSortOrder.LAST_UPDATED -> sortedByDescending { it.lastUpdated } + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/apps/MyAppsViewModel.kt b/app/src/main/kotlin/org/fdroid/ui/apps/MyAppsViewModel.kt new file mode 100644 index 000000000..7b453667f --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/apps/MyAppsViewModel.kt @@ -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 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() + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/apps/NotAvailableDialog.kt b/app/src/main/kotlin/org/fdroid/ui/apps/NotAvailableDialog.kt new file mode 100644 index 000000000..d9cf84763 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/apps/NotAvailableDialog.kt @@ -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") {} + } + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/apps/UpdatableAppRow.kt b/app/src/main/kotlin/org/fdroid/ui/apps/UpdatableAppRow.kt new file mode 100644 index 000000000..29ca70a8a --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/apps/UpdatableAppRow.kt @@ -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) + } + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/categories/CategoryChip.kt b/app/src/main/kotlin/org/fdroid/ui/categories/CategoryChip.kt new file mode 100644 index 000000000..9cfb486b3 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/categories/CategoryChip.kt @@ -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 = {}, + ) + } + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/categories/CategoryGroup.kt b/app/src/main/kotlin/org/fdroid/ui/categories/CategoryGroup.kt new file mode 100644 index 000000000..1a9b49d6c --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/categories/CategoryGroup.kt @@ -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) +} diff --git a/app/src/main/kotlin/org/fdroid/ui/categories/CategoryItem.kt b/app/src/main/kotlin/org/fdroid/ui/categories/CategoryItem.kt new file mode 100644 index 000000000..51d3ef0a3 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/categories/CategoryItem.kt @@ -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 + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/categories/CategoryList.kt b/app/src/main/kotlin/org/fdroid/ui/categories/CategoryList.kt new file mode 100644 index 000000000..9ee70addd --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/categories/CategoryList.kt @@ -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>?, + 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 { 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, {}) + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/crash/Crash.kt b/app/src/main/kotlin/org/fdroid/ui/crash/Crash.kt new file mode 100644 index 000000000..fdc57cd5b --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/crash/Crash.kt @@ -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 }) + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/crash/CrashActivity.kt b/app/src/main/kotlin/org/fdroid/ui/crash/CrashActivity.kt new file mode 100644 index 000000000..891a1bac9 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/crash/CrashActivity.kt @@ -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 + } + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/crash/CrashContent.kt b/app/src/main/kotlin/org/fdroid/ui/crash/CrashContent.kt new file mode 100644 index 000000000..739f6a7a4 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/crash/CrashContent.kt @@ -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 }) + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/crash/NoRetryPolicy.kt b/app/src/main/kotlin/org/fdroid/ui/crash/NoRetryPolicy.kt new file mode 100644 index 000000000..a79dc1e6c --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/crash/NoRetryPolicy.kt @@ -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, + failedSenders: List + ): Boolean { + return false + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/details/AntiFeatures.kt b/app/src/main/kotlin/org/fdroid/ui/details/AntiFeatures.kt new file mode 100644 index 000000000..26807650e --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/details/AntiFeatures.kt @@ -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, +) { + 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!!) + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/details/AppDetails.kt b/app/src/main/kotlin/org/fdroid/ui/details/AppDetails.kt new file mode 100644 index 000000000..6577b2ba6 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/details/AppDetails.kt @@ -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, { }, {}) + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsEntry.kt b/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsEntry.kt new file mode 100644 index 000000000..c6fcc93e4 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsEntry.kt @@ -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.appDetailsEntry( + navigator: Navigator, + isBigScreen: Boolean, +) { + entry( + metadata = ListDetailSceneStrategy.detailPane("appdetails") + ) { + val viewModel = hiltViewModel( + creationCallback = { factory -> + factory.create(it.packageName) + } + ) + AppDetails( + item = viewModel.appDetails.collectAsStateWithLifecycle().value, + onNav = { navKey -> navigator.navigate(navKey) }, + onBackNav = if (isBigScreen) null else { + { navigator.goBack() } + }, + ) + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsHeader.kt b/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsHeader.kt new file mode 100644 index 000000000..227d7e6af --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsHeader.kt @@ -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)) + } + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsItem.kt b/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsItem.kt new file mode 100644 index 000000000..76721d6ca --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsItem.kt @@ -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 = emptyList(), + val name: String, + val summary: String? = null, + val description: String? = null, + val icon: Any? = null, + val featureGraphic: Any? = null, + val phoneScreenshots: List = emptyList(), + val categories: List? = null, + val versions: List? = null, + val installedVersion: PackageVersion? = null, + /** + * Needed, because the [installedVersion] may not be available, e.g. too old. + */ + val installedVersionCode: Long? = null, + val installedVersionName: String? = null, + /** + * The currently suggested version for installation. + */ + val suggestedVersion: AppVersion? = null, + /** + * Similar to [suggestedVersion], but doesn't obey [appPrefs] for ignoring versions. + * This is useful for (un-)ignoring this version. + */ + val possibleUpdate: PackageVersion? = null, + val appPrefs: AppPrefs? = null, + val whatsNew: String? = null, + val antiFeatures: List? = null, + val issue: AppIssue? = null, + val authorHasMoreThanOneApp: Boolean = false, + val proxy: ProxyConfig?, +) { + constructor( + repository: Repository, + preferredRepoId: Long, + repositories: List, + dbApp: App, + actions: AppDetailsActions, + installState: InstallState, + networkState: NetworkState, + versions: List?, + installedVersion: AppVersion?, + installedVersionCode: Long?, + installedVersionName: String?, + 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? { + return this?.antiFeatureKeys?.mapNotNull { key -> + val antiFeature = repository.getAntiFeatures()[key] ?: return@mapNotNull null + AntiFeature( + id = key, + icon = antiFeature.getIcon(localeList)?.getImageModel(repository, proxy), + name = antiFeature.getName(localeList) ?: key, + reason = getAntiFeatureReason(key, localeList), + ) + } +} + +@VisibleForTesting +internal fun getHtmlDescription(description: String?): String? { + return description?.replace("".toRegex(), "") + ?.replace("(\\s\\(?)(https://\\S+[^\\s).])([\\s\\n).]|$)".toRegex()) { + val prefix = it.groups[1]?.value ?: it.value + val url = it.groups[2]?.value ?: it.value + val suffix = it.groups[3]?.value ?: it.value + "$prefix$url$suffix" + }?.replace("(?|ul>|)\n".toRegex(), "
\n") +} diff --git a/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsLink.kt b/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsLink.kt new file mode 100644 index 000000000..f8417a712 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsLink.kt @@ -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) + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsMenu.kt b/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsMenu.kt new file mode 100644 index 000000000..18837858a --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsMenu.kt @@ -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) {} + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsTopAppBar.kt b/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsTopAppBar.kt new file mode 100644 index 000000000..c575966f3 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsTopAppBar.kt @@ -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, + ) +} diff --git a/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsViewModel.kt b/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsViewModel.kt new file mode 100644 index 000000000..3bc4558ae --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsViewModel.kt @@ -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(null) + private val currentRepoIdFlow = MutableStateFlow(null) + private val moleculeScope = + CoroutineScope(viewModelScope.coroutineContext + AndroidUiDispatcher.Main) + + val appDetails: StateFlow by lazy(LazyThreadSafetyMode.NONE) { + moleculeScope.launchMolecule(mode = ContextClock) { + DetailsPresenter( + db = db, + scope = scope, + repoManager = repoManager, + repoPreLoader = repoPreLoader, + updateChecker = updateChecker, + settingsManager = settingsManager, + appInstallManager = appInstallManager, + viewModel = this, + packageInfoFlow = packageInfoFlow, + currentRepoIdFlow = currentRepoIdFlow, + appsWithIssuesFlow = updatesManager.appsWithIssues, + networkStateFlow = networkMonitor.networkState, + ) + } + } + + 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, +) diff --git a/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsWarnings.kt b/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsWarnings.kt new file mode 100644 index 000000000..4ae495622 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/details/AppDetailsWarnings.kt @@ -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), + ), + ) + ) + } + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/details/DetailsPresenter.kt b/app/src/main/kotlin/org/fdroid/ui/details/DetailsPresenter.kt new file mode 100644 index 000000000..4ff9f91f5 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/details/DetailsPresenter.kt @@ -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, + currentRepoIdFlow: StateFlow, + appsWithIssuesFlow: StateFlow?>, + networkStateFlow: StateFlow, +): AppDetailsItem? { + val packagePair = packageInfoFlow.collectAsState().value ?: return null + val packageName = packagePair.packageName + val packageInfo = packagePair.packageInfo + val currentRepoId = currentRepoIdFlow.collectAsState().value + val appsWithIssues = appsWithIssuesFlow.collectAsState().value + val appDao = db.getAppDao() + val app = produceState(null, currentRepoId) { + 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(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?>(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(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) +} diff --git a/app/src/main/kotlin/org/fdroid/ui/details/NoAppSelected.kt b/app/src/main/kotlin/org/fdroid/ui/details/NoAppSelected.kt new file mode 100644 index 000000000..38aeb7498 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/details/NoAppSelected.kt @@ -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() + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/details/RepoChooser.kt b/app/src/main/kotlin/org/fdroid/ui/details/RepoChooser.kt new file mode 100644 index 000000000..812310408 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/details/RepoChooser.kt @@ -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, + 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, {}, {}) + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/details/Screenshots.kt b/app/src/main/kotlin/org/fdroid/ui/details/Screenshots.kt new file mode 100644 index 000000000..3237605e6 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/details/Screenshots.kt @@ -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) { + 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) { + val carouselState = rememberCarouselState { phoneScreenshots.size } + var showScreenshot by remember { mutableStateOf(null) } + val screenshotIndex = showScreenshot + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + if (screenshotIndex != null) ModalBottomSheet( + onDismissRequest = { showScreenshot = null }, + sheetState = sheetState, + properties = ModalBottomSheetProperties(), + ) { + val pagerState = rememberPagerState( + initialPage = screenshotIndex, + pageCount = { phoneScreenshots.size }, + ) + Surface { + 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) + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/details/TechnicalInfo.kt b/app/src/main/kotlin/org/fdroid/ui/details/TechnicalInfo.kt new file mode 100644 index 000000000..a9b33ab99 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/details/TechnicalInfo.kt @@ -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) + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/details/Versions.kt b/app/src/main/kotlin/org/fdroid/ui/details/Versions.kt new file mode 100644 index 000000000..96ed19843 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/details/Versions.kt @@ -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) {} + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/discover/AppCarousel.kt b/app/src/main/kotlin/org/fdroid/ui/discover/AppCarousel.kt new file mode 100644 index 000000000..4b289c907 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/discover/AppCarousel.kt @@ -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, + 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 = {}) {} + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/discover/AppDiscoverItem.kt b/app/src/main/kotlin/org/fdroid/ui/discover/AppDiscoverItem.kt new file mode 100644 index 000000000..a0d8995c7 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/discover/AppDiscoverItem.kt @@ -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, +) diff --git a/app/src/main/kotlin/org/fdroid/ui/discover/AppSearchInputField.kt b/app/src/main/kotlin/org/fdroid/ui/discover/AppSearchInputField.kt new file mode 100644 index 000000000..1a4974020 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/discover/AppSearchInputField.kt @@ -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), + ) + } + } + } + ) +} diff --git a/app/src/main/kotlin/org/fdroid/ui/discover/AppsSearch.kt b/app/src/main/kotlin/org/fdroid/ui/discover/AppsSearch.kt new file mode 100644 index 000000000..7a7c26459 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/discover/AppsSearch.kt @@ -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, 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), {}, {}, {}) + } + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/discover/Discover.kt b/app/src/main/kotlin/org/fdroid/ui/discover/Discover.kt new file mode 100644 index 000000000..4fb1f0d11 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/discover/Discover.kt @@ -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 = {}, + ) + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/discover/DiscoverContent.kt b/app/src/main/kotlin/org/fdroid/ui/discover/DiscoverContent.kt new file mode 100644 index 000000000..1b5a8fdd1 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/discover/DiscoverContent.kt @@ -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() + ) + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/discover/DiscoverEntry.kt b/app/src/main/kotlin/org/fdroid/ui/discover/DiscoverEntry.kt new file mode 100644 index 000000000..4d7de9c3f --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/discover/DiscoverEntry.kt @@ -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.discoverEntry( + navigator: Navigator, +) { + entry( + metadata = ListDetailSceneStrategy.listPane("appdetails") { + NoAppSelected() + }, + ) { + val viewModel = hiltViewModel() + Discover( + discoverModel = viewModel.discoverModel.collectAsStateWithLifecycle().value, + onListTap = { + navigator.navigate(NavigationKey.AppList(it)) + }, + onAppTap = { + val new = NavigationKey.AppDetails(it.packageName) + if (navigator.last is NavigationKey.AppDetails) { + navigator.replaceLast(new) + } else { + navigator.navigate(new) + } + }, + onNav = { navKey -> navigator.navigate(navKey) }, + onSearch = viewModel::search, + onSearchCleared = viewModel::onSearchCleared, + ) + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/discover/DiscoverOverflowMenu.kt b/app/src/main/kotlin/org/fdroid/ui/discover/DiscoverOverflowMenu.kt new file mode 100644 index 000000000..46067a971 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/discover/DiscoverOverflowMenu.kt @@ -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() }, + ) + } + ) + } + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/discover/DiscoverPresenter.kt b/app/src/main/kotlin/org/fdroid/ui/discover/DiscoverPresenter.kt new file mode 100644 index 000000000..f6589c3ad --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/discover/DiscoverPresenter.kt @@ -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>, + recentlyUpdatedAppsFlow: Flow>, + mostDownloadedAppsFlow: MutableStateFlow?>, + categoriesFlow: Flow>, + repositoriesFlow: Flow>, + searchResultsFlow: StateFlow, + isFirstStart: Boolean, + networkState: NetworkState, + repoUpdateStateFlow: StateFlow, + hasRepoIssuesFlow: Flow, +): DiscoverModel { + val newApps = newAppsFlow.collectAsState(null).value + val recentlyUpdatedApps = recentlyUpdatedAppsFlow.collectAsState(null).value + val mostDownloadedApps = mostDownloadedAppsFlow.collectAsState().value + val categories = categoriesFlow.collectAsState(null).value + val 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, + val recentlyUpdatedApps: List, + val mostDownloadedApps: List?, + val categories: Map>?, + val searchResults: SearchResults? = null, + val hasRepoIssues: Boolean, +) : DiscoverModel() diff --git a/app/src/main/kotlin/org/fdroid/ui/discover/DiscoverViewModel.kt b/app/src/main/kotlin/org/fdroid/ui/discover/DiscoverViewModel.kt new file mode 100644 index 000000000..06bf47298 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/discover/DiscoverViewModel.kt @@ -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?>(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(null) + private val hasRepoIssues = repoManager.repositoriesState.map { repos -> + repos.any { it.enabled && it.errorCount >= 5 } + } + + val localeList = LocaleListCompat.getDefault() + val discoverModel: StateFlow by lazy(LazyThreadSafetyMode.NONE) { + @SuppressLint("StateFlowValueCalledInComposition") // see comment below + moleculeScope.launchMolecule(mode = ContextClock) { + DiscoverPresenter( + newAppsFlow = newApps, + recentlyUpdatedAppsFlow = recentlyUpdatedApps, + mostDownloadedAppsFlow = mostDownloadedApps, + categoriesFlow = categories, + repositoriesFlow = repoManager.repositoriesState, + 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>(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 + }, + ) + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/discover/FirstStart.kt b/app/src/main/kotlin/org/fdroid/ui/discover/FirstStart.kt new file mode 100644 index 000000000..81254564d --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/discover/FirstStart.kt @@ -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(), + ) + } + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/discover/SearchResults.kt b/app/src/main/kotlin/org/fdroid/ui/discover/SearchResults.kt new file mode 100644 index 000000000..5909724af --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/discover/SearchResults.kt @@ -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, + val categories: List, +) diff --git a/app/src/main/kotlin/org/fdroid/ui/icons/License.kt b/app/src/main/kotlin/org/fdroid/ui/icons/License.kt new file mode 100644 index 000000000..58f44edb7 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/icons/License.kt @@ -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 = "") + } + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/icons/Litecoin.kt b/app/src/main/kotlin/org/fdroid/ui/icons/Litecoin.kt new file mode 100644 index 000000000..ba6945a3a --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/icons/Litecoin.kt @@ -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 diff --git a/app/src/main/kotlin/org/fdroid/ui/icons/PackageVariant.kt b/app/src/main/kotlin/org/fdroid/ui/icons/PackageVariant.kt new file mode 100644 index 000000000..58e31b823 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/icons/PackageVariant.kt @@ -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 = "") + } + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/lists/AppList.kt b/app/src/main/kotlin/org/fdroid/ui/lists/AppList.kt new file mode 100644 index 000000000..0655c50d8 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/lists/AppList.kt @@ -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 = {}) + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/lists/AppListEntry.kt b/app/src/main/kotlin/org/fdroid/ui/lists/AppListEntry.kt new file mode 100644 index 000000000..e85d7937a --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/lists/AppListEntry.kt @@ -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.appListEntry( + navigator: Navigator, + isBigScreen: Boolean, +) { + entry( + metadata = ListDetailSceneStrategy.listPane("appdetails"), + ) { + val viewModel = hiltViewModel( + creationCallback = { factory -> + factory.create(it.type) + } + ) + val appListInfo = object : AppListInfo { + override val model = viewModel.appListModel.collectAsStateWithLifecycle().value + override val list: AppListType = it.type + override val actions: AppListActions = viewModel + override val showFilters: Boolean = + viewModel.showFilters.collectAsStateWithLifecycle().value + override val showOnboarding: Boolean = + viewModel.showOnboarding.collectAsStateWithLifecycle().value + } + AppList( + appListInfo = appListInfo, + currentPackageName = if (isBigScreen) { + (navigator.last as? NavigationKey.AppDetails)?.packageName + } else null, + onBackClicked = { navigator.goBack() }, + ) { packageName -> + val new = NavigationKey.AppDetails(packageName) + if (navigator.last is NavigationKey.AppDetails) { + navigator.replaceLast(new) + } else { + navigator.navigate(new) + } + } + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/lists/AppListInfo.kt b/app/src/main/kotlin/org/fdroid/ui/lists/AppListInfo.kt new file mode 100644 index 000000000..270acca99 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/lists/AppListInfo.kt @@ -0,0 +1,37 @@ +package org.fdroid.ui.lists + +import org.fdroid.database.AppListSortOrder +import org.fdroid.ui.categories.CategoryItem +import org.fdroid.ui.repositories.RepositoryItem + +interface AppListInfo { + val model: AppListModel + val actions: AppListActions + val list: AppListType + val showFilters: Boolean + val showOnboarding: Boolean +} + +data class AppListModel( + val apps: List?, + val sortBy: AppListSortOrder, + val filterIncompatible: Boolean, + val categories: List?, + val filteredCategoryIds: Set, + val repositories: List, + val filteredRepositoryIds: Set, +) + +interface AppListActions { + fun toggleFilterVisibility() + fun sortBy(sort: AppListSortOrder) + fun toggleFilterIncompatible() + fun addCategory(categoryId: String) + fun removeCategory(categoryId: String) + fun addRepository(repoId: Long) + fun removeRepository(repoId: Long) + fun saveFilters() + fun clearFilters() + fun onSearch(query: String) + fun onOnboardingSeen() +} diff --git a/app/src/main/kotlin/org/fdroid/ui/lists/AppListItem.kt b/app/src/main/kotlin/org/fdroid/ui/lists/AppListItem.kt new file mode 100644 index 000000000..de65ed327 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/lists/AppListItem.kt @@ -0,0 +1,13 @@ +package org.fdroid.ui.lists + +data class AppListItem( + val repoId: Long, + val packageName: String, + val name: String, + val summary: String, + val lastUpdated: Long, + val isInstalled: Boolean, + val isCompatible: Boolean, + val iconModel: Any? = null, + val categoryIds: Set? = null, +) diff --git a/app/src/main/kotlin/org/fdroid/ui/lists/AppListPresenter.kt b/app/src/main/kotlin/org/fdroid/ui/lists/AppListPresenter.kt new file mode 100644 index 000000000..20eafc36b --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/lists/AppListPresenter.kt @@ -0,0 +1,78 @@ +@file:Suppress("ktlint:standard:filename") + +package org.fdroid.ui.lists + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import org.fdroid.database.AppListSortOrder +import org.fdroid.ui.categories.CategoryItem +import org.fdroid.ui.repositories.RepositoryItem +import org.fdroid.ui.utils.normalize +import java.util.Locale + +@Composable +fun AppListPresenter( + appsFlow: StateFlow?>, + sortByFlow: StateFlow, + filterIncompatibleFlow: StateFlow, + categoriesFlow: Flow>, + filteredCategoryIdsFlow: StateFlow>, + repositoriesFlow: Flow>, + filteredRepositoryIdsFlow: StateFlow>, + searchQueryFlow: StateFlow, +): AppListModel { + val apps = appsFlow.collectAsState(null).value + val sortBy = sortByFlow.collectAsState().value + val filterIncompatible = filterIncompatibleFlow.collectAsState().value + val categories = categoriesFlow.collectAsState(null).value + val filteredCategoryIds = filteredCategoryIdsFlow.collectAsState().value + val repositories = repositoriesFlow.collectAsState(emptyList()).value + val filteredRepositoryIds = filteredRepositoryIdsFlow.collectAsState().value + val searchQuery = searchQueryFlow.collectAsState().value.normalize() + + val availableCategoryIds = remember(apps) { + // if there's only one category, we'll not show the filters for it + apps?.flatMap { it.categoryIds ?: emptySet() }?.toSet()?.takeIf { it.size > 1 } + ?: emptySet() + } + val filteredCategories = remember(categories, apps) { + categories?.filter { + it.id in availableCategoryIds + } + } + val availableRepositories = remember(apps) { + val repoIds = mutableSetOf() + apps?.forEach { repoIds.add(it.repoId) } + val repos = repositories.filter { it.repoId in repoIds } + // if there's only one repository, we'll not show the filters for it + if (repos.size > 1) repos else emptyList() + } + val filteredApps = apps?.filter { + val matchesCategories = filteredCategoryIds.isEmpty() || + (it.categoryIds ?: emptySet()).intersect(filteredCategoryIds).isNotEmpty() + val matchesRepos = filteredRepositoryIds.isEmpty() || it.repoId in filteredRepositoryIds + val matchesQuery = searchQuery.isEmpty() || + it.name.normalize().contains(searchQuery, ignoreCase = true) || + it.summary.normalize().contains(searchQuery, ignoreCase = true) || + it.packageName.contains(searchQuery, ignoreCase = true) + val matchesCompatibility = !filterIncompatible || it.isCompatible + matchesCategories && matchesRepos && matchesQuery && matchesCompatibility + } + val locale = Locale.getDefault() + return AppListModel( + apps = if (sortBy == AppListSortOrder.NAME) { + filteredApps?.sortedBy { it.name.lowercase(locale) } + } else { + filteredApps?.sortedByDescending { it.lastUpdated } + }, + sortBy = sortBy, + filterIncompatible = filterIncompatible, + categories = filteredCategories, + filteredCategoryIds = filteredCategoryIds, + repositories = availableRepositories, + filteredRepositoryIds = filteredRepositoryIds, + ) +} diff --git a/app/src/main/kotlin/org/fdroid/ui/lists/AppListRow.kt b/app/src/main/kotlin/org/fdroid/ui/lists/AppListRow.kt new file mode 100644 index 000000000..f234d1476 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/lists/AppListRow.kt @@ -0,0 +1,80 @@ +package org.fdroid.ui.lists + +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +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.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.InstalledBadge + +@Composable +fun AppListRow( + item: AppListItem, + isSelected: Boolean, + modifier: Modifier = Modifier, +) { + ListItem( + headlineContent = { Text(item.name) }, + supportingContent = { Text(item.summary) }, + leadingContent = { + BadgedBox(badge = { if (item.isInstalled) InstalledBadge() }) { + AsyncShimmerImage( + model = item.iconModel, + error = painterResource(R.drawable.ic_repo_app_default), + contentDescription = null, + modifier = Modifier + .size(48.dp) + .semantics { hideFromAccessibility() }, + ) + } + }, + colors = ListItemDefaults.colors( + containerColor = if (isSelected) { + MaterialTheme.colorScheme.surfaceVariant + } else { + Color.Transparent + } + ), + modifier = modifier, + ) +} + +@Preview +@Composable +fun AppListRowPreview() { + FDroidContent { + val item1 = AppListItem(1, "1", "This is app 1", "It has summary 2", 0, false, true, null) + val item2 = AppListItem(2, "2", "This is app 2", "It has summary 2", 0, true, true, null) + Column { + AppListRow(item1, false) + AppListRow(item2, true) + } + } +} + +@Preview(uiMode = UI_MODE_NIGHT_YES) +@Composable +fun AppListRowPreviewNight() { + FDroidContent { + val item1 = AppListItem(1, "1", "This is app 1", "It has summary 2", 0, true, true, null) + val item2 = AppListItem(2, "2", "This is app 2", "It has summary 2", 0, false, true, null) + Column { + AppListRow(item1, false) + AppListRow(item2, true) + } + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/lists/AppListType.kt b/app/src/main/kotlin/org/fdroid/ui/lists/AppListType.kt new file mode 100644 index 000000000..d273430c3 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/lists/AppListType.kt @@ -0,0 +1,30 @@ +package org.fdroid.ui.lists + +import kotlinx.serialization.Serializable + +@Serializable +sealed class AppListType { + abstract val title: String + + @Serializable + data class New(override val title: String) : AppListType() + + @Serializable + data class RecentlyUpdated(override val title: String) : AppListType() + + @Serializable + data class MostDownloaded(override val title: String) : AppListType() + + @Serializable + data class All(override val title: String) : AppListType() + + @Serializable + data class Category(override val title: String, val categoryId: String) : AppListType() + + @Serializable + data class Repository(override val title: String, val repoId: Long) : AppListType() + + @Serializable + data class Author(override val title: String, val authorName: String) : AppListType() + +} diff --git a/app/src/main/kotlin/org/fdroid/ui/lists/AppListViewModel.kt b/app/src/main/kotlin/org/fdroid/ui/lists/AppListViewModel.kt new file mode 100644 index 000000000..05d2e6e30 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/lists/AppListViewModel.kt @@ -0,0 +1,213 @@ +package org.fdroid.ui.lists + +import android.app.Application +import androidx.annotation.WorkerThread +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.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.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import org.fdroid.database.AppListSortOrder +import org.fdroid.database.FDroidDatabase +import org.fdroid.download.DownloadRequest +import org.fdroid.download.PackageName +import org.fdroid.download.getImageModel +import org.fdroid.index.RepoManager +import org.fdroid.install.InstalledAppsCache +import org.fdroid.settings.OnboardingManager +import org.fdroid.settings.SettingsManager +import org.fdroid.ui.categories.CategoryItem +import org.fdroid.ui.repositories.RepositoryItem +import java.text.Collator +import java.util.Locale + +@HiltViewModel(assistedFactory = AppListViewModel.Factory::class) +class AppListViewModel @AssistedInject constructor( + private val app: Application, + @Assisted val type: AppListType, + savedStateHandle: SavedStateHandle, + private val db: FDroidDatabase, + private val repoManager: RepoManager, + private val settingsManager: SettingsManager, + private val onboardingManager: OnboardingManager, + private val installedAppsCache: InstalledAppsCache, +) : AndroidViewModel(app), AppListActions { + + private val moleculeScope = + CoroutineScope(viewModelScope.coroutineContext + AndroidUiDispatcher.Main) + + private val localeList = LocaleListCompat.getDefault() + private val apps = MutableStateFlow?>(null) + private val categories = db.getRepositoryDao().getLiveCategories().asFlow().map { categories -> + val collator = Collator.getInstance(Locale.getDefault()) + categories.map { category -> + CategoryItem( + id = category.id, + name = category.getName(localeList) ?: "Unknown Category", + ) + }.sortedWith { c1, c2 -> collator.compare(c1.name, c2.name) } + } + private val repositories = repoManager.repositoriesState.map { repositories -> + val proxyConfig = settingsManager.proxyConfig + repositories.mapNotNull { + if (it.enabled) RepositoryItem(it, localeList, proxyConfig) + else null + }.sortedBy { it.weight } + } + private val query = MutableStateFlow("") + + private val _showFilters = savedStateHandle.getMutableStateFlow("showFilters", false) + val showFilters = _showFilters.asStateFlow() + + private val sortBy = MutableStateFlow(settingsManager.appListSortOrder) + private val filterIncompatible = MutableStateFlow(settingsManager.filterIncompatible) + private val filteredCategoryIds = MutableStateFlow>(emptySet()) + private val filteredRepositoryIds = MutableStateFlow>(emptySet()) + val showOnboarding = onboardingManager.showFilterOnboarding + + val appListModel: StateFlow by lazy(LazyThreadSafetyMode.NONE) { + moleculeScope.launchMolecule(mode = ContextClock) { + AppListPresenter( + appsFlow = apps, + sortByFlow = sortBy, + filterIncompatibleFlow = filterIncompatible, + categoriesFlow = categories, + filteredCategoryIdsFlow = filteredCategoryIds, + repositoriesFlow = repositories, + filteredRepositoryIdsFlow = filteredRepositoryIds, + searchQueryFlow = query, + ) + } + } + + init { + viewModelScope.launch(Dispatchers.IO) { + apps.value = loadApps(type) + } + } + + @WorkerThread + private suspend fun loadApps(type: AppListType): List { + val appDao = db.getAppDao() + val proxyConfig = settingsManager.proxyConfig + return when (type) { + is AppListType.Author -> appDao.getAppsByAuthor(type.authorName) + is AppListType.Category -> appDao.getAppsByCategory(type.categoryId) + is AppListType.New -> appDao.getNewApps() + is AppListType.RecentlyUpdated -> appDao.getRecentlyUpdatedApps() + is AppListType.MostDownloaded -> { + val packageNames = app.assets.open("most_downloaded_apps.json").use { inputStream -> + @OptIn(ExperimentalSerializationApi::class) + Json.decodeFromStream>(inputStream) + } + appDao.getApps(packageNames) + } + is AppListType.All -> appDao.getAllApps() + is AppListType.Repository -> appDao.getAppsByRepository(type.repoId) + }.mapNotNull { + val repository = repoManager.getRepository(it.repoId) + ?: return@mapNotNull null + val iconModel = it.getIcon(localeList)?.getImageModel(repository, proxyConfig) + as? DownloadRequest + val isInstalled = installedAppsCache.isInstalled(it.packageName) + AppListItem( + repoId = it.repoId, + packageName = it.packageName, + name = it.getName(localeList) ?: "Unknown App", + summary = it.getSummary(localeList) ?: "Unknown", + lastUpdated = it.lastUpdated, + isInstalled = isInstalled, + isCompatible = it.isCompatible, + iconModel = if (isInstalled) { + PackageName(it.packageName, iconModel) + } else { + iconModel + }, + categoryIds = it.categories?.toSet(), + ) + } + } + + override fun toggleFilterVisibility() { + _showFilters.update { !it } + } + + override fun sortBy(sort: AppListSortOrder) { + sortBy.update { sort } + } + + override fun toggleFilterIncompatible() { + filterIncompatible.update { !it } + } + + override fun addCategory(categoryId: String) { + filteredCategoryIds.update { + filteredCategoryIds.value.toMutableSet().apply { + add(categoryId) + } + } + } + + override fun removeCategory(categoryId: String) { + filteredCategoryIds.update { + filteredCategoryIds.value.toMutableSet().apply { + remove(categoryId) + } + } + } + + override fun addRepository(repoId: Long) { + filteredRepositoryIds.update { + filteredRepositoryIds.value.toMutableSet().apply { + add(repoId) + } + } + } + + override fun removeRepository(repoId: Long) { + filteredRepositoryIds.update { + filteredRepositoryIds.value.toMutableSet().apply { + remove(repoId) + } + } + } + + override fun saveFilters() { + settingsManager.saveAppListFilter(sortBy.value, filterIncompatible.value) + } + + override fun clearFilters() { + filterIncompatible.value = false + filteredCategoryIds.value = emptySet() + filteredRepositoryIds.value = emptySet() + } + + override fun onSearch(query: String) { + this.query.value = query + } + + override fun onOnboardingSeen() = onboardingManager.onFilterOnboardingSeen() + + @AssistedFactory + interface Factory { + fun create(type: AppListType): AppListViewModel + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/lists/AppsFilter.kt b/app/src/main/kotlin/org/fdroid/ui/lists/AppsFilter.kt new file mode 100644 index 000000000..a3fbde1d8 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/lists/AppsFilter.kt @@ -0,0 +1,263 @@ +package org.fdroid.ui.lists + +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +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.Category +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.PhonelinkErase +import androidx.compose.material.icons.filled.Save +import androidx.compose.material.icons.filled.SortByAlpha +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +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.database.AppListSortOrder +import org.fdroid.ui.FDroidContent +import org.fdroid.ui.categories.CategoryChip +import org.fdroid.ui.categories.CategoryItem +import org.fdroid.ui.icons.PackageVariant +import org.fdroid.ui.utils.AsyncShimmerImage +import org.fdroid.ui.utils.getAppListInfo +import org.fdroid.ui.utils.repoItems +import kotlin.random.Random + +@Composable +fun AppsFilter( + info: AppListInfo, + modifier: Modifier = Modifier, +) { + val scrollState = rememberScrollState() + Column(modifier = Modifier.verticalScroll(scrollState)) { + FilterHeader( + icon = Icons.AutoMirrored.Default.Sort, + text = stringResource(R.string.sort_title), + ) + FlowRow( + horizontalArrangement = spacedBy(8.dp), + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + val byNameSelected = info.model.sortBy == AppListSortOrder.NAME + FilterChip( + selected = byNameSelected, + leadingIcon = { + if (byNameSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = stringResource(R.string.filter_selected), + ) + } else { + Icon(Icons.Default.SortByAlpha, null) + } + }, + label = { + Text(stringResource(R.string.sort_by_name)) + }, + onClick = { + if (!byNameSelected) info.actions.sortBy(AppListSortOrder.NAME) + }, + ) + val byLatestSelected = info.model.sortBy == AppListSortOrder.LAST_UPDATED + FilterChip( + selected = byLatestSelected, + leadingIcon = { + if (byLatestSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = stringResource(R.string.filter_selected), + ) + } else { + Icon(Icons.Default.AccessTime, null) + } + }, + label = { + Text(stringResource(R.string.sort_by_latest)) + }, + onClick = { + if (!byLatestSelected) info.actions.sortBy(AppListSortOrder.LAST_UPDATED) + }, + ) + FilterChip( + selected = info.model.filterIncompatible, + leadingIcon = { + if (info.model.filterIncompatible) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = stringResource(R.string.filter_selected), + ) + } else { + Icon(Icons.Default.PhonelinkErase, null) + } + }, + label = { + Text(stringResource(R.string.filter_only_compatible)) + }, + onClick = info.actions::toggleFilterIncompatible, + ) + } + TextButton( + onClick = info.actions::saveFilters, + modifier = modifier + .align(Alignment.End) + .padding(horizontal = 16.dp), + ) { + Icon(Icons.Default.Save, null) + Spacer(modifier.width(ButtonDefaults.IconSpacing)) + Text( + text = stringResource(R.string.filter_button_save) + ) + } + val categories = info.model.categories + if (categories != null) { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + FilterHeader( + icon = Icons.Default.Category, + text = stringResource(R.string.main_menu__categories), + ) + FlowRow( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + categories.forEach { item -> + val isSelected = item.id in info.model.filteredCategoryIds + CategoryChip(item, selected = isSelected, onSelected = { + if (isSelected) { + info.actions.removeCategory(item.id) + } else { + info.actions.addCategory(item.id) + } + }) + } + } + } + if (info.model.repositories.isNotEmpty()) { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + FilterHeader( + icon = PackageVariant, + text = stringResource(R.string.app_details_repositories), + ) + FlowRow( + horizontalArrangement = spacedBy(8.dp), + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + info.model.repositories.forEach { repo -> + val selected = repo.repoId in info.model.filteredRepositoryIds + FilterChip( + selected = selected, + leadingIcon = { + if (selected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = stringResource(R.string.filter_selected), + ) + } else AsyncShimmerImage( + model = repo.icon, + contentDescription = null, + modifier = Modifier + .size(24.dp) + .semantics { hideFromAccessibility() }, + ) + }, + label = { + Text(repo.name) + }, + onClick = { + if (selected) { + info.actions.removeRepository(repo.repoId) + } else { + info.actions.addRepository(repo.repoId) + } + }, + ) + } + } + } + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + TextButton( + onClick = info.actions::clearFilters, + modifier = modifier + .align(Alignment.End) + .padding(horizontal = 16.dp), + ) { + Icon(Icons.Default.Clear, null) + Spacer(modifier.width(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.filter_button_clear_all)) + } + } +} + +@Composable +private fun FilterHeader(icon: ImageVector, text: String) { + Row( + horizontalArrangement = spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.semantics { hideFromAccessibility() }, + ) + Text(text = text, style = MaterialTheme.typography.titleMedium) + } +} + +@Composable +@Preview +private fun Preview() { + FDroidContent { + val model = AppListModel( + apps = listOf( + AppListItem(1, "1", "This is app 1", "It has summary 2", 0, false, true, null), + AppListItem(2, "2", "This is app 2", "It has summary 2", 0, true, true, null), + ), + sortBy = AppListSortOrder.NAME, + filterIncompatible = Random.nextBoolean(), + categories = listOf( + CategoryItem("App Store & Updater", "App Store & Updater"), + CategoryItem("Browser", "Browser"), + CategoryItem("Calendar & Agenda", "Calendar & Agenda"), + CategoryItem("Cloud Storage & File Sync", "Cloud Storage & File Sync"), + CategoryItem("Connectivity", "Connectivity"), + CategoryItem("Development", "Development"), + CategoryItem("doesn't exist", "Foo bar"), + ), + filteredCategoryIds = setOf("Browser"), + repositories = repoItems, + filteredRepositoryIds = setOf(2), + ) + val info = getAppListInfo(model) + AppsFilter(info) + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/lists/TopSearchBar.kt b/app/src/main/kotlin/org/fdroid/ui/lists/TopSearchBar.kt new file mode 100644 index 000000000..dd0e5570c --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/lists/TopSearchBar.kt @@ -0,0 +1,85 @@ +package org.fdroid.ui.lists + +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.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material3.AppBarWithSearch +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.rememberSearchBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce +import org.fdroid.R +import org.fdroid.ui.discover.AppSearchInputField +import org.fdroid.ui.discover.SEARCH_THRESHOLD + +/** + * This is a top app bar that isn't mean to ever expand with results, but for in-list filtering. + * There may still be potential to factor out common code with [AppSearchInputField]. + */ +@Composable +@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class) +fun TopSearchBar( + onSearch: (String) -> Unit, + onSearchCleared: () -> Unit, + onHideSearch: () -> Unit, +) { + val searchFieldState = rememberTextFieldState() + val focusRequester = remember { FocusRequester() } + AppBarWithSearch( + state = rememberSearchBarState(), + inputField = { + SearchBarDefaults.InputField( + state = searchFieldState, + leadingIcon = { + IconButton(onClick = onHideSearch) { + Icon( + imageVector = Icons.AutoMirrored.Default.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } + }, + trailingIcon = { + if (searchFieldState.text.isNotEmpty()) { + IconButton(onClick = { + searchFieldState.setTextAndPlaceCursorAtEnd("") + onSearchCleared() + }) { + Icon( + imageVector = Icons.Filled.Clear, + contentDescription = stringResource(R.string.clear_search), + ) + } + } + }, + onSearch = onSearch, + expanded = false, + onExpandedChange = {}, + modifier = Modifier.focusRequester(focusRequester) + ) + }, + ) + LaunchedEffect(Unit) { + focusRequester.requestFocus() + snapshotFlow { searchFieldState.text } + .debounce(500) + .collectLatest { + if (it.length >= SEARCH_THRESHOLD || it.isEmpty()) { + onSearch(searchFieldState.text.toString()) + } + } + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/navigation/BottomBar.kt b/app/src/main/kotlin/org/fdroid/ui/navigation/BottomBar.kt new file mode 100644 index 000000000..0a6b6aa15 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/navigation/BottomBar.kt @@ -0,0 +1,170 @@ +package org.fdroid.ui.navigation + +import androidx.compose.foundation.layout.Row +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Error +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem +import androidx.compose.material3.NavigationRailItemDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.tooling.preview.Preview +import androidx.navigation3.runtime.NavKey +import org.fdroid.R +import org.fdroid.ui.FDroidContent + +@Composable +fun BottomBar( + numUpdates: Int, + hasIssues: Boolean, + currentNavKey: NavKey, + onNav: (MainNavKey) -> Unit, +) { + val res = LocalResources.current + NavigationBar { + topLevelRoutes.forEach { dest -> + NavigationBarItem( + icon = { NavIcon(dest, numUpdates, hasIssues) }, + label = { Text(stringResource(dest.label)) }, + selected = dest == currentNavKey, + colors = NavigationBarItemDefaults.colors( + indicatorColor = MaterialTheme.colorScheme.primary, + selectedTextColor = MaterialTheme.colorScheme.primary, + selectedIconColor = contentColorFor(MaterialTheme.colorScheme.primary), + ), + onClick = { + if (dest != currentNavKey) onNav(dest) + }, + modifier = Modifier.semantics { + if (dest == NavigationKey.MyApps) { + if (numUpdates > 0) { + stateDescription = + res.getString(R.string.notification_channel_updates_available_title) + } else if (hasIssues) { + stateDescription = + res.getString(R.string.my_apps_header_apps_with_issue) + } + } + } + ) + } + } +} + +@Composable +fun NavigationRail( + numUpdates: Int, + hasIssues: Boolean, + currentNavKey: NavKey, + onNav: (MainNavKey) -> Unit, + modifier: Modifier = Modifier, +) { + val res = LocalResources.current + NavigationRail(modifier) { + topLevelRoutes.forEach { dest -> + NavigationRailItem( + icon = { NavIcon(dest, numUpdates, hasIssues) }, + label = { Text(stringResource(dest.label)) }, + selected = dest == currentNavKey, + colors = NavigationRailItemDefaults.colors( + indicatorColor = MaterialTheme.colorScheme.primary, + selectedTextColor = MaterialTheme.colorScheme.primary, + selectedIconColor = contentColorFor(MaterialTheme.colorScheme.primary), + ), + onClick = { + if (dest != currentNavKey) onNav(dest) + }, + modifier = Modifier.semantics { + if (dest == NavigationKey.MyApps) { + if (numUpdates > 0) { + stateDescription = + res.getString(R.string.notification_channel_updates_available_title) + } else if (hasIssues) { + stateDescription = + res.getString(R.string.my_apps_header_apps_with_issue) + } + } + } + ) + } + } +} + +@Composable +private fun NavIcon(dest: MainNavKey, numUpdates: Int, hasIssues: Boolean) { + BadgedBox( + badge = { + if (dest == NavigationKey.MyApps && numUpdates > 0) { + Badge(containerColor = MaterialTheme.colorScheme.secondary) { + Text(text = numUpdates.toString()) + } + } else if (dest == NavigationKey.MyApps && hasIssues) { + Icon( + imageVector = Icons.Default.Error, + tint = MaterialTheme.colorScheme.error, + contentDescription = stringResource(R.string.my_apps_header_apps_with_issue) + ) + } + } + ) { + Icon( + dest.icon, + contentDescription = stringResource(dest.label) + ) + } +} + +@Preview +@Composable +private fun Preview() { + FDroidContent { + Row { + NavigationRail( + numUpdates = 3, + hasIssues = false, + currentNavKey = NavigationKey.Discover, + onNav = {}, + ) + BottomBar( + numUpdates = 3, + hasIssues = false, + currentNavKey = NavigationKey.Discover, + onNav = {}, + ) + } + } +} + +@Preview +@Composable +private fun PreviewIssues() { + FDroidContent { + Row { + NavigationRail( + numUpdates = 0, + hasIssues = true, + currentNavKey = NavigationKey.MyApps, + onNav = {}, + ) + BottomBar( + numUpdates = 0, + hasIssues = true, + currentNavKey = NavigationKey.MyApps, + onNav = {}, + ) + } + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/navigation/IntentRouter.kt b/app/src/main/kotlin/org/fdroid/ui/navigation/IntentRouter.kt new file mode 100644 index 000000000..1bf92697b --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/navigation/IntentRouter.kt @@ -0,0 +1,59 @@ +package org.fdroid.ui.navigation + +import android.content.Intent +import android.content.Intent.ACTION_MAIN +import android.content.Intent.ACTION_SHOW_APP_INFO +import android.content.Intent.EXTRA_PACKAGE_NAME +import android.os.Build.VERSION.SDK_INT +import androidx.core.util.Consumer +import mu.KotlinLogging + +class IntentRouter(private val navigator: Navigator) : Consumer { + private val log = KotlinLogging.logger { } + private val packageNameRegex = "[A-Za-z\\d_.]+".toRegex() + + companion object { + const val ACTION_MY_APPS = "org.fdroid.action.MY_APPS" + } + + override fun accept(value: Intent) { + val intent = value + log.info { "Incoming intent: $intent" } + val uri = intent.data + if (ACTION_MAIN == intent.action) { + // launcher intent, do nothing + } else if (ACTION_SHOW_APP_INFO == intent.action) { // App Details + val packageName = intent.getStringExtra(EXTRA_PACKAGE_NAME) ?: return + if (packageName.matches(packageNameRegex)) { + navigator.navigate(NavigationKey.AppDetails(packageName)) + } else { + log.warn { "Malformed package name: $packageName" } + } + } else if (uri != null) { + val packagesUrlRegex = "^/([a-z][a-z][a-zA-Z_-]*/)?packages/[A-Za-z\\d_.]+/?$".toRegex() + if (uri.scheme == "market" && uri.host == "details") { + val packageName = uri.getQueryParameter("id") ?: return + if (packageName.matches(packageNameRegex)) { + navigator.navigate(NavigationKey.AppDetails(packageName)) + } else { + log.warn { "Malformed package name: $packageName" } + } + } else if (uri.path?.matches(packagesUrlRegex) == true) { + val packageName = uri.lastPathSegment ?: return + navigator.navigate(NavigationKey.AppDetails(packageName)) + } else if (uri.scheme == "fdroidrepos" || + uri.scheme == "FDROIDREPOS" || + (uri.scheme == "https" && uri.host == "fdroid.link") + ) { + navigator.navigate(NavigationKey.AddRepo(uri.toString())) + } + } else if (ACTION_MY_APPS == intent.action) { + val lastOnBackStack = navigator.last + if (lastOnBackStack !is NavigationKey.MyApps) { + navigator.navigate(NavigationKey.MyApps) + } + } else { + log.warn { "Unknown intent: $intent - uri: $uri $SDK_INT" } + } + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/navigation/NavigationKey.kt b/app/src/main/kotlin/org/fdroid/ui/navigation/NavigationKey.kt new file mode 100644 index 000000000..391e03808 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/navigation/NavigationKey.kt @@ -0,0 +1,93 @@ +package org.fdroid.ui.navigation + +import android.content.Context +import androidx.annotation.StringRes +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Apps +import androidx.compose.material.icons.filled.Explore +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Settings +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable +import org.fdroid.R +import org.fdroid.ui.icons.PackageVariant +import org.fdroid.ui.lists.AppListType + +sealed interface NavigationKey : NavKey { + + @Serializable + data object Discover : NavigationKey, MainNavKey { + override val label: Int = R.string.menu_discover + override val icon: ImageVector = Icons.Filled.Explore + } + + @Serializable + data object MyApps : NavigationKey, MainNavKey { + override val label: Int = R.string.menu_apps_my + override val icon: ImageVector = Icons.Filled.Apps + } + + @Serializable + data class AppDetails(val packageName: String) : NavigationKey + + @Serializable + data class AppList(val type: AppListType) : NavigationKey + + @Serializable + data object Repos : NavigationKey + + @Serializable + data class RepoDetails(val repoId: Long) : NavigationKey + + @Serializable + data class AddRepo(val uri: String? = null) : NavigationKey + + @Serializable + data object Settings : NavigationKey + + @Serializable + data object About : NavigationKey + +} + +sealed interface MainNavKey : NavKey { + @get:StringRes + val label: Int + val icon: ImageVector +} + +val topLevelRoutes = listOf( + NavigationKey.Discover, + NavigationKey.MyApps, +) + +sealed class NavDestinations( + val id: NavigationKey, + @param:StringRes val label: Int, + val icon: ImageVector, +) { + object Repos : + NavDestinations(NavigationKey.Repos, R.string.app_details_repositories, PackageVariant) + + object Settings : + NavDestinations(NavigationKey.Settings, R.string.menu_settings, Icons.Filled.Settings) + + class AllApps(title: String) : NavDestinations( + id = NavigationKey.AppList(AppListType.All(title)), + label = R.string.app_list_all, + icon = Icons.Filled.Apps, + ) + + object About : NavDestinations(NavigationKey.About, R.string.menu_about, Icons.Filled.Info) +} + +val topBarMenuItems = listOf( + NavDestinations.Repos, + NavDestinations.Settings, +) + +fun getMoreMenuItems(context: Context) = listOf( + NavDestinations.AllApps(context.getString(R.string.app_list_all)), + NavDestinations.About, +) diff --git a/app/src/main/kotlin/org/fdroid/ui/navigation/NavigationState.kt b/app/src/main/kotlin/org/fdroid/ui/navigation/NavigationState.kt new file mode 100644 index 000000000..72aaae03f --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/navigation/NavigationState.kt @@ -0,0 +1,110 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * From: https://github.com/android/nav3-recipes/blob/549398ffeefbdf8c0b09b71a098cd14b1520695b/app/src/main/java/com/example/nav3recipes/multiplestacks/NavigationState.kt + */ + +package org.fdroid.ui.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSerializable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.toMutableStateList +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.rememberDecoratedNavEntries +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.runtime.serialization.NavKeySerializer +import androidx.savedstate.compose.serialization.serializers.MutableStateSerializer + +/** + * Create a navigation state that persists config changes and process death. + */ +@Composable +fun rememberNavigationState( + startRoute: NavKey, + topLevelRoutes: List +): NavigationState { + val topLevelRoute = rememberSerializable( + startRoute, topLevelRoutes, + serializer = MutableStateSerializer(NavKeySerializer()) + ) { + mutableStateOf(startRoute) + } + + val backStacks = topLevelRoutes.associateWith { key -> rememberNavBackStack(key) } + + return remember(startRoute, topLevelRoutes) { + NavigationState( + startRoute = startRoute, + topLevelRoute = topLevelRoute, + backStacks = backStacks + ) + } +} + +/** + * State holder for navigation state. + * + * @param startRoute - the start route. The user will exit the app through this route. + * @param topLevelRoute - the current top level route + * @param backStacks - the back stacks for each top level route + */ +class NavigationState( + val startRoute: NavKey, + topLevelRoute: MutableState, + val backStacks: Map> +) { + var topLevelRoute: NavKey by topLevelRoute + val stacksInUse: List + get() = if (topLevelRoute == startRoute) { + listOf(startRoute) + } else { + listOf(startRoute, topLevelRoute) + } + +} + +/** + * Convert NavigationState into NavEntries. + */ +@Composable +fun NavigationState.toEntries( + entryProvider: (NavKey) -> NavEntry +): SnapshotStateList> { + val decoratedEntries = backStacks.mapValues { (_, stack) -> + val decorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ) + rememberDecoratedNavEntries( + backStack = stack, + entryDecorators = decorators, + entryProvider = entryProvider + ) + } + + return stacksInUse + .flatMap { decoratedEntries[it] ?: emptyList() } + .toMutableStateList() +} diff --git a/app/src/main/kotlin/org/fdroid/ui/navigation/Navigator.kt b/app/src/main/kotlin/org/fdroid/ui/navigation/Navigator.kt new file mode 100644 index 000000000..4b97e6bd4 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/navigation/Navigator.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * From: https://github.com/android/nav3-recipes/blob/549398ffeefbdf8c0b09b71a098cd14b1520695b/app/src/main/java/com/example/nav3recipes/multiplestacks/Navigator.kt + */ + +package org.fdroid.ui.navigation + +import androidx.navigation3.runtime.NavKey + +/** + * Handles navigation events (forward and back) by updating the navigation state. + */ +class Navigator(val state: NavigationState) { + val last: NavKey? + get() { + val currentStack = state.backStacks[state.topLevelRoute] + ?: error("Stack for ${state.topLevelRoute} not found") + return currentStack.lastOrNull() + } + + fun navigate(route: NavKey) { + if (route in state.backStacks.keys) { + // This is a top level route, just switch to it + state.topLevelRoute = route + } else { + state.backStacks[state.topLevelRoute]?.add(route) + } + } + + fun replaceLast(route: NavKey) { + val stack = state.backStacks[state.topLevelRoute] ?: return + stack[stack.lastIndex] = route + } + + fun goBack() { + val currentStack = state.backStacks[state.topLevelRoute] + ?: error("Stack for ${state.topLevelRoute} not found") + val currentRoute = currentStack.last() + + // If we're at the base of the current route, go back to the start route stack. + if (currentRoute == state.topLevelRoute) { + state.topLevelRoute = state.startRoute + } else { + currentStack.removeLastOrNull() + } + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/NoRepoSelected.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/NoRepoSelected.kt new file mode 100644 index 000000000..d5727a899 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/NoRepoSelected.kt @@ -0,0 +1,61 @@ +package org.fdroid.ui.repositories + +import androidx.compose.foundation.background +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.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +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 +import androidx.compose.ui.Alignment.Companion.Center +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.icons.PackageVariant + +@Composable +fun NoRepoSelected() { + Box( + contentAlignment = Center, + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .background(MaterialTheme.colorScheme.background) + .padding(16.dp) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = spacedBy(32.dp) + ) { + Icon( + imageVector = PackageVariant, + contentDescription = null, + tint = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier.size(64.dp) + ) + Text( + text = stringResource(R.string.repo_list_info_text), + modifier = Modifier.fillMaxWidth(fraction = 0.7f) + ) + } + } +} + +@Preview(widthDp = 200, heightDp = 400) +@Composable +private fun Preview() { + FDroidContent { + NoRepoSelected() + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/RepoEntry.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/RepoEntry.kt new file mode 100644 index 000000000..fac239863 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/RepoEntry.kt @@ -0,0 +1,118 @@ +package org.fdroid.ui.repositories + +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy +import androidx.compose.runtime.LaunchedEffect +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.lists.AppListType +import org.fdroid.ui.navigation.NavigationKey +import org.fdroid.ui.navigation.Navigator +import org.fdroid.ui.repositories.add.AddRepo +import org.fdroid.ui.repositories.add.AddRepoViewModel +import org.fdroid.ui.repositories.details.RepoDetails +import org.fdroid.ui.repositories.details.RepoDetailsInfo +import org.fdroid.ui.repositories.details.RepoDetailsViewModel + +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +fun EntryProviderScope.repoEntry( + navigator: Navigator, + isBigScreen: Boolean, +) { + entry( + metadata = ListDetailSceneStrategy.listPane("repos") { + NoRepoSelected() + }, + ) { + val viewModel = hiltViewModel() + val info = object : RepositoryInfo { + override val model: RepositoryModel = + viewModel.model.collectAsStateWithLifecycle().value + + override val currentRepositoryId: Long? = if (isBigScreen) { + (navigator.last as? NavigationKey.RepoDetails)?.repoId + } else null + + override fun onOnboardingSeen() = viewModel.onOnboardingSeen() + + override fun onRepositorySelected(repositoryItem: RepositoryItem) { + val last = navigator.last + val new = NavigationKey.RepoDetails(repositoryItem.repoId) + if (last is NavigationKey.RepoDetails) { + navigator.replaceLast(new) + } else { + navigator.navigate(new) + } + } + + override fun onRepositoryEnabled(repoId: Long, enabled: Boolean) = + viewModel.onRepositoryEnabled(repoId, enabled) + + override fun onAddRepo() { + navigator.navigate(NavigationKey.AddRepo()) + } + + override fun onRepositoryMoved(fromRepoId: Long, toRepoId: Long) = + viewModel.onRepositoriesMoved(fromRepoId, toRepoId) + + override fun onRepositoriesFinishedMoving( + fromRepoId: Long, + toRepoId: Long, + ) = viewModel.onRepositoriesFinishedMoving(fromRepoId, toRepoId) + } + Repositories(info, isBigScreen) { + navigator.goBack() + } + } + entry( + metadata = ListDetailSceneStrategy.detailPane("repos") + ) { navKey -> + val viewModel = hiltViewModel( + creationCallback = { factory -> + factory.create(navKey.repoId) + } + ) + RepoDetails( + info = object : RepoDetailsInfo { + override val model = viewModel.model.collectAsStateWithLifecycle().value + override val actions = viewModel + }, + onShowAppsClicked = { title, repoId -> + val type = AppListType.Repository(title, repoId) + navigator.navigate(NavigationKey.AppList(type)) + }, + onBackNav = if (isBigScreen) null else { + { navigator.goBack() } + }, + ) + } + entry { navKey -> + val viewModel = hiltViewModel() + // this is for intents we receive via IntentRouter, usually the user provides URI later + LaunchedEffect(navKey) { + if (navKey.uri != null) { + viewModel.onFetchRepo(navKey.uri) + } + } + AddRepo( + state = viewModel.state.collectAsStateWithLifecycle().value, + networkStateFlow = viewModel.networkState, + proxyConfig = viewModel.proxyConfig, + onFetchRepo = viewModel::onFetchRepo, + onAddRepo = viewModel::addFetchedRepository, + onExistingRepo = { repoId -> + navigator.goBack() + navigator.navigate(NavigationKey.RepoDetails(repoId)) + }, + onRepoAdded = { title, repoId -> + navigator.goBack() + navigator.navigate(NavigationKey.RepoDetails(repoId)) + val type = AppListType.Repository(title, repoId) + navigator.navigate(NavigationKey.AppList(type)) + }, + onBackClicked = { navigator.goBack() }, + ) + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/RepoIcon.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/RepoIcon.kt new file mode 100644 index 000000000..e9d893433 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/RepoIcon.kt @@ -0,0 +1,21 @@ +package org.fdroid.ui.repositories + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.core.os.LocaleListCompat +import io.ktor.client.engine.ProxyConfig +import org.fdroid.R +import org.fdroid.database.Repository +import org.fdroid.download.getImageModel +import org.fdroid.ui.utils.AsyncShimmerImage + +@Composable +fun RepoIcon(repo: Repository, proxy: ProxyConfig?, modifier: Modifier = Modifier) { + AsyncShimmerImage( + model = repo.getIcon(LocaleListCompat.getDefault())?.getImageModel(repo, proxy), + contentDescription = null, + error = painterResource(R.drawable.ic_repo_app_default), + modifier = modifier, + ) +} diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/Repositories.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/Repositories.kt new file mode 100644 index 000000000..67930de59 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/Repositories.kt @@ -0,0 +1,181 @@ +package org.fdroid.ui.repositories + +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import android.content.res.Configuration.UI_MODE_TYPE_NORMAL +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +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.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FloatingActionButton +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.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.viktormykhailiv.compose.hints.HintHost +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.download.NetworkState +import org.fdroid.ui.FDroidContent +import org.fdroid.ui.utils.BigLoadingIndicator +import org.fdroid.ui.utils.OnboardingCard +import org.fdroid.ui.utils.getHintOverlayColor +import org.fdroid.ui.utils.getRepositoriesInfo +import org.fdroid.ui.utils.repoItems + +@Composable +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +fun Repositories( + info: RepositoryInfo, + isBigScreen: Boolean, + onBackClicked: () -> Unit, +) { + val hintController = rememberHintController( + overlay = getHintOverlayColor(), + ) + val hint = rememberHint { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { + OnboardingCard( + title = stringResource(R.string.repo_list_info_title), + message = stringResource(R.string.repo_list_info_text), + modifier = Modifier + .padding(horizontal = 32.dp, vertical = 8.dp), + onGotIt = { + info.onOnboardingSeen() + hintController.dismiss() + }, + ) + } + } + val hintAnchor = rememberHintAnchorState(hint) + LaunchedEffect(info.model.showOnboarding) { + if (!isBigScreen && info.model.showOnboarding) { + hintController.show(hintAnchor) + info.onOnboardingSeen() + } + } + val scrollBehavior = enterAlwaysScrollBehavior(rememberTopAppBarState()) + val listState = rememberLazyListState() + val showFab by remember { + derivedStateOf { + val firstVisibleItemIndex = listState.firstVisibleItemIndex + val firstVisibleItemScrollOffset = listState.firstVisibleItemScrollOffset + if (firstVisibleItemIndex == 0 && firstVisibleItemScrollOffset == 0) { + true + } else if (listState.isScrollInProgress) { + false + } else { + listState.canScrollForward + } + } + } + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = onBackClicked) { + Icon( + imageVector = Icons.AutoMirrored.Default.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } + }, + title = { + Text(stringResource(R.string.app_details_repositories)) + }, + subtitle = { + val lastUpdated = info.model.lastCheckForUpdate + Text(stringResource(R.string.repo_last_update_check, lastUpdated)) + }, + scrollBehavior = scrollBehavior, + ) + }, + floatingActionButton = { + AnimatedVisibility(showFab) { + FloatingActionButton( + onClick = info::onAddRepo, + modifier = Modifier + .padding(16.dp) + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.menu_add_repo), + ) + } + } + }, + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + ) { paddingValues -> + if (info.model.repositories == null) BigLoadingIndicator() + else RepositoriesList( + info = info, + listState = listState, + // we split up top and bottom padding to not cause bugs with list drag and drop + paddingValues = PaddingValues(bottom = paddingValues.calculateBottomPadding()), + modifier = Modifier + .padding(top = paddingValues.calculateTopPadding()), + ) + } +} + +@Preview( + showBackground = true, + uiMode = UI_MODE_NIGHT_YES or UI_MODE_TYPE_NORMAL, +) +@Composable +fun RepositoriesScaffoldLoadingPreview() { + HintHost { + FDroidContent { + val model = RepositoryModel( + repositories = null, + showOnboarding = false, + lastCheckForUpdate = "never", + networkState = NetworkState(isOnline = true, isMetered = false), + ) + val info = getRepositoriesInfo(model) + Repositories(info, true) {} + } + } +} + +@Preview( + showBackground = true, + uiMode = UI_MODE_NIGHT_YES or UI_MODE_TYPE_NORMAL, +) +@Composable +private fun RepositoriesScaffoldPreview() { + HintHost { + FDroidContent { + val model = RepositoryModel( + repositories = repoItems, + showOnboarding = false, + lastCheckForUpdate = "42min. ago", + networkState = NetworkState(isOnline = true, isMetered = false), + ) + val info = getRepositoriesInfo(model, repoItems[0].repoId) + Repositories(info, true) { } + } + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/RepositoriesList.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/RepositoriesList.kt new file mode 100644 index 000000000..73f29f0e2 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/RepositoriesList.kt @@ -0,0 +1,151 @@ +package org.fdroid.ui.repositories + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.plus +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.RoundedCornerShape +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.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.draw.dropShadow +import androidx.compose.ui.graphics.shadow.Shadow +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import org.fdroid.R +import org.fdroid.ui.utils.DraggableItem +import org.fdroid.ui.utils.MeteredConnectionDialog +import org.fdroid.ui.utils.dragContainer +import org.fdroid.ui.utils.rememberDragDropState + +@Composable +fun RepositoriesList( + info: RepositoryInfo, + listState: LazyListState, + paddingValues: PaddingValues, + modifier: Modifier = Modifier, +) { + val repositories = info.model.repositories ?: return + var showDisableRepoDialog by remember { mutableStateOf(null) } + var showMeteredDialog by remember { mutableStateOf<(() -> Unit)?>(null) } + val currentRepositoryId = info.currentRepositoryId + val dragDropState = rememberDragDropState( + lazyListState = listState, + onMove = { from, to -> + from as? Long ?: error("from $from was not a repoId") + to as? Long ?: error("to $to was not a repoId") + info.onRepositoryMoved(from, to) + }, + onEnd = { from, to -> + from as? Long ?: error("from $from was not a repoId") + to as? Long ?: error("to $to was not a repoId") + info.onRepositoriesFinishedMoving(from, to) + }, + ) + LazyColumn( + state = listState, + contentPadding = paddingValues + PaddingValues(top = 8.dp), + verticalArrangement = spacedBy(8.dp), + modifier = modifier + .then( + if (repositories.size > 1) Modifier.dragContainer(dragDropState) + else Modifier + ) + .then( + if (currentRepositoryId == null) Modifier + else Modifier.selectableGroup() + ), + ) { + itemsIndexed( + items = repositories, + key = { _, item -> item.repoId }, + ) { index, repoItem -> + val isSelected = currentRepositoryId == repoItem.repoId + val interactionModifier = if (currentRepositoryId == null) { + Modifier.clickable( + onClick = { info.onRepositorySelected(repoItem) } + ) + } else { + Modifier.selectable( + selected = isSelected, + onClick = { info.onRepositorySelected(repoItem) } + ) + } + DraggableItem(dragDropState, index) { isDragging -> + val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp) + RepositoryRow( + repoItem = repoItem, + isSelected = isSelected, + onRepoEnabled = { enabled -> + if (enabled) { + if (info.model.networkState.isMetered) showMeteredDialog = { + info.onRepositoryEnabled(repoItem.repoId, true) + } else info.onRepositoryEnabled(repoItem.repoId, true) + } else { + showDisableRepoDialog = repoItem.repoId + } + }, + modifier = Modifier + .fillMaxWidth() + .then(interactionModifier) + .let { + if (isDragging) it.dropShadow( + shape = RoundedCornerShape(4.dp), + shadow = Shadow( + radius = 8.dp, + offset = DpOffset(x = elevation, elevation) + ) + ) else it + } + ) + } + } + } + val repoId = showDisableRepoDialog + if (repoId != null) { + AlertDialog( + text = { + Text(text = stringResource(R.string.repo_disable_warning)) + }, + onDismissRequest = { showDisableRepoDialog = null }, + confirmButton = { + TextButton(onClick = { + info.onRepositoryEnabled(repoId, false) + showDisableRepoDialog = null + }) { + Text( + text = stringResource(R.string.repo_disable_warning_button), + color = MaterialTheme.colorScheme.error, + ) + } + }, + dismissButton = { + TextButton(onClick = { showDisableRepoDialog = null }) { + Text(stringResource(android.R.string.cancel)) + } + } + ) + } + // Metered warning dialog + val meteredLambda = showMeteredDialog + if (meteredLambda != null) MeteredConnectionDialog( + numBytes = null, + onConfirm = { meteredLambda() }, + onDismiss = { showMeteredDialog = null }, + ) +} diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/RepositoriesPresenter.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/RepositoriesPresenter.kt new file mode 100644 index 000000000..f71c4c91c --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/RepositoriesPresenter.kt @@ -0,0 +1,35 @@ +package org.fdroid.ui.repositories + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import kotlinx.coroutines.flow.StateFlow +import org.fdroid.R +import org.fdroid.download.NetworkState +import org.fdroid.ui.utils.asRelativeTimeString + +@Composable +fun RepositoriesPresenter( + context: Context, + repositoriesFlow: StateFlow?>, + repoSortingMapFlow: StateFlow>, + showOnboardingFlow: StateFlow, + lastUpdateFlow: StateFlow, + networkStateFlow: StateFlow, +): RepositoryModel { + val repositories = repositoriesFlow.collectAsState().value + val repoSortingMap = repoSortingMapFlow.collectAsState().value + val lastUpdated = lastUpdateFlow.collectAsState().value + return RepositoryModel( + repositories = repositories?.sortedBy { repo -> + repoSortingMap[repo.repoId] ?: repoSortingMap.size + }, + showOnboarding = showOnboardingFlow.collectAsState().value, + lastCheckForUpdate = if (lastUpdated <= 0) { + context.getString(R.string.repositories_last_update_never) + } else { + lastUpdated.asRelativeTimeString() + }, + networkState = networkStateFlow.collectAsState().value, + ) +} diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/RepositoriesViewModel.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/RepositoriesViewModel.kt new file mode 100644 index 000000000..6e1012dbe --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/RepositoriesViewModel.kt @@ -0,0 +1,120 @@ +package org.fdroid.ui.repositories + +import android.app.Application +import androidx.core.os.LocaleListCompat +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 dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +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.database.Repository +import org.fdroid.download.NetworkMonitor +import org.fdroid.index.RepoManager +import org.fdroid.repo.RepoUpdateWorker +import org.fdroid.settings.OnboardingManager +import org.fdroid.settings.SettingsManager +import org.fdroid.updates.UpdatesManager +import org.fdroid.utils.IoDispatcher +import javax.inject.Inject + +@HiltViewModel +class RepositoriesViewModel @Inject constructor( + app: Application, + networkMonitor: NetworkMonitor, + private val repoManager: RepoManager, + private val updateManager: UpdatesManager, + private val settingsManager: SettingsManager, + private val onboardingManager: OnboardingManager, + @param:IoDispatcher private val ioScope: CoroutineScope, +) : AndroidViewModel(app) { + + private val log = KotlinLogging.logger { } + private val localeList = LocaleListCompat.getDefault() + private val moleculeScope = + CoroutineScope(viewModelScope.coroutineContext + AndroidUiDispatcher.Main) + private val repos = MutableStateFlow?>(null) + private val repoSortingMap = MutableStateFlow>(emptyMap()) + private val showOnboarding = onboardingManager.showRepositoriesOnboarding + + init { + viewModelScope.launch { + repoManager.repositoriesState.collect { + onRepositoriesChanged(it) + } + } + } + + // define below init, because this only defines repoSortingMap + val model: StateFlow by lazy(LazyThreadSafetyMode.NONE) { + moleculeScope.launchMolecule(mode = ContextClock) { + RepositoriesPresenter( + context = application, + repositoriesFlow = repos, + repoSortingMapFlow = repoSortingMap, + showOnboardingFlow = showOnboarding, + lastUpdateFlow = settingsManager.lastRepoUpdateFlow, + networkStateFlow = networkMonitor.networkState, + ) + } + } + + private fun onRepositoriesChanged(repositories: List) { + log.info("onRepositoriesChanged(${repositories.size})") + repos.update { + repositories.mapNotNull { + if (it.isArchiveRepo) null + else RepositoryItem(it, localeList, settingsManager.proxyConfig) + } + } + repoSortingMap.update { + // just add repos to sortingMap, because they are already pre-sorted by weight + mutableMapOf().apply { + repositories.forEachIndexed { index, repository -> + this[repository.repoId] = index + } + } + } + } + + fun onRepositoryEnabled(repoId: Long, enabled: Boolean) { + ioScope.launch { + repoManager.setRepositoryEnabled(repoId, enabled) + updateManager.loadUpdates() + if (enabled) withContext(Dispatchers.Main) { + RepoUpdateWorker.updateNow(application, repoId) + } + } + } + + fun onRepositoriesMoved(fromRepoId: Long, toRepoId: Long) { + log.info { "onRepositoriesMoved($fromRepoId, $toRepoId)" } + repoSortingMap.update { + repoSortingMap.value.toMutableMap().apply { + val toIndex = get(toRepoId) ?: error("No position for toRepoId $toRepoId") + replace(toRepoId, replace(fromRepoId, toIndex)!!) + } + } + } + + fun onRepositoriesFinishedMoving(fromRepoId: Long, toRepoId: Long) { + log.info { "onRepositoriesFinishedMoving($fromRepoId, $toRepoId)" } + val fromRepo = repoManager.getRepository(fromRepoId) + ?: error("No repo for repoId $fromRepoId") + val toRepo = repoManager.getRepository(toRepoId) + ?: error("No repo for repoId $toRepoId") + log.info { " ${fromRepo.address} => ${toRepo.address}" } + repoManager.reorderRepositories(fromRepo, toRepo) + } + + fun onOnboardingSeen() = onboardingManager.onRepositoriesOnboardingSeen() +} diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/RepositoryInfo.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/RepositoryInfo.kt new file mode 100644 index 000000000..1b827d903 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/RepositoryInfo.kt @@ -0,0 +1,21 @@ +package org.fdroid.ui.repositories + +import org.fdroid.download.NetworkState + +interface RepositoryInfo { + val model: RepositoryModel + val currentRepositoryId: Long? + fun onOnboardingSeen() + fun onRepositorySelected(repositoryItem: RepositoryItem) + fun onRepositoryEnabled(repoId: Long, enabled: Boolean) + fun onAddRepo() + fun onRepositoryMoved(fromRepoId: Long, toRepoId: Long) + fun onRepositoriesFinishedMoving(fromRepoId: Long, toRepoId: Long) +} + +data class RepositoryModel( + val repositories: List?, + val showOnboarding: Boolean, + val lastCheckForUpdate: String, + val networkState: NetworkState, +) diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/RepositoryItem.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/RepositoryItem.kt new file mode 100644 index 000000000..8ef97db07 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/RepositoryItem.kt @@ -0,0 +1,32 @@ +package org.fdroid.ui.repositories + +import androidx.core.os.LocaleListCompat +import io.ktor.client.engine.ProxyConfig +import org.fdroid.database.Repository +import org.fdroid.download.getImageModel + +data class RepositoryItem( + val repoId: Long, + val address: String, + val name: String, + val icon: Any? = null, + val timestamp: Long, + val lastUpdated: Long?, + val weight: Int, + val enabled: Boolean, + private val errorCount: Int, +) { + constructor(repo: Repository, localeList: LocaleListCompat, proxy: ProxyConfig?) : this( + repoId = repo.repoId, + address = repo.address, + name = repo.getName(localeList) ?: "Unknown Repo", + icon = repo.getIcon(localeList)?.getImageModel(repo, proxy), + timestamp = repo.timestamp, + lastUpdated = repo.lastUpdated, + weight = repo.weight, + enabled = repo.enabled, + errorCount = repo.errorCount, + ) + + val hasIssue: Boolean = enabled && errorCount >= 3 +} diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/RepositoryRow.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/RepositoryRow.kt new file mode 100644 index 000000000..49d5f4c77 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/RepositoryRow.kt @@ -0,0 +1,85 @@ +package org.fdroid.ui.repositories + +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.Switch +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.AsyncShimmerImage +import org.fdroid.ui.utils.BadgeIcon +import org.fdroid.ui.utils.asRelativeTimeString +import org.fdroid.ui.utils.repoItems + +@Composable +fun RepositoryRow( + repoItem: RepositoryItem, + isSelected: Boolean, + onRepoEnabled: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + ListItem( + leadingContent = { + BadgedBox( + badge = { + if (repoItem.hasIssue) BadgeIcon( + icon = Icons.Filled.Error, + color = MaterialTheme.colorScheme.error, + contentDescription = stringResource(R.string.repo_has_update_error), + ) + }, + ) { + AsyncShimmerImage( + model = repoItem.icon, + contentDescription = null, + modifier = Modifier.size(48.dp) + ) + } + }, + headlineContent = { + Text(repoItem.name) + }, + supportingContent = { + val lastUpdated = if (repoItem.timestamp <= 0) { + stringResource(R.string.repositories_last_update_never) + } else { + repoItem.timestamp.asRelativeTimeString() + } + Text(stringResource(R.string.repo_last_update_upstream, lastUpdated)) + }, + trailingContent = { + Switch(repoItem.enabled, onCheckedChange = onRepoEnabled) + }, + colors = ListItemDefaults.colors( + containerColor = if (isSelected) { + MaterialTheme.colorScheme.surfaceVariant + } else { + MaterialTheme.colorScheme.background + } + ), + modifier = modifier, + ) +} + +@Preview +@Composable +private fun Preview() { + FDroidContent { + Column { + RepositoryRow(repoItems[0], false, {}) + RepositoryRow(repoItems[1], true, {}) + RepositoryRow(repoItems[2].copy(timestamp = 0), false, {}) + } + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/add/AddRepo.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/add/AddRepo.kt new file mode 100644 index 000000000..91fa69480 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/add/AddRepo.kt @@ -0,0 +1,111 @@ +package org.fdroid.ui.repositories.add + +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +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.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.core.os.LocaleListCompat +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.ktor.client.engine.ProxyConfig +import kotlinx.coroutines.flow.StateFlow +import org.fdroid.R +import org.fdroid.download.NetworkState +import org.fdroid.index.IndexUpdateResult +import org.fdroid.repo.AddRepoError +import org.fdroid.repo.AddRepoState +import org.fdroid.repo.Added +import org.fdroid.repo.Adding +import org.fdroid.repo.FetchResult +import org.fdroid.repo.Fetching +import org.fdroid.repo.None +import org.fdroid.repo.RepoUpdateWorker + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddRepo( + state: AddRepoState, + networkStateFlow: StateFlow, + proxyConfig: ProxyConfig?, + onFetchRepo: (String) -> Unit, + onAddRepo: () -> Unit, + onExistingRepo: (Long) -> Unit, + onRepoAdded: (String, Long) -> Unit, + onBackClicked: () -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = onBackClicked) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back)) + } + }, + title = { + Text( + text = if (state is Fetching) { + when (state.fetchResult) { + is FetchResult.IsNewMirror, + is FetchResult.IsExistingMirror -> { + stringResource(R.string.repo_add_mirror) + } + else -> stringResource(R.string.repo_add_new_title) + } + } else { + stringResource(R.string.repo_add_new_title) + }, + ) + }, + ) + }, + ) { paddingValues -> + when (state) { + None -> { + val networkState = networkStateFlow.collectAsStateWithLifecycle().value + AddRepoIntroContent(networkState, onFetchRepo, Modifier.padding(paddingValues)) + } + is Fetching -> { + if (state.receivedRepo == null) { + AddRepoProgressScreen( + text = stringResource(R.string.repo_state_fetching), + modifier = Modifier.padding(paddingValues) + ) + } else { + AddRepoPreviewScreen( + state = state, + proxyConfig = proxyConfig, + onAddRepo = onAddRepo, + onExistingRepo = onExistingRepo, + modifier = Modifier.padding(paddingValues), + ) + } + } + Adding -> AddRepoProgressScreen( + text = stringResource(R.string.repo_state_adding), + modifier = Modifier.padding(paddingValues) + ) + is Added -> { + val context = LocalContext.current + LaunchedEffect(state) { + if (state.updateResult is IndexUpdateResult.Error) { + // try updating newly added repo again + RepoUpdateWorker.updateNow(context, state.repo.repoId) + } + // tell UI that repo got added, so it can show info on it + val name = state.repo.getName(LocaleListCompat.getDefault()) ?: "Unknown Repo" + onRepoAdded(name, state.repo.repoId) + } + } + is AddRepoError -> AddRepoErrorScreen(state, Modifier.padding(paddingValues)) + } + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/add/AddRepoErrorScreen.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/add/AddRepoErrorScreen.kt new file mode 100644 index 000000000..628dac0e3 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/add/AddRepoErrorScreen.kt @@ -0,0 +1,128 @@ +package org.fdroid.ui.repositories.add + +import android.content.Context +import android.os.Build.VERSION.SDK_INT +import android.os.UserManager +import android.os.UserManager.DISALLOW_INSTALL_UNKNOWN_SOURCES_GLOBALLY +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Error +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +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.repo.AddRepoError +import org.fdroid.repo.AddRepoError.ErrorType.INVALID_FINGERPRINT +import org.fdroid.repo.AddRepoError.ErrorType.INVALID_INDEX +import org.fdroid.repo.AddRepoError.ErrorType.IO_ERROR +import org.fdroid.repo.AddRepoError.ErrorType.IS_ARCHIVE_REPO +import org.fdroid.repo.AddRepoError.ErrorType.UNKNOWN_SOURCES_DISALLOWED +import org.fdroid.ui.FDroidContent +import java.io.IOException + +@Composable +fun AddRepoErrorScreen(state: AddRepoError, modifier: Modifier = Modifier) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp, CenterVertically), + horizontalAlignment = CenterHorizontally, + modifier = modifier + .padding(16.dp) + .fillMaxSize(), + ) { + Image( + imageVector = Icons.Default.Error, + contentDescription = stringResource(R.string.error), + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.error), + modifier = Modifier.size(48.dp), + ) + val title = when (state.errorType) { + INVALID_FINGERPRINT -> stringResource(R.string.bad_fingerprint) + UNKNOWN_SOURCES_DISALLOWED -> { + if (LocalInspectionMode.current) { + stringResource(R.string.has_disallow_install_unknown_sources) + } else { + getDisallowInstallUnknownSourcesErrorMessage(LocalContext.current) + } + } + INVALID_INDEX -> stringResource(R.string.repo_invalid) + IO_ERROR -> stringResource(R.string.repo_io_error) + IS_ARCHIVE_REPO -> stringResource(R.string.repo_error_adding_archive) + } + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + ) + if (state.exception != null) Text( + text = state.exception.toString(), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +fun getDisallowInstallUnknownSourcesErrorMessage(context: Context): String { + val userManager = context.getSystemService(Context.USER_SERVICE) as UserManager + return if (SDK_INT >= 29 && + userManager.hasUserRestriction(DISALLOW_INSTALL_UNKNOWN_SOURCES_GLOBALLY) + ) { + context.getString(R.string.has_disallow_install_unknown_sources_globally) + } else { + context.getString(R.string.has_disallow_install_unknown_sources) + } +} + +@Preview +@Composable +fun AddRepoErrorInvalidFingerprintPreview() { + FDroidContent { + AddRepoErrorScreen(AddRepoError(INVALID_FINGERPRINT)) + } +} + +@Preview +@Composable +fun AddRepoErrorIoErrorPreview() { + FDroidContent { + AddRepoErrorScreen(AddRepoError(IO_ERROR, IOException("foo bar"))) + } +} + +@Preview +@Composable +fun AddRepoErrorInvalidIndexPreview() { + FDroidContent { + AddRepoErrorScreen(AddRepoError(INVALID_INDEX, RuntimeException("foo bar"))) + } +} + +@Preview +@Composable +fun AddRepoErrorUnknownSourcesPreview() { + FDroidContent { + AddRepoErrorScreen(AddRepoError(UNKNOWN_SOURCES_DISALLOWED)) + } +} + +@Preview +@Composable +fun AddRepoErrorArchivePreview() { + FDroidContent { + AddRepoErrorScreen(AddRepoError(IS_ARCHIVE_REPO)) + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/add/AddRepoIntro.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/add/AddRepoIntro.kt new file mode 100644 index 000000000..3d6b1c4b8 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/add/AddRepoIntro.kt @@ -0,0 +1,270 @@ +package org.fdroid.ui.repositories.add + +import android.Manifest.permission.CAMERA +import android.content.Intent +import android.content.pm.PackageManager.PERMISSION_GRANTED +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import android.net.Uri +import android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts.RequestPermission +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement.SpaceBetween +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentPaste +import androidx.compose.material.icons.filled.QrCode +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +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.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalClipboardManager +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.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat.checkSelfPermission +import com.google.zxing.client.android.Intents.Scan.MIXED_SCAN +import com.google.zxing.client.android.Intents.Scan.SCAN_TYPE +import com.journeyapps.barcodescanner.ScanContract +import com.journeyapps.barcodescanner.ScanOptions +import com.journeyapps.barcodescanner.ScanOptions.QR_CODE +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import org.fdroid.R +import org.fdroid.download.NetworkState +import org.fdroid.repo.None +import org.fdroid.ui.FDroidContent +import org.fdroid.ui.utils.ExpandIconArrow +import org.fdroid.ui.utils.FDroidButton +import org.fdroid.ui.utils.FDroidOutlineButton +import org.fdroid.ui.utils.MeteredConnectionDialog +import org.fdroid.ui.utils.OfflineBar +import org.fdroid.ui.utils.startActivitySafe + +@Composable +fun AddRepoIntroContent( + networkState: NetworkState, + onFetchRepo: (String) -> Unit, + modifier: Modifier = Modifier +) { + val scrollState = rememberScrollState() + val context = LocalContext.current + val isPreview = LocalInspectionMode.current + var showPermissionWarning by remember { mutableStateOf(isPreview) } + val startForResult = rememberLauncherForActivityResult(ScanContract()) { result -> + if (result.contents != null) { + onFetchRepo(result.contents) + } + } + + fun startScanning() { + startForResult.launch(ScanOptions().apply { + setPrompt("") + setBeepEnabled(true) + setOrientationLocked(false) + setDesiredBarcodeFormats(QR_CODE) + addExtra(SCAN_TYPE, MIXED_SCAN) + }) + } + + val permissionLauncher = rememberLauncherForActivityResult( + contract = RequestPermission() + ) { isGranted: Boolean -> + showPermissionWarning = !isGranted + if (isGranted) startScanning() + } + var showMeteredDialog by remember { mutableStateOf<(() -> Unit)?>(null) } + Column( + verticalArrangement = spacedBy(16.dp), + horizontalAlignment = CenterHorizontally, + modifier = modifier + .imePadding() + .fillMaxSize() + .verticalScroll(scrollState) + .padding(16.dp), + ) { + if (!networkState.isOnline) OfflineBar() + Text( + text = stringResource(R.string.repo_intro), + style = MaterialTheme.typography.bodyLarge, + ) + FDroidButton( + stringResource(R.string.repo_scan_qr_code), + imageVector = Icons.Filled.QrCode, + onClick = { + val scanLambda = { + if (checkSelfPermission(context, CAMERA) == PERMISSION_GRANTED) { + startScanning() + } else { + permissionLauncher.launch(CAMERA) + } + } + if (networkState.isMetered) showMeteredDialog = scanLambda + else scanLambda() + }, + ) + AnimatedVisibility( + visible = showPermissionWarning, + modifier = Modifier + .semantics { liveRegion = LiveRegionMode.Polite }, + ) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.inverseSurface, + ), + modifier = Modifier + .fillMaxWidth() + .clickable { + val intent = Intent(ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + } + context.startActivitySafe(intent) + } + ) { + Text( + modifier = Modifier.padding(8.dp), + text = stringResource(R.string.permission_camera_denied), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge, + ) + } + } + var manualExpanded by rememberSaveable { mutableStateOf(isPreview) } + Row( + horizontalArrangement = SpaceBetween, + verticalAlignment = CenterVertically, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = ButtonDefaults.MinHeight) + .clickable { manualExpanded = !manualExpanded }, + ) { + Text( + text = stringResource(R.string.repo_enter_url), + style = MaterialTheme.typography.bodyMedium, + // avoid occupying the whole row + modifier = Modifier.weight(1f), + ) + ExpandIconArrow(manualExpanded) + } + val textState = remember { mutableStateOf(TextFieldValue()) } + val focusRequester = remember { FocusRequester() } + val coroutineScope = rememberCoroutineScope() + AnimatedVisibility( + visible = manualExpanded, + modifier = Modifier + .semantics { liveRegion = LiveRegionMode.Polite }, + ) { + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = spacedBy(16.dp), + ) { + TextField( + value = textState.value, + minLines = 2, + onValueChange = { textState.value = it }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Go), + keyboardActions = KeyboardActions( + onGo = { + onFetchRepo(textState.value.text) + }, + ), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .onGloballyPositioned { + focusRequester.requestFocus() + coroutineScope.launch { + scrollState.animateScrollTo(scrollState.maxValue) + } + }, + ) + Row( + horizontalArrangement = spacedBy(16.dp), + verticalAlignment = CenterVertically, + ) { + val clipboardManager = LocalClipboardManager.current + FDroidOutlineButton( + stringResource(id = R.string.paste), + imageVector = Icons.Default.ContentPaste, + onClick = { + if (clipboardManager.hasText()) { + textState.value = + TextFieldValue(clipboardManager.getText()?.text ?: "") + } + }, + ) + Spacer(modifier = Modifier.weight(1f)) + FDroidButton( + text = stringResource(R.string.repo_add_add), + onClick = { + if (networkState.isMetered) showMeteredDialog = { + onFetchRepo(textState.value.text) + } else onFetchRepo(textState.value.text) + }, + ) + } + } + } + } + val meteredLambda = showMeteredDialog + if (meteredLambda != null) MeteredConnectionDialog( + numBytes = null, + onConfirm = { meteredLambda() }, + onDismiss = { showMeteredDialog = null }, + ) +} + +@Composable +@Preview +private fun Preview() { + FDroidContent { + val networkStateFlow = MutableStateFlow(NetworkState(isOnline = true, isMetered = false)) + AddRepo(None, networkStateFlow, null, {}, {}, {}, { _, _ -> }) {} + } +} + +@Composable +@Preview(uiMode = UI_MODE_NIGHT_YES, widthDp = 720, heightDp = 360) +private fun PreviewNight() { + FDroidContent { + val networkStateFlow = MutableStateFlow(NetworkState(isOnline = false, isMetered = false)) + AddRepo(None, networkStateFlow, null, {}, {}, {}, { _, _ -> }) {} + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/add/AddRepoPreviewScreen.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/add/AddRepoPreviewScreen.kt new file mode 100644 index 000000000..406e72b91 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/add/AddRepoPreviewScreen.kt @@ -0,0 +1,150 @@ +package org.fdroid.ui.repositories.add + +import androidx.compose.foundation.layout.Arrangement.spacedBy +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.LinearWavyProgressIndicator +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 io.ktor.client.engine.ProxyConfig +import org.fdroid.R +import org.fdroid.database.MinimalApp +import org.fdroid.download.getImageModel +import org.fdroid.index.v2.FileV2 +import org.fdroid.repo.FetchResult.IsNewRepoAndNewMirror +import org.fdroid.repo.FetchResult.IsNewRepository +import org.fdroid.repo.Fetching +import org.fdroid.ui.FDroidContent +import org.fdroid.ui.lists.AppListItem +import org.fdroid.ui.lists.AppListRow +import org.fdroid.ui.utils.getRepository + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun AddRepoPreviewScreen( + state: Fetching, + proxyConfig: ProxyConfig?, + modifier: Modifier = Modifier, + onAddRepo: () -> Unit, + onExistingRepo: (Long) -> Unit, +) { + val localeList = LocaleListCompat.getDefault() + LazyColumn( + contentPadding = PaddingValues(horizontal = 8.dp), + verticalArrangement = spacedBy(8.dp), + modifier = modifier + .fillMaxWidth() + ) { + item { + RepoPreviewHeader( + state = state, + proxyConfig = proxyConfig, + onAddRepo = onAddRepo, + onExistingRepo = onExistingRepo, + modifier = Modifier + .padding(top = 16.dp) + .padding(horizontal = 8.dp), + localeList = localeList, + ) + } + if (state.fetchResult == null || + state.fetchResult is IsNewRepository || + state.fetchResult is IsNewRepoAndNewMirror + ) { + item { + Row( + verticalAlignment = CenterVertically, + horizontalArrangement = spacedBy(8.dp), + modifier = Modifier + .padding(top = 8.dp) + .padding(horizontal = 8.dp), + ) { + Text( + text = stringResource(R.string.repo_preview_included_apps), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = state.apps.size.toString(), + style = MaterialTheme.typography.bodyLarge, + ) + if (!state.done) { + LinearWavyProgressIndicator(modifier = Modifier.weight(1f)) + } + } + } + items(items = state.apps, key = { it.packageName }) { app -> + val repo = state.receivedRepo ?: error("no repo") + // TODO this conversion ideally doesn't happen in the UI layer + val item = AppListItem( + repoId = repo.repoId, + packageName = app.packageName, + name = app.name ?: "Unknown app", + summary = app.summary ?: "", + iconModel = app.getIcon(localeList)?.getImageModel(repo, proxyConfig), + lastUpdated = 1L, + isInstalled = false, + isCompatible = true, + ) + AppListRow( + item = item, + isSelected = false, + modifier = Modifier + .animateItem() + .padding(horizontal = 8.dp) + .fillMaxWidth() + ) + } + } + } +} + +@Preview +@Composable +private fun Preview() { + val address = "https://example.org" + val repo = getRepository(address) + val app1 = object : MinimalApp { + override val repoId = 0L + override val packageName = "org.example" + override val name: String = "App 1 with a long name" + override val summary: String = "Summary of App1 which can also be a bit longer" + override fun getIcon(localeList: LocaleListCompat): FileV2? = null + } + val app2 = object : MinimalApp { + override val repoId = 0L + override val packageName = "com.example" + override val name: String = "App 2 with a name that is even longer than the first app" + override val summary: String = + "Summary of App2 which can also be a bit longer, even longer than other apps." + + override fun getIcon(localeList: LocaleListCompat): FileV2? = null + } + val app3 = object : MinimalApp { + override val repoId = 0L + override val packageName = "net.example" + override val name: String = "App 3" + override val summary: String = "short summary" + + override fun getIcon(localeList: LocaleListCompat): FileV2? = null + } + FDroidContent { + AddRepoPreviewScreen( + Fetching(address, repo, listOf(app1, app2, app3), IsNewRepository), + proxyConfig = null, + onAddRepo = { }, + onExistingRepo = {}, + ) + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/add/AddRepoProgressScreen.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/add/AddRepoProgressScreen.kt new file mode 100644 index 000000000..80aae4260 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/add/AddRepoProgressScreen.kt @@ -0,0 +1,46 @@ +package org.fdroid.ui.repositories.add + +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularWavyProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +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 org.fdroid.R +import org.fdroid.ui.FDroidContent + +@Composable +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +fun AddRepoProgressScreen(text: String, modifier: Modifier = Modifier) { + Column( + verticalArrangement = spacedBy(16.dp, CenterVertically), + horizontalAlignment = CenterHorizontally, + modifier = modifier + .padding(16.dp) + .fillMaxSize(), + ) { + Text( + text = text, + style = MaterialTheme.typography.headlineSmall, + ) + CircularWavyProgressIndicator(modifier = Modifier.size(64.dp)) + } +} + +@Preview +@Composable +private fun Preview() { + FDroidContent { + AddRepoProgressScreen(stringResource(R.string.repo_state_fetching)) + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/add/AddRepoViewModel.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/add/AddRepoViewModel.kt new file mode 100644 index 000000000..e0b90df16 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/add/AddRepoViewModel.kt @@ -0,0 +1,48 @@ +package org.fdroid.ui.repositories.add + +import android.app.Application +import androidx.core.net.toUri +import androidx.lifecycle.AndroidViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.StateFlow +import mu.KotlinLogging +import org.fdroid.download.NetworkMonitor +import org.fdroid.index.RepoManager +import org.fdroid.repo.AddRepoState +import org.fdroid.settings.SettingsManager +import javax.inject.Inject + +@HiltViewModel +class AddRepoViewModel @Inject constructor( + app: Application, + networkMonitor: NetworkMonitor, + settingsManager: SettingsManager, + private val repoManager: RepoManager, +) : AndroidViewModel(app) { + + private val log = KotlinLogging.logger { } + val state: StateFlow = repoManager.addRepoState + + val proxyConfig = settingsManager.proxyConfig + val networkState = networkMonitor.networkState + + override fun onCleared() { + log.info { "onCleared() abort adding repository" } + repoManager.abortAddingRepository() + } + + fun onFetchRepo(uriStr: String) { + val uri = uriStr.trim().toUri() + if (repoManager.isSwapUri(uri)) { + // TODO full only + } else { + repoManager.abortAddingRepository() + repoManager.fetchRepositoryPreview(uri.toString(), proxyConfig) + } + } + + fun addFetchedRepository() { + log.info { "addFetchedRepository()" } + repoManager.addFetchedRepository() + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/add/RepoPreviewHeader.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/add/RepoPreviewHeader.kt new file mode 100644 index 000000000..bbd2a33e8 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/add/RepoPreviewHeader.kt @@ -0,0 +1,210 @@ +package org.fdroid.ui.repositories.add + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Box +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.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Alignment.Companion.End +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.datasource.LoremIpsum +import androidx.compose.ui.unit.dp +import androidx.core.os.LocaleListCompat +import io.ktor.client.engine.ProxyConfig +import org.fdroid.R +import org.fdroid.repo.FetchResult.IsExistingMirror +import org.fdroid.repo.FetchResult.IsExistingRepository +import org.fdroid.repo.FetchResult.IsNewMirror +import org.fdroid.repo.FetchResult.IsNewRepoAndNewMirror +import org.fdroid.repo.FetchResult.IsNewRepository +import org.fdroid.repo.Fetching +import org.fdroid.ui.FDroidContent +import org.fdroid.ui.repositories.RepoIcon +import org.fdroid.ui.utils.FDroidButton +import org.fdroid.ui.utils.asRelativeTimeString +import org.fdroid.ui.utils.getRepository + +@Composable +fun RepoPreviewHeader( + state: Fetching, + proxyConfig: ProxyConfig?, + onAddRepo: () -> Unit, + onExistingRepo: (Long) -> Unit, + modifier: Modifier = Modifier, + localeList: LocaleListCompat, +) { + val repo = state.receivedRepo ?: error("repo was null") + val isDevPreview = LocalInspectionMode.current + + val buttonText = when (state.fetchResult) { + is IsNewRepository -> stringResource(R.string.repo_add_new_title) + is IsNewRepoAndNewMirror -> stringResource(R.string.repo_add_repo_and_mirror) + is IsNewMirror -> stringResource(R.string.repo_add_mirror) + is IsExistingRepository, is IsExistingMirror -> stringResource(R.string.repo_view_repo) + else -> error("Unexpected fetch state: ${state.fetchResult}") + } + val buttonAction: () -> Unit = when (val res = state.fetchResult) { + is IsNewRepository, is IsNewRepoAndNewMirror, is IsNewMirror -> onAddRepo + is IsExistingRepository -> { + { onExistingRepo(res.existingRepoId) } + } + is IsExistingMirror -> { + { onExistingRepo(res.existingRepoId) } + } + else -> error("Unexpected fetch state: ${state.fetchResult}") + } + + val warningText: String? = when (state.fetchResult) { + is IsNewRepository -> null + is IsNewRepoAndNewMirror -> stringResource( + R.string.repo_and_mirror_add_both_info, + state.fetchUrl + ) + is IsNewMirror -> stringResource(R.string.repo_mirror_add_info, state.fetchUrl) + is IsExistingRepository -> stringResource(R.string.repo_exists) + is IsExistingMirror -> stringResource(R.string.repo_mirror_exists, state.fetchUrl) + else -> error("Unexpected fetch state: ${state.fetchResult}") + } + + Column( + verticalArrangement = spacedBy(16.dp), + modifier = modifier.fillMaxWidth(), + ) { + Row( + horizontalArrangement = spacedBy(16.dp), + verticalAlignment = CenterVertically, + ) { + RepoIcon(repo, proxyConfig, Modifier.size(48.dp)) + Column(horizontalAlignment = Alignment.Start) { + Text( + text = repo.getName(localeList) ?: "Unknown Repository", + maxLines = 1, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = repo.address.replaceFirst("https://", ""), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = repo.timestamp.asRelativeTimeString(), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + + if (warningText != null) Box( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.inverseSurface), + contentAlignment = Alignment.Center, + ) { + Text( + modifier = Modifier + .padding(8.dp), + text = warningText, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.inverseOnSurface, + ) + } + + FDroidButton( + text = buttonText, + onClick = buttonAction, + modifier = Modifier.align(End), + ) + + val description = if (isDevPreview) { + LoremIpsum(42).values.joinToString(" ") + } else { + repo.getDescription(localeList) + } + if (description != null) Text( + // repos are still messing up their line breaks + text = description.replace("\n", " "), + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = 720, heightDp = 360) +fun RepoPreviewScreenNewMirrorPreview() { + val repo = getRepository("https://example.org") + FDroidContent { + RepoPreviewHeader( + Fetching("https://mirror.example.org", repo, emptyList(), IsNewMirror(0L)), + onAddRepo = { }, + onExistingRepo = {}, + localeList = LocaleListCompat.getDefault(), + proxyConfig = null, + ) + } +} + +@Composable +@Preview +fun RepoPreviewScreenNewRepoAndNewMirrorPreview() { + val repo = getRepository("https://example.org") + FDroidContent { + RepoPreviewHeader( + state = Fetching( + fetchUrl = "https://mirror.example.org", + receivedRepo = repo, + apps = emptyList(), + fetchResult = IsNewRepoAndNewMirror, + ), + onAddRepo = { }, + onExistingRepo = {}, + localeList = LocaleListCompat.getDefault(), + proxyConfig = null, + ) + } +} + +@Preview +@Composable +fun RepoPreviewScreenExistingRepoPreview() { + val address = "https://example.org" + val repo = getRepository(address) + FDroidContent { + RepoPreviewHeader( + Fetching(address, repo, emptyList(), IsExistingRepository(0L)), + onAddRepo = { }, + onExistingRepo = {}, + localeList = LocaleListCompat.getDefault(), + proxyConfig = null, + ) + } +} + +@Preview +@Composable +fun RepoPreviewScreenExistingMirrorPreview() { + val repo = getRepository("https://example.org") + FDroidContent { + RepoPreviewHeader( + Fetching("https://mirror.example.org", repo, emptyList(), IsExistingMirror(0L)), + onAddRepo = { }, + onExistingRepo = {}, + localeList = LocaleListCompat.getDefault(), + proxyConfig = null, + ) + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/details/BasicAuth.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/details/BasicAuth.kt new file mode 100644 index 000000000..171a3bda1 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/details/BasicAuth.kt @@ -0,0 +1,85 @@ +package org.fdroid.ui.repositories.details + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +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.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +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.semantics.LiveRegionMode +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.FDroidOutlineButton + +@Composable +fun BasicAuth( + username: String, + modifier: Modifier = Modifier, + onEditCredentials: (String, String) -> Unit, +) { + val usernameState = rememberTextFieldState(initialText = username) + val passwordState = rememberTextFieldState() + var showSaveButton by remember { mutableStateOf(true) } + Column( + verticalArrangement = spacedBy(8.dp), + modifier = modifier + .fillMaxWidth() + .imePadding() + ) { + Text( + text = stringResource(R.string.repo_basic_auth_title), + style = MaterialTheme.typography.titleMedium, + ) + TextField( + state = usernameState, + label = { Text(stringResource(R.string.repo_basicauth_username)) } + ) + TextField( + state = passwordState, + label = { Text(stringResource(R.string.repo_basicauth_password)) } + ) + AnimatedVisibility( + visible = showSaveButton, + modifier = Modifier + .align(Alignment.End) + .semantics { liveRegion = LiveRegionMode.Polite }, + ) { + FDroidOutlineButton( + text = stringResource(R.string.repo_basicauth_edit), + onClick = { + val username = usernameState.text.toString() + val password = passwordState.text.toString() + onEditCredentials(username, password) + showSaveButton = false + }, + imageVector = Icons.Default.Save, + ) + } + } +} + +@Composable +@Preview +fun BasicAuthCardPreview() { + FDroidContent { + BasicAuth("username", Modifier.padding(16.dp)) { _, _ -> } + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/details/DeleteDialog.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/details/DeleteDialog.kt new file mode 100644 index 000000000..e93c8f1cd --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/details/DeleteDialog.kt @@ -0,0 +1,37 @@ +package org.fdroid.ui.repositories.details + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +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 +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +fun DeleteDialog(onDismissDialog: () -> Unit, onDelete: () -> Unit) { + AlertDialog( + title = { + Text(text = stringResource(R.string.repo_confirm_delete_title)) + }, + text = { + Text(text = stringResource(R.string.repo_confirm_delete_body)) + }, + onDismissRequest = onDismissDialog, + confirmButton = { + TextButton(onClick = onDelete) { + Text( + text = stringResource(R.string.delete), + color = MaterialTheme.colorScheme.error, + ) + } + }, + dismissButton = { + TextButton(onClick = onDismissDialog) { + Text(stringResource(android.R.string.cancel)) + } + } + ) +} diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/details/OfficialMirrors.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/details/OfficialMirrors.kt new file mode 100644 index 000000000..a1ff4a819 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/details/OfficialMirrors.kt @@ -0,0 +1,119 @@ +package org.fdroid.ui.repositories.details + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Public +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.fdroid.R +import org.fdroid.download.Mirror +import org.fdroid.ui.FDroidContent +import org.fdroid.ui.utils.ExpandableSection + +@Composable +fun OfficialMirrors( + mirrors: List, + setMirrorEnabled: (Mirror, Boolean) -> Unit, +) { + ExpandableSection( + icon = rememberVectorPainter(Icons.Default.Public), + title = stringResource(R.string.repo_official_mirrors), + modifier = Modifier.padding(horizontal = 16.dp), + ) { + Column { + mirrors.forEach { m -> + OfficialMirrorRow( + item = m, + setMirrorEnabled = { m, enabled -> + setMirrorEnabled(m, enabled) + }, + ) + } + } + } +} + +@Composable +private fun OfficialMirrorRow( + item: OfficialMirrorItem, + setMirrorEnabled: (Mirror, Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + ListItem( + leadingContent = { + Text( + text = item.emoji, + modifier = Modifier.width(20.dp), + ) + }, + headlineContent = { + Text(item.url) + }, + trailingContent = { + Switch( + checked = item.isEnabled, + onCheckedChange = null, + ) + }, + colors = ListItemDefaults.colors(MaterialTheme.colorScheme.background), + modifier = modifier.toggleable( + value = item.isEnabled, + role = Role.Switch, + onValueChange = { checked -> setMirrorEnabled(item.mirror, checked) }, + ), + ) +} + +@Preview +@Composable +fun OfficialMirrorsPreview() { + FDroidContent { + val mirrors = listOf( + OfficialMirrorItem( + mirror = Mirror(baseUrl = "https://mirror.example.com/fdroid/repo"), + isEnabled = true, + isRepoAddress = true, + ), + OfficialMirrorItem( + mirror = Mirror("https://mirror.example.com/foo/bar/fdroid/repo", "de"), + isEnabled = false, + isRepoAddress = false, + ), + OfficialMirrorItem( + mirror = Mirror("https://foobar.onion"), + isEnabled = true, + isRepoAddress = false, + ), + OfficialMirrorItem( + mirror = Mirror( + "https://mirror.example.org/with/a/very/long/url/that/wraps/repo", + "fr" + ), + isEnabled = true, + isRepoAddress = false, + ), + OfficialMirrorItem( + mirror = Mirror("https://mirror.example.net/repo"), + isEnabled = false, + isRepoAddress = false, + ), + ).sorted() + OfficialMirrors( + mirrors = mirrors, + setMirrorEnabled = { _, _ -> }, + ) + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/details/QrCodeDialog.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/details/QrCodeDialog.kt new file mode 100644 index 000000000..814fae819 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/details/QrCodeDialog.kt @@ -0,0 +1,59 @@ +package org.fdroid.ui.repositories.details + +import android.graphics.Bitmap +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import org.fdroid.R + +@Composable +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +fun QrCodeDialog(onDismissDialog: () -> Unit, generateQrCode: suspend () -> Bitmap?) { + val qrCodeBitmap: ImageBitmap? by produceState(null) { + value = generateQrCode()?.asImageBitmap() + } + AlertDialog( + title = { + Text(text = stringResource(R.string.share_repository)) + }, + text = { + val bitmap = qrCodeBitmap + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 128.dp) + ) { + if (bitmap == null) { + LoadingIndicator() + } else { + Image( + bitmap = bitmap, + contentDescription = stringResource(R.string.swap_scan_qr) + ) + } + } + }, + onDismissRequest = onDismissDialog, + confirmButton = { + TextButton(onClick = onDismissDialog) { + Text(stringResource(R.string.ok)) + } + }, + ) +} diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoDetails.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoDetails.kt new file mode 100644 index 000000000..e59925777 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoDetails.kt @@ -0,0 +1,186 @@ +package org.fdroid.ui.repositories.details + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.QrCode +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.Update +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.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +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 +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 androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.viktormykhailiv.compose.hints.HintHost +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.repo.RepoUpdateWorker +import org.fdroid.ui.FDroidContent +import org.fdroid.ui.utils.BigLoadingIndicator +import org.fdroid.ui.utils.MeteredConnectionDialog +import org.fdroid.ui.utils.OnboardingCard +import org.fdroid.ui.utils.getHintOverlayColor +import org.fdroid.ui.utils.getRepoDetailsInfo + +@Composable +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +fun RepoDetails( + info: RepoDetailsInfo, + onShowAppsClicked: (String, Long) -> Unit, + onBackNav: (() -> Unit)?, +) { + val context = LocalContext.current + val repo = info.model.repo + + val hintController = rememberHintController( + overlay = getHintOverlayColor(), + ) + val hint = rememberHint { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { + OnboardingCard( + title = stringResource(R.string.repo_details), + message = stringResource(R.string.repo_details_info_text), + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + onGotIt = { + info.actions.onOnboardingSeen() + hintController.dismiss() + }, + ) + } + } + val hintAnchor = rememberHintAnchorState(hint) + LaunchedEffect(info.model.showOnboarding) { + if (info.model.showOnboarding) { + hintController.show(hintAnchor) + info.actions.onOnboardingSeen() + } + } + + var qrCodeDialog by remember { mutableStateOf(false) } + var deleteDialog by remember { mutableStateOf(false) } + var showMeteredDialog by remember { mutableStateOf<(() -> Unit)?>(null) } + // QrCode dialog + if (repo != null && qrCodeDialog) QrCodeDialog({ qrCodeDialog = false }) { + info.actions.generateQrCode(repo) + } + // Repo delete dialog + if (repo != null && deleteDialog) DeleteDialog({ deleteDialog = false }) { + info.actions.deleteRepository() + deleteDialog = false + onBackNav?.invoke() + } + // Metered warning dialog + val meteredLambda = showMeteredDialog + if (meteredLambda != null) MeteredConnectionDialog( + numBytes = null, + onConfirm = { meteredLambda() }, + onDismiss = { showMeteredDialog = null }, + ) + Scaffold(topBar = { + TopAppBar( + navigationIcon = { + if (onBackNav != null) IconButton(onClick = onBackNav) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } + }, + title = { }, + actions = { + if (repo == null) return@TopAppBar + IconButton(onClick = { info.model.shareRepo(context) }) { + Icon( + imageVector = Icons.Default.Share, + contentDescription = stringResource(R.string.share_repository) + ) + } + IconButton(onClick = { qrCodeDialog = true }) { + Icon( + imageVector = Icons.Default.QrCode, + contentDescription = stringResource(R.string.show_repository_qr) + ) + } + IconButton( + enabled = info.model.isUpdateButtonEnabled, + onClick = { + if (info.model.networkState.isMetered) showMeteredDialog = { + RepoUpdateWorker.updateNow(context, repo.repoId) + } else RepoUpdateWorker.updateNow(context, repo.repoId) + }, + ) { + Icon( + imageVector = Icons.Default.Update, + contentDescription = stringResource(R.string.repo_force_update) + ) + } + var menuExpanded by remember { mutableStateOf(false) } + IconButton(onClick = { menuExpanded = !menuExpanded }) { + Icon( + Icons.Default.MoreVert, + contentDescription = stringResource(R.string.more), + ) + } + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false } + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.delete)) }, + onClick = { + menuExpanded = false + deleteDialog = true + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + modifier = Modifier.semantics { hideFromAccessibility() }, + ) + } + ) + } + }) + }) { paddingValues -> + if (repo == null) BigLoadingIndicator() + else RepoDetailsContent( + info = info, + onShowAppsClicked = onShowAppsClicked, + modifier = Modifier.padding(paddingValues) + ) + } +} + +@Preview +@Composable +fun RepoDetailsScreenPreview() { + HintHost { + FDroidContent { + RepoDetails(getRepoDetailsInfo(), { _, _ -> }, {}) + } + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoDetailsContent.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoDetailsContent.kt new file mode 100644 index 000000000..ac436c05e --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoDetailsContent.kt @@ -0,0 +1,120 @@ +package org.fdroid.ui.repositories.details + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +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.Fingerprint +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.LinearWavyProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.viktormykhailiv.compose.hints.HintHost +import org.fdroid.R +import org.fdroid.database.Repository +import org.fdroid.repo.RepoUpdateProgress +import org.fdroid.ui.FDroidContent +import org.fdroid.ui.utils.ExpandableSection +import org.fdroid.ui.utils.getRepoDetailsInfo + +@Composable +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +fun RepoDetailsContent( + info: RepoDetailsInfo, + onShowAppsClicked: (String, Long) -> Unit, + modifier: Modifier, +) { + val repo = info.model.repo as Repository + val context = LocalContext.current + Column( + verticalArrangement = spacedBy(16.dp), + modifier = modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + ) { + // show progress here as well, if repo is currently updating + val updateState = info.model.updateState + if (updateState is RepoUpdateProgress) { + val animatedProgress by animateFloatAsState(targetValue = updateState.progress) + LinearWavyProgressIndicator( + progress = { animatedProgress }, + stopSize = 0.dp, + modifier = Modifier.fillMaxWidth() + ) + } + RepoDetailsHeader( + repo = repo, + numberOfApps = info.model.numberApps, + proxy = info.model.proxy, + onShowAppsClicked = onShowAppsClicked, + ) + if (info.model.showOfficialMirrors) { + OfficialMirrors( + mirrors = info.model.officialMirrors, + setMirrorEnabled = { m, e -> + info.actions.setMirrorEnabled(m, e) + }, + ) + } + if (info.model.showUserMirrors) { + UserMirrors( + mirrors = info.model.userMirrors, + setMirrorEnabled = { m, e -> + info.actions.setMirrorEnabled(m, e) + }, + onShareMirror = { mirror -> + mirror.share(context, repo.fingerprint) + }, + onDeleteMirror = { info.actions.deleteUserMirror(it) }, + ) + } + FingerprintExpandable(repo.fingerprint) + RepoSettings( + repo = repo, + archiveState = info.model.archiveState, + onToggleArchiveClicked = info.actions::setArchiveRepoEnabled, + onCredentialsUpdated = info.actions::updateUsernameAndPassword, + ) + } +} + +@Composable +private fun FingerprintExpandable( + fingerprint: String, +) { + ExpandableSection( + icon = rememberVectorPainter(Icons.Default.Fingerprint), + title = stringResource(R.string.repo_fingerprint), + modifier = Modifier.padding(horizontal = 16.dp), + ) { + Text( + text = fingerprint, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(8.dp), + ) + } +} + +@Composable +@Preview +private fun Preview() { + HintHost { + FDroidContent { + RepoDetails(getRepoDetailsInfo(), { _, _ -> }, {}) + } + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoDetailsHeader.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoDetailsHeader.kt new file mode 100644 index 000000000..1f19c65eb --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoDetailsHeader.kt @@ -0,0 +1,146 @@ +package org.fdroid.ui.repositories.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.Spacer +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.text.selection.SelectionContainer +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.ExperimentalMaterial3ExpressiveApi +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 +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +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.ui.FDroidContent +import org.fdroid.ui.repositories.RepoIcon +import org.fdroid.ui.utils.FDroidOutlineButton +import org.fdroid.ui.utils.addressForUi +import org.fdroid.ui.utils.asRelativeTimeString +import org.fdroid.ui.utils.getRepository + +@Composable +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +fun RepoDetailsHeader( + repo: Repository, + numberOfApps: Int?, + proxy: ProxyConfig?, + onShowAppsClicked: (String, Long) -> Unit, +) { + val localeList = LocaleListCompat.getDefault() + val name = repo.getName(LocaleListCompat.getDefault()) ?: "Unknown Repo" + val description = repo.getDescription(localeList)?.replace("\n", " ") + + val lastIndexTime = if (repo.timestamp < 0) { + stringResource(R.string.repositories_last_update_never) + } else { + repo.timestamp.asRelativeTimeString() + } + val lastPublishedTime = stringResource(R.string.repo_last_update_upstream, lastIndexTime) + + val lastDownloadedTime = repo.lastUpdated?.asRelativeTimeString() + ?: stringResource(R.string.repositories_last_update_never) + val lastUpdated = stringResource(R.string.repo_last_downloaded, lastDownloadedTime) + + Column( + verticalArrangement = spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Row( + horizontalArrangement = spacedBy(8.dp), + ) { + RepoIcon(repo, proxy, Modifier.size(64.dp)) + Column(horizontalAlignment = Alignment.Start) { + Text( + text = name, + maxLines = 1, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.headlineMediumEmphasized, + ) + Text( + text = repo.addressForUi, + style = MaterialTheme.typography.bodyMedium, + ) + if (numberOfApps != null) Text( + text = pluralStringResource( + R.plurals.repo_num_apps_text, + numberOfApps, + numberOfApps, + ), + style = MaterialTheme.typography.bodySmall, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = lastPublishedTime, + style = MaterialTheme.typography.bodySmall, + ) + Text( + text = lastUpdated, + style = MaterialTheme.typography.bodySmall, + ) + } + } + if (repo.lastError != null) ElevatedCard( + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + ), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) { + Row( + horizontalArrangement = spacedBy(16.dp), + verticalAlignment = CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Icon(Icons.Default.WarningAmber, null) + SelectionContainer { + Text( + text = stringResource(R.string.repo_has_update_error_intro) + + "\n\n${repo.lastError}", + style = MaterialTheme.typography.bodyLarge + ) + } + } + } + if (repo.enabled) FDroidOutlineButton( + stringResource(R.string.repo_num_apps_button), + onClick = { onShowAppsClicked(name, repo.repoId) }, + modifier = Modifier.fillMaxWidth(), + ) + if (description?.isNotBlank() == true) { + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + ) + } + } +} + +@Preview +@Composable +private fun Preview() { + FDroidContent { + RepoDetailsHeader(getRepository(), 45, null) { _, _ -> } + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoDetailsInfo.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoDetailsInfo.kt new file mode 100644 index 000000000..ace1b452e --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoDetailsInfo.kt @@ -0,0 +1,138 @@ +package org.fdroid.ui.repositories.details + +import android.content.Context +import android.content.Intent +import android.content.Intent.ACTION_SEND +import android.content.Intent.EXTRA_TEXT +import android.graphics.Bitmap +import io.ktor.client.engine.ProxyConfig +import org.fdroid.R +import org.fdroid.database.Repository +import org.fdroid.download.Mirror +import org.fdroid.download.NetworkState +import org.fdroid.repo.RepoUpdateProgress +import org.fdroid.repo.RepoUpdateState +import org.fdroid.ui.utils.flagEmoji +import org.fdroid.ui.utils.generateQrBitmap +import org.fdroid.ui.utils.startActivitySafe + +interface RepoDetailsInfo { + val model: RepoDetailsModel + val actions: RepoDetailsActions +} + +interface RepoDetailsActions { + fun deleteRepository() + fun updateUsernameAndPassword(username: String, password: String) + fun setMirrorEnabled(mirror: Mirror, enabled: Boolean) + fun deleteUserMirror(mirror: Mirror) + fun setArchiveRepoEnabled(enabled: Boolean) + fun onOnboardingSeen() + suspend fun generateQrCode(repo: Repository): Bitmap? { + if (repo.address.startsWith("content://") || repo.address.startsWith("file://")) { + // no need to show a QR Code, it is not shareable + return null + } + return generateQrBitmap(repo.shareUri) + } +} + +data class RepoDetailsModel( + val repo: Repository?, + val numberApps: Int?, + val officialMirrors: List, + val userMirrors: List, + val archiveState: ArchiveState, + val showOnboarding: Boolean, + val updateState: RepoUpdateState?, + val networkState: NetworkState, + val proxy: ProxyConfig?, +) { + /** + * The repo's address is currently also an official mirror. + * So if there is only one mirror, this is the address => don't show this section. + * If there are 2 or more official mirrors, it makes sense to allow users + * to disable the canonical address. + */ + val showOfficialMirrors: Boolean = officialMirrors.size >= 2 + + val showUserMirrors: Boolean = userMirrors.isNotEmpty() + + val isUpdateButtonEnabled: Boolean = repo?.enabled == true && updateState !is RepoUpdateProgress + + fun shareRepo(context: Context) { + require(repo != null) { "repo was null when sharing it" } + val intent = Intent(ACTION_SEND).apply { + type = "text/plain" + putExtra(EXTRA_TEXT, repo.shareUri) + } + val chooserTitle = context.getString(R.string.share_repository) + context.startActivitySafe( + Intent.createChooser(intent, chooserTitle) + ) + } +} + +data class OfficialMirrorItem( + val mirror: Mirror, + val isEnabled: Boolean, + val isRepoAddress: Boolean, +) : MirrorItem(mirror.baseUrl), Comparable { + + private val isOnion = mirror.isOnion() + + val emoji: String = if (isOnion) { + "🧅" + } else if (mirror.countryCode == null) { + if (isRepoAddress) "⭐" else "" + } else { + mirror.countryCode?.flagEmoji ?: "" + } + + override fun compareTo(other: OfficialMirrorItem): Int { + return if (isRepoAddress && !other.isRepoAddress) -1 + else if (!isRepoAddress && other.isRepoAddress) 1 + else if (isOnion && !other.isOnion) 1 + else if (!isOnion && other.isOnion) -1 + else if (isOnion) mirror.baseUrl.compareTo(other.mirror.baseUrl) + else if (mirror.countryCode == other.mirror.countryCode) { + mirror.baseUrl.compareTo(other.mirror.baseUrl) + } else { + val countryCode = mirror.countryCode ?: "" + val otherCountryCode = other.mirror.countryCode ?: "" + countryCode.compareTo(otherCountryCode) + } + } +} + +data class UserMirrorItem( + val mirror: Mirror, + val isEnabled: Boolean, +) : MirrorItem(mirror.baseUrl) { + fun share(context: Context, fingerprint: String) { + val uri = mirror.getFDroidLinkUrl(fingerprint) + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, uri) + } + context.startActivitySafe( + Intent.createChooser(intent, context.getString(R.string.share_mirror)) + ) + } +} + +abstract class MirrorItem(baseUrl: String) { + val url: String = baseUrl + .removePrefix("https://") + .removePrefix("http://") + .removeSuffix("/fdroid/repo") + .removeSuffix("/repo") + .removeSuffix("/") +} + +enum class ArchiveState { + ENABLED, + DISABLED, + LOADING, + UNKNOWN, +} diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoDetailsPresenter.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoDetailsPresenter.kt new file mode 100644 index 000000000..31221961a --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoDetailsPresenter.kt @@ -0,0 +1,43 @@ +package org.fdroid.ui.repositories.details + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import io.ktor.client.engine.ProxyConfig +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import org.fdroid.database.Repository +import org.fdroid.download.NetworkState +import org.fdroid.repo.RepoUpdateState + +@Composable +fun RepoDetailsPresenter( + repoFlow: Flow, + numAppsFlow: Flow, + archiveStateFlow: StateFlow, + showOnboardingFlow: StateFlow, + updateFlow: Flow, + networkStateFlow: StateFlow, + proxyConfig: ProxyConfig?, +): RepoDetailsModel { + val repo = repoFlow.collectAsState(null).value + return RepoDetailsModel( + repo = repo, + numberApps = numAppsFlow.collectAsState(null).value, + officialMirrors = repo?.allOfficialMirrors?.map { mirror -> + val disabledMirrors = repo.disabledMirrors + OfficialMirrorItem( + mirror = mirror, + isEnabled = !disabledMirrors.contains(mirror.baseUrl), + isRepoAddress = repo.address == mirror.baseUrl, + ) + }?.sorted() ?: emptyList(), + userMirrors = repo?.allUserMirrors?.map { mirror -> + UserMirrorItem(mirror, !repo.disabledMirrors.contains(mirror.baseUrl)) + } ?: emptyList(), + archiveState = archiveStateFlow.collectAsState().value, + showOnboarding = showOnboardingFlow.collectAsState().value, + updateState = updateFlow.collectAsState(null).value, + networkState = networkStateFlow.collectAsState().value, + proxy = proxyConfig, + ) +} diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoDetailsViewModel.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoDetailsViewModel.kt new file mode 100644 index 000000000..2dc85ea51 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoDetailsViewModel.kt @@ -0,0 +1,164 @@ +package org.fdroid.ui.repositories.details + +import android.app.Application +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 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.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import mu.KotlinLogging +import org.fdroid.database.FDroidDatabase +import org.fdroid.database.Repository +import org.fdroid.download.Mirror +import org.fdroid.download.NetworkMonitor +import org.fdroid.index.RepoManager +import org.fdroid.repo.RepoUpdateManager +import org.fdroid.repo.RepoUpdateWorker +import org.fdroid.settings.OnboardingManager +import org.fdroid.settings.SettingsManager +import org.fdroid.ui.repositories.details.ArchiveState.UNKNOWN +import org.fdroid.utils.IoDispatcher + +@HiltViewModel(assistedFactory = RepoDetailsViewModel.Factory::class) +class RepoDetailsViewModel @AssistedInject constructor( + app: Application, + @Assisted private val repoId: Long, + networkMonitor: NetworkMonitor, + private val db: FDroidDatabase, + private val repoManager: RepoManager, + repoUpdateManager: RepoUpdateManager, + private val settingsManager: SettingsManager, + private val onboardingManager: OnboardingManager, + @param:IoDispatcher private val ioScope: CoroutineScope, +) : AndroidViewModel(app), RepoDetailsActions { + + private val log = KotlinLogging.logger {} + private val moleculeScope = + CoroutineScope(viewModelScope.coroutineContext + AndroidUiDispatcher.Main) + + private val repoFlow = MutableStateFlow(null) + private val numAppsFlow: Flow = repoFlow.map { repo -> + if (repo != null) { + db.getAppDao().getNumberOfAppsInRepository(repo.repoId) + } else null + }.flowOn(Dispatchers.IO).distinctUntilChanged() + private val archiveStateFlow = MutableStateFlow(UNKNOWN) + private val showOnboarding = onboardingManager.showRepoDetailsOnboarding + private val updateFlow = repoUpdateManager.repoUpdateState.map { + if (it?.repoId == repoId) it else null + } + + val model: StateFlow by lazy(LazyThreadSafetyMode.NONE) { + moleculeScope.launchMolecule(mode = ContextClock) { + RepoDetailsPresenter( + repoFlow = repoFlow, + numAppsFlow = numAppsFlow, + archiveStateFlow = archiveStateFlow, + showOnboardingFlow = showOnboarding, + updateFlow = updateFlow, + networkStateFlow = networkMonitor.networkState, + proxyConfig = settingsManager.proxyConfig, + ) + } + } + + init { + viewModelScope.launch { + repoManager.repositoriesState.collect { repos -> + val repo = repos.find { it.repoId == repoId } + onRepoChanged(repo) + } + } + } + + private fun onRepoChanged(repo: Repository?) { + repoFlow.update { repo } + archiveStateFlow.update { repo?.archiveState() ?: UNKNOWN } + } + + override fun deleteRepository() { + ioScope.launch { + repoManager.deleteRepository(repoId) + } + } + + override fun updateUsernameAndPassword(username: String, password: String) { + ioScope.launch { + repoManager.updateUsernameAndPassword(repoId, username, password) + withContext(Dispatchers.Main) { + RepoUpdateWorker.updateNow(application, repoId) + } + } + } + + override fun setMirrorEnabled(mirror: Mirror, enabled: Boolean) { + ioScope.launch { + repoManager.setMirrorEnabled(repoId, mirror, enabled) + } + } + + override fun deleteUserMirror(mirror: Mirror) { + ioScope.launch { + repoManager.deleteUserMirror(repoId, mirror) + } + } + + override fun setArchiveRepoEnabled(enabled: Boolean) { + ioScope.launch { + val repo = repoFlow.value ?: return@launch + archiveStateFlow.value = ArchiveState.LOADING + try { + val archiveRepoId = repoManager.setArchiveRepoEnabled( + repository = repo, + enabled = enabled, + proxy = settingsManager.proxyConfig, + ) + archiveStateFlow.value = enabled.toArchiveState() + if (enabled && archiveRepoId != null) withContext(Dispatchers.Main) { + RepoUpdateWorker.updateNow(application, archiveRepoId) + } + } catch (e: Exception) { + log.error(e) { "Error toggling archive repo: " } + archiveStateFlow.value = repo.archiveState() + } + } + } + + override fun onOnboardingSeen() = onboardingManager.onRepoDetailsOnboardingSeen() + + private fun Repository.archiveState(): ArchiveState { + val isEnabled = repoManager.getRepositories().find { r -> + r.isArchiveRepo && r.certificate == certificate + }?.enabled + return when (isEnabled) { + true -> ArchiveState.ENABLED + false -> ArchiveState.DISABLED + null -> UNKNOWN + } + } + + private fun Boolean.toArchiveState(): ArchiveState { + return if (this) ArchiveState.ENABLED else ArchiveState.DISABLED + } + + @AssistedFactory + interface Factory { + fun create(repoId: Long): RepoDetailsViewModel + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoSettings.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoSettings.kt new file mode 100644 index 000000000..fcecb8f50 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/details/RepoSettings.kt @@ -0,0 +1,110 @@ +package org.fdroid.ui.repositories.details + +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.LinearWavyProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +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.database.Repository +import org.fdroid.ui.FDroidContent +import org.fdroid.ui.utils.ExpandableSection +import org.fdroid.ui.utils.FDroidOutlineButton +import org.fdroid.ui.utils.FDroidSwitchRow +import org.fdroid.ui.utils.getRepository + +@Composable +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +fun RepoSettings( + repo: Repository, + archiveState: ArchiveState, + onToggleArchiveClicked: (Boolean) -> Unit, + onCredentialsUpdated: (String, String) -> Unit, +) { + ExpandableSection( + icon = rememberVectorPainter(Icons.Default.Settings), + title = stringResource(R.string.menu_settings), + modifier = Modifier.padding(horizontal = 16.dp), + ) { + Column(verticalArrangement = spacedBy(16.dp)) { + when (archiveState) { + ArchiveState.UNKNOWN -> { + Column( + verticalArrangement = spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.repo_archive_unknown)) + FDroidOutlineButton( + text = stringResource(R.string.repo_archive_check), + onClick = { onToggleArchiveClicked(true) }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + ArchiveState.LOADING -> { + LinearWavyProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp), + ) + } + else -> { + FDroidSwitchRow( + text = stringResource(R.string.repo_archive_toggle_description), + checked = archiveState == ArchiveState.ENABLED, + enabled = true, + onCheckedChange = onToggleArchiveClicked, + ) + } + } + val username = repo.username + if (username != null && username.isNotBlank()) { + BasicAuth(username) { username, password -> + onCredentialsUpdated(username, password) + } + } + } + } +} + +@Preview +@Composable +private fun PreviewUnknown() { + FDroidContent { + RepoSettings(getRepository(), ArchiveState.UNKNOWN, {}) { _, _ -> } + } +} + +@Preview +@Composable +private fun PreviewLoading() { + FDroidContent { + RepoSettings(getRepository(), ArchiveState.LOADING, {}) { _, _ -> } + } +} + +@Preview +@Composable +private fun PreviewEnabled() { + FDroidContent { + RepoSettings(getRepository(), ArchiveState.ENABLED, {}) { _, _ -> } + } +} + +@Preview +@Composable +private fun PreviewDisabled() { + FDroidContent { + RepoSettings(getRepository(), ArchiveState.DISABLED, {}) { _, _ -> } + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/repositories/details/UserMirrors.kt b/app/src/main/kotlin/org/fdroid/ui/repositories/details/UserMirrors.kt new file mode 100644 index 000000000..73d9e4bd6 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/repositories/details/UserMirrors.kt @@ -0,0 +1,152 @@ +package org.fdroid.ui.repositories.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.selection.toggleable +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Dns +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.vector.rememberVectorPainter +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.fdroid.R +import org.fdroid.download.Mirror +import org.fdroid.ui.FDroidContent +import org.fdroid.ui.utils.ExpandableSection +import org.fdroid.ui.utils.FDroidOutlineButton + +@Composable +fun UserMirrors( + mirrors: List, + setMirrorEnabled: (Mirror, Boolean) -> Unit, + onShareMirror: (UserMirrorItem) -> Unit, + onDeleteMirror: (Mirror) -> Unit, +) { + var showDeleteDialog by remember { mutableStateOf(null) } + if (showDeleteDialog != null) AlertDialog( + title = { + Text(text = stringResource(R.string.repo_confirm_delete_mirror_title)) + }, + text = { + Text(text = stringResource(R.string.repo_confirm_delete_mirror_body)) + }, + onDismissRequest = { showDeleteDialog = null }, + confirmButton = { + TextButton(onClick = { + onDeleteMirror(showDeleteDialog!!) + showDeleteDialog = null + }) { + Text( + text = stringResource(R.string.delete), + color = MaterialTheme.colorScheme.error, + ) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog = null }) { + Text(stringResource(android.R.string.cancel)) + } + } + ) + ExpandableSection( + icon = rememberVectorPainter(Icons.Default.Dns), + title = stringResource(R.string.repo_user_mirrors), + modifier = Modifier.padding(horizontal = 16.dp), + ) { + Column { + mirrors.forEach { m -> + UserMirrorRow( + item = m, + setMirrorEnabled = setMirrorEnabled, + onShareMirror = onShareMirror, + onDeleteMirror = { showDeleteDialog = m.mirror }, + ) + } + } + } +} + +@Composable +private fun UserMirrorRow( + item: UserMirrorItem, + setMirrorEnabled: (Mirror, Boolean) -> Unit, + onShareMirror: (UserMirrorItem) -> Unit, + onDeleteMirror: (Mirror) -> Unit, + modifier: Modifier = Modifier, +) { + ListItem( + headlineContent = { + Text(item.url) + }, + supportingContent = { + Row( + horizontalArrangement = spacedBy(16.dp), + ) { + FDroidOutlineButton( + text = stringResource(R.string.menu_share), + imageVector = Icons.Default.Share, + onClick = { onShareMirror(item) }, + ) + FDroidOutlineButton( + text = stringResource(R.string.delete), + imageVector = Icons.Default.Delete, + onClick = { onDeleteMirror(item.mirror) }, + color = MaterialTheme.colorScheme.error, + ) + } + }, + trailingContent = { + Switch( + checked = item.isEnabled, + onCheckedChange = null, + ) + }, + colors = ListItemDefaults.colors(MaterialTheme.colorScheme.background), + modifier = modifier.toggleable( + value = item.isEnabled, + role = Role.Switch, + onValueChange = { checked -> setMirrorEnabled(item.mirror, checked) }, + ), + ) +} + +@Preview +@Composable +fun UserMirrorsPreview() { + FDroidContent { + val mirrors = listOf( + UserMirrorItem(Mirror("https://mirror.example.com/fdroid/repo"), true), + UserMirrorItem( + Mirror( + "https://mirror.example.org/with/a/very/long/url/that/wraps/repo", + "fr" + ), true + ), + UserMirrorItem(Mirror("https://mirror.example.com/foo/bar/fdroid/repo"), false), + ) + UserMirrors( + mirrors = mirrors, + setMirrorEnabled = { _, _ -> }, + onShareMirror = { }, + onDeleteMirror = { }, + ) + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/settings/PreferenceProxy.kt b/app/src/main/kotlin/org/fdroid/ui/settings/PreferenceProxy.kt new file mode 100644 index 000000000..a8bb30fdb --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/settings/PreferenceProxy.kt @@ -0,0 +1,131 @@ +package org.fdroid.ui.settings + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.VpnLock +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.input.TextFieldValue +import androidx.compose.ui.tooling.preview.Preview +import me.zhanghai.compose.preference.ProvidePreferenceLocals +import me.zhanghai.compose.preference.textFieldPreference +import org.fdroid.R +import org.fdroid.settings.SettingsConstants.PREF_DEFAULT_PROXY +import org.fdroid.settings.SettingsConstants.PREF_KEY_PROXY +import org.fdroid.ui.FDroidContent +import java.net.InetSocketAddress + +fun LazyListScope.preferenceProxy( + proxyState: MutableState, + showError: MutableState, +) { + textFieldPreference( + key = PREF_KEY_PROXY, + defaultValue = PREF_DEFAULT_PROXY, + rememberState = { proxyState }, + icon = { + Icon( + imageVector = Icons.Default.VpnLock, + contentDescription = null, + modifier = Modifier.semantics { hideFromAccessibility() }, + ) + }, + title = { + Text(stringResource(R.string.pref_proxy_title)) + }, + summary = { + val value = proxyState.value + val s = if (value.isBlank()) { + stringResource(R.string.pref_proxy_disabled) + } else { + stringResource(R.string.pref_proxy_enabled, value) + } + Text(s) + }, + textToValue = { + if (it.isBlank() || isProxyValid(it)) { + showError.value = false + it + } else { + showError.value = true + // null is currently treated as an error and won't cause an update + null + } + }, + textField = { value, onValueChange, onOk -> + OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier.fillMaxWidth(), + keyboardActions = KeyboardActions { onOk() }, + singleLine = true, + trailingIcon = { + if (value.text.isNotBlank()) { + IconButton(onClick = { onValueChange(TextFieldValue("")) }) { + Icon(Icons.Default.Clear, stringResource(R.string.clear)) + } + } + }, + isError = showError.value, + supportingText = { + val s = if (showError.value) { + stringResource(R.string.pref_proxy_error) + } else { + stringResource(R.string.pref_proxy_hint) + } + Text(s) + }, + ) + }, + ) +} + +private fun isProxyValid(proxyStr: String): Boolean = try { + val (host, port) = proxyStr.split(':') + InetSocketAddress.createUnresolved(host, port.toInt()) + true +} catch (_: Exception) { + false +} + +@Preview +@Composable +private fun PreviewDefault() { + FDroidContent { + ProvidePreferenceLocals { + val showProxyError = remember { mutableStateOf(false) } + val proxyState = remember { mutableStateOf(PREF_DEFAULT_PROXY) } + LazyColumn { + preferenceProxy(proxyState, showProxyError) + } + } + } +} + +@Preview +@Composable +private fun PreviewProxySet() { + FDroidContent { + ProvidePreferenceLocals { + val showProxyError = remember { mutableStateOf(false) } + val proxyState = remember { mutableStateOf("127.0.0.1:8000") } + LazyColumn { + preferenceProxy(proxyState, showProxyError) + } + } + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/settings/Settings.kt b/app/src/main/kotlin/org/fdroid/ui/settings/Settings.kt new file mode 100644 index 000000000..f71eaf8ae --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/settings/Settings.kt @@ -0,0 +1,356 @@ +package org.fdroid.ui.settings + +import android.content.Intent +import android.net.Uri +import android.os.Build.VERSION.SDK_INT +import android.provider.Settings.ACTION_APP_LOCALE_SETTINGS +import android.provider.Settings.ACTION_APP_NOTIFICATION_SETTINGS +import android.provider.Settings.EXTRA_APP_PACKAGE +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts.CreateDocument +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.BrightnessMedium +import androidx.compose.material.icons.filled.ColorLens +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.Save +import androidx.compose.material.icons.filled.SystemSecurityUpdate +import androidx.compose.material.icons.filled.SystemSecurityUpdateWarning +import androidx.compose.material.icons.filled.Translate +import androidx.compose.material.icons.filled.Update +import androidx.compose.material.icons.filled.UpdateDisabled +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.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.semantics.hideFromAccessibility +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.MutableStateFlow +import me.zhanghai.compose.preference.MapPreferences +import me.zhanghai.compose.preference.ProvidePreferenceLocals +import me.zhanghai.compose.preference.listPreference +import me.zhanghai.compose.preference.preference +import me.zhanghai.compose.preference.preferenceCategory +import me.zhanghai.compose.preference.rememberPreferenceState +import me.zhanghai.compose.preference.switchPreference +import org.fdroid.R +import org.fdroid.settings.SettingsConstants.AutoUpdateValues +import org.fdroid.settings.SettingsConstants.AutoUpdateValues.Always +import org.fdroid.settings.SettingsConstants.AutoUpdateValues.Never +import org.fdroid.settings.SettingsConstants.AutoUpdateValues.OnlyWifi +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_PROXY +import org.fdroid.settings.SettingsConstants.PREF_DEFAULT_REPO_UPDATES +import org.fdroid.settings.SettingsConstants.PREF_DEFAULT_THEME +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_PROXY +import org.fdroid.settings.SettingsConstants.PREF_KEY_REPO_UPDATES +import org.fdroid.settings.SettingsConstants.PREF_KEY_THEME +import org.fdroid.settings.toAutoUpdateValue +import org.fdroid.ui.FDroidContent +import org.fdroid.ui.utils.asRelativeTimeString +import org.fdroid.ui.utils.startActivitySafe +import org.fdroid.utils.getLogName +import java.lang.System.currentTimeMillis +import java.util.concurrent.TimeUnit.HOURS + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun Settings( + model: SettingsModel, + onSaveLogcat: (Uri?) -> Unit, + onBackClicked: () -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = onBackClicked) { + Icon( + imageVector = Icons.AutoMirrored.Default.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } + }, + title = { + Text(stringResource(R.string.menu_settings)) + }, + ) + }, + ) { paddingValues -> + val launcher = rememberLauncherForActivityResult(CreateDocument("text/plain")) { + onSaveLogcat(it) + } + val context = LocalContext.current + val res = LocalResources.current + ProvidePreferenceLocals(model.prefsFlow) { + val showProxyError = remember { mutableStateOf(false) } + val proxyState = rememberPreferenceState(PREF_KEY_PROXY, PREF_DEFAULT_PROXY) + LazyColumn( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + ) { + preferenceCategory( + key = "pref_category_display", + title = { Text(stringResource(R.string.display)) }, + ) + val themeToString = { value: String -> + AnnotatedString( + when (value) { + "light" -> res.getString(R.string.theme_light) + "dark" -> res.getString(R.string.theme_dark) + "followSystem" -> res.getString(R.string.theme_follow_system) + else -> error("Unknown value: $value") + } + ) + } + listPreference( + key = PREF_KEY_THEME, + values = listOf( + "light", + "dark", + "followSystem", + ), + valueToText = themeToString, + defaultValue = PREF_DEFAULT_THEME, + title = { Text(text = stringResource(R.string.theme)) }, + icon = { + Icon( + imageVector = Icons.Default.BrightnessMedium, + contentDescription = null, + modifier = Modifier.semantics { hideFromAccessibility() }, + ) + }, + summary = { Text(text = "${themeToString(it)}") }, + ) + if (SDK_INT >= 31) switchPreference( + key = PREF_KEY_DYNAMIC_COLORS, + defaultValue = PREF_DEFAULT_DYNAMIC_COLORS, + title = { + Text(stringResource(R.string.pref_dyn_colors_title)) + }, + icon = { + Icon( + imageVector = Icons.Default.ColorLens, + contentDescription = null, + modifier = Modifier.semantics { hideFromAccessibility() }, + ) + }, + summary = { + Text(text = stringResource(R.string.pref_dyn_colors_summary)) + }, + ) + if (SDK_INT >= 33) preference( + key = "languages", + icon = { + Icon( + imageVector = Icons.Default.Translate, + contentDescription = null, + modifier = Modifier.semantics { hideFromAccessibility() }, + ) + }, + title = { Text(stringResource(R.string.pref_language)) }, + summary = { Text(stringResource(R.string.pref_language_summary)) }, + onClick = { + val intent = Intent(ACTION_APP_LOCALE_SETTINGS).apply { + setData(Uri.fromParts("package", context.packageName, null)) + } + context.startActivitySafe(intent) + }, + ) + if (SDK_INT >= 26) preference( + key = "notifications", + icon = { + Icon( + imageVector = Icons.Default.Notifications, + contentDescription = null, + modifier = Modifier.semantics { hideFromAccessibility() }, + ) + }, + title = { Text(stringResource(R.string.notification_title)) }, + summary = { Text(stringResource(R.string.notification_summary)) }, + onClick = { + val intent = Intent(ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(EXTRA_APP_PACKAGE, context.packageName) + } + context.startActivitySafe(intent) + }, + ) + preferenceCategory( + key = "pref_category_updates", + title = { Text(stringResource(R.string.updates)) }, + ) + listPreference( + key = PREF_KEY_REPO_UPDATES, + defaultValue = PREF_DEFAULT_REPO_UPDATES, + title = { + Text(stringResource(R.string.pref_repo_updates_title)) + }, + icon = { strValue -> + if (strValue != Never.name) Icon( + imageVector = Icons.Default.SystemSecurityUpdate, + contentDescription = null, + modifier = Modifier.semantics { hideFromAccessibility() }, + ) else Icon( + imageVector = Icons.Default.SystemSecurityUpdateWarning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.semantics { hideFromAccessibility() }, + ) + }, + summary = { strValue -> + if (strValue != Never.name) { + val nextUpdate = + model.nextRepoUpdateFlow.collectAsState(Long.MAX_VALUE).value + val nextUpdateStr = if (nextUpdate == Long.MAX_VALUE) { + stringResource( + R.string.auto_update_time, + stringResource(R.string.repositories_last_update_never) + ) + } else if (nextUpdate - currentTimeMillis() <= 0) { + stringResource(R.string.auto_update_time_past) + } else { + stringResource( + R.string.auto_update_time, + nextUpdate.asRelativeTimeString() + ) + } + val s = if (strValue == OnlyWifi.name) { + stringResource(R.string.pref_repo_updates_summary_only_wifi) + } else if (strValue == Always.name) { + stringResource(R.string.pref_repo_updates_summary_always) + } else error("Unknown value: $strValue") + Text(s + "\n" + nextUpdateStr) + } else { + Text( + text = stringResource(R.string.pref_repo_updates_summary_never), + color = MaterialTheme.colorScheme.error, + ) + } + }, + values = AutoUpdateValues.entries.map { it.name }, + valueToText = { value: String -> + AnnotatedString( + when (value.toAutoUpdateValue()) { + OnlyWifi -> res.getString(R.string.pref_auto_updates_only_wifi) + Always -> res.getString(R.string.pref_auto_updates_only_always) + Never -> res.getString(R.string.pref_auto_updates_only_never) + } + ) + }, + ) + listPreference( + key = PREF_KEY_AUTO_UPDATES, + defaultValue = PREF_DEFAULT_AUTO_UPDATES, + title = { + Text(stringResource(R.string.update_auto_install)) + }, + icon = { strValue -> + Icon( + imageVector = if (strValue != Never.name) { + Icons.Default.Update + } else { + Icons.Default.UpdateDisabled + }, + contentDescription = null, + modifier = Modifier.semantics { hideFromAccessibility() }, + ) + }, + summary = { strValue -> + val s = if (strValue != Never.name) { + val nextUpdate = + model.nextAppUpdateFlow.collectAsState(Long.MAX_VALUE).value + val nextUpdateStr = if (nextUpdate == Long.MAX_VALUE) { + stringResource( + R.string.auto_update_time, + stringResource(R.string.repositories_last_update_never) + ) + } else if (nextUpdate - currentTimeMillis() <= 0) { + stringResource(R.string.auto_update_time_past) + } else { + stringResource( + R.string.auto_update_time, + nextUpdate.asRelativeTimeString() + ) + } + val s = if (strValue == OnlyWifi.name) { + stringResource(R.string.pref_auto_updates_summary_only_wifi) + } else if (strValue == Always.name) { + stringResource(R.string.pref_auto_updates_summary_always) + } else error("Unknown value: $strValue") + s + "\n" + nextUpdateStr + } else { + stringResource(R.string.pref_auto_updates_summary_never) + } + Text(s) + }, + values = AutoUpdateValues.entries.map { it.name }, + valueToText = { value: String -> + AnnotatedString( + when (value.toAutoUpdateValue()) { + OnlyWifi -> res.getString(R.string.pref_auto_updates_only_wifi) + Always -> res.getString(R.string.pref_auto_updates_only_always) + Never -> res.getString(R.string.pref_auto_updates_only_never) + } + ) + }, + ) + preferenceCategory( + key = "pref_category_network", + title = { Text(stringResource(R.string.pref_category_network)) }, + ) + preferenceProxy(proxyState, showProxyError) + item { + OutlinedButton( + onClick = { launcher.launch("${getLogName(context)}.txt") }, + modifier = Modifier + .padding(16.dp) + ) { + Icon(Icons.Default.Save, null) + Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) + Text( + text = stringResource(R.string.pref_export_log_title), + ) + } + } + } + } + } +} + +@Preview +@Composable +fun SettingsPreview() { + FDroidContent { + val model = SettingsModel( + prefsFlow = MutableStateFlow(MapPreferences()), + nextRepoUpdateFlow = MutableStateFlow(Long.MAX_VALUE), + nextAppUpdateFlow = MutableStateFlow(currentTimeMillis() - HOURS.toMillis(12)), + ) + Settings(model, {}, { }) + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/settings/SettingsModel.kt b/app/src/main/kotlin/org/fdroid/ui/settings/SettingsModel.kt new file mode 100644 index 000000000..33b8d0f8b --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/settings/SettingsModel.kt @@ -0,0 +1,11 @@ +package org.fdroid.ui.settings + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import me.zhanghai.compose.preference.Preferences + +data class SettingsModel( + val prefsFlow: MutableStateFlow, + val nextRepoUpdateFlow: Flow, + val nextAppUpdateFlow: Flow, +) diff --git a/app/src/main/kotlin/org/fdroid/ui/settings/SettingsViewModel.kt b/app/src/main/kotlin/org/fdroid/ui/settings/SettingsViewModel.kt new file mode 100644 index 000000000..67c6c7779 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/settings/SettingsViewModel.kt @@ -0,0 +1,92 @@ +package org.fdroid.ui.settings + +import android.app.Application +import android.net.Uri +import android.os.Process +import android.widget.Toast +import android.widget.Toast.LENGTH_SHORT +import androidx.annotation.StringRes +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.application +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import mu.KotlinLogging +import org.fdroid.R +import org.fdroid.repo.RepoUpdateWorker +import org.fdroid.settings.SettingsManager +import org.fdroid.ui.utils.applyNewTheme +import org.fdroid.updates.AppUpdateWorker +import org.fdroid.updates.UpdatesManager +import java.io.IOException +import java.lang.Runtime.getRuntime +import javax.inject.Inject + +@HiltViewModel +class SettingsViewModel @Inject constructor( + app: Application, + updatesManager: UpdatesManager, + private val settingsManager: SettingsManager, +) : AndroidViewModel(app) { + + private val log = KotlinLogging.logger {} + + val model = SettingsModel( + prefsFlow = settingsManager.prefsFlow, + nextRepoUpdateFlow = updatesManager.nextRepoUpdateFlow, + nextAppUpdateFlow = updatesManager.nextAppUpdateFlow, + ) + + init { + viewModelScope.launch { + // react to theme changes right away + settingsManager.themeFlow.drop(1).collect { + if (it != null) applyNewTheme(it) + } + } + viewModelScope.launch { + // react to repo auto update changes + settingsManager.repoUpdatesFlow.drop(1).collect { value -> + RepoUpdateWorker.scheduleOrCancel(application, value) + } + } + viewModelScope.launch { + // react to app auto update changes + settingsManager.autoUpdateAppsFlow.drop(1).collect { value -> + AppUpdateWorker.scheduleOrCancel(application, value) + } + } + } + + fun onSaveLogcat(uri: Uri?) = viewModelScope.launch(Dispatchers.IO) { + if (uri == null) { + sendToast(R.string.export_log_error) + return@launch + } + // support for --pid was introduced in SDK 24 + val command = "logcat -d --pid=" + Process.myPid() + " *:V" + try { + application.contentResolver.openOutputStream(uri, "wt")?.use { outputStream -> + getRuntime().exec(command).inputStream.use { inputStream -> + // first log command, so we see if it is correct, e.g. has our own pid + outputStream.write("$command\n\n".toByteArray()) + inputStream.copyTo(outputStream) + } + } ?: throw IOException("OutputStream was null") + sendToast(R.string.export_log_success) + } catch (e: Exception) { + log.error(e) { "Error saving logcat " } + sendToast(R.string.export_log_error) + } + } + + private suspend fun sendToast(@StringRes s: Int, duration: Int = LENGTH_SHORT) { + withContext(Dispatchers.Main) { + Toast.makeText(application, s, duration).show() + } + } + +} diff --git a/app/src/main/kotlin/org/fdroid/ui/utils/AsyncShimmerImage.kt b/app/src/main/kotlin/org/fdroid/ui/utils/AsyncShimmerImage.kt new file mode 100644 index 000000000..8257aa8e7 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/utils/AsyncShimmerImage.kt @@ -0,0 +1,118 @@ +package org.fdroid.ui.utils + +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorFilter.Companion.tint +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil3.compose.SubcomposeAsyncImage +import org.fdroid.R +import org.fdroid.ui.FDroidContent + +@Composable +fun AsyncShimmerImage( + model: Any?, + contentDescription: String?, + modifier: Modifier = Modifier, + colorFilter: ColorFilter? = null, + contentScale: ContentScale = ContentScale.Fit, + error: Painter = painterResource(R.drawable.ic_repo_app_default), + placeholder: Painter = error, +) { + Box(modifier = modifier) { + SubcomposeAsyncImage( + model = model, + loading = { + Image( + painter = placeholder, + contentDescription = contentDescription, + colorFilter = colorFilter ?: tint(MaterialTheme.colorScheme.onSurface), + contentScale = contentScale, + modifier = Modifier + .matchParentSize() + .shimmer(), + ) + }, + error = { + Image( + painter = error, + contentDescription = contentDescription, + colorFilter = colorFilter ?: tint(MaterialTheme.colorScheme.onSurface), + contentScale = contentScale, + modifier = Modifier.matchParentSize(), + ) + }, + colorFilter = colorFilter, + contentScale = contentScale, + contentDescription = contentDescription, + modifier = Modifier.matchParentSize() + ) + } +} + +@Composable +@Preview +private fun Preview() { + FDroidContent { + Image( + painter = painterResource(R.drawable.ic_repo_app_default), + contentDescription = null, + modifier = Modifier + .size(128.dp) + .shimmer(), + ) + } +} + +@Composable +fun Modifier.shimmer(): Modifier { + val shimmerColors = listOf( + Color.Transparent, + MaterialTheme.colorScheme.background.copy(alpha = 0.6f), + Color.Transparent + ) + val transition = rememberInfiniteTransition(label = "Shimmer") + val translateAnim by transition.animateFloat( + initialValue = -400f, + targetValue = 1200f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 2000, + easing = FastOutLinearInEasing, + ) + ), + label = "Translate" + ) + return this.drawWithCache { + val brush = Brush.linearGradient( + colors = shimmerColors, + start = Offset(translateAnim, 0f), + end = Offset(translateAnim + size.width / 1.5f, size.height) + ) + onDrawWithContent { + drawContent() + drawRect( + brush = brush, + size = size, + ) + } + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/utils/BadgeIcon.kt b/app/src/main/kotlin/org/fdroid/ui/utils/BadgeIcon.kt new file mode 100644 index 000000000..4ec975f15 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/utils/BadgeIcon.kt @@ -0,0 +1,82 @@ +package org.fdroid.ui.utils + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.SecurityUpdate +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +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.ui.FDroidContent + +@Composable +fun BadgeIcon( + icon: ImageVector, + contentDescription: String, + color: Color = MaterialTheme.colorScheme.error +) = Icon( + imageVector = icon, + tint = color, + contentDescription = contentDescription, + modifier = Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surface) + .padding(1.dp) + .size(24.dp) +) + +@Composable +fun InstalledBadge() = BadgeIcon( + icon = Icons.Filled.CheckCircle, + contentDescription = stringResource(R.string.app_installed), + color = MaterialTheme.colorScheme.secondary, +) + +@Preview +@Composable +private fun Preview() { + FDroidContent { + Column { + BadgedBox(badge = { + BadgeIcon( + icon = Icons.Filled.SecurityUpdate, + contentDescription = stringResource(R.string.app_installed), + color = MaterialTheme.colorScheme.error + ) + }, modifier = Modifier.padding(16.dp)) { + Image( + painter = painterResource(R.drawable.ic_launcher_foreground), + contentDescription = null, + modifier = Modifier + .size(48.dp), + ) + } + BadgedBox( + badge = { InstalledBadge() }, + modifier = Modifier.padding(16.dp) + ) { + Image( + painter = painterResource(R.drawable.ic_launcher_foreground), + contentDescription = null, + modifier = Modifier + .size(48.dp), + ) + } + } + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/utils/BigLoadingIndicator.kt b/app/src/main/kotlin/org/fdroid/ui/utils/BigLoadingIndicator.kt new file mode 100644 index 000000000..b598b9245 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/utils/BigLoadingIndicator.kt @@ -0,0 +1,32 @@ +package org.fdroid.ui.utils + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.LoadingIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.fdroid.ui.FDroidContent + +@Composable +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +fun BigLoadingIndicator(modifier: Modifier = Modifier) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier.fillMaxSize() + ) { + LoadingIndicator(Modifier.size(128.dp)) + } +} + +@Preview +@Composable +private fun Preview() { + FDroidContent { + BigLoadingIndicator() + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/utils/DragDropState.kt b/app/src/main/kotlin/org/fdroid/ui/utils/DragDropState.kt new file mode 100644 index 000000000..dd9a4b3c4 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/utils/DragDropState.kt @@ -0,0 +1,225 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Taken from (and then modified to provide onEnd callback): + * https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/LazyColumnDragAndDropDemo.kt;drc=e6d33dd5d0a60001a5784d84123b05308d35f410 + */ + +package org.fdroid.ui.utils + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.LazyListItemInfo +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.zIndex +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch + +// We are not passing indexes like in upstream code, because in larger lists these weren't correct +// so we pass [Any] for the item key instead. +// Hopefully, dragging items will be natively supported soon, so we can remove this entire file. + +@Composable +fun rememberDragDropState( + lazyListState: LazyListState, + onMove: (Any, Any) -> Unit, + onEnd: (Any, Any) -> Unit +): DragDropState { + val scope = rememberCoroutineScope() + val state = remember(lazyListState) { + DragDropState(state = lazyListState, onMove = onMove, onEnd = onEnd, scope = scope) + } + LaunchedEffect(state) { + while (true) { + val diff = state.scrollChannel.receive() + lazyListState.scrollBy(diff) + } + } + return state +} + +class DragDropState internal constructor( + private val state: LazyListState, + private val scope: CoroutineScope, + private val onMove: (Any, Any) -> Unit, + private val onEnd: (Any, Any) -> Unit, +) { + private var movedFrom: LazyListItemInfo? = null + private var movedTo: LazyListItemInfo? = null + var draggingItemIndex by mutableStateOf(null) + private set + + internal val scrollChannel = Channel() + + private var draggingItemDraggedDelta by mutableFloatStateOf(0f) + private var draggingItemInitialOffset by mutableIntStateOf(0) + internal val draggingItemOffset: Float + get() = + draggingItemLayoutInfo?.let { item -> + draggingItemInitialOffset + draggingItemDraggedDelta - item.offset + } ?: 0f + + private val draggingItemLayoutInfo: LazyListItemInfo? + get() = state.layoutInfo.visibleItemsInfo.firstOrNull { it.index == draggingItemIndex } + + internal var previousIndexOfDraggedItem by mutableStateOf(null) + private set + + internal var previousItemOffset = Animatable(0f) + private set + + internal fun onDragStart(offset: Offset) { + state.layoutInfo.visibleItemsInfo + .firstOrNull { item -> offset.y.toInt() in item.offset..(item.offset + item.size) } + ?.also { + movedFrom = it + draggingItemIndex = it.index + draggingItemInitialOffset = it.offset + } + } + + internal fun onDragInterrupted() { + if (draggingItemIndex != null) { + previousIndexOfDraggedItem = draggingItemIndex + val startOffset = draggingItemOffset + scope.launch { + previousItemOffset.snapTo(startOffset) + previousItemOffset.animateTo( + 0f, + spring(stiffness = Spring.StiffnessMediumLow, visibilityThreshold = 1f), + ) + previousIndexOfDraggedItem = null + } + } + draggingItemDraggedDelta = 0f + draggingItemIndex = null + draggingItemInitialOffset = 0 + movedFrom = null + movedTo = null + } + + internal fun onDragEnd() { + val from = movedFrom ?: error("Moved from was null") + val to = movedTo ?: from + if (from.key != to.key && from.index != to.index) onEnd(from.key, to.key) + onDragInterrupted() + } + + internal fun onDrag(offset: Offset) { + draggingItemDraggedDelta += offset.y + + val draggingItem = draggingItemLayoutInfo ?: return + val startOffset = draggingItem.offset + draggingItemOffset + val endOffset = startOffset + draggingItem.size + val middleOffset = startOffset + (endOffset - startOffset) / 2f + + val targetItem = + state.layoutInfo.visibleItemsInfo.find { item -> + middleOffset.toInt() in item.offset..item.offsetEnd && + draggingItem.index != item.index + } + if (targetItem != null) { + if ( + draggingItem.index == state.firstVisibleItemIndex || + targetItem.index == state.firstVisibleItemIndex + ) { + state.requestScrollToItem( + state.firstVisibleItemIndex, + state.firstVisibleItemScrollOffset, + ) + } + onMove(draggingItem.key, targetItem.key) + draggingItemIndex = targetItem.index + movedTo = targetItem + } else { + val overscroll = + when { + draggingItemDraggedDelta > 0 -> + (endOffset - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f) + draggingItemDraggedDelta < 0 -> + (startOffset - state.layoutInfo.viewportStartOffset).coerceAtMost(0f) + else -> 0f + } + if (overscroll != 0f) { + scrollChannel.trySend(overscroll) + } + } + } + + private val LazyListItemInfo.offsetEnd: Int + get() = this.offset + this.size +} + +fun Modifier.dragContainer(dragDropState: DragDropState): Modifier { + return pointerInput(dragDropState) { + detectDragGesturesAfterLongPress( + onDrag = { change, offset -> + change.consume() + dragDropState.onDrag(offset = offset) + }, + onDragStart = { offset -> dragDropState.onDragStart(offset) }, + onDragEnd = { dragDropState.onDragEnd() }, + onDragCancel = { dragDropState.onDragInterrupted() }, + ) + } +} + +@Composable +fun LazyItemScope.DraggableItem( + dragDropState: DragDropState, + index: Int, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.(isDragging: Boolean) -> Unit, +) { + val dragging = index == dragDropState.draggingItemIndex + val draggingModifier = + if (dragging) { + LocalHapticFeedback.current.performHapticFeedback(HapticFeedbackType.LongPress) + Modifier + .zIndex(1f) + .graphicsLayer { translationY = dragDropState.draggingItemOffset } + } else if (index == dragDropState.previousIndexOfDraggedItem) { + Modifier + .zIndex(1f) + .graphicsLayer { + translationY = dragDropState.previousItemOffset.value + } + } else { + Modifier // not animating here as this caused strange item jumps + } + Column(modifier = modifier.then(draggingModifier)) { content(dragging) } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/utils/ExpandIcon.kt b/app/src/main/kotlin/org/fdroid/ui/utils/ExpandIcon.kt new file mode 100644 index 000000000..e4f77f372 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/utils/ExpandIcon.kt @@ -0,0 +1,41 @@ +package org.fdroid.ui.utils + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import org.fdroid.R + +@Composable +fun ExpandIconChevron(isExpanded: Boolean) { + Icon( + imageVector = if (isExpanded) { + Icons.Default.ExpandLess + } else { + Icons.Default.ExpandMore + }, + contentDescription = if (isExpanded) { + stringResource(R.string.collapse) + } else { + stringResource(R.string.expand) + }, + ) +} + +@Composable +fun ExpandIconArrow(isExpanded: Boolean) { + Icon( + imageVector = if (isExpanded) { + Icons.Default.ExpandLess + } else { + Icons.Default.ExpandMore + }, + contentDescription = if (isExpanded) { + stringResource(R.string.collapse) + } else { + stringResource(R.string.expand) + }, + ) +} diff --git a/app/src/main/kotlin/org/fdroid/ui/utils/ExpandableSection.kt b/app/src/main/kotlin/org/fdroid/ui/utils/ExpandableSection.kt new file mode 100644 index 000000000..bb2576b29 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/utils/ExpandableSection.kt @@ -0,0 +1,67 @@ +package org.fdroid.ui.utils + +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.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.LocalInspectionMode +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.unit.dp + +@Composable +fun ExpandableSection( + icon: Painter?, + title: String, + modifier: Modifier = Modifier, + initiallyExpanded: Boolean = LocalInspectionMode.current, + content: @Composable () -> Unit, +) { + var sectionExpanded by rememberSaveable { mutableStateOf(initiallyExpanded) } + Column(modifier = modifier.fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = spacedBy(8.dp), + modifier = Modifier + .heightIn(min = 48.dp) + .clickable(onClick = { sectionExpanded = !sectionExpanded }) + ) { + if (icon != null) Icon( + painter = icon, + contentDescription = null, + modifier = Modifier.semantics { hideFromAccessibility() }, + ) + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = { sectionExpanded = !sectionExpanded }) { + ExpandIconArrow(sectionExpanded) + } + } + AnimatedVisibility( + visible = sectionExpanded, + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, + ) { + content() + } + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/utils/FDroidButton.kt b/app/src/main/kotlin/org/fdroid/ui/utils/FDroidButton.kt new file mode 100644 index 000000000..66300a6ee --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/utils/FDroidButton.kt @@ -0,0 +1,67 @@ +package org.fdroid.ui.utils + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +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.vector.ImageVector +import androidx.compose.ui.unit.dp + +@Composable +fun FDroidButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + imageVector: ImageVector? = null, +) { + Button( + onClick = onClick, + shape = RoundedCornerShape(32.dp), + modifier = modifier.heightIn(min = ButtonDefaults.MinHeight) + ) { + if (imageVector != null) { + Icon( + imageVector = imageVector, + contentDescription = text, + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing)) + } + Text(text = text) + } +} + +@Composable +fun FDroidOutlineButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + imageVector: ImageVector? = null, + color: Color = MaterialTheme.colorScheme.primary, +) { + OutlinedButton( + onClick = onClick, + shape = RoundedCornerShape(32.dp), + modifier = modifier.heightIn(min = ButtonDefaults.MinHeight), + colors = ButtonDefaults.outlinedButtonColors(contentColor = color), + ) { + if (imageVector != null) { + Icon( + imageVector = imageVector, + contentDescription = text, + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing)) + } + Text(text = text, maxLines = 1) + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/utils/FDroidSwitchRow.kt b/app/src/main/kotlin/org/fdroid/ui/utils/FDroidSwitchRow.kt new file mode 100644 index 000000000..4be3f521a --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/utils/FDroidSwitchRow.kt @@ -0,0 +1,66 @@ +package org.fdroid.ui.utils + +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +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.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.fdroid.ui.FDroidContent + +@Composable +fun FDroidSwitchRow( + text: String, + leadingContent: (@Composable () -> Unit)? = null, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit = {}, + enabled: Boolean = true, + verticalPadding: Dp = 8.dp, +) { + Row( + horizontalArrangement = spacedBy(8.dp), + verticalAlignment = CenterVertically, + modifier = Modifier + .fillMaxWidth() + .toggleable( + value = checked, + enabled = enabled, + role = Role.Switch, + onValueChange = onCheckedChange, + ) + // add padding after toggleable to have a larger touch area + .padding(vertical = verticalPadding), + ) { + leadingContent?.invoke() + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + Switch( + checked = checked, + onCheckedChange = null, + enabled = enabled, + ) + } +} + +@Composable +@Preview +fun FDroidSwitchRowPreview() { + FDroidContent { + FDroidSwitchRow( + text = "Important setting", + checked = true, + ) + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/utils/MeteredConnectionDialog.kt b/app/src/main/kotlin/org/fdroid/ui/utils/MeteredConnectionDialog.kt new file mode 100644 index 000000000..6aaccfca3 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/utils/MeteredConnectionDialog.kt @@ -0,0 +1,53 @@ +package org.fdroid.ui.utils + +import android.text.format.Formatter +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.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import org.fdroid.R + +@Composable +fun MeteredConnectionDialog(numBytes: Long?, onConfirm: () -> Unit, onDismiss: () -> Unit) { + AlertDialog( + title = { + Text(text = stringResource(R.string.dialog_metered_title)) + }, + text = { + val s = if (numBytes == null) { + stringResource(R.string.dialog_metered_text_no_size) + } else { + val sizeStr = Formatter.formatFileSize(LocalContext.current, numBytes) + stringResource(R.string.dialog_metered_text, sizeStr) + } + Text(text = s) + }, + onDismissRequest = onDismiss, + confirmButton = { + TextButton(onClick = { + onDismiss() + onConfirm() + }) { + Text( + text = stringResource(R.string.dialog_metered_button), + color = MaterialTheme.colorScheme.error, + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + } + ) +} + +@Preview +@Composable +private fun Preview() { + MeteredConnectionDialog(9_999_999, {}, {}) +} diff --git a/app/src/main/kotlin/org/fdroid/ui/utils/OfflineBar.kt b/app/src/main/kotlin/org/fdroid/ui/utils/OfflineBar.kt new file mode 100644 index 000000000..a416028fc --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/utils/OfflineBar.kt @@ -0,0 +1,40 @@ +package org.fdroid.ui.utils + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.fdroid.R +import org.fdroid.ui.FDroidContent + +@Composable +fun OfflineBar(modifier: Modifier = Modifier) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .background(MaterialTheme.colorScheme.errorContainer) + .fillMaxWidth() + ) { + Text( + text = stringResource(R.string.banner_no_internet), + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(4.dp) + ) + } +} + +@Preview +@Composable +private fun Preview() { + FDroidContent { + OfflineBar() + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/utils/OnboardingCard.kt b/app/src/main/kotlin/org/fdroid/ui/utils/OnboardingCard.kt new file mode 100644 index 000000000..4ef3969cb --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/utils/OnboardingCard.kt @@ -0,0 +1,63 @@ +package org.fdroid.ui.utils + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TooltipDefaults +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 + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun OnboardingCard( + title: String, + message: String, + modifier: Modifier = Modifier, + onGotIt: () -> Unit = {} +) { + ElevatedCard( + modifier = modifier + .widthIn(max = TooltipDefaults.richTooltipMaxWidth) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp, bottom = 8.dp) + ) + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 16.dp) + ) + TextButton( + onClick = onGotIt, + modifier = Modifier + .padding(vertical = 8.dp, horizontal = 16.dp) + ) { + Text(text = stringResource(R.string.got_it)) + } + } +} + +@Preview +@Composable +private fun Preview() { + FDroidContent { + OnboardingCard( + title = "Filter", + message = "Here you can apply filters to the list of apps," + + " e.g. showing only apps within a certain category or repository. " + + "Changing the sort order is also possible.", + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) { } + } +} diff --git a/app/src/main/kotlin/org/fdroid/ui/utils/PreviewUtils.kt b/app/src/main/kotlin/org/fdroid/ui/utils/PreviewUtils.kt new file mode 100644 index 000000000..e805efdaf --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/utils/PreviewUtils.kt @@ -0,0 +1,456 @@ +package org.fdroid.ui.utils + +import android.content.Intent +import androidx.annotation.RestrictTo +import androidx.compose.ui.tooling.preview.datasource.LoremIpsum +import org.fdroid.database.AppListSortOrder +import org.fdroid.database.AppMetadata +import org.fdroid.database.AppPrefs +import org.fdroid.database.KnownVulnerability +import org.fdroid.database.NotAvailable +import org.fdroid.database.Repository +import org.fdroid.download.Mirror +import org.fdroid.download.NetworkState +import org.fdroid.index.IndexFormatVersion +import org.fdroid.index.v2.PackageManifest +import org.fdroid.index.v2.PackageVersion +import org.fdroid.index.v2.SignerV2 +import org.fdroid.install.InstallConfirmationState +import org.fdroid.install.InstallState +import org.fdroid.repo.RepoUpdateProgress +import org.fdroid.ui.apps.AppUpdateItem +import org.fdroid.ui.apps.AppWithIssueItem +import org.fdroid.ui.apps.InstalledAppItem +import org.fdroid.ui.apps.InstallingAppItem +import org.fdroid.ui.apps.MyAppsInfo +import org.fdroid.ui.apps.MyAppsModel +import org.fdroid.ui.categories.CategoryItem +import org.fdroid.ui.details.AntiFeature +import org.fdroid.ui.details.AppDetailsActions +import org.fdroid.ui.details.AppDetailsItem +import org.fdroid.ui.details.VersionItem +import org.fdroid.ui.lists.AppListActions +import org.fdroid.ui.lists.AppListInfo +import org.fdroid.ui.lists.AppListModel +import org.fdroid.ui.lists.AppListType +import org.fdroid.ui.repositories.RepositoryInfo +import org.fdroid.ui.repositories.RepositoryItem +import org.fdroid.ui.repositories.RepositoryModel +import org.fdroid.ui.repositories.details.ArchiveState +import org.fdroid.ui.repositories.details.OfficialMirrorItem +import org.fdroid.ui.repositories.details.RepoDetailsActions +import org.fdroid.ui.repositories.details.RepoDetailsInfo +import org.fdroid.ui.repositories.details.RepoDetailsModel +import org.fdroid.ui.repositories.details.UserMirrorItem +import java.util.concurrent.TimeUnit.DAYS + +object Names { + val randomName: String get() = names.random() + val names = listOf( + "Anstop", + "PipePipe", + "A2DP Volume", + "Com-Phone Story Maker", + "Lightning", + "BitAC - Bitcoin Address Checker", + "Text Launcher", + "Polaris", + "Chubby Click - Metronome", + "SUSI.AI", + "Moon Phase", + "Export Contacts", + "Import Contacts", + "DNG Processor", + "Rootless Pixel Launcher", + "AndroDNS", + "androidVNC", + "PrBoom For Android", + "FakeStandby", + "eBooks", + "ANONguard", + "Acrylic Paint", + "Immich", + ) +} + +val testVersion1 = object : PackageVersion { + override val versionCode: Long = 42 + override val versionName: String = "42.23.0-alpha1337-33d2252b90" + override val added: Long = System.currentTimeMillis() - DAYS.toMillis(4) + override val size: Long = 1024 * 1024 * 42 + override val signer: SignerV2 = SignerV2( + listOf("271721a9cddc96660336c19a39ae3cca4375072c80d3c8170860c333d2252b90") + ) + override val releaseChannels: List? = null + override val packageManifest: PackageManifest = object : PackageManifest { + override val minSdkVersion: Int = 2 + override val targetSdkVersion: Int = 13 + override val maxSdkVersion: Int? = null + override val featureNames: List? = null + override val nativecode: List = listOf("amd64", "x86") + } + override val hasKnownVulnerability: Boolean = false +} +val testVersion2 = object : PackageVersion { + override val versionCode: Long = 23 + override val versionName: String = "23.42.0" + override val added: Long = System.currentTimeMillis() - DAYS.toMillis(4) + override val size: Long = 1024 * 1024 * 23 + override val signer: SignerV2 = SignerV2( + listOf("271721a9cddc96660336c19a39ae3cca4375072c80d3c8170860c333d2252b90") + ) + override val releaseChannels: List? = null + override val packageManifest: PackageManifest = object : PackageManifest { + override val minSdkVersion: Int? = null + override val targetSdkVersion: Int = 13 + override val maxSdkVersion: Int = 99 + override val featureNames: List? = null + override val nativecode: List? = null + } + override val hasKnownVulnerability: Boolean = false +} +val testApp = AppDetailsItem( + app = AppMetadata( + repoId = 1, + packageName = "org.schabi.newpipe", + added = 1441756800000, + lastUpdated = 1747214796000, + webSite = "https://newpipe.net", + changelog = "https://github.com/TeamNewPipe/NewPipe/releases", + license = "GPL-3.0-or-later", + sourceCode = "https://github.com/TeamNewPipe/NewPipe", + issueTracker = "https://github.com/TeamNewPipe/NewPipe/issues", + translation = "https://hosted.weblate.org/projects/newpipe/", + preferredSigner = "cb84069bd68116bafae5ee4ee5b08a567aa6d898404e7cb12f9e756df5cf5cab", + video = null, + authorName = "Team NewPipe", + authorEmail = "team@newpipe.net", + authorWebSite = "https://newpipe.net", + authorPhone = "123456", + donate = listOf("https://newpipe.net/donate"), + liberapayID = null, + liberapay = "TeamNewPipe", + openCollective = "TeamNewPipe", + bitcoin = "TeamNewPipe", + litecoin = "TeamNewPipe", + flattrID = null, + categories = listOf("Internet", "Multimedia"), + isCompatible = true, + ), + actions = AppDetailsActions( + installAction = { _, _, _ -> }, + requestUserConfirmation = { _ -> }, + checkUserConfirmation = { _ -> }, + cancelInstall = {}, + onUninstallResult = { _ -> }, + onRepoChanged = {}, + onPreferredRepoChanged = {}, + allowBetaVersions = {}, + ignoreAllUpdates = {}, + ignoreThisUpdate = {}, + shareApk = Intent(), + uninstallIntent = Intent(), + launchIntent = Intent(), + shareIntent = Intent(), + ), + installState = InstallState.Unknown, + networkState = NetworkState(isOnline = false, isMetered = false), + appPrefs = AppPrefs("org.schabi.newpipe"), + name = "New Pipe", + summary = "Lightweight YouTube frontend", + description = "NewPipe does not use any Google framework libraries, or the YouTube API. " + + "It only parses the website in order to gain the information it needs. " + + "Therefore this app can be used on devices without Google Services installed. " + + "Also, you don't need a YouTube account to use NewPipe, and it's FLOSS.\n\n" + + LoremIpsum(128).values.joinToString(" "), + categories = listOf( + CategoryItem("Multimedia", "Multimedia"), + CategoryItem("Internet", "Internet"), + ), + antiFeatures = listOf( + AntiFeature( + id = "NonFreeNet", + icon = null, + name = "This app promotes or depends entirely on a non-free network service", + reason = "Depends on Youtube for videos.", + ), + AntiFeature( + id = "FooBar", + icon = null, + name = "This app promotes or depends entirely on a non-free network service", + reason = "Depends on Youtube for videos.", + ), + ), + whatsNew = "This release fixes YouTube only providing a 360p stream.\n\n" + + "Note that the solution employed in this version is likely temporary, " + + "and in the long run the SABR video protocol needs to be implemented, " + + "but TeamNewPipe members are currently busy so any help would be greatly appreciated! " + + "https://github.com/TeamNewPipe/NewPipe/issues/12248", + authorHasMoreThanOneApp = true, + versions = listOf( + VersionItem( + testVersion1, + isInstalled = false, + isSuggested = true, + isCompatible = true, + isSignerCompatible = true, + showInstallButton = true, + ), + VersionItem( + testVersion1, + isInstalled = false, + isSuggested = false, + isCompatible = true, + isSignerCompatible = false, + showInstallButton = false, + ), + VersionItem( + testVersion2, + isInstalled = false, + isSuggested = false, + isCompatible = false, + isSignerCompatible = true, + showInstallButton = true, + ), + VersionItem( + testVersion2, + isInstalled = true, + isSuggested = false, + isCompatible = true, + isSignerCompatible = true, + showInstallButton = false, + ), + ), + installedVersion = testVersion2, + installedVersionCode = testVersion2.versionCode, + installedVersionName = testVersion2.versionName, + suggestedVersion = null, + possibleUpdate = testVersion1, + proxy = null, +) + +fun getPreviewVersion(versionName: String, size: Long? = null) = object : PackageVersion { + override val versionCode: Long = 23 + override val versionName: String = versionName + override val added: Long = System.currentTimeMillis() - DAYS.toMillis(3) + override val size: Long? = size + override val signer: SignerV2? = null + override val releaseChannels: List? = null + override val packageManifest: PackageManifest = object : PackageManifest { + override val minSdkVersion: Int? = null + override val maxSdkVersion: Int? = null + override val featureNames: List? = null + override val nativecode: List? = null + override val targetSdkVersion: Int? = null + } + override val hasKnownVulnerability: Boolean = false +} + +fun getAppListInfo(model: AppListModel) = object : AppListInfo { + override val model: AppListModel = model + override val actions: AppListActions = object : AppListActions { + override fun toggleFilterVisibility() {} + override fun sortBy(sort: AppListSortOrder) {} + override fun toggleFilterIncompatible() {} + override fun addCategory(categoryId: String) {} + override fun removeCategory(categoryId: String) {} + override fun addRepository(repoId: Long) {} + override fun removeRepository(repoId: Long) {} + override fun saveFilters() {} + override fun clearFilters() {} + override fun onSearch(query: String) {} + override fun onOnboardingSeen() {} + } + override val list: AppListType = AppListType.New("New") + override val showFilters: Boolean = false + override val showOnboarding: Boolean = false +} + +fun getMyAppsInfo(model: MyAppsModel): MyAppsInfo = object : MyAppsInfo { + override val model = model + override fun updateAll() {} + override fun changeSortOrder(sort: AppListSortOrder) {} + override fun search(query: String) {} + override fun confirmAppInstall(packageName: String, state: InstallConfirmationState) {} + override fun ignoreAppIssue(item: AppWithIssueItem) {} +} + +@RestrictTo(RestrictTo.Scope.TESTS) +internal val myAppsModel = MyAppsModel( + appUpdates = listOf( + AppUpdateItem( + repoId = 1, + packageName = "B1", + name = "App Update 123", + installedVersionName = "1.0.1", + update = getPreviewVersion("1.1.0", 123456789), + whatsNew = "This is new, all is new, nothing old.", + ), + AppUpdateItem( + repoId = 2, + packageName = "B2", + name = Names.randomName, + installedVersionName = "3.0.1", + update = getPreviewVersion("3.1.0", 9876543), + whatsNew = null, + ) + ), + installingApps = listOf( + InstallingAppItem( + packageName = "A1", + installState = InstallState.Downloading( + name = "Installing App 1", + versionName = "1.0.4", + currentVersionName = null, + lastUpdated = 23, + iconDownloadRequest = null, + downloadedBytes = 25, + totalBytes = 100, + startMillis = System.currentTimeMillis(), + ) + ) + ), + appsWithIssue = listOf( + AppWithIssueItem( + packageName = "C1", + name = Names.randomName, + installedVersionName = "1", + installedVersionCode = 1, + issue = KnownVulnerability(true), + lastUpdated = System.currentTimeMillis() - DAYS.toMillis(5) + ), + AppWithIssueItem( + packageName = "C2", + name = Names.randomName, + installedVersionName = "2", + installedVersionCode = 2, + issue = NotAvailable, + lastUpdated = System.currentTimeMillis() - DAYS.toMillis(7) + ), + ), + installedApps = listOf( + InstalledAppItem( + packageName = "D1", + name = Names.randomName, + installedVersionName = "1", + lastUpdated = System.currentTimeMillis() - DAYS.toMillis(1) + ), + InstalledAppItem( + packageName = "D2", + name = Names.randomName, + installedVersionName = "2", + lastUpdated = System.currentTimeMillis() - DAYS.toMillis(2) + ), + InstalledAppItem( + packageName = "D3", + name = Names.randomName, + installedVersionName = "3", + lastUpdated = System.currentTimeMillis() - DAYS.toMillis(3) + ) + ), + sortOrder = AppListSortOrder.NAME, + networkState = NetworkState(isOnline = false, isMetered = false), + appUpdatesBytes = null, +) + +val repoItems = listOf( + RepositoryItem( + repoId = 1, + address = "http://example.org", + name = "F-Droid", + icon = null, + timestamp = System.currentTimeMillis() - 1_111_111, + lastUpdated = System.currentTimeMillis() - 9_999_999, + weight = 1, + enabled = true, + errorCount = 0, + ), + RepositoryItem( + repoId = 2, + address = "http://example.org", + name = "Guardian Project Repository", + icon = null, + timestamp = System.currentTimeMillis() - 9_999_999, + lastUpdated = System.currentTimeMillis() - 99_999_999, + weight = 2, + enabled = true, + errorCount = 3, + ), + RepositoryItem( + repoId = 3, + address = "http://example.net", + name = "My first Repo", + icon = null, + timestamp = System.currentTimeMillis() - 888_888, + lastUpdated = System.currentTimeMillis(), + weight = 3, + enabled = true, + errorCount = 1, + ), +) + +fun getRepositoriesInfo( + model: RepositoryModel, + currentRepositoryId: Long? = null, +): RepositoryInfo = object : RepositoryInfo { + override val model: RepositoryModel = model + override val currentRepositoryId: Long? = currentRepositoryId + override fun onOnboardingSeen() {} + override fun onRepositorySelected(repositoryItem: RepositoryItem) {} + override fun onRepositoryEnabled(repoId: Long, enabled: Boolean) {} + override fun onAddRepo() {} + override fun onRepositoryMoved(fromRepoId: Long, toRepoId: Long) {} + override fun onRepositoriesFinishedMoving(fromRepoId: Long, toRepoId: Long) {} +} + +fun getRepoDetailsInfo( + model: RepoDetailsModel = RepoDetailsModel( + repo = getRepository(), + numberApps = 42, + officialMirrors = listOf( + OfficialMirrorItem( + mirror = Mirror(baseUrl = "https://mirror.example.com/fdroid/repo"), + isEnabled = true, + isRepoAddress = true, + ), + OfficialMirrorItem( + mirror = Mirror("https://mirror.example.com/foo/bar/fdroid/repo", "de"), + isEnabled = false, + isRepoAddress = false, + ), + ), + userMirrors = listOf( + UserMirrorItem(Mirror("https://mirror.example.com/fdroid/repo"), true), + UserMirrorItem(Mirror("https://mirror.example.com/foo/bar/fdroid/repo"), false), + ), + archiveState = ArchiveState.LOADING, + showOnboarding = false, + updateState = RepoUpdateProgress(42L, true, 0.75f), + networkState = NetworkState(isOnline = false, isMetered = false), + proxy = null, + ), +) = object : RepoDetailsInfo { + override val model = model + override val actions: RepoDetailsActions = object : RepoDetailsActions { + override fun deleteRepository() {} + override fun updateUsernameAndPassword(username: String, password: String) {} + override fun setMirrorEnabled(mirror: Mirror, enabled: Boolean) {} + override fun deleteUserMirror(mirror: Mirror) {} + override fun setArchiveRepoEnabled(enabled: Boolean) {} + override fun onOnboardingSeen() {} + } +} + +fun getRepository(address: String = "https://example.org/repo") = Repository( + repoId = 42L, + address = address, + timestamp = 42L, + formatVersion = IndexFormatVersion.ONE, + certificate = "010203", + version = 20001L, + weight = 42, + lastUpdated = 1337, + username = "foo", + password = "bar", + lastError = "NotFoundException FooBar technical blabla" +) diff --git a/app/src/main/kotlin/org/fdroid/ui/utils/UiUtils.kt b/app/src/main/kotlin/org/fdroid/ui/utils/UiUtils.kt new file mode 100644 index 000000000..32e1b1d6e --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/ui/utils/UiUtils.kt @@ -0,0 +1,130 @@ +package org.fdroid.ui.utils + +import android.app.ActivityManager +import android.app.ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Color +import android.os.PowerManager +import android.text.format.DateUtils +import android.util.Log +import androidx.appcompat.app.AppCompatDelegate +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.UriHandler +import androidx.core.content.ContextCompat +import androidx.core.graphics.createBitmap +import com.google.zxing.BarcodeFormat +import com.google.zxing.qrcode.QRCodeWriter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.fdroid.database.Repository +import java.text.Normalizer +import java.text.Normalizer.Form.NFKD + +@Composable +fun getHintOverlayColor() = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f) + +fun Context.startActivitySafe(i: Intent?) { + if (i == null) return + try { + startActivity(i) + } catch (e: Exception) { + Log.e("Context", "Error opening $i ", e) + } +} + +fun applyNewTheme(theme: String) { + val mode = when (theme) { + "light" -> AppCompatDelegate.MODE_NIGHT_NO + "dark" -> AppCompatDelegate.MODE_NIGHT_YES + else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + } + AppCompatDelegate.setDefaultNightMode(mode) +} + +fun UriHandler.openUriSafe(uri: String) { + try { + openUri(uri) + } catch (e: Exception) { + Log.e("UriHandler", "Error opening $uri ", e) + } +} + +private val normalizerRegex = "\\p{M}".toRegex() + +/** + * Normalizes the string by removing any diacritics that may appear. + */ +fun String.normalize(): String { + if (Normalizer.isNormalized(this, NFKD)) return this + return Normalizer.normalize(this, NFKD).replace(normalizerRegex, "") +} + +/** + * Same as the Java function Utils.generateQrBitmap, but using coroutines instead of Single and Disposable. + */ +suspend fun generateQrBitmap(qrData: String): Bitmap? = withContext(Dispatchers.Default) { + return@withContext try { + val bitMatrix = QRCodeWriter().encode(qrData, BarcodeFormat.QR_CODE, 800, 800) + val qrCodeWidth = bitMatrix.width + val qrCodeHeight = bitMatrix.height + val pixels = IntArray(qrCodeWidth * qrCodeHeight) + for (y in 0 until qrCodeHeight) { + val offset = y * qrCodeWidth + for (x in 0 until qrCodeWidth) { + pixels[offset + x] = if (bitMatrix.get(x, y)) Color.BLACK else Color.WHITE + } + } + createBitmap(qrCodeWidth, qrCodeHeight).apply { + setPixels(pixels, 0, qrCodeWidth, 0, 0, qrCodeWidth, qrCodeHeight) + } + } catch (e: Exception) { + Log.e("generateQrBitmap", "Could not encode QR as bitmap", e) + null + } +} + +fun Long.asRelativeTimeString(): String { + return DateUtils.getRelativeTimeSpanString( + this, + System.currentTimeMillis(), + DateUtils.MINUTE_IN_MILLIS, + DateUtils.FORMAT_ABBREV_ALL + ).toString() +} + +val String.flagEmoji: String? + get() { + if (this.length != 2) { + return null + } + val chars = this.uppercase().toCharArray() + val first = chars[0].code - 0x41 + 0x1F1E6 + val second = chars[1].code - 0x41 + 0x1F1E6 + val flagEmoji = String(Character.toChars(first) + Character.toChars(second)) + return flagEmoji + } + +val Repository.addressForUi: String + get() = address.replaceFirst("https://", "") + .replaceFirst("/repo", "") + +fun canStartForegroundService(context: Context): Boolean { + val powerManager = ContextCompat.getSystemService(context, PowerManager::class.java) + ?: return false + return powerManager.isIgnoringBatteryOptimizations(context.packageName) || + context.isAppInForeground() +} + +fun Context.isAppInForeground(): Boolean { + val activityManager = ContextCompat.getSystemService(this, ActivityManager::class.java) + val runningAppProcesses = activityManager?.runningAppProcesses ?: return false + for (appProcess in runningAppProcesses) { + if (appProcess.importance == IMPORTANCE_FOREGROUND && + appProcess.processName == packageName + ) return true + } + return false +} diff --git a/app/src/main/kotlin/org/fdroid/updates/AppUpdateWorker.kt b/app/src/main/kotlin/org/fdroid/updates/AppUpdateWorker.kt new file mode 100644 index 000000000..2e0a4bc93 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/updates/AppUpdateWorker.kt @@ -0,0 +1,142 @@ +package org.fdroid.updates + +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.VisibleForTesting +import androidx.hilt.work.HiltWorker +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ForegroundInfo +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.WorkerParameters +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_APP_INSTALLS +import org.fdroid.install.AppInstallManager +import org.fdroid.install.InstallNotificationState +import org.fdroid.settings.SettingsConstants.AutoUpdateValues +import org.fdroid.ui.utils.canStartForegroundService +import java.util.concurrent.TimeUnit + +@HiltWorker +class AppUpdateWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted workerParams: WorkerParameters, + private val nm: NotificationManager, + private val updatesManager: UpdatesManager, + private val appInstallManager: AppInstallManager, +) : CoroutineWorker(appContext, workerParams) { + + companion object { + private val TAG = AppUpdateWorker::class.simpleName + + @VisibleForTesting + internal const val UNIQUE_WORK_NAME_APP_UPDATE = "autoAppUpdate" + + @JvmStatic + fun scheduleOrCancel(context: Context, autoUpdate: AutoUpdateValues) { + val workManager = WorkManager.getInstance(context) + if (autoUpdate != AutoUpdateValues.Never) { + Log.i(TAG, "scheduleOrCancel: enqueueUniquePeriodicWork") + val networkType = if (autoUpdate == AutoUpdateValues.Always) { + NetworkType.CONNECTED + } else { + NetworkType.UNMETERED + } + val constraints = Constraints.Builder() + .setRequiresBatteryNotLow(true) + .setRequiresStorageNotLow(true) + .setRequiresDeviceIdle(true) + .setRequiredNetworkType(networkType) + .build() + val workRequest = PeriodicWorkRequestBuilder( + repeatInterval = TimeUnit.HOURS.toMillis(24), + repeatIntervalTimeUnit = TimeUnit.MILLISECONDS, + flexTimeInterval = 60, + flexTimeIntervalUnit = TimeUnit.MINUTES, + ) + .setConstraints(constraints) + .build() + workManager.enqueueUniquePeriodicWork( + uniqueWorkName = UNIQUE_WORK_NAME_APP_UPDATE, + existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.UPDATE, + request = workRequest, + ) + } else { + Log.w(TAG, "Cancelling job due to settings!") + workManager.cancelUniqueWork(UNIQUE_WORK_NAME_APP_UPDATE) + } + } + + fun getAutoUpdateWorkInfo(context: Context): Flow { + return WorkManager.getInstance(context).getWorkInfosForUniqueWorkFlow( + UNIQUE_WORK_NAME_APP_UPDATE + ).map { it.getOrNull(0) } + } + } + + private val log = KotlinLogging.logger { } + + override suspend fun doWork(): Result { + log.info { + if (SDK_INT >= 31) { + "doWork $this stopReason: ${this.stopReason} runAttemptCount: $runAttemptCount" + } else { + "doWork $this runAttemptCount: $runAttemptCount" + } + } + try { + if (canStartForegroundService(applicationContext)) setForeground(getForegroundInfo()) + } catch (e: Exception) { + log.error(e) { "Error while running setForeground: " } + } + return try { + currentCoroutineContext().ensureActive() + nm.cancelAppUpdatesAvailableNotification() + // Updating apps will try start a foreground service + // and it will "share" the same notification. + // This is easier than trying to tell the [AppInstallManager] + // not to start a foreground service in this specific case. + updatesManager.updateAll(false) + // show success notification, if at least one app got installed + val notificationState = appInstallManager.installNotificationState + if (notificationState.numInstalled > 0) { + nm.showInstallSuccessNotification(notificationState) + } + Result.success() + } catch (e: Exception) { + log.error(e) { "Error updating apps: " } + if (runAttemptCount <= 3) { + Result.retry() + } else { + log.warn { "Not retrying, already tried $runAttemptCount times." } + Result.failure() + } + } finally { + log.info { + if (SDK_INT >= 31) "finished doWork $this (stopReason: ${this.stopReason})" + else "finished doWork $this" + } + } + } + + override suspend fun getForegroundInfo(): ForegroundInfo { + return ForegroundInfo( + NOTIFICATION_ID_APP_INSTALLS, + nm.getAppInstallNotification(InstallNotificationState()).build(), + if (SDK_INT >= 29) FOREGROUND_SERVICE_TYPE_MANIFEST else 0 + ) + } +} diff --git a/app/src/main/kotlin/org/fdroid/updates/UpdateNotificationState.kt b/app/src/main/kotlin/org/fdroid/updates/UpdateNotificationState.kt new file mode 100644 index 000000000..6b734fa2f --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/updates/UpdateNotificationState.kt @@ -0,0 +1,29 @@ +package org.fdroid.updates + +import android.content.Context +import org.fdroid.R + +data class UpdateNotificationState( + private val updates: List, +) { + fun getTitle(context: Context) = context.resources.getQuantityString( + R.plurals.notification_summary_app_updates, + updates.size, updates.size, + ) + + fun getBigText(): String { + return StringBuilder().apply { + updates.forEach { update -> + append("• ${update.name}") + append(" ${update.currentVersionName} → ${update.updateVersionName}\n") + } + }.toString() + } +} + +data class AppUpdate( + val packageName: String, + val name: String, + val currentVersionName: String, + val updateVersionName: String, +) diff --git a/app/src/main/kotlin/org/fdroid/updates/UpdatesManager.kt b/app/src/main/kotlin/org/fdroid/updates/UpdatesManager.kt new file mode 100644 index 000000000..9b69d8620 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/updates/UpdatesManager.kt @@ -0,0 +1,218 @@ +package org.fdroid.updates + +import android.content.Context +import android.content.pm.PackageInfo +import androidx.core.os.LocaleListCompat +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import mu.KotlinLogging +import org.fdroid.LocaleChooser.getBestLocale +import org.fdroid.NotificationManager +import org.fdroid.database.AppVersion +import org.fdroid.database.AvailableAppWithIssue +import org.fdroid.database.DbAppChecker +import org.fdroid.database.FDroidDatabase +import org.fdroid.database.UnavailableAppWithIssue +import org.fdroid.download.DownloadRequest +import org.fdroid.download.PackageName +import org.fdroid.download.getImageModel +import org.fdroid.index.RepoManager +import org.fdroid.install.AppInstallManager +import org.fdroid.install.InstalledAppsCache +import org.fdroid.repo.RepoUpdateWorker +import org.fdroid.settings.SettingsManager +import org.fdroid.ui.apps.AppUpdateItem +import org.fdroid.ui.apps.AppWithIssueItem +import org.fdroid.utils.IoDispatcher +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.math.min + +@Singleton +class UpdatesManager @Inject constructor( + @param:ApplicationContext private val context: Context, + private val db: FDroidDatabase, + private val dbAppChecker: DbAppChecker, + private val settingsManager: SettingsManager, + private val repoManager: RepoManager, + private val appInstallManager: AppInstallManager, + private val installedAppsCache: InstalledAppsCache, + private val notificationManager: NotificationManager, + @param:IoDispatcher private val coroutineScope: CoroutineScope, +) { + private val log = KotlinLogging.logger { } + + private val _updates = MutableStateFlow?>(null) + val updates = _updates.asStateFlow() + private val _appsWithIssues = MutableStateFlow?>(null) + val appsWithIssues = _appsWithIssues.asStateFlow() + private val _numUpdates = MutableStateFlow(0) + val numUpdates = _numUpdates.asStateFlow() + + /** + * The time in milliseconds of the (earliest!) next repository update run. + * This is [Long.MAX_VALUE], if no time is known. + */ + val nextRepoUpdateFlow = RepoUpdateWorker.getAutoUpdateWorkInfo(context).map { workInfo -> + workInfo?.nextScheduleTimeMillis ?: Long.MAX_VALUE + } + + /** + * The time in milliseconds of the (earliest!) next automatic app update run. + * This is [Long.MAX_VALUE], if no time is known. + */ + val nextAppUpdateFlow = AppUpdateWorker.getAutoUpdateWorkInfo(context).map { workInfo -> + workInfo?.nextScheduleTimeMillis ?: Long.MAX_VALUE + } + + val notificationStates: UpdateNotificationState + get() = UpdateNotificationState( + updates = updates.value?.map { update -> + AppUpdate( + packageName = update.packageName, + name = update.name, + currentVersionName = update.installedVersionName, + updateVersionName = update.update.versionName, + ) + } ?: emptyList() + ) + + init { + coroutineScope.launch { + // refresh updates whenever installed apps change + installedAppsCache.installedApps.collect { + // don't load updates on very first start or we may find issues too early + if (!settingsManager.isFirstStart) loadUpdates(it) + } + } + } + + fun loadUpdates( + packageInfoMap: Map = installedAppsCache.installedApps.value, + ) = coroutineScope.launch { + if (packageInfoMap.isEmpty()) return@launch + val localeList = LocaleListCompat.getDefault() + try { + log.info { "Checking for updates (${packageInfoMap.size} apps)..." } + val proxyConfig = settingsManager.proxyConfig + val apps = dbAppChecker.getApps(packageInfoMap = packageInfoMap) + val updates = apps.updates.map { update -> + val iconModel = repoManager.getRepository(update.repoId)?.let { repo -> + update.getIcon(localeList)?.getImageModel(repo, proxyConfig) + } as? DownloadRequest + AppUpdateItem( + repoId = update.repoId, + packageName = update.packageName, + name = update.name ?: "Unknown app", + installedVersionName = update.installedVersionName, + update = update.update, + whatsNew = update.update.getWhatsNew(localeList), + iconModel = PackageName(update.packageName, iconModel), + ) + } + _updates.value = updates + _numUpdates.value = updates.size + // update 'update available' notification, if it is currently showing + if (notificationManager.isAppUpdatesAvailableNotificationShowing) { + if (updates.isEmpty()) notificationManager.cancelAppUpdatesAvailableNotification() + else notificationManager.showAppUpdatesAvailableNotification(notificationStates) + } + + val issueItems = apps.issues.mapNotNull { app -> + if (app.packageName in settingsManager.ignoredAppIssues) return@mapNotNull null + when (app) { + is AvailableAppWithIssue -> AppWithIssueItem( + packageName = app.app.packageName, + name = app.app.getName(localeList) ?: "Unknown app", + installedVersionName = app.installVersionName, + installedVersionCode = app.installVersionCode, + issue = app.issue, + lastUpdated = app.app.lastUpdated, + iconModel = PackageName( + packageName = app.app.packageName, + iconDownloadRequest = repoManager.getRepository(app.app.repoId)?.let { + app.app.getIcon(localeList)?.getImageModel(it, proxyConfig) + } as? DownloadRequest), + ) + is UnavailableAppWithIssue -> AppWithIssueItem( + packageName = app.packageName, + name = app.name.toString(), + installedVersionName = app.installVersionName, + installedVersionCode = app.installVersionCode, + issue = app.issue, + lastUpdated = -1, + iconModel = PackageName(app.packageName, null), + ) + } + } + _appsWithIssues.value = issueItems + } catch (e: Exception) { + log.error(e) { "Error loading updates: " } + return@launch + } + } + + suspend fun updateAll(canAskPreApprovalNow: Boolean) { + val appsToUpdate = updates.value ?: updates.first() ?: return + // we could do more in-depth checks regarding pre-approval, but this also works + val preApprovalNow = canAskPreApprovalNow && appsToUpdate.size == 1 + val concurrencyLimit = min(Runtime.getRuntime().availableProcessors(), 8) + val semaphore = Semaphore(concurrencyLimit) + // remember our own app, if it is to be updated as well + val updateLast = appsToUpdate.find { it.packageName == context.packageName } + appsToUpdate.mapNotNull { update -> + // don't update our own app just yet + if (update.packageName == context.packageName) { + // set app to update last to Starting as well, so it doesn't seem stuck + val app = db.getAppDao().getApp(update.repoId, update.packageName) + ?: return@mapNotNull null + appInstallManager.setWaitingState( + packageName = update.packageName, + name = app.metadata.name.getBestLocale(LocaleListCompat.getDefault()) + ?: "Unknown", + versionName = update.update.versionName, + currentVersionName = update.installedVersionName, + lastUpdated = update.update.added, + ) + return@mapNotNull null + } + currentCoroutineContext().ensureActive() + // launch a new co-routine for each app to update + coroutineScope.launch { + // suspend here until we get a permit from the semaphore (there's free workers) + semaphore.withPermit { + currentCoroutineContext().ensureActive() + updateApp(update, preApprovalNow) + } + } + }.joinAll() + currentCoroutineContext().ensureActive() + // now it is time to update our own app + updateLast?.let { + updateApp(it, preApprovalNow) + } + } + + private suspend fun updateApp(update: AppUpdateItem, canAskPreApprovalNow: Boolean) { + val app = db.getAppDao().getApp(update.repoId, update.packageName) ?: return + appInstallManager.install( + appMetadata = app.metadata, + // we know this is true, because we set this above in loadUpdates() + version = update.update as AppVersion, + currentVersionName = update.installedVersionName, + repo = repoManager.getRepository(update.repoId) ?: return, + iconModel = update.iconModel, + canAskPreApprovalNow = canAskPreApprovalNow, + ) + } +} diff --git a/app/src/main/kotlin/org/fdroid/updates/UpdatesModule.kt b/app/src/main/kotlin/org/fdroid/updates/UpdatesModule.kt new file mode 100644 index 000000000..0d404f95e --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/updates/UpdatesModule.kt @@ -0,0 +1,41 @@ +package org.fdroid.updates + +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.CompatibilityCheckerImpl +import org.fdroid.UpdateChecker +import org.fdroid.database.DbAppChecker +import org.fdroid.database.FDroidDatabase +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object UpdatesModule { + @Provides + @Singleton + fun provideCompatibilityChecker(@ApplicationContext context: Context): CompatibilityChecker { + return CompatibilityCheckerImpl(context.packageManager) + } + + @Provides + @Singleton + fun provideUpdateChecker(compatibilityChecker: CompatibilityChecker): UpdateChecker { + return UpdateChecker(compatibilityChecker) + } + + @Provides + @Singleton + fun provideDbAppChecker( + @ApplicationContext context: Context, + db: FDroidDatabase, + updateChecker: UpdateChecker, + compatibilityChecker: CompatibilityChecker, + ): DbAppChecker { + return DbAppChecker(db, context, compatibilityChecker, updateChecker) + } +} diff --git a/app/src/main/kotlin/org/fdroid/utils/CoroutinesScopesModule.kt b/app/src/main/kotlin/org/fdroid/utils/CoroutinesScopesModule.kt new file mode 100644 index 000000000..b56785fa5 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/utils/CoroutinesScopesModule.kt @@ -0,0 +1,27 @@ +package org.fdroid.utils + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import javax.inject.Qualifier +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object CoroutinesScopesModule { + @Singleton + @Provides + @IoDispatcher + fun providesCoroutineIoScope(): CoroutineScope { + // Run this code when providing an instance of CoroutineScope + return CoroutineScope(SupervisorJob() + Dispatchers.IO) + } +} + +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class IoDispatcher diff --git a/app/src/main/kotlin/org/fdroid/utils/Utils.kt b/app/src/main/kotlin/org/fdroid/utils/Utils.kt new file mode 100644 index 000000000..6d310cf32 --- /dev/null +++ b/app/src/main/kotlin/org/fdroid/utils/Utils.kt @@ -0,0 +1,28 @@ +package org.fdroid.utils + +import android.content.Context +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +@OptIn(ExperimentalStdlibApi::class) +fun sha256(bytes: ByteArray): String { + val messageDigest: MessageDigest = try { + MessageDigest.getInstance("SHA-256") + } catch (e: NoSuchAlgorithmException) { + throw AssertionError(e) + } + messageDigest.update(bytes) + return messageDigest.digest().toHexString() +} + +fun getLogName(context: Context): String { + val sdf = SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + val time = sdf.format(Date()) + return "${context.packageName}-$time" +} diff --git a/app/src/main/res/drawable/ic_crash.xml b/app/src/main/res/drawable/ic_crash.xml new file mode 100644 index 000000000..6cd8e1bf1 --- /dev/null +++ b/app/src/main/res/drawable/ic_crash.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 000000000..5a9726517 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_notification.xml b/app/src/main/res/drawable/ic_notification.xml index ecba10fa2..53951dfa5 100644 --- a/app/src/main/res/drawable/ic_notification.xml +++ b/app/src/main/res/drawable/ic_notification.xml @@ -1,6 +1,6 @@ diff --git a/app/src/main/res/drawable/ic_refresh.xml b/app/src/main/res/drawable/ic_refresh.xml index e9b1ec853..15e746762 100644 --- a/app/src/main/res/drawable/ic_refresh.xml +++ b/app/src/main/res/drawable/ic_refresh.xml @@ -1,7 +1,7 @@ + + diff --git a/app/src/main/res/drawable/screenshots_placeholder.png b/app/src/main/res/drawable/screenshots_placeholder.png new file mode 100644 index 000000000..94698f3aa Binary files /dev/null and b/app/src/main/res/drawable/screenshots_placeholder.png differ diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..c78bee3b5 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..3fbe681d0 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..e7bf26889 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..8068afb72 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..2d2bcf9d7 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..d9b447816 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/values-night/ic_launcher_background.xml b/app/src/main/res/values-night/ic_launcher_background.xml new file mode 100644 index 000000000..cdde88fb8 --- /dev/null +++ b/app/src/main/res/values-night/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #000000 + diff --git a/app/src/main/res/values-sw/strings.xml b/app/src/main/res/values-sw/strings.xml index 62535eb6a..8018e2af1 100644 --- a/app/src/main/res/values-sw/strings.xml +++ b/app/src/main/res/values-sw/strings.xml @@ -336,7 +336,6 @@ Anwani Idadi ya apu Maelezo - Sasisho la mwisho kupakuliwa Vioo rasmi Vioo vya watumiaji Jina diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 000000000..c5d5899fd --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/new-strings.xml b/app/src/main/res/values/new-strings.xml new file mode 100644 index 000000000..1e99908b0 --- /dev/null +++ b/app/src/main/res/values/new-strings.xml @@ -0,0 +1,179 @@ + + + + + F-Droid distributes (Free and Open Source Software) apps for Android. All apps in its official app repository are built from their source code to ensure that they are completely transparent and only include Free Software.\n\nThis app makes it easy to browse and install apps from F-Droid or other repositories. It also helps you to keep your apps up to date.\n\nF-Droid is a non-profit volunteer project. Although every effort is made to ensure all apps are safe to install, you use it AT YOUR OWN RISK. The app’s source code is automatically checked for potential security or privacy issues. However, this is far from exhaustive and there are no guarantees.\n\nF-Droid respects your privacy. We don’t track you, or your device. We don’t track what you install. You don’t need an account to use this app, and it sends no additional identifying data when communicating with repositories, other than its version number. + 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. + + About + Discover + My apps + + Retrieving apps…\n\nThis may take some time. + We need to download the latest app repository to get you started. + However, you seem to be offline. Connect to the internet and tap the button below when ready. + However, you are on mobile data and we\'ll need to download more than 10 MB. Tap the button below when ready to download. + Download app repository + No repositories enabled.\nEnable or add at least one repository to see apps. + + No apps installed.\n\nInstall apps and they will appear here. + Apps with issues + Hide app issue + Do you want to hide the issue with the app \"%1$s\"? + Hide + + New apps + Recently updated + Most downloaded + All apps + Apps by %s + + Search… + No apps found.\n\nTry to use less search terms or add/enable more repositories. + No matching apps.\n\nTry to use less search terms or remove filters. + No matching apps.\n\nTry to use less search terms. + Sorting and compatibility + Sort by name + Sort by latest + Apps compatible with this device + Save above as default + Clear all filters + + Filter + + selected + Category + + Continue with mobile data? + + You are currently on a metered internet connection. Do you want to continue downloading %1$s? + You are currently on a metered internet connection. Do you want to continue downloading? + Download + Tap to load screenshots via mobile data + + Productivity + Tools + Finances & Wallets + Entertainment & Media + Communication + Device + Network + Files & Storage + Interests + Miscellaneous + + By %1$s + Last updated: %1$s + + Last updated: %1$s (%2$s) + Preparing installation… + What\'s new + Donate + This app has anti-features + Developer contact + Technical information + Package Name: + Installed Version: + Copy link + This app is not compatible with your device. + Can not update this app, because all versions have an incompatible signature.\n\nIf you don\'t receive updates through other means, you may need to uninstall and then reinstall this app. The app\'s data will be lost. + Can not update this app, because there are no compatible versions in the preferred repository.\n\nTry changing the preferred repository. + Auto-update not available, because app targets old version of Android. + An update is available in another repository, but will not get installed, because that repository is not preferred. + No longer available + This app is not receiving updates, because it is no longer in any enabled repository.\n\nIt may have been in a repository you removed or disabled. Or it was simply removed. + Show app info + Added %1$s + Size: %1$s + SDK versions: %1$s + + %1$s (Min) + + %1$s (Target) + + %1$s (Max) + Signer: %1$s + Architectures: %1$s + Incompatible • Installation likely to fail + Signature mismatch, can\'t install + Sorry! There was an unexpected error installing this app. + + Notifications + Opens system notification settings + Automatically installed apps + Displays a notification after apps were installed automatically. + Available app updates + Displays a notification after repositories were updated and app updates were found. + + Connecting to %1$s… + Downloaded %1$s from %2$s + + Saved %1$d app from %2$s + Saved %1$d apps from %2$s + + + Installing %1$d app… + Installing %1$d apps… + + + Updating %1$d app… + Updating %1$d apps… + + + %1$d app updated + %1$d apps updated + + Installing: + Needs user confirmation: + Installed: + Tap to confirm. + + Filter + Here you can apply filters to the list of apps, e.g. showing only apps within a certain category or repository. Changing the sort order is also possible. + Got it + Clear + Error + Scanning the QR code can only work if you grant camera permission. Tap to grant it in settings. + Had errors updating + An error occurred while trying to update this repository: + Published %s + Downloaded %s + Username + Password + Save Changes + Check for updates + + Use system colors + Apply dynamic colors from your system settings + Opens system language settings + Only on WiFi + Always (even on mobile data) + Never + Download and update apps daily when on WiFi and the device isn\'t being used. + Download and update apps daily even on mobile data when the device isn\'t being used. + Auto-updates disabled • Apps will need to be updated manually + Check for updates + Periodically ask all repositories for app updates, but only when on WiFi. + Periodically ask all repositories for app updates even when on mobile data. + Do NOT check for updates • Apps will become outdated + Network + Connect via SOCKS proxy + Connect to the internet without proxy + Uses proxy %s to connect + Proxy is expected in host:port format. + Proxy format invalid + + An unexpected error occurred. + This is not your fault. + Would you like to e-mail the details to help us fix the issue? + + Tell us what happened right before the crash + Send report + Could not send report. No email program found :( + Save crash report as file + Saved report to file + Error saving crash report to file + + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 90c68bdeb..33872af90 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,134 +1,4 @@ - - - - - - - - - - - - - + + + + + + + + diff --git a/app/src/main/res/xml/apk_file_provider.xml b/legacy/src/main/res/xml/apk_file_provider.xml similarity index 100% rename from app/src/main/res/xml/apk_file_provider.xml rename to legacy/src/main/res/xml/apk_file_provider.xml diff --git a/app/src/main/res/xml/backup_extraction_rules.xml b/legacy/src/main/res/xml/backup_extraction_rules.xml similarity index 100% rename from app/src/main/res/xml/backup_extraction_rules.xml rename to legacy/src/main/res/xml/backup_extraction_rules.xml diff --git a/app/src/main/res/xml/backup_rules.xml b/legacy/src/main/res/xml/backup_rules.xml similarity index 100% rename from app/src/main/res/xml/backup_rules.xml rename to legacy/src/main/res/xml/backup_rules.xml diff --git a/app/src/main/res/xml/installer_file_provider.xml b/legacy/src/main/res/xml/installer_file_provider.xml similarity index 100% rename from app/src/main/res/xml/installer_file_provider.xml rename to legacy/src/main/res/xml/installer_file_provider.xml diff --git a/legacy/src/main/res/xml/locales_config.xml b/legacy/src/main/res/xml/locales_config.xml new file mode 100644 index 000000000..36ffa9cd9 --- /dev/null +++ b/legacy/src/main/res/xml/locales_config.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/legacy/src/main/res/xml/network_security_config.xml b/legacy/src/main/res/xml/network_security_config.xml new file mode 100644 index 000000000..cfcccb746 --- /dev/null +++ b/legacy/src/main/res/xml/network_security_config.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + amazonaws.com + + + + f-droid.org + + + + github.com + + + + githubusercontent.com + + + + github.io + + + + gitlab.com + + + + gitlab.io + + + diff --git a/app/src/main/res/xml/preferences.xml b/legacy/src/main/res/xml/preferences.xml similarity index 100% rename from app/src/main/res/xml/preferences.xml rename to legacy/src/main/res/xml/preferences.xml diff --git a/app/src/main/res/xml/searchable.xml b/legacy/src/main/res/xml/searchable.xml similarity index 100% rename from app/src/main/res/xml/searchable.xml rename to legacy/src/main/res/xml/searchable.xml diff --git a/app/src/main/scripts/update-binary b/legacy/src/main/scripts/update-binary similarity index 100% rename from app/src/main/scripts/update-binary rename to legacy/src/main/scripts/update-binary diff --git a/app/src/test/assets/urzip.apk b/legacy/src/test/assets/urzip.apk similarity index 100% rename from app/src/test/assets/urzip.apk rename to legacy/src/test/assets/urzip.apk diff --git a/app/src/test/java/org/fdroid/fdroid/AppUpdateManagerTest.kt b/legacy/src/test/java/org/fdroid/fdroid/AppUpdateManagerTest.kt similarity index 100% rename from app/src/test/java/org/fdroid/fdroid/AppUpdateManagerTest.kt rename to legacy/src/test/java/org/fdroid/fdroid/AppUpdateManagerTest.kt diff --git a/app/src/test/java/org/fdroid/fdroid/PreferencesTest.java b/legacy/src/test/java/org/fdroid/fdroid/PreferencesTest.java similarity index 100% rename from app/src/test/java/org/fdroid/fdroid/PreferencesTest.java rename to legacy/src/test/java/org/fdroid/fdroid/PreferencesTest.java diff --git a/app/src/test/java/org/fdroid/fdroid/RepoUpdateManagerTest.kt b/legacy/src/test/java/org/fdroid/fdroid/RepoUpdateManagerTest.kt similarity index 100% rename from app/src/test/java/org/fdroid/fdroid/RepoUpdateManagerTest.kt rename to legacy/src/test/java/org/fdroid/fdroid/RepoUpdateManagerTest.kt diff --git a/app/src/test/java/org/fdroid/fdroid/RepoUrlsTest.java b/legacy/src/test/java/org/fdroid/fdroid/RepoUrlsTest.java similarity index 100% rename from app/src/test/java/org/fdroid/fdroid/RepoUrlsTest.java rename to legacy/src/test/java/org/fdroid/fdroid/RepoUrlsTest.java diff --git a/app/src/test/java/org/fdroid/fdroid/TestFDroidApp.java b/legacy/src/test/java/org/fdroid/fdroid/TestFDroidApp.java similarity index 100% rename from app/src/test/java/org/fdroid/fdroid/TestFDroidApp.java rename to legacy/src/test/java/org/fdroid/fdroid/TestFDroidApp.java diff --git a/app/src/test/java/org/fdroid/fdroid/TestUtils.java b/legacy/src/test/java/org/fdroid/fdroid/TestUtils.java similarity index 100% rename from app/src/test/java/org/fdroid/fdroid/TestUtils.java rename to legacy/src/test/java/org/fdroid/fdroid/TestUtils.java diff --git a/app/src/test/java/org/fdroid/fdroid/UtilsTest.java b/legacy/src/test/java/org/fdroid/fdroid/UtilsTest.java similarity index 100% rename from app/src/test/java/org/fdroid/fdroid/UtilsTest.java rename to legacy/src/test/java/org/fdroid/fdroid/UtilsTest.java diff --git a/app/src/test/java/org/fdroid/fdroid/data/ApkTest.java b/legacy/src/test/java/org/fdroid/fdroid/data/ApkTest.java similarity index 100% rename from app/src/test/java/org/fdroid/fdroid/data/ApkTest.java rename to legacy/src/test/java/org/fdroid/fdroid/data/ApkTest.java diff --git a/app/src/test/java/org/fdroid/fdroid/data/DBHelperTest.java b/legacy/src/test/java/org/fdroid/fdroid/data/DBHelperTest.java similarity index 100% rename from app/src/test/java/org/fdroid/fdroid/data/DBHelperTest.java rename to legacy/src/test/java/org/fdroid/fdroid/data/DBHelperTest.java diff --git a/app/src/test/java/org/fdroid/fdroid/data/SanitizedFileTest.java b/legacy/src/test/java/org/fdroid/fdroid/data/SanitizedFileTest.java similarity index 100% rename from app/src/test/java/org/fdroid/fdroid/data/SanitizedFileTest.java rename to legacy/src/test/java/org/fdroid/fdroid/data/SanitizedFileTest.java diff --git a/app/src/test/java/org/fdroid/fdroid/data/SuggestedVersionTest.java b/legacy/src/test/java/org/fdroid/fdroid/data/SuggestedVersionTest.java similarity index 100% rename from app/src/test/java/org/fdroid/fdroid/data/SuggestedVersionTest.java rename to legacy/src/test/java/org/fdroid/fdroid/data/SuggestedVersionTest.java diff --git a/app/src/test/java/org/fdroid/fdroid/installer/ApkCacheTest.java b/legacy/src/test/java/org/fdroid/fdroid/installer/ApkCacheTest.java similarity index 100% rename from app/src/test/java/org/fdroid/fdroid/installer/ApkCacheTest.java rename to legacy/src/test/java/org/fdroid/fdroid/installer/ApkCacheTest.java diff --git a/app/src/test/java/org/fdroid/fdroid/installer/FileInstallerTest.java b/legacy/src/test/java/org/fdroid/fdroid/installer/FileInstallerTest.java similarity index 100% rename from app/src/test/java/org/fdroid/fdroid/installer/FileInstallerTest.java rename to legacy/src/test/java/org/fdroid/fdroid/installer/FileInstallerTest.java diff --git a/app/src/test/java/org/fdroid/fdroid/installer/InstallerFactoryTest.java b/legacy/src/test/java/org/fdroid/fdroid/installer/InstallerFactoryTest.java similarity index 100% rename from app/src/test/java/org/fdroid/fdroid/installer/InstallerFactoryTest.java rename to legacy/src/test/java/org/fdroid/fdroid/installer/InstallerFactoryTest.java diff --git a/app/src/test/java/org/fdroid/fdroid/views/AppDetailsAdapterTest.java b/legacy/src/test/java/org/fdroid/fdroid/views/AppDetailsAdapterTest.java similarity index 100% rename from app/src/test/java/org/fdroid/fdroid/views/AppDetailsAdapterTest.java rename to legacy/src/test/java/org/fdroid/fdroid/views/AppDetailsAdapterTest.java diff --git a/app/src/test/java/org/fdroid/fdroid/views/main/MainActivityTest.java b/legacy/src/test/java/org/fdroid/fdroid/views/main/MainActivityTest.java similarity index 100% rename from app/src/test/java/org/fdroid/fdroid/views/main/MainActivityTest.java rename to legacy/src/test/java/org/fdroid/fdroid/views/main/MainActivityTest.java diff --git a/app/src/test/java/org/fdroid/fdroid/work/CleanCacheWorkerTest.java b/legacy/src/test/java/org/fdroid/fdroid/work/CleanCacheWorkerTest.java similarity index 100% rename from app/src/test/java/org/fdroid/fdroid/work/CleanCacheWorkerTest.java rename to legacy/src/test/java/org/fdroid/fdroid/work/CleanCacheWorkerTest.java diff --git a/app/src/test/java/org/fdroid/fdroid/work/FDroidMetricsWorkerTest.java b/legacy/src/test/java/org/fdroid/fdroid/work/FDroidMetricsWorkerTest.java similarity index 97% rename from app/src/test/java/org/fdroid/fdroid/work/FDroidMetricsWorkerTest.java rename to legacy/src/test/java/org/fdroid/fdroid/work/FDroidMetricsWorkerTest.java index d5f8883f5..ce7ccbd4c 100644 --- a/app/src/test/java/org/fdroid/fdroid/work/FDroidMetricsWorkerTest.java +++ b/legacy/src/test/java/org/fdroid/fdroid/work/FDroidMetricsWorkerTest.java @@ -16,6 +16,7 @@ import org.fdroid.fdroid.TestUtils; import org.fdroid.fdroid.installer.InstallHistoryService; import org.fdroid.fdroid.work.FDroidMetricsWorker.MatomoEvent; import org.junit.Before; +import org.junit.Ignore; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.RobolectricTestRunner; @@ -89,6 +90,7 @@ public class FDroidMetricsWorkerTest { } @Test + @Ignore("This fails in the first week of the year") // FIXME public void testGetReportingWeekStart() throws ParseException { long now = System.currentTimeMillis(); long start = FDroidMetricsWorker.getReportingWeekStart(now); diff --git a/app/src/test/resources/Norway_bouvet_europe_2.obf.zip b/legacy/src/test/resources/Norway_bouvet_europe_2.obf.zip similarity index 100% rename from app/src/test/resources/Norway_bouvet_europe_2.obf.zip rename to legacy/src/test/resources/Norway_bouvet_europe_2.obf.zip diff --git a/app/src/test/resources/additional_repos.xml b/legacy/src/test/resources/additional_repos.xml similarity index 100% rename from app/src/test/resources/additional_repos.xml rename to legacy/src/test/resources/additional_repos.xml diff --git a/app/src/test/resources/all_fields_index-v1.json b/legacy/src/test/resources/all_fields_index-v1.json similarity index 100% rename from app/src/test/resources/all_fields_index-v1.json rename to legacy/src/test/resources/all_fields_index-v1.json diff --git a/app/src/test/resources/install_history_all b/legacy/src/test/resources/install_history_all similarity index 100% rename from app/src/test/resources/install_history_all rename to legacy/src/test/resources/install_history_all diff --git a/app/src/test/resources/org.fdroid.fdroid.privileged.ota_2110.zip b/legacy/src/test/resources/org.fdroid.fdroid.privileged.ota_2110.zip similarity index 100% rename from app/src/test/resources/org.fdroid.fdroid.privileged.ota_2110.zip rename to legacy/src/test/resources/org.fdroid.fdroid.privileged.ota_2110.zip diff --git a/app/src/test/resources/ugly_additional_repos.xml b/legacy/src/test/resources/ugly_additional_repos.xml similarity index 100% rename from app/src/test/resources/ugly_additional_repos.xml rename to legacy/src/test/resources/ugly_additional_repos.xml diff --git a/app/src/testFull/java/kellinwood/security/zipsigner/ZipSignerTest.java b/legacy/src/testFull/java/kellinwood/security/zipsigner/ZipSignerTest.java similarity index 100% rename from app/src/testFull/java/kellinwood/security/zipsigner/ZipSignerTest.java rename to legacy/src/testFull/java/kellinwood/security/zipsigner/ZipSignerTest.java diff --git a/app/src/testFull/java/org/fdroid/fdroid/nearby/LocalHTTPDManagerTest.java b/legacy/src/testFull/java/org/fdroid/fdroid/nearby/LocalHTTPDManagerTest.java similarity index 100% rename from app/src/testFull/java/org/fdroid/fdroid/nearby/LocalHTTPDManagerTest.java rename to legacy/src/testFull/java/org/fdroid/fdroid/nearby/LocalHTTPDManagerTest.java diff --git a/app/src/testFull/java/org/fdroid/fdroid/nearby/LocalHTTPDTest.java b/legacy/src/testFull/java/org/fdroid/fdroid/nearby/LocalHTTPDTest.java similarity index 100% rename from app/src/testFull/java/org/fdroid/fdroid/nearby/LocalHTTPDTest.java rename to legacy/src/testFull/java/org/fdroid/fdroid/nearby/LocalHTTPDTest.java diff --git a/app/src/testFull/java/org/fdroid/fdroid/nearby/LocalRepoKeyStoreTest.java b/legacy/src/testFull/java/org/fdroid/fdroid/nearby/LocalRepoKeyStoreTest.java similarity index 100% rename from app/src/testFull/java/org/fdroid/fdroid/nearby/LocalRepoKeyStoreTest.java rename to legacy/src/testFull/java/org/fdroid/fdroid/nearby/LocalRepoKeyStoreTest.java diff --git a/app/src/testFull/java/org/fdroid/fdroid/nearby/WifiStateChangeServiceTest.java b/legacy/src/testFull/java/org/fdroid/fdroid/nearby/WifiStateChangeServiceTest.java similarity index 100% rename from app/src/testFull/java/org/fdroid/fdroid/nearby/WifiStateChangeServiceTest.java rename to legacy/src/testFull/java/org/fdroid/fdroid/nearby/WifiStateChangeServiceTest.java diff --git a/app/src/testFull/resources/icon.png b/legacy/src/testFull/resources/icon.png similarity index 100% rename from app/src/testFull/resources/icon.png rename to legacy/src/testFull/resources/icon.png diff --git a/app/src/testFull/resources/index.html b/legacy/src/testFull/resources/index.html similarity index 100% rename from app/src/testFull/resources/index.html rename to legacy/src/testFull/resources/index.html diff --git a/app/src/testFull/resources/test.html b/legacy/src/testFull/resources/test.html similarity index 100% rename from app/src/testFull/resources/test.html rename to legacy/src/testFull/resources/test.html diff --git a/app/src/testFull/resources/urzip.apk b/legacy/src/testFull/resources/urzip.apk similarity index 100% rename from app/src/testFull/resources/urzip.apk rename to legacy/src/testFull/resources/urzip.apk diff --git a/app/tools/download-material-icon.sh b/legacy/tools/download-material-icon.sh similarity index 100% rename from app/tools/download-material-icon.sh rename to legacy/tools/download-material-icon.sh diff --git a/app/tools/svg-to-drawables.sh b/legacy/tools/svg-to-drawables.sh similarity index 100% rename from app/tools/svg-to-drawables.sh rename to legacy/tools/svg-to-drawables.sh diff --git a/app/tools/test-search-intents.sh b/legacy/tools/test-search-intents.sh similarity index 100% rename from app/tools/test-search-intents.sh rename to legacy/tools/test-search-intents.sh diff --git a/libs/database/api/database.api b/libs/database/api/database.api index e69de29bb..63ff7c4c5 100644 --- a/libs/database/api/database.api +++ b/libs/database/api/database.api @@ -0,0 +1,884 @@ +public final class org/fdroid/database/AntiFeature : org/fdroid/database/RepoAttribute { + public static final field TABLE Ljava/lang/String; + public fun (JLjava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)V + public synthetic fun (JLjava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component3 ()Ljava/util/Map; + public final fun copy (JLjava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Lorg/fdroid/database/AntiFeature; + public static synthetic fun copy$default (Lorg/fdroid/database/AntiFeature;JLjava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Lorg/fdroid/database/AntiFeature; + public fun equals (Ljava/lang/Object;)Z + public fun getIcon ()Ljava/util/Map; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/fdroid/database/App : org/fdroid/database/MinimalApp { + public final fun component1 ()Lorg/fdroid/database/AppMetadata; + public fun equals (Ljava/lang/Object;)Z + public final fun getAuthorName ()Ljava/lang/String; + public final fun getDescription (Landroidx/core/os/LocaleListCompat;)Ljava/lang/String; + public final fun getFeatureGraphic (Landroidx/core/os/LocaleListCompat;)Lorg/fdroid/index/v2/FileV2; + public fun getIcon (Landroidx/core/os/LocaleListCompat;)Lorg/fdroid/index/v2/FileV2; + public final fun getMetadata ()Lorg/fdroid/database/AppMetadata; + public fun getName ()Ljava/lang/String; + public fun getPackageName ()Ljava/lang/String; + public final fun getPhoneScreenshots (Landroidx/core/os/LocaleListCompat;)Ljava/util/List; + public final fun getPromoGraphic (Landroidx/core/os/LocaleListCompat;)Lorg/fdroid/index/v2/FileV2; + public fun getRepoId ()J + public final fun getSevenInchScreenshots (Landroidx/core/os/LocaleListCompat;)Ljava/util/List; + public fun getSummary ()Ljava/lang/String; + public final fun getTenInchScreenshots (Landroidx/core/os/LocaleListCompat;)Ljava/util/List; + public final fun getTvBanner (Landroidx/core/os/LocaleListCompat;)Lorg/fdroid/index/v2/FileV2; + public final fun getTvScreenshots (Landroidx/core/os/LocaleListCompat;)Ljava/util/List; + public final fun getVideo (Landroidx/core/os/LocaleListCompat;)Ljava/lang/String; + public final fun getWearScreenshots (Landroidx/core/os/LocaleListCompat;)Ljava/util/List; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/fdroid/database/AppCheckResult { + public fun (Ljava/util/List;Ljava/util/List;)V + public final fun component1 ()Ljava/util/List; + public final fun component2 ()Ljava/util/List; + public final fun copy (Ljava/util/List;Ljava/util/List;)Lorg/fdroid/database/AppCheckResult; + public static synthetic fun copy$default (Lorg/fdroid/database/AppCheckResult;Ljava/util/List;Ljava/util/List;ILjava/lang/Object;)Lorg/fdroid/database/AppCheckResult; + public fun equals (Ljava/lang/Object;)Z + public final fun getIssues ()Ljava/util/List; + public final fun getUpdates ()Ljava/util/List; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class org/fdroid/database/AppDao { + public abstract fun getAllApps (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun getApp (JLjava/lang/String;)Lorg/fdroid/database/App; + public abstract fun getApp (Ljava/lang/String;)Landroidx/lifecycle/LiveData; + public abstract fun getAppListItems (Landroid/content/pm/PackageManager;JLjava/lang/String;Lorg/fdroid/database/AppListSortOrder;)Landroidx/lifecycle/LiveData; + public abstract fun getAppListItems (Landroid/content/pm/PackageManager;Ljava/lang/String;Ljava/lang/String;Lorg/fdroid/database/AppListSortOrder;)Landroidx/lifecycle/LiveData; + public abstract fun getAppListItems (Landroid/content/pm/PackageManager;Ljava/lang/String;Lorg/fdroid/database/AppListSortOrder;)Landroidx/lifecycle/LiveData; + public abstract fun getAppListItemsForAuthor (Landroid/content/pm/PackageManager;Ljava/lang/String;Ljava/lang/String;Lorg/fdroid/database/AppListSortOrder;)Landroidx/lifecycle/LiveData; + public abstract fun getAppOverviewItems (I)Landroidx/lifecycle/LiveData; + public abstract fun getAppOverviewItems (Ljava/lang/String;I)Landroidx/lifecycle/LiveData; + public static synthetic fun getAppOverviewItems$default (Lorg/fdroid/database/AppDao;IILjava/lang/Object;)Landroidx/lifecycle/LiveData; + public static synthetic fun getAppOverviewItems$default (Lorg/fdroid/database/AppDao;Ljava/lang/String;IILjava/lang/Object;)Landroidx/lifecycle/LiveData; + public abstract fun getAppSearchItems (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun getApps (Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun getAppsByAuthor (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun getAppsByCategory (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun getAppsByRepository (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun getAppsFlow (Ljava/util/List;)Lkotlinx/coroutines/flow/Flow; + public abstract fun getInstalledAppListItems (Landroid/content/pm/PackageManager;)Landroidx/lifecycle/LiveData; + public abstract fun getInstalledAppListItems (Ljava/util/Map;)Lkotlinx/coroutines/flow/Flow; + public abstract fun getNewApps (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun getNewApps$default (Lorg/fdroid/database/AppDao;JLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public abstract fun getNewAppsFlow (J)Lkotlinx/coroutines/flow/Flow; + public static synthetic fun getNewAppsFlow$default (Lorg/fdroid/database/AppDao;JILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow; + public abstract fun getNumberOfAppsInCategory (Ljava/lang/String;)I + public abstract fun getNumberOfAppsInRepository (J)I + public abstract fun getRecentlyUpdatedApps (ILkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun getRecentlyUpdatedApps$default (Lorg/fdroid/database/AppDao;ILkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public abstract fun getRecentlyUpdatedAppsFlow (I)Lkotlinx/coroutines/flow/Flow; + public static synthetic fun getRecentlyUpdatedAppsFlow$default (Lorg/fdroid/database/AppDao;IILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow; + public abstract fun getRepositoryIdsForApp (Ljava/lang/String;)Ljava/util/List; + public abstract fun hasAuthorMoreThanOneApp (Ljava/lang/String;)Landroidx/lifecycle/LiveData; + public abstract fun insert (JLjava/lang/String;Lorg/fdroid/index/v2/MetadataV2;Landroidx/core/os/LocaleListCompat;)V + public static synthetic fun insert$default (Lorg/fdroid/database/AppDao;JLjava/lang/String;Lorg/fdroid/index/v2/MetadataV2;Landroidx/core/os/LocaleListCompat;ILjava/lang/Object;)V + public abstract fun updateCompatibility (J)V +} + +public final class org/fdroid/database/AppDao$DefaultImpls { + public static synthetic fun getAppOverviewItems$default (Lorg/fdroid/database/AppDao;IILjava/lang/Object;)Landroidx/lifecycle/LiveData; + public static synthetic fun getAppOverviewItems$default (Lorg/fdroid/database/AppDao;Ljava/lang/String;IILjava/lang/Object;)Landroidx/lifecycle/LiveData; + public static synthetic fun getNewApps$default (Lorg/fdroid/database/AppDao;JLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public static synthetic fun getNewAppsFlow$default (Lorg/fdroid/database/AppDao;JILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow; + public static synthetic fun getRecentlyUpdatedApps$default (Lorg/fdroid/database/AppDao;ILkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public static synthetic fun getRecentlyUpdatedAppsFlow$default (Lorg/fdroid/database/AppDao;IILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow; + public static synthetic fun insert$default (Lorg/fdroid/database/AppDao;JLjava/lang/String;Lorg/fdroid/index/v2/MetadataV2;Landroidx/core/os/LocaleListCompat;ILjava/lang/Object;)V +} + +public abstract interface class org/fdroid/database/AppIssue { +} + +public final class org/fdroid/database/AppListItem : org/fdroid/database/MinimalApp { + public final fun component1 ()J + public final fun component10 ()Ljava/lang/String; + public final fun component11 ()Ljava/lang/String; + public final fun component12 ()Ljava/lang/Long; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()J + public final fun component6 ()Ljava/util/List; + public final fun component9 ()Z + public fun equals (Ljava/lang/Object;)Z + public final fun getAntiFeatureKeys ()Ljava/util/List; + public final fun getAntiFeatureReason (Ljava/lang/String;Landroidx/core/os/LocaleListCompat;)Ljava/lang/String; + public final fun getCategories ()Ljava/util/List; + public fun getIcon (Landroidx/core/os/LocaleListCompat;)Lorg/fdroid/index/v2/FileV2; + public final fun getInstalledVersionCode ()Ljava/lang/Long; + public final fun getInstalledVersionName ()Ljava/lang/String; + public final fun getLastUpdated ()J + public fun getName ()Ljava/lang/String; + public fun getPackageName ()Ljava/lang/String; + public final fun getPreferredSigner ()Ljava/lang/String; + public fun getRepoId ()J + public fun getSummary ()Ljava/lang/String; + public fun hashCode ()I + public final fun isCompatible ()Z + public fun toString ()Ljava/lang/String; +} + +public final class org/fdroid/database/AppListSortOrder : java/lang/Enum { + public static final field LAST_UPDATED Lorg/fdroid/database/AppListSortOrder; + public static final field NAME Lorg/fdroid/database/AppListSortOrder; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lorg/fdroid/database/AppListSortOrder; + public static fun values ()[Lorg/fdroid/database/AppListSortOrder; +} + +public final class org/fdroid/database/AppManifest : org/fdroid/index/v2/PackageManifest { + public fun (Ljava/lang/String;JLorg/fdroid/index/v2/UsesSdkV2;Ljava/lang/Integer;Lorg/fdroid/index/v2/SignerV2;Ljava/util/List;Ljava/util/List;)V + public synthetic fun (Ljava/lang/String;JLorg/fdroid/index/v2/UsesSdkV2;Ljava/lang/Integer;Lorg/fdroid/index/v2/SignerV2;Ljava/util/List;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()J + public final fun component3 ()Lorg/fdroid/index/v2/UsesSdkV2; + public final fun component4 ()Ljava/lang/Integer; + public final fun component5 ()Lorg/fdroid/index/v2/SignerV2; + public final fun component6 ()Ljava/util/List; + public final fun component7 ()Ljava/util/List; + public final fun copy (Ljava/lang/String;JLorg/fdroid/index/v2/UsesSdkV2;Ljava/lang/Integer;Lorg/fdroid/index/v2/SignerV2;Ljava/util/List;Ljava/util/List;)Lorg/fdroid/database/AppManifest; + public static synthetic fun copy$default (Lorg/fdroid/database/AppManifest;Ljava/lang/String;JLorg/fdroid/index/v2/UsesSdkV2;Ljava/lang/Integer;Lorg/fdroid/index/v2/SignerV2;Ljava/util/List;Ljava/util/List;ILjava/lang/Object;)Lorg/fdroid/database/AppManifest; + public fun equals (Ljava/lang/Object;)Z + public fun getFeatureNames ()Ljava/util/List; + public final fun getFeatures ()Ljava/util/List; + public fun getMaxSdkVersion ()Ljava/lang/Integer; + public fun getMinSdkVersion ()Ljava/lang/Integer; + public fun getNativecode ()Ljava/util/List; + public final fun getSigner ()Lorg/fdroid/index/v2/SignerV2; + public fun getTargetSdkVersion ()Ljava/lang/Integer; + public final fun getUsesSdk ()Lorg/fdroid/index/v2/UsesSdkV2; + public final fun getVersionCode ()J + public final fun getVersionName ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/fdroid/database/AppMetadata { + public static final field TABLE Ljava/lang/String; + public fun (JLjava/lang/String;JJLjava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Z)V + public synthetic fun (JLjava/lang/String;JJLjava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()J + public final fun component10 ()Ljava/lang/String; + public final fun component11 ()Ljava/lang/String; + public final fun component12 ()Ljava/lang/String; + public final fun component13 ()Ljava/lang/String; + public final fun component14 ()Ljava/lang/String; + public final fun component15 ()Ljava/lang/String; + public final fun component16 ()Ljava/lang/String; + public final fun component17 ()Ljava/util/Map; + public final fun component18 ()Ljava/lang/String; + public final fun component19 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component20 ()Ljava/lang/String; + public final fun component21 ()Ljava/lang/String; + public final fun component22 ()Ljava/util/List; + public final fun component23 ()Ljava/lang/String; + public final fun component24 ()Ljava/lang/String; + public final fun component25 ()Ljava/lang/String; + public final fun component26 ()Ljava/lang/String; + public final fun component27 ()Ljava/lang/String; + public final fun component28 ()Ljava/lang/String; + public final fun component29 ()Ljava/util/List; + public final fun component3 ()J + public final fun component30 ()Z + public final fun component4 ()J + public final fun component5 ()Ljava/util/Map; + public final fun component6 ()Ljava/util/Map; + public final fun component7 ()Ljava/util/Map; + public final fun component8 ()Ljava/lang/String; + public final fun component9 ()Ljava/lang/String; + public final fun copy (JLjava/lang/String;JJLjava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Z)Lorg/fdroid/database/AppMetadata; + public static synthetic fun copy$default (Lorg/fdroid/database/AppMetadata;JLjava/lang/String;JJLjava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;ZILjava/lang/Object;)Lorg/fdroid/database/AppMetadata; + public fun equals (Ljava/lang/Object;)Z + public final fun getAdded ()J + public final fun getAuthorEmail ()Ljava/lang/String; + public final fun getAuthorName ()Ljava/lang/String; + public final fun getAuthorPhone ()Ljava/lang/String; + public final fun getAuthorWebSite ()Ljava/lang/String; + public final fun getBitcoin ()Ljava/lang/String; + public final fun getCategories ()Ljava/util/List; + public final fun getChangelog ()Ljava/lang/String; + public final fun getDescription ()Ljava/util/Map; + public final fun getDonate ()Ljava/util/List; + public final fun getFlattrID ()Ljava/lang/String; + public final fun getIssueTracker ()Ljava/lang/String; + public final fun getLastUpdated ()J + public final fun getLiberapay ()Ljava/lang/String; + public final fun getLiberapayID ()Ljava/lang/String; + public final fun getLicense ()Ljava/lang/String; + public final fun getLitecoin ()Ljava/lang/String; + public final fun getLocalizedName ()Ljava/lang/String; + public final fun getLocalizedSummary ()Ljava/lang/String; + public final fun getName ()Ljava/util/Map; + public final fun getOpenCollective ()Ljava/lang/String; + public final fun getPackageName ()Ljava/lang/String; + public final fun getPreferredSigner ()Ljava/lang/String; + public final fun getRepoId ()J + public final fun getSourceCode ()Ljava/lang/String; + public final fun getSummary ()Ljava/util/Map; + public final fun getTranslation ()Ljava/lang/String; + public final fun getVideo ()Ljava/util/Map; + public final fun getWebSite ()Ljava/lang/String; + public fun hashCode ()I + public final fun isCompatible ()Z + public fun toString ()Ljava/lang/String; +} + +public final class org/fdroid/database/AppOverviewItem : org/fdroid/database/MinimalApp { + public final fun component1 ()J + public final fun component12 ()Z + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()J + public final fun component4 ()J + public final fun component5 ()Ljava/lang/String; + public final fun component6 ()Ljava/lang/String; + public final fun component9 ()Ljava/util/List; + public fun equals (Ljava/lang/Object;)Z + public final fun getAdded ()J + public final fun getAntiFeatureKeys ()Ljava/util/List; + public final fun getCategories ()Ljava/util/List; + public fun getIcon (Landroidx/core/os/LocaleListCompat;)Lorg/fdroid/index/v2/FileV2; + public final fun getLastUpdated ()J + public fun getName ()Ljava/lang/String; + public final fun getName (Landroidx/core/os/LocaleListCompat;)Ljava/lang/String; + public fun getPackageName ()Ljava/lang/String; + public fun getRepoId ()J + public fun getSummary ()Ljava/lang/String; + public final fun getSummary (Landroidx/core/os/LocaleListCompat;)Ljava/lang/String; + public fun hashCode ()I + public final fun isCompatible ()Z + public fun toString ()Ljava/lang/String; +} + +public final class org/fdroid/database/AppPrefs : org/fdroid/PackagePreference { + public static final field TABLE Ljava/lang/String; + public fun (Ljava/lang/String;JLjava/lang/Long;Ljava/util/List;)V + public synthetic fun (Ljava/lang/String;JLjava/lang/Long;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()J + public final fun component3 ()Ljava/lang/Long; + public final fun copy (Ljava/lang/String;JLjava/lang/Long;Ljava/util/List;)Lorg/fdroid/database/AppPrefs; + public static synthetic fun copy$default (Lorg/fdroid/database/AppPrefs;Ljava/lang/String;JLjava/lang/Long;Ljava/util/List;ILjava/lang/Object;)Lorg/fdroid/database/AppPrefs; + public fun equals (Ljava/lang/Object;)Z + public final fun getIgnoreAllUpdates ()Z + public fun getIgnoreVersionCodeUpdate ()J + public final fun getPackageName ()Ljava/lang/String; + public final fun getPreferredRepoId ()Ljava/lang/Long; + public fun getReleaseChannels ()Ljava/util/List; + public fun hashCode ()I + public final fun shouldIgnoreUpdate (J)Z + public fun toString ()Ljava/lang/String; + public final fun toggleIgnoreAllUpdates ()Lorg/fdroid/database/AppPrefs; + public final fun toggleIgnoreVersionCodeUpdate (J)Lorg/fdroid/database/AppPrefs; + public final fun toggleReleaseChannel (Ljava/lang/String;)Lorg/fdroid/database/AppPrefs; +} + +public abstract interface class org/fdroid/database/AppPrefsDao { + public abstract fun getAppPrefs (Ljava/lang/String;)Landroidx/lifecycle/LiveData; + public abstract fun update (Lorg/fdroid/database/AppPrefs;)V +} + +public final class org/fdroid/database/AppSearchItem : java/lang/Comparable { + public synthetic fun compareTo (Ljava/lang/Object;)I + public fun compareTo (Lorg/fdroid/database/AppSearchItem;)I + public final fun component1 ()J + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()J + public final fun component4 ()Ljava/util/Map; + public final fun component5 ()Ljava/util/Map; + public final fun component6 ()Ljava/util/Map; + public final fun component7 ()Ljava/lang/String; + public final fun component8 ()Ljava/util/List; + public fun equals (Ljava/lang/Object;)Z + public final fun getAuthorName ()Ljava/lang/String; + public final fun getCategories ()Ljava/util/List; + public final fun getDescription ()Ljava/util/Map; + public final fun getIcon (Landroidx/core/os/LocaleListCompat;)Lorg/fdroid/index/v2/FileV2; + public final fun getLastUpdated ()J + public final fun getName ()Ljava/util/Map; + public final fun getPackageName ()Ljava/lang/String; + public final fun getRepoId ()J + public final fun getScore ()D + public final fun getSummary ()Ljava/util/Map; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/fdroid/database/AppVersion : org/fdroid/index/v2/PackageVersion { + public fun equals (Ljava/lang/Object;)Z + public fun getAdded ()J + public final fun getAntiFeatureKeys ()Ljava/util/List; + public final fun getAntiFeatureReason (Ljava/lang/String;Landroidx/core/os/LocaleListCompat;)Ljava/lang/String; + public final fun getFeatureNames ()Ljava/util/List; + public final fun getFile ()Lorg/fdroid/index/v2/FileV1; + public fun getHasKnownVulnerability ()Z + public final fun getManifest ()Lorg/fdroid/database/AppManifest; + public final fun getNativeCode ()Ljava/util/List; + public fun getPackageManifest ()Lorg/fdroid/index/v2/PackageManifest; + public final fun getPackageName ()Ljava/lang/String; + public fun getReleaseChannels ()Ljava/util/List; + public final fun getRepoId ()J + public fun getSigner ()Lorg/fdroid/index/v2/SignerV2; + public fun getSize ()Ljava/lang/Long; + public final fun getSrc ()Lorg/fdroid/index/v2/FileV2; + public final fun getUsesPermission ()Ljava/util/List; + public final fun getUsesPermissionSdk23 ()Ljava/util/List; + public fun getVersionCode ()J + public fun getVersionName ()Ljava/lang/String; + public final fun getWhatsNew (Landroidx/core/os/LocaleListCompat;)Ljava/lang/String; + public fun hashCode ()I + public final fun isCompatible ()Z + public fun toString ()Ljava/lang/String; +} + +public abstract interface class org/fdroid/database/AppWithIssue { + public abstract fun getInstallVersionName ()Ljava/lang/String; + public abstract fun getIssue ()Lorg/fdroid/database/AppIssue; + public abstract fun getPackageName ()Ljava/lang/String; +} + +public final class org/fdroid/database/AvailableAppWithIssue : org/fdroid/database/AppWithIssue { + public fun (Lorg/fdroid/database/AppOverviewItem;Ljava/lang/String;JLorg/fdroid/database/AppIssue;)V + public final fun component1 ()Lorg/fdroid/database/AppOverviewItem; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()J + public final fun component4 ()Lorg/fdroid/database/AppIssue; + public final fun copy (Lorg/fdroid/database/AppOverviewItem;Ljava/lang/String;JLorg/fdroid/database/AppIssue;)Lorg/fdroid/database/AvailableAppWithIssue; + public static synthetic fun copy$default (Lorg/fdroid/database/AvailableAppWithIssue;Lorg/fdroid/database/AppOverviewItem;Ljava/lang/String;JLorg/fdroid/database/AppIssue;ILjava/lang/Object;)Lorg/fdroid/database/AvailableAppWithIssue; + public fun equals (Ljava/lang/Object;)Z + public final fun getApp ()Lorg/fdroid/database/AppOverviewItem; + public final fun getInstallVersionCode ()J + public fun getInstallVersionName ()Ljava/lang/String; + public fun getIssue ()Lorg/fdroid/database/AppIssue; + public fun getPackageName ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/fdroid/database/Category : org/fdroid/database/RepoAttribute { + public static final field TABLE Ljava/lang/String; + public fun (JLjava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)V + public synthetic fun (JLjava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()J + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/util/Map; + public final fun copy (JLjava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Lorg/fdroid/database/Category; + public static synthetic fun copy$default (Lorg/fdroid/database/Category;JLjava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Lorg/fdroid/database/Category; + public fun equals (Ljava/lang/Object;)Z + public fun getIcon ()Ljava/util/Map; + public final fun getId ()Ljava/lang/String; + public final fun getRepoId ()J + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/fdroid/database/DbAppChecker { + public fun (Lorg/fdroid/database/FDroidDatabase;Landroid/content/Context;Lorg/fdroid/CompatibilityChecker;Lorg/fdroid/UpdateChecker;)V + public synthetic fun (Lorg/fdroid/database/FDroidDatabase;Landroid/content/Context;Lorg/fdroid/CompatibilityChecker;Lorg/fdroid/UpdateChecker;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getApps (Ljava/util/Map;)Lorg/fdroid/database/AppCheckResult; +} + +public final class org/fdroid/database/DbUpdateChecker { + public fun (Lorg/fdroid/database/FDroidDatabase;Landroid/content/pm/PackageManager;)V + public fun (Lorg/fdroid/database/FDroidDatabase;Landroid/content/pm/PackageManager;Lorg/fdroid/CompatibilityChecker;)V + public fun (Lorg/fdroid/database/FDroidDatabase;Landroid/content/pm/PackageManager;Lorg/fdroid/CompatibilityChecker;Lorg/fdroid/UpdateChecker;)V + public synthetic fun (Lorg/fdroid/database/FDroidDatabase;Landroid/content/pm/PackageManager;Lorg/fdroid/CompatibilityChecker;Lorg/fdroid/UpdateChecker;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getSuggestedVersion (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Z)Lorg/fdroid/database/AppVersion; + public static synthetic fun getSuggestedVersion$default (Lorg/fdroid/database/DbUpdateChecker;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;ZILjava/lang/Object;)Lorg/fdroid/database/AppVersion; + public final fun getUpdatableApps ()Ljava/util/List; + public final fun getUpdatableApps (Ljava/util/List;)Ljava/util/List; + public final fun getUpdatableApps (Ljava/util/List;Z)Ljava/util/List; + public final fun getUpdatableApps (Ljava/util/List;ZZ)Ljava/util/List; + public static synthetic fun getUpdatableApps$default (Lorg/fdroid/database/DbUpdateChecker;Ljava/util/List;ZZILjava/lang/Object;)Ljava/util/List; +} + +public abstract interface class org/fdroid/database/FDroidDatabase { + public abstract fun afterLocalesChanged (Landroidx/core/os/LocaleListCompat;)V + public static synthetic fun afterLocalesChanged$default (Lorg/fdroid/database/FDroidDatabase;Landroidx/core/os/LocaleListCompat;ILjava/lang/Object;)V + public abstract fun clearAllAppData ()V + public abstract fun getAppDao ()Lorg/fdroid/database/AppDao; + public abstract fun getAppPrefsDao ()Lorg/fdroid/database/AppPrefsDao; + public abstract fun getRepositoryDao ()Lorg/fdroid/database/RepositoryDao; + public abstract fun getVersionDao ()Lorg/fdroid/database/VersionDao; + public abstract fun runInTransaction (Ljava/lang/Runnable;)V + public abstract fun runInTransaction (Ljava/util/concurrent/Callable;)Ljava/lang/Object; +} + +public final class org/fdroid/database/FDroidDatabase$DefaultImpls { + public static synthetic fun afterLocalesChanged$default (Lorg/fdroid/database/FDroidDatabase;Landroidx/core/os/LocaleListCompat;ILjava/lang/Object;)V +} + +public final class org/fdroid/database/FDroidDatabaseHolder { + public static final field INSTANCE Lorg/fdroid/database/FDroidDatabaseHolder; + public static final fun getDb (Landroid/content/Context;)Lorg/fdroid/database/FDroidDatabase; + public static final fun getDb (Landroid/content/Context;Ljava/lang/String;)Lorg/fdroid/database/FDroidDatabase; + public static final fun getDb (Landroid/content/Context;Ljava/lang/String;Lorg/fdroid/database/FDroidFixture;)Lorg/fdroid/database/FDroidDatabase; + public static synthetic fun getDb$default (Landroid/content/Context;Ljava/lang/String;Lorg/fdroid/database/FDroidFixture;ILjava/lang/Object;)Lorg/fdroid/database/FDroidDatabase; +} + +public abstract interface class org/fdroid/database/FDroidFixture { + public abstract fun prePopulateDb (Lorg/fdroid/database/FDroidDatabase;)V +} + +public final class org/fdroid/database/InitialRepository { + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JZ)V + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;JZ)V + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;JZI)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;JZIILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/util/List; + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()Ljava/lang/String; + public final fun component6 ()J + public final fun component7 ()Z + public final fun component8 ()I + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;JZI)Lorg/fdroid/database/InitialRepository; + public static synthetic fun copy$default (Lorg/fdroid/database/InitialRepository;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;JZIILjava/lang/Object;)Lorg/fdroid/database/InitialRepository; + public fun equals (Ljava/lang/Object;)Z + public final fun getAddress ()Ljava/lang/String; + public final fun getCertificate ()Ljava/lang/String; + public final fun getDescription ()Ljava/lang/String; + public final fun getEnabled ()Z + public final fun getMirrors ()Ljava/util/List; + public final fun getName ()Ljava/lang/String; + public final fun getVersion ()J + public final fun getWeight ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/fdroid/database/KnownVulnerability : org/fdroid/database/AppIssue { + public fun (Z)V + public final fun component1 ()Z + public final fun copy (Z)Lorg/fdroid/database/KnownVulnerability; + public static synthetic fun copy$default (Lorg/fdroid/database/KnownVulnerability;ZILjava/lang/Object;)Lorg/fdroid/database/KnownVulnerability; + public fun equals (Ljava/lang/Object;)Z + public final fun getFromPreferredRepo ()Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class org/fdroid/database/MinimalApp { + public abstract fun getIcon (Landroidx/core/os/LocaleListCompat;)Lorg/fdroid/index/v2/FileV2; + public abstract fun getName ()Ljava/lang/String; + public abstract fun getPackageName ()Ljava/lang/String; + public abstract fun getRepoId ()J + public abstract fun getSummary ()Ljava/lang/String; +} + +public final class org/fdroid/database/NewRepository { + public fun (Ljava/util/Map;Ljava/util/Map;Ljava/lang/String;Lorg/fdroid/index/IndexFormatVersion;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public synthetic fun (Ljava/util/Map;Ljava/util/Map;Ljava/lang/String;Lorg/fdroid/index/IndexFormatVersion;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/util/Map; + public final fun component2 ()Ljava/util/Map; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Lorg/fdroid/index/IndexFormatVersion; + public final fun component5 ()Ljava/lang/String; + public final fun component6 ()Ljava/lang/String; + public final fun component7 ()Ljava/lang/String; + public final fun copy (Ljava/util/Map;Ljava/util/Map;Ljava/lang/String;Lorg/fdroid/index/IndexFormatVersion;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lorg/fdroid/database/NewRepository; + public static synthetic fun copy$default (Lorg/fdroid/database/NewRepository;Ljava/util/Map;Ljava/util/Map;Ljava/lang/String;Lorg/fdroid/index/IndexFormatVersion;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lorg/fdroid/database/NewRepository; + public fun equals (Ljava/lang/Object;)Z + public final fun getAddress ()Ljava/lang/String; + public final fun getCertificate ()Ljava/lang/String; + public final fun getFormatVersion ()Lorg/fdroid/index/IndexFormatVersion; + public final fun getIcon ()Ljava/util/Map; + public final fun getName ()Ljava/util/Map; + public final fun getPassword ()Ljava/lang/String; + public final fun getUsername ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/fdroid/database/NoCompatibleSigner : org/fdroid/database/AppIssue { + public fun ()V + public fun (Ljava/lang/Long;)V + public synthetic fun (Ljava/lang/Long;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/Long; + public final fun copy (Ljava/lang/Long;)Lorg/fdroid/database/NoCompatibleSigner; + public static synthetic fun copy$default (Lorg/fdroid/database/NoCompatibleSigner;Ljava/lang/Long;ILjava/lang/Object;)Lorg/fdroid/database/NoCompatibleSigner; + public fun equals (Ljava/lang/Object;)Z + public final fun getRepoIdWithCompatibleSigner ()Ljava/lang/Long; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/fdroid/database/NotAvailable : org/fdroid/database/AppIssue { + public static final field INSTANCE Lorg/fdroid/database/NotAvailable; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/fdroid/database/ReleaseChannel : org/fdroid/database/RepoAttribute { + public static final field TABLE Ljava/lang/String; + public fun (JLjava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)V + public synthetic fun (JLjava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component3 ()Ljava/util/Map; + public final fun copy (JLjava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Lorg/fdroid/database/ReleaseChannel; + public static synthetic fun copy$default (Lorg/fdroid/database/ReleaseChannel;JLjava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Lorg/fdroid/database/ReleaseChannel; + public fun equals (Ljava/lang/Object;)Z + public fun getIcon ()Ljava/util/Map; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract class org/fdroid/database/RepoAttribute { + public fun ()V + public final fun getDescription (Landroidx/core/os/LocaleListCompat;)Ljava/lang/String; + public abstract fun getIcon ()Ljava/util/Map; + public final fun getIcon (Landroidx/core/os/LocaleListCompat;)Lorg/fdroid/index/v2/FileV2; + public final fun getName (Landroidx/core/os/LocaleListCompat;)Ljava/lang/String; +} + +public final class org/fdroid/database/Repository { + public fun (JLjava/lang/String;JLorg/fdroid/index/IndexFormatVersion;Ljava/lang/String;JIJ)V + public fun (JLjava/lang/String;JLorg/fdroid/index/IndexFormatVersion;Ljava/lang/String;JIJLjava/lang/String;)V + public fun (JLjava/lang/String;JLorg/fdroid/index/IndexFormatVersion;Ljava/lang/String;JIJLjava/lang/String;Ljava/lang/String;)V + public fun (JLjava/lang/String;JLorg/fdroid/index/IndexFormatVersion;Ljava/lang/String;JIJLjava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public synthetic fun (JLjava/lang/String;JLorg/fdroid/index/IndexFormatVersion;Ljava/lang/String;JIJLjava/lang/String;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun equals (Ljava/lang/Object;)Z + public final fun getAddress ()Ljava/lang/String; + public final fun getAllMirrors ()Ljava/util/List; + public final fun getAllMirrors (Z)Ljava/util/List; + public static synthetic fun getAllMirrors$default (Lorg/fdroid/database/Repository;ZILjava/lang/Object;)Ljava/util/List; + public final fun getAllOfficialMirrors ()Ljava/util/List; + public final fun getAllUserMirrors ()Ljava/util/List; + public final fun getAntiFeatures ()Ljava/util/Map; + public final fun getCategories ()Ljava/util/Map; + public final fun getCertificate ()Ljava/lang/String; + public final fun getDescription (Landroidx/core/os/LocaleListCompat;)Ljava/lang/String; + public final fun getDisabledMirrors ()Ljava/util/List; + public final fun getEnabled ()Z + public final fun getErrorCount ()I + public final fun getFingerprint ()Ljava/lang/String; + public final fun getFormatVersion ()Lorg/fdroid/index/IndexFormatVersion; + public final fun getIcon (Landroidx/core/os/LocaleListCompat;)Lorg/fdroid/index/v2/FileV2; + public final fun getLastETag ()Ljava/lang/String; + public final fun getLastError ()Ljava/lang/String; + public final fun getLastUpdated ()Ljava/lang/Long; + public final fun getMirrors ()Ljava/util/List; + public final fun getName (Landroidx/core/os/LocaleListCompat;)Ljava/lang/String; + public final fun getPassword ()Ljava/lang/String; + public final fun getReleaseChannels ()Ljava/util/Map; + public final fun getRepoId ()J + public final fun getShareUri ()Ljava/lang/String; + public final fun getTimestamp ()J + public final fun getUserMirrors ()Ljava/util/List; + public final fun getUsername ()Ljava/lang/String; + public final fun getVersion ()J + public final fun getWebBaseUrl ()Ljava/lang/String; + public final fun getWeight ()I + public fun hashCode ()I + public final fun isArchiveRepo ()Z + public fun toString ()Ljava/lang/String; +} + +public abstract interface class org/fdroid/database/RepositoryDao { + public abstract fun clearAll ()V + public abstract fun deleteRepository (J)V + public abstract fun getLiveCategories ()Landroidx/lifecycle/LiveData; + public abstract fun getLiveRepositories ()Landroidx/lifecycle/LiveData; + public abstract fun getRepositories ()Ljava/util/List; + public abstract fun getRepository (J)Lorg/fdroid/database/Repository; + public abstract fun insert (Lorg/fdroid/database/InitialRepository;)J + public abstract fun insert (Lorg/fdroid/database/NewRepository;)J + public abstract fun setRepositoryEnabled (JZ)V + public abstract fun updateDisabledMirrors (JLjava/util/List;)V + public abstract fun updateUserMirrors (JLjava/util/List;)V + public abstract fun updateUsernameAndPassword (JLjava/lang/String;Ljava/lang/String;)V + public abstract fun walCheckpoint ()V +} + +public final class org/fdroid/database/RepositoryKt { + public static final fun getDUMMY_TEST_REPO ()Lorg/fdroid/database/Repository; +} + +public final class org/fdroid/database/UnavailableAppWithIssue : org/fdroid/database/AppWithIssue { + public fun (Ljava/lang/String;Ljava/lang/CharSequence;Ljava/lang/String;J)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/CharSequence; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()J + public final fun copy (Ljava/lang/String;Ljava/lang/CharSequence;Ljava/lang/String;J)Lorg/fdroid/database/UnavailableAppWithIssue; + public static synthetic fun copy$default (Lorg/fdroid/database/UnavailableAppWithIssue;Ljava/lang/String;Ljava/lang/CharSequence;Ljava/lang/String;JILjava/lang/Object;)Lorg/fdroid/database/UnavailableAppWithIssue; + public fun equals (Ljava/lang/Object;)Z + public final fun getInstallVersionCode ()J + public fun getInstallVersionName ()Ljava/lang/String; + public fun getIssue ()Lorg/fdroid/database/AppIssue; + public final fun getName ()Ljava/lang/CharSequence; + public fun getPackageName ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/fdroid/database/UpdatableApp : org/fdroid/database/MinimalApp { + public final fun component1 ()J + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()J + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()Lorg/fdroid/database/AppVersion; + public final fun component6 ()Z + public final fun component7 ()Z + public final fun component8 ()Ljava/lang/String; + public final fun component9 ()Ljava/lang/String; + public fun equals (Ljava/lang/Object;)Z + public final fun getHasKnownVulnerability ()Z + public fun getIcon (Landroidx/core/os/LocaleListCompat;)Lorg/fdroid/index/v2/FileV2; + public final fun getInstalledVersionCode ()J + public final fun getInstalledVersionName ()Ljava/lang/String; + public fun getName ()Ljava/lang/String; + public fun getPackageName ()Ljava/lang/String; + public fun getRepoId ()J + public fun getSummary ()Ljava/lang/String; + public final fun getUpdate ()Lorg/fdroid/database/AppVersion; + public fun hashCode ()I + public final fun isFromPreferredRepo ()Z + public fun toString ()Ljava/lang/String; +} + +public final class org/fdroid/database/UpdateInOtherRepo : org/fdroid/database/AppIssue { + public fun (J)V + public final fun component1 ()J + public final fun copy (J)Lorg/fdroid/database/UpdateInOtherRepo; + public static synthetic fun copy$default (Lorg/fdroid/database/UpdateInOtherRepo;JILjava/lang/Object;)Lorg/fdroid/database/UpdateInOtherRepo; + public fun equals (Ljava/lang/Object;)Z + public final fun getRepoIdWithUpdate ()J + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class org/fdroid/database/VersionDao { + public abstract fun getAppVersions (JLjava/lang/String;)Landroidx/lifecycle/LiveData; + public abstract fun getAppVersions (Ljava/lang/String;)Landroidx/lifecycle/LiveData; + public abstract fun insert (JLjava/lang/String;Ljava/util/Map;Lkotlin/jvm/functions/Function1;)V +} + +public abstract class org/fdroid/download/DownloaderFactory { + public fun ()V + public abstract fun create (Lorg/fdroid/database/Repository;Landroid/net/Uri;Lorg/fdroid/IndexFile;Ljava/io/File;)Lorg/fdroid/download/Downloader; + protected abstract fun create (Lorg/fdroid/database/Repository;Ljava/util/List;Landroid/net/Uri;Lorg/fdroid/IndexFile;Ljava/io/File;Lorg/fdroid/download/Mirror;)Lorg/fdroid/download/Downloader; + public final fun createWithTryFirstMirror (Lorg/fdroid/database/Repository;Landroid/net/Uri;Lorg/fdroid/IndexFile;Ljava/io/File;)Lorg/fdroid/download/Downloader; +} + +public final class org/fdroid/index/IndexFormatVersion : java/lang/Enum { + public static final field ONE Lorg/fdroid/index/IndexFormatVersion; + public static final field TWO Lorg/fdroid/index/IndexFormatVersion; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lorg/fdroid/index/IndexFormatVersion; + public static fun values ()[Lorg/fdroid/index/IndexFormatVersion; +} + +public abstract interface class org/fdroid/index/IndexUpdateListener { + public abstract fun onDownloadProgress (Lorg/fdroid/database/Repository;JJ)V + public abstract fun onUpdateProgress (Lorg/fdroid/database/Repository;II)V +} + +public abstract class org/fdroid/index/IndexUpdateResult { +} + +public final class org/fdroid/index/IndexUpdateResult$Error : org/fdroid/index/IndexUpdateResult { + public fun (Ljava/lang/Exception;)V + public final fun component1 ()Ljava/lang/Exception; + public final fun copy (Ljava/lang/Exception;)Lorg/fdroid/index/IndexUpdateResult$Error; + public static synthetic fun copy$default (Lorg/fdroid/index/IndexUpdateResult$Error;Ljava/lang/Exception;ILjava/lang/Object;)Lorg/fdroid/index/IndexUpdateResult$Error; + public fun equals (Ljava/lang/Object;)Z + public final fun getE ()Ljava/lang/Exception; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/fdroid/index/IndexUpdateResult$NotFound : org/fdroid/index/IndexUpdateResult { + public static final field INSTANCE Lorg/fdroid/index/IndexUpdateResult$NotFound; +} + +public final class org/fdroid/index/IndexUpdateResult$Processed : org/fdroid/index/IndexUpdateResult { + public static final field INSTANCE Lorg/fdroid/index/IndexUpdateResult$Processed; +} + +public final class org/fdroid/index/IndexUpdateResult$Unchanged : org/fdroid/index/IndexUpdateResult { + public static final field INSTANCE Lorg/fdroid/index/IndexUpdateResult$Unchanged; +} + +public abstract class org/fdroid/index/IndexUpdater { + public fun ()V + public abstract fun getFormatVersion ()Lorg/fdroid/index/IndexFormatVersion; + public final fun update (Lorg/fdroid/database/Repository;)Lorg/fdroid/index/IndexUpdateResult; + protected abstract fun updateRepo (Lorg/fdroid/database/Repository;)Lorg/fdroid/index/IndexUpdateResult; +} + +public final class org/fdroid/index/RepoManager { + public fun (Landroid/content/Context;Lorg/fdroid/database/FDroidDatabase;Lorg/fdroid/download/DownloaderFactory;Lorg/fdroid/download/HttpManager;)V + public fun (Landroid/content/Context;Lorg/fdroid/database/FDroidDatabase;Lorg/fdroid/download/DownloaderFactory;Lorg/fdroid/download/HttpManager;Lorg/fdroid/index/RepoUriBuilder;)V + public fun (Landroid/content/Context;Lorg/fdroid/database/FDroidDatabase;Lorg/fdroid/download/DownloaderFactory;Lorg/fdroid/download/HttpManager;Lorg/fdroid/index/RepoUriBuilder;Lorg/fdroid/CompatibilityChecker;)V + public fun (Landroid/content/Context;Lorg/fdroid/database/FDroidDatabase;Lorg/fdroid/download/DownloaderFactory;Lorg/fdroid/download/HttpManager;Lorg/fdroid/index/RepoUriBuilder;Lorg/fdroid/CompatibilityChecker;Lkotlin/coroutines/CoroutineContext;)V + public synthetic fun (Landroid/content/Context;Lorg/fdroid/database/FDroidDatabase;Lorg/fdroid/download/DownloaderFactory;Lorg/fdroid/download/HttpManager;Lorg/fdroid/index/RepoUriBuilder;Lorg/fdroid/CompatibilityChecker;Lkotlin/coroutines/CoroutineContext;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun abortAddingRepository ()V + public final fun addFetchedRepository ()V + public final fun deleteRepository (J)V + public final fun deleteUserMirror (JLorg/fdroid/download/Mirror;)V + public final fun fetchRepositoryPreview (Ljava/lang/String;)V + public final fun fetchRepositoryPreview (Ljava/lang/String;Ljava/net/Proxy;)V + public static synthetic fun fetchRepositoryPreview$default (Lorg/fdroid/index/RepoManager;Ljava/lang/String;Ljava/net/Proxy;ILjava/lang/Object;)V + public final fun getAddRepoState ()Lkotlinx/coroutines/flow/StateFlow; + public final fun getLiveAddRepoState ()Landroidx/lifecycle/LiveData; + public final fun getLiveRepositories ()Landroidx/lifecycle/LiveData; + public final fun getRepositories ()Ljava/util/List; + public final fun getRepositoriesState ()Lkotlinx/coroutines/flow/StateFlow; + public final fun getRepository (J)Lorg/fdroid/database/Repository; + public final fun isSwapUri (Landroid/net/Uri;)Z + public final fun reorderRepositories (Lorg/fdroid/database/Repository;Lorg/fdroid/database/Repository;)V + public final fun setArchiveRepoEnabled (Lorg/fdroid/database/Repository;ZLjava/net/Proxy;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun setArchiveRepoEnabled$default (Lorg/fdroid/index/RepoManager;Lorg/fdroid/database/Repository;ZLjava/net/Proxy;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public final fun setMirrorEnabled (JLorg/fdroid/download/Mirror;Z)V + public final fun setPreferredRepoId (Ljava/lang/String;J)Lkotlinx/coroutines/Job; + public final fun setRepositoryEnabled (JZ)V + public final fun updateUsernameAndPassword (JLjava/lang/String;Ljava/lang/String;)V +} + +public final class org/fdroid/index/RepoUpdater { + public fun (Ljava/io/File;Lorg/fdroid/database/FDroidDatabase;Lorg/fdroid/download/DownloaderFactory;Lorg/fdroid/index/RepoUriBuilder;Lorg/fdroid/CompatibilityChecker;Lorg/fdroid/index/IndexUpdateListener;)V + public synthetic fun (Ljava/io/File;Lorg/fdroid/database/FDroidDatabase;Lorg/fdroid/download/DownloaderFactory;Lorg/fdroid/index/RepoUriBuilder;Lorg/fdroid/CompatibilityChecker;Lorg/fdroid/index/IndexUpdateListener;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun update (Lorg/fdroid/database/Repository;)Lorg/fdroid/index/IndexUpdateResult; +} + +public abstract interface class org/fdroid/index/RepoUriBuilder { + public abstract fun getUri (Lorg/fdroid/database/Repository;[Ljava/lang/String;)Landroid/net/Uri; +} + +public abstract interface class org/fdroid/index/TempFileProvider { + public abstract fun createTempFile (Ljava/lang/String;)Ljava/io/File; +} + +public final class org/fdroid/index/v1/IndexV1Updater : org/fdroid/index/IndexUpdater { + public fun (Lorg/fdroid/database/FDroidDatabase;Lorg/fdroid/index/TempFileProvider;Lorg/fdroid/download/DownloaderFactory;Lorg/fdroid/index/RepoUriBuilder;Lorg/fdroid/CompatibilityChecker;Lorg/fdroid/index/IndexUpdateListener;)V + public synthetic fun (Lorg/fdroid/database/FDroidDatabase;Lorg/fdroid/index/TempFileProvider;Lorg/fdroid/download/DownloaderFactory;Lorg/fdroid/index/RepoUriBuilder;Lorg/fdroid/CompatibilityChecker;Lorg/fdroid/index/IndexUpdateListener;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun getFormatVersion ()Lorg/fdroid/index/IndexFormatVersion; +} + +public final class org/fdroid/index/v1/IndexV1UpdaterKt { + public static final field SIGNED_FILE_NAME Ljava/lang/String; +} + +public final class org/fdroid/index/v2/IndexV2Updater : org/fdroid/index/IndexUpdater { + public fun (Lorg/fdroid/database/FDroidDatabase;Lorg/fdroid/index/TempFileProvider;Lorg/fdroid/download/DownloaderFactory;Lorg/fdroid/index/RepoUriBuilder;Lorg/fdroid/CompatibilityChecker;Lorg/fdroid/index/IndexUpdateListener;)V + public synthetic fun (Lorg/fdroid/database/FDroidDatabase;Lorg/fdroid/index/TempFileProvider;Lorg/fdroid/download/DownloaderFactory;Lorg/fdroid/index/RepoUriBuilder;Lorg/fdroid/CompatibilityChecker;Lorg/fdroid/index/IndexUpdateListener;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun getFormatVersion ()Lorg/fdroid/index/IndexFormatVersion; +} + +public final class org/fdroid/index/v2/IndexV2UpdaterKt { + public static final field SIGNED_FILE_NAME Ljava/lang/String; +} + +public final class org/fdroid/repo/AddRepoError : org/fdroid/repo/AddRepoState { + public fun (Lorg/fdroid/repo/AddRepoError$ErrorType;Ljava/lang/Exception;)V + public synthetic fun (Lorg/fdroid/repo/AddRepoError$ErrorType;Ljava/lang/Exception;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lorg/fdroid/repo/AddRepoError$ErrorType; + public final fun component2 ()Ljava/lang/Exception; + public final fun copy (Lorg/fdroid/repo/AddRepoError$ErrorType;Ljava/lang/Exception;)Lorg/fdroid/repo/AddRepoError; + public static synthetic fun copy$default (Lorg/fdroid/repo/AddRepoError;Lorg/fdroid/repo/AddRepoError$ErrorType;Ljava/lang/Exception;ILjava/lang/Object;)Lorg/fdroid/repo/AddRepoError; + public fun equals (Ljava/lang/Object;)Z + public final fun getErrorType ()Lorg/fdroid/repo/AddRepoError$ErrorType; + public final fun getException ()Ljava/lang/Exception; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/fdroid/repo/AddRepoError$ErrorType : java/lang/Enum { + public static final field INVALID_FINGERPRINT Lorg/fdroid/repo/AddRepoError$ErrorType; + public static final field INVALID_INDEX Lorg/fdroid/repo/AddRepoError$ErrorType; + public static final field IO_ERROR Lorg/fdroid/repo/AddRepoError$ErrorType; + public static final field IS_ARCHIVE_REPO Lorg/fdroid/repo/AddRepoError$ErrorType; + public static final field UNKNOWN_SOURCES_DISALLOWED Lorg/fdroid/repo/AddRepoError$ErrorType; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lorg/fdroid/repo/AddRepoError$ErrorType; + public static fun values ()[Lorg/fdroid/repo/AddRepoError$ErrorType; +} + +public abstract class org/fdroid/repo/AddRepoState { +} + +public final class org/fdroid/repo/Added : org/fdroid/repo/AddRepoState { + public fun (Lorg/fdroid/database/Repository;Lorg/fdroid/index/IndexUpdateResult;)V + public final fun getRepo ()Lorg/fdroid/database/Repository; + public final fun getUpdateResult ()Lorg/fdroid/index/IndexUpdateResult; +} + +public final class org/fdroid/repo/Adding : org/fdroid/repo/AddRepoState { + public static final field INSTANCE Lorg/fdroid/repo/Adding; +} + +public abstract class org/fdroid/repo/FetchResult { +} + +public final class org/fdroid/repo/FetchResult$IsExistingMirror : org/fdroid/repo/FetchResult { + public fun (J)V + public final fun component1 ()J + public final fun copy (J)Lorg/fdroid/repo/FetchResult$IsExistingMirror; + public static synthetic fun copy$default (Lorg/fdroid/repo/FetchResult$IsExistingMirror;JILjava/lang/Object;)Lorg/fdroid/repo/FetchResult$IsExistingMirror; + public fun equals (Ljava/lang/Object;)Z + public final fun getExistingRepoId ()J + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/fdroid/repo/FetchResult$IsExistingRepository : org/fdroid/repo/FetchResult { + public fun (J)V + public final fun component1 ()J + public final fun copy (J)Lorg/fdroid/repo/FetchResult$IsExistingRepository; + public static synthetic fun copy$default (Lorg/fdroid/repo/FetchResult$IsExistingRepository;JILjava/lang/Object;)Lorg/fdroid/repo/FetchResult$IsExistingRepository; + public fun equals (Ljava/lang/Object;)Z + public final fun getExistingRepoId ()J + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/fdroid/repo/FetchResult$IsNewMirror : org/fdroid/repo/FetchResult { + public fun (J)V + public final fun copy (J)Lorg/fdroid/repo/FetchResult$IsNewMirror; + public static synthetic fun copy$default (Lorg/fdroid/repo/FetchResult$IsNewMirror;JILjava/lang/Object;)Lorg/fdroid/repo/FetchResult$IsNewMirror; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/fdroid/repo/FetchResult$IsNewRepoAndNewMirror : org/fdroid/repo/FetchResult { + public static final field INSTANCE Lorg/fdroid/repo/FetchResult$IsNewRepoAndNewMirror; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/fdroid/repo/FetchResult$IsNewRepository : org/fdroid/repo/FetchResult { + public static final field INSTANCE Lorg/fdroid/repo/FetchResult$IsNewRepository; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/fdroid/repo/Fetching : org/fdroid/repo/AddRepoState { + public fun (Ljava/lang/String;Lorg/fdroid/database/Repository;Ljava/util/List;Lorg/fdroid/repo/FetchResult;Ljava/io/File;)V + public synthetic fun (Ljava/lang/String;Lorg/fdroid/database/Repository;Ljava/util/List;Lorg/fdroid/repo/FetchResult;Ljava/io/File;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getApps ()Ljava/util/List; + public final fun getDone ()Z + public final fun getFetchResult ()Lorg/fdroid/repo/FetchResult; + public final fun getFetchUrl ()Ljava/lang/String; + public final fun getIndexFile ()Ljava/io/File; + public final fun getReceivedRepo ()Lorg/fdroid/database/Repository; + public fun toString ()Ljava/lang/String; +} + +public final class org/fdroid/repo/None : org/fdroid/repo/AddRepoState { + public static final field INSTANCE Lorg/fdroid/repo/None; +} + diff --git a/libs/database/schemas/org.fdroid.database.FDroidDatabaseInt/10.json b/libs/database/schemas/org.fdroid.database.FDroidDatabaseInt/10.json new file mode 100644 index 000000000..7d6b060a1 --- /dev/null +++ b/libs/database/schemas/org.fdroid.database.FDroidDatabaseInt/10.json @@ -0,0 +1,1057 @@ +{ + "formatVersion": 1, + "database": { + "version": 10, + "identityHash": "8f0f4908b8d68da8d14d4f7cd51f9861", + "entities": [ + { + "tableName": "CoreRepository", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon` TEXT, `address` TEXT NOT NULL, `webBaseUrl` TEXT, `timestamp` INTEGER NOT NULL, `version` INTEGER, `formatVersion` TEXT, `maxAge` INTEGER, `description` TEXT NOT NULL, `certificate` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT" + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "webBaseUrl", + "columnName": "webBaseUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER" + }, + { + "fieldPath": "formatVersion", + "columnName": "formatVersion", + "affinity": "TEXT" + }, + { + "fieldPath": "maxAge", + "columnName": "maxAge", + "affinity": "INTEGER" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "certificate", + "columnName": "certificate", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "repoId" + ] + } + }, + { + "tableName": "Mirror", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `url` TEXT NOT NULL, `countryCode` TEXT, `isPrimary` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`repoId`, `url`), FOREIGN KEY(`repoId`) REFERENCES `CoreRepository`(`repoId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "countryCode", + "columnName": "countryCode", + "affinity": "TEXT" + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repoId", + "url" + ] + }, + "foreignKeys": [ + { + "table": "CoreRepository", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repoId" + ], + "referencedColumns": [ + "repoId" + ] + } + ] + }, + { + "tableName": "AntiFeature", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `id` TEXT NOT NULL, `icon` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, PRIMARY KEY(`repoId`, `id`), FOREIGN KEY(`repoId`) REFERENCES `CoreRepository`(`repoId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repoId", + "id" + ] + }, + "foreignKeys": [ + { + "table": "CoreRepository", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repoId" + ], + "referencedColumns": [ + "repoId" + ] + } + ] + }, + { + "tableName": "Category", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `id` TEXT NOT NULL, `icon` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, PRIMARY KEY(`repoId`, `id`), FOREIGN KEY(`repoId`) REFERENCES `CoreRepository`(`repoId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repoId", + "id" + ] + }, + "foreignKeys": [ + { + "table": "CoreRepository", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repoId" + ], + "referencedColumns": [ + "repoId" + ] + } + ] + }, + { + "tableName": "ReleaseChannel", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `id` TEXT NOT NULL, `icon` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, PRIMARY KEY(`repoId`, `id`), FOREIGN KEY(`repoId`) REFERENCES `CoreRepository`(`repoId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repoId", + "id" + ] + }, + "foreignKeys": [ + { + "table": "CoreRepository", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repoId" + ], + "referencedColumns": [ + "repoId" + ] + } + ] + }, + { + "tableName": "RepositoryPreferences", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `lastUpdated` INTEGER, `lastETag` TEXT, `userMirrors` TEXT, `disabledMirrors` TEXT, `username` TEXT, `password` TEXT, `errorCount` INTEGER NOT NULL DEFAULT 0, `lastError` TEXT, PRIMARY KEY(`repoId`))", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastETag", + "columnName": "lastETag", + "affinity": "TEXT" + }, + { + "fieldPath": "userMirrors", + "columnName": "userMirrors", + "affinity": "TEXT" + }, + { + "fieldPath": "disabledMirrors", + "columnName": "disabledMirrors", + "affinity": "TEXT" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT" + }, + { + "fieldPath": "errorCount", + "columnName": "errorCount", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastError", + "columnName": "lastError", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repoId" + ] + } + }, + { + "tableName": "AppMetadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `packageName` TEXT NOT NULL, `added` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `name` TEXT, `summary` TEXT, `description` TEXT, `localizedName` TEXT, `localizedSummary` TEXT, `webSite` TEXT, `changelog` TEXT, `license` TEXT, `sourceCode` TEXT, `issueTracker` TEXT, `translation` TEXT, `preferredSigner` TEXT, `video` TEXT, `authorName` TEXT, `authorEmail` TEXT, `authorWebSite` TEXT, `authorPhone` TEXT, `donate` TEXT, `liberapayID` TEXT, `liberapay` TEXT, `openCollective` TEXT, `bitcoin` TEXT, `litecoin` TEXT, `flattrID` TEXT, `categories` TEXT, `isCompatible` INTEGER NOT NULL, PRIMARY KEY(`repoId`, `packageName`), FOREIGN KEY(`repoId`) REFERENCES `CoreRepository`(`repoId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "added", + "columnName": "added", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "summary", + "columnName": "summary", + "affinity": "TEXT" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "localizedName", + "columnName": "localizedName", + "affinity": "TEXT" + }, + { + "fieldPath": "localizedSummary", + "columnName": "localizedSummary", + "affinity": "TEXT" + }, + { + "fieldPath": "webSite", + "columnName": "webSite", + "affinity": "TEXT" + }, + { + "fieldPath": "changelog", + "columnName": "changelog", + "affinity": "TEXT" + }, + { + "fieldPath": "license", + "columnName": "license", + "affinity": "TEXT" + }, + { + "fieldPath": "sourceCode", + "columnName": "sourceCode", + "affinity": "TEXT" + }, + { + "fieldPath": "issueTracker", + "columnName": "issueTracker", + "affinity": "TEXT" + }, + { + "fieldPath": "translation", + "columnName": "translation", + "affinity": "TEXT" + }, + { + "fieldPath": "preferredSigner", + "columnName": "preferredSigner", + "affinity": "TEXT" + }, + { + "fieldPath": "video", + "columnName": "video", + "affinity": "TEXT" + }, + { + "fieldPath": "authorName", + "columnName": "authorName", + "affinity": "TEXT" + }, + { + "fieldPath": "authorEmail", + "columnName": "authorEmail", + "affinity": "TEXT" + }, + { + "fieldPath": "authorWebSite", + "columnName": "authorWebSite", + "affinity": "TEXT" + }, + { + "fieldPath": "authorPhone", + "columnName": "authorPhone", + "affinity": "TEXT" + }, + { + "fieldPath": "donate", + "columnName": "donate", + "affinity": "TEXT" + }, + { + "fieldPath": "liberapayID", + "columnName": "liberapayID", + "affinity": "TEXT" + }, + { + "fieldPath": "liberapay", + "columnName": "liberapay", + "affinity": "TEXT" + }, + { + "fieldPath": "openCollective", + "columnName": "openCollective", + "affinity": "TEXT" + }, + { + "fieldPath": "bitcoin", + "columnName": "bitcoin", + "affinity": "TEXT" + }, + { + "fieldPath": "litecoin", + "columnName": "litecoin", + "affinity": "TEXT" + }, + { + "fieldPath": "flattrID", + "columnName": "flattrID", + "affinity": "TEXT" + }, + { + "fieldPath": "categories", + "columnName": "categories", + "affinity": "TEXT" + }, + { + "fieldPath": "isCompatible", + "columnName": "isCompatible", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repoId", + "packageName" + ] + }, + "foreignKeys": [ + { + "table": "CoreRepository", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repoId" + ], + "referencedColumns": [ + "repoId" + ] + } + ] + }, + { + "tableName": "AppMetadataFts", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`repoId` INTEGER NOT NULL, `name` TEXT, `summary` TEXT, `description` TEXT, `authorName` TEXT, `packageName` TEXT NOT NULL, tokenize=unicode61 `remove_diacritics=1` `separators=.` `tokenchars=-`, content=`AppMetadata`, notindexed=`repoId`)", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "summary", + "columnName": "summary", + "affinity": "TEXT" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "authorName", + "columnName": "authorName", + "affinity": "TEXT" + }, + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [] + }, + "ftsVersion": "FTS4", + "ftsOptions": { + "tokenizer": "unicode61", + "tokenizerArgs": [ + "remove_diacritics=1", + "separators=.", + "tokenchars=-" + ], + "contentTable": "AppMetadata", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [ + "repoId" + ], + "prefixSizes": [], + "preferredOrder": "ASC" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_AppMetadataFts_BEFORE_UPDATE BEFORE UPDATE ON `AppMetadata` BEGIN DELETE FROM `AppMetadataFts` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_AppMetadataFts_BEFORE_DELETE BEFORE DELETE ON `AppMetadata` BEGIN DELETE FROM `AppMetadataFts` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_AppMetadataFts_AFTER_UPDATE AFTER UPDATE ON `AppMetadata` BEGIN INSERT INTO `AppMetadataFts`(`docid`, `repoId`, `name`, `summary`, `description`, `authorName`, `packageName`) VALUES (NEW.`rowid`, NEW.`repoId`, NEW.`name`, NEW.`summary`, NEW.`description`, NEW.`authorName`, NEW.`packageName`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_AppMetadataFts_AFTER_INSERT AFTER INSERT ON `AppMetadata` BEGIN INSERT INTO `AppMetadataFts`(`docid`, `repoId`, `name`, `summary`, `description`, `authorName`, `packageName`) VALUES (NEW.`rowid`, NEW.`repoId`, NEW.`name`, NEW.`summary`, NEW.`description`, NEW.`authorName`, NEW.`packageName`); END" + ] + }, + { + "tableName": "LocalizedFile", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `packageName` TEXT NOT NULL, `type` TEXT NOT NULL, `locale` TEXT NOT NULL, `name` TEXT NOT NULL, `sha256` TEXT, `size` INTEGER, `ipfsCidV1` TEXT, PRIMARY KEY(`repoId`, `packageName`, `type`, `locale`), FOREIGN KEY(`repoId`, `packageName`) REFERENCES `AppMetadata`(`repoId`, `packageName`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sha256", + "columnName": "sha256", + "affinity": "TEXT" + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER" + }, + { + "fieldPath": "ipfsCidV1", + "columnName": "ipfsCidV1", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repoId", + "packageName", + "type", + "locale" + ] + }, + "foreignKeys": [ + { + "table": "AppMetadata", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repoId", + "packageName" + ], + "referencedColumns": [ + "repoId", + "packageName" + ] + } + ] + }, + { + "tableName": "LocalizedFileList", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `packageName` TEXT NOT NULL, `type` TEXT NOT NULL, `locale` TEXT NOT NULL, `name` TEXT NOT NULL, `sha256` TEXT, `size` INTEGER, `ipfsCidV1` TEXT, PRIMARY KEY(`repoId`, `packageName`, `type`, `locale`, `name`), FOREIGN KEY(`repoId`, `packageName`) REFERENCES `AppMetadata`(`repoId`, `packageName`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sha256", + "columnName": "sha256", + "affinity": "TEXT" + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER" + }, + { + "fieldPath": "ipfsCidV1", + "columnName": "ipfsCidV1", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repoId", + "packageName", + "type", + "locale", + "name" + ] + }, + "foreignKeys": [ + { + "table": "AppMetadata", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repoId", + "packageName" + ], + "referencedColumns": [ + "repoId", + "packageName" + ] + } + ] + }, + { + "tableName": "Version", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `packageName` TEXT NOT NULL, `versionId` TEXT NOT NULL, `added` INTEGER NOT NULL, `releaseChannels` TEXT, `antiFeatures` TEXT, `whatsNew` TEXT, `appLabel` TEXT, `isCompatible` INTEGER NOT NULL, `file_name` TEXT NOT NULL, `file_sha256` TEXT NOT NULL, `file_size` INTEGER, `file_ipfsCidV1` TEXT, `src_name` TEXT, `src_sha256` TEXT, `src_size` INTEGER, `src_ipfsCidV1` TEXT, `manifest_versionName` TEXT NOT NULL, `manifest_versionCode` INTEGER NOT NULL, `manifest_maxSdkVersion` INTEGER, `manifest_nativecode` TEXT, `manifest_features` TEXT, `manifest_usesSdk_minSdkVersion` INTEGER, `manifest_usesSdk_targetSdkVersion` INTEGER, `manifest_signer_sha256` TEXT, `manifest_signer_hasMultipleSigners` INTEGER, PRIMARY KEY(`repoId`, `packageName`, `versionId`), FOREIGN KEY(`repoId`, `packageName`) REFERENCES `AppMetadata`(`repoId`, `packageName`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "versionId", + "columnName": "versionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "added", + "columnName": "added", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "releaseChannels", + "columnName": "releaseChannels", + "affinity": "TEXT" + }, + { + "fieldPath": "antiFeatures", + "columnName": "antiFeatures", + "affinity": "TEXT" + }, + { + "fieldPath": "whatsNew", + "columnName": "whatsNew", + "affinity": "TEXT" + }, + { + "fieldPath": "appLabel", + "columnName": "appLabel", + "affinity": "TEXT" + }, + { + "fieldPath": "isCompatible", + "columnName": "isCompatible", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "file.name", + "columnName": "file_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "file.sha256", + "columnName": "file_sha256", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "file.size", + "columnName": "file_size", + "affinity": "INTEGER" + }, + { + "fieldPath": "file.ipfsCidV1", + "columnName": "file_ipfsCidV1", + "affinity": "TEXT" + }, + { + "fieldPath": "src.name", + "columnName": "src_name", + "affinity": "TEXT" + }, + { + "fieldPath": "src.sha256", + "columnName": "src_sha256", + "affinity": "TEXT" + }, + { + "fieldPath": "src.size", + "columnName": "src_size", + "affinity": "INTEGER" + }, + { + "fieldPath": "src.ipfsCidV1", + "columnName": "src_ipfsCidV1", + "affinity": "TEXT" + }, + { + "fieldPath": "manifest.versionName", + "columnName": "manifest_versionName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "manifest.versionCode", + "columnName": "manifest_versionCode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "manifest.maxSdkVersion", + "columnName": "manifest_maxSdkVersion", + "affinity": "INTEGER" + }, + { + "fieldPath": "manifest.nativecode", + "columnName": "manifest_nativecode", + "affinity": "TEXT" + }, + { + "fieldPath": "manifest.features", + "columnName": "manifest_features", + "affinity": "TEXT" + }, + { + "fieldPath": "manifest.usesSdk.minSdkVersion", + "columnName": "manifest_usesSdk_minSdkVersion", + "affinity": "INTEGER" + }, + { + "fieldPath": "manifest.usesSdk.targetSdkVersion", + "columnName": "manifest_usesSdk_targetSdkVersion", + "affinity": "INTEGER" + }, + { + "fieldPath": "manifest.signer.sha256", + "columnName": "manifest_signer_sha256", + "affinity": "TEXT" + }, + { + "fieldPath": "manifest.signer.hasMultipleSigners", + "columnName": "manifest_signer_hasMultipleSigners", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repoId", + "packageName", + "versionId" + ] + }, + "foreignKeys": [ + { + "table": "AppMetadata", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repoId", + "packageName" + ], + "referencedColumns": [ + "repoId", + "packageName" + ] + } + ] + }, + { + "tableName": "VersionedString", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `packageName` TEXT NOT NULL, `versionId` TEXT NOT NULL, `type` TEXT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER, PRIMARY KEY(`repoId`, `packageName`, `versionId`, `type`, `name`), FOREIGN KEY(`repoId`, `packageName`, `versionId`) REFERENCES `Version`(`repoId`, `packageName`, `versionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "versionId", + "columnName": "versionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repoId", + "packageName", + "versionId", + "type", + "name" + ] + }, + "foreignKeys": [ + { + "table": "Version", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repoId", + "packageName", + "versionId" + ], + "referencedColumns": [ + "repoId", + "packageName", + "versionId" + ] + } + ] + }, + { + "tableName": "AppPrefs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `ignoreVersionCodeUpdate` INTEGER NOT NULL, `preferredRepoId` INTEGER, `appPrefReleaseChannels` TEXT, PRIMARY KEY(`packageName`))", + "fields": [ + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ignoreVersionCodeUpdate", + "columnName": "ignoreVersionCodeUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "preferredRepoId", + "columnName": "preferredRepoId", + "affinity": "INTEGER" + }, + { + "fieldPath": "appPrefReleaseChannels", + "columnName": "appPrefReleaseChannels", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "packageName" + ] + } + } + ], + "views": [ + { + "viewName": "LocalizedIcon", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM LocalizedFile WHERE type='icon'" + }, + { + "viewName": "HighestVersion", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT repoId, packageName, antiFeatures FROM Version\n GROUP BY repoId, packageName HAVING MAX(manifest_versionCode)" + }, + { + "viewName": "PreferredRepo", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT packageName, repoId AS preferredRepoId FROM AppMetadata\n JOIN RepositoryPreferences AS pref USING (repoId)\n LEFT JOIN AppPrefs USING (packageName)\n WHERE pref.enabled = 1 AND (repoId = COALESCE(preferredRepoId, repoId) OR\n NOT EXISTS (SELECT 1 FROM AppMetadata WHERE repoId=AppPrefs.preferredRepoId AND packageName=AppPrefs.packageName)\n )\n GROUP BY packageName HAVING MAX(pref.weight)" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8f0f4908b8d68da8d14d4f7cd51f9861')" + ] + } +} \ No newline at end of file diff --git a/libs/database/src/dbTest/java/org/fdroid/database/AppListItemsTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/AppListItemsTest.kt index 460a1f462..d25bae15f 100644 --- a/libs/database/src/dbTest/java/org/fdroid/database/AppListItemsTest.kt +++ b/libs/database/src/dbTest/java/org/fdroid/database/AppListItemsTest.kt @@ -6,6 +6,8 @@ import androidx.core.content.pm.PackageInfoCompat import androidx.test.ext.junit.runners.AndroidJUnit4 import io.mockk.every import io.mockk.mockk +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking import org.fdroid.LocaleChooser.getBestLocale import org.fdroid.database.AppListSortOrder.LAST_UPDATED import org.fdroid.database.AppListSortOrder.NAME @@ -17,7 +19,6 @@ import org.fdroid.test.TestUtils.getRandomString import org.fdroid.test.TestVersionUtils.getRandomPackageVersionV2 import org.junit.Test import org.junit.runner.RunWith -import kotlin.collections.map import kotlin.random.Random import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -74,8 +75,10 @@ internal class AppListItemsTest : AppTest() { appDao.getAppListItems(pm, "Two", NAME).getOrFail().let { apps -> assertEquals(1, apps.size) assertEquals(app2, apps[0]) - assertEquals(PackageInfoCompat.getLongVersionCode(packageInfo2), - apps[0].installedVersionCode) + assertEquals( + PackageInfoCompat.getLongVersionCode(packageInfo2), + apps[0].installedVersionCode + ) assertEquals(packageInfo2.versionName, apps[0].installedVersionName) } @@ -127,8 +130,10 @@ internal class AppListItemsTest : AppTest() { appDao.getAppListItems(pm, "A", "Two", NAME).getOrFail().let { apps -> assertEquals(1, apps.size) assertEquals(app2, apps[0]) - assertEquals(PackageInfoCompat.getLongVersionCode(packageInfo2), - apps[0].installedVersionCode) + assertEquals( + PackageInfoCompat.getLongVersionCode(packageInfo2), + apps[0].installedVersionCode + ) assertEquals(packageInfo2.versionName, apps[0].installedVersionName) } @@ -190,8 +195,10 @@ internal class AppListItemsTest : AppTest() { assertEquals(2, apps.size) assertEquals(app3a, apps[0]) assertEquals(app2, apps[1]) - assertEquals(PackageInfoCompat.getLongVersionCode(packageInfo2), - apps[1].installedVersionCode) + assertEquals( + PackageInfoCompat.getLongVersionCode(packageInfo2), + apps[1].installedVersionCode + ) assertEquals(packageInfo2.versionName, apps[1].installedVersionName) } @@ -201,8 +208,10 @@ internal class AppListItemsTest : AppTest() { val sortedApps = apps.sortedBy { it.lastUpdated } assertEquals(app2, sortedApps[0]) assertEquals(app3a, sortedApps[1]) - assertEquals(PackageInfoCompat.getLongVersionCode(packageInfo2), - sortedApps[0].installedVersionCode) + assertEquals( + PackageInfoCompat.getLongVersionCode(packageInfo2), + sortedApps[0].installedVersionCode + ) assertEquals(packageInfo2.versionName, sortedApps[0].installedVersionName) } @@ -577,6 +586,50 @@ internal class AppListItemsTest : AppTest() { } } + @Test + fun testGetInstalledAppListItemsFlow() = runBlocking { + // insert three apps + val repoId = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId, packageName1, app1, locales) + appDao.insert(repoId, packageName2, app2, locales) + appDao.insert(repoId, packageName3, app3, locales) + + // define packageInfo for each test + val packageInfo1 = PackageInfo().apply { + packageName = packageName1 + versionName = getRandomString() + versionCode = Random.nextInt(1, Int.MAX_VALUE) + } + val packageInfo2 = PackageInfo().apply { packageName = packageName2 } + val packageInfo3 = PackageInfo().apply { packageName = packageName3 } + + // all apps get returned, if we consider all of them installed + val pmInfoMap1 = mapOf( + packageName1 to packageInfo1, + packageName2 to packageInfo2, + packageName3 to packageInfo3, + ) + assertEquals(3, appDao.getInstalledAppListItems(pmInfoMap1).first().size) + + // one apps get returned, if we consider only that one installed + val pmInfoMap2 = mapOf(packageName1 to packageInfo1) + appDao.getInstalledAppListItems(pmInfoMap2).first().let { apps -> + assertEquals(1, apps.size) + assertEquals(app1, apps[0]) + // version code and version name gets taken from supplied packageInfo + assertEquals( + PackageInfoCompat.getLongVersionCode(packageInfo1), + apps[0].installedVersionCode + ) + assertEquals(packageInfo1.versionName, apps[0].installedVersionName) + } + + // no app gets returned, if we consider none installed + appDao.getInstalledAppListItems(emptyMap()).first().let { apps -> + assertEquals(0, apps.size) + } + } + @Test fun testGetInstalledAppListItemsMaxVars() { // insert an app @@ -592,12 +645,12 @@ internal class AppListItemsTest : AppTest() { } val packageInfo = packageInfoCreator(packageName) - // sqlite has a maximum number of 999 variables that can be used in a query + // sqlite (before 3.32.0) has a maximum number of 999 variables that can be used in a query // one additional package info is added to the package lists with each test case val listPackageInfo = listOf(packageInfo) - val packageInfoOk = MutableList(998) { packageInfoCreator(getRandomString()) } - val packageInfoNotOk1 = MutableList(999) { packageInfoCreator(getRandomString()) } - val packageInfoNotOk2 = MutableList(5000) { packageInfoCreator(getRandomString()) } + val packageInfoOk = MutableList(998) { packageInfoCreator("${it + 1}") } + val packageInfoNotOk1 = MutableList(999) { packageInfoCreator("${it + 1}") } + val packageInfoNotOk2 = MutableList(5000) { packageInfoCreator("${it + 1}") } // app gets returned no matter how many packages are installed every { pm.getInstalledPackages(0) } returns packageInfoOk + listPackageInfo @@ -616,6 +669,51 @@ internal class AppListItemsTest : AppTest() { assertNotNull(appDao.getInstalledAppListItems(pm).getOrFail()[0].installedVersionName) } + @Test + fun testGetInstalledAppListItemsFlowMaxVars(): Unit = runBlocking { + // insert an app + val repoId = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId, packageName, app1, locales) + + val packageInfoCreator = { name: String -> + PackageInfo().apply { + packageName = name + versionName = name + versionCode = Random.nextInt(1, Int.MAX_VALUE) + } + } + val packageInfo = packageInfoCreator(packageName) + // sqlite (before 3.32.0) has a maximum number of 999 variables that can be used in a query + // one additional package info is added to the package lists with each test case + val packageInfoOk = mutableMapOf().apply { + for (i in 2..999) set("$i", packageInfoCreator("$i")) + set(packageName, packageInfo) + } + val packageInfoNotOk1 = mutableMapOf().apply { + for (i in 2..1000) set("$i", packageInfoCreator("$i")) + set(packageName, packageInfo) + } + val packageInfoNotOk2 = mutableMapOf().apply { + for (i in 2..5000) set("$i", packageInfoCreator("$i")) + set(packageName, packageInfo) + } + // app gets returned no matter how many packages are installed + assertEquals(1, appDao.getInstalledAppListItems(packageInfoOk).first().size) + assertEquals(1, appDao.getInstalledAppListItems(packageInfoNotOk1).first().size) + assertEquals(1, appDao.getInstalledAppListItems(packageInfoNotOk2).first().size) + + // ensure they have version info set + assertNotNull( + appDao.getInstalledAppListItems(packageInfoOk).first()[0].installedVersionName + ) + assertNotNull( + appDao.getInstalledAppListItems(packageInfoNotOk1).first()[0].installedVersionName + ) + assertNotNull( + appDao.getInstalledAppListItems(packageInfoNotOk2).first()[0].installedVersionName + ) + } + // region author tests @Test fun testAuthor_NoApp() { @@ -625,8 +723,10 @@ internal class AppListItemsTest : AppTest() { assertFalse(appDao.hasAuthorMoreThanOneApp(author).getOrFail()) assertTrue(appDao.getAppListItemsForAuthor(pm, author, null, NAME).getOrFail().isEmpty()) - assertTrue(appDao.getAppListItemsForAuthor(pm, author, null, LAST_UPDATED) - .getOrFail().isEmpty()) + assertTrue( + appDao.getAppListItemsForAuthor(pm, author, null, LAST_UPDATED) + .getOrFail().isEmpty() + ) } @Test @@ -640,8 +740,10 @@ internal class AppListItemsTest : AppTest() { assertFalse(appDao.hasAuthorMoreThanOneApp(author).getOrFail()) val appsForAuthor = appDao.getAppListItemsForAuthor(pm, author, null, NAME).getOrFail() assertEquals(1, appsForAuthor.size) - assertEquals(1, appDao.getAppListItemsForAuthor(pm, author, null, LAST_UPDATED) - .getOrFail().size) + assertEquals( + 1, appDao.getAppListItemsForAuthor(pm, author, null, LAST_UPDATED) + .getOrFail().size + ) assertEquals(packageName, appsForAuthor[0].packageName) } diff --git a/libs/database/src/dbTest/java/org/fdroid/index/v1/IndexV1UpdaterTest.kt b/libs/database/src/dbTest/java/org/fdroid/index/v1/IndexV1UpdaterTest.kt index fc2d1b021..b8706492d 100644 --- a/libs/database/src/dbTest/java/org/fdroid/index/v1/IndexV1UpdaterTest.kt +++ b/libs/database/src/dbTest/java/org/fdroid/index/v1/IndexV1UpdaterTest.kt @@ -129,7 +129,14 @@ internal class IndexV1UpdaterTest : DbTest() { assertIs(result.e) // check that the DB transaction was rolled back and the DB wasn't changed - assertEquals(repo, repoDao.getRepository(repoId) ?: fail()) + // except for adding the error to the repo + val expectedRepo = repo.copy( + preferences = repo.preferences.copy( + errorCount = 1, + lastError = "Signing certificate does not match" + ), + ) + assertEquals(expectedRepo, repoDao.getRepository(repoId) ?: fail()) assertEquals(0, appDao.countApps()) assertEquals(0, versionDao.countAppVersions()) } diff --git a/libs/database/src/main/java/org/fdroid/database/App.kt b/libs/database/src/main/java/org/fdroid/database/App.kt index fed489aa6..a9609f9f2 100644 --- a/libs/database/src/main/java/org/fdroid/database/App.kt +++ b/libs/database/src/main/java/org/fdroid/database/App.kt @@ -397,11 +397,13 @@ public data class UpdatableApp internal constructor( public val installedVersionCode: Long, public val installedVersionName: String, public val update: AppVersion, + @Deprecated("Use AppWithIssue instead: UpdateInOtherRepo") public val isFromPreferredRepo: Boolean, /** * If true, this is not necessarily an update (contrary to the class name), * but an app with the `KnownVuln` anti-feature. */ + @Deprecated("Use AppWithIssue instead: KnownVulnerability") public val hasKnownVulnerability: Boolean, public override val name: String? = null, public override val summary: String? = null, diff --git a/libs/database/src/main/java/org/fdroid/database/AppCheckResult.kt b/libs/database/src/main/java/org/fdroid/database/AppCheckResult.kt new file mode 100644 index 000000000..e642cf47b --- /dev/null +++ b/libs/database/src/main/java/org/fdroid/database/AppCheckResult.kt @@ -0,0 +1,54 @@ +package org.fdroid.database + +public data class AppCheckResult( + val updates: List, + val issues: List, +) + +public sealed interface AppWithIssue { + public val packageName: String + public val installVersionName: String + public val issue: AppIssue +} + +public data class AvailableAppWithIssue( + val app: AppOverviewItem, + override val installVersionName: String, + val installVersionCode: Long, + override val issue: AppIssue, +) : AppWithIssue { + override val packageName: String = app.packageName +} + +public data class UnavailableAppWithIssue( + override val packageName: String, + val name: CharSequence?, + override val installVersionName: String, + val installVersionCode: Long, +) : AppWithIssue { + override val issue: AppIssue = NotAvailable +} + +public sealed interface AppIssue + +/** + * An app that we installed in the past, but is no longer available in any (enabled) repository. + */ +public data object NotAvailable : AppIssue + +/** + * An app that can not get updated, because all versions have an incompatible signer. + * There may be compatible versions in another repo. + */ +public data class NoCompatibleSigner(val repoIdWithCompatibleSigner: Long? = null) : AppIssue + +/** + * An app that could get updated, but only from another repo. + */ +public data class UpdateInOtherRepo(val repoIdWithUpdate: Long) : AppIssue + +/** + * Has a known vulnerability and should either get updated or uninstalled. + * @param fromPreferredRepo true if the preferred repo had marked the app with known vulnerability. + */ +public data class KnownVulnerability(val fromPreferredRepo: Boolean) : AppIssue diff --git a/libs/database/src/main/java/org/fdroid/database/AppDao.kt b/libs/database/src/main/java/org/fdroid/database/AppDao.kt index 9541bdfae..8dc055689 100644 --- a/libs/database/src/main/java/org/fdroid/database/AppDao.kt +++ b/libs/database/src/main/java/org/fdroid/database/AppDao.kt @@ -3,12 +3,14 @@ package org.fdroid.database import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.content.res.Resources +import android.os.Build.VERSION.SDK_INT import androidx.annotation.VisibleForTesting import androidx.core.content.pm.PackageInfoCompat import androidx.core.os.ConfigurationCompat.getLocales import androidx.core.os.LocaleListCompat import androidx.lifecycle.LiveData import androidx.lifecycle.MediatorLiveData +import androidx.lifecycle.asFlow import androidx.lifecycle.map import androidx.room.Dao import androidx.room.Insert @@ -132,6 +134,11 @@ public interface AppDao { */ public suspend fun getAppsByRepository(repoId: Long): List + /** + * Returns apps for the given [packageNames]. + */ + public suspend fun getApps(packageNames: List): List + /** * Same as [getNewApps], but returns an observable [Flow]. */ @@ -142,6 +149,11 @@ public interface AppDao { */ public fun getRecentlyUpdatedAppsFlow(limit: Int = 200): Flow> + /** + * Returns apps for the given [packageNames]. + */ + public fun getAppsFlow(packageNames: List): Flow> + /** * Returns a list of all [AppListItem] sorted by the given [sortOrder], * or a subset of [AppListItem]s filtered by the given [searchQuery] if it is non-null. @@ -189,6 +201,9 @@ public interface AppDao { public fun hasAuthorMoreThanOneApp(author: String): LiveData public fun getInstalledAppListItems(packageManager: PackageManager): LiveData> + public fun getInstalledAppListItems( + packageInfoMap: Map, + ): Flow> public suspend fun getAppSearchItems(searchQuery: String): List @@ -515,6 +530,20 @@ internal interface AppDaoInt : AppDao { WHERE repoId = :repoId""") override suspend fun getAppsByRepository(repoId: Long): List + override suspend fun getApps(packageNames: List): List { + val placeholders = buildString { + repeat(packageNames.size) { append("?,") } + }.trimEnd(',') + val query = getAppsQuery( + "packageName IN ($placeholders) ORDER BY app.lastUpdated DESC" + ) { statement -> + packageNames.forEachIndexed { i, packageName -> + statement.bindText(i + 1, packageName) + } + } + return getApps(query) + } + override fun getNewAppsFlow(maxAgeInDays: Long): Flow> { val query = getAppsQuery( @@ -537,6 +566,20 @@ internal interface AppDaoInt : AppDao { return getAppsFlow(query) } + override fun getAppsFlow(packageNames: List): Flow> { + val placeholders = buildString { + repeat(packageNames.size) { append("?,") } + }.trimEnd(',') + val query = getAppsQuery( + "packageName IN ($placeholders) ORDER BY app.lastUpdated DESC" + ) { statement -> + packageNames.forEachIndexed { i, packageName -> + statement.bindText(i + 1, packageName) + } + } + return getAppsFlow(query) + } + private fun getAppsQuery( whereQuery: String, onBindStatement: (SQLiteStatement) -> Unit, @@ -682,8 +725,14 @@ internal interface AppDaoInt : AppDao { private fun LiveData>.map( packageManager: PackageManager, - installedPackages: Map = packageManager.getInstalledPackages(0) - .associateBy { packageInfo -> packageInfo.packageName }, + ): LiveData> { + val installedPackages = packageManager.getInstalledPackages(0) + .associateBy { packageInfo -> packageInfo.packageName } + return map(installedPackages) + } + + private fun LiveData>.map( + installedPackages: Map, ) = map { items -> items.map { item -> val packageInfo = installedPackages[item.packageName] @@ -823,15 +872,30 @@ internal interface AppDaoInt : AppDao { val installedPackages = packageManager.getInstalledPackages(0) .associateBy { packageInfo -> packageInfo.packageName } val packageNames = installedPackages.keys.toList() - return if (packageNames.size <= 999) { - getAppListItems(packageNames).map(packageManager, installedPackages) + // since sqlite 3.32.0 the max variables number was increased to 32766 + return if (packageNames.size <= 999 || SDK_INT >= 31) { + getAppListItems(packageNames).map(installedPackages) } else { AppListLiveData().apply { packageNames.chunked(999) { addSource(getAppListItems(it)) } - }.map(packageManager, installedPackages) + }.map(installedPackages) } } + override fun getInstalledAppListItems( + packageInfoMap: Map, + ): Flow> { + val packageNames = packageInfoMap.keys.toList() + // since sqlite 3.32.0 the max variables number was increased to 32766 + return if (packageNames.size <= 999 || SDK_INT >= 31) { + getAppListItems(packageNames).map(packageInfoMap) + } else { + AppListLiveData().apply { + packageNames.chunked(999) { addSource(getAppListItems(it)) } + }.map(packageInfoMap) + }.asFlow() + } + private class AppListLiveData : MediatorLiveData>() { private val list = ArrayList>>() diff --git a/libs/database/src/main/java/org/fdroid/database/AppPrefsDao.kt b/libs/database/src/main/java/org/fdroid/database/AppPrefsDao.kt index 8a55d9cf2..cca5fdd0e 100644 --- a/libs/database/src/main/java/org/fdroid/database/AppPrefsDao.kt +++ b/libs/database/src/main/java/org/fdroid/database/AppPrefsDao.kt @@ -1,5 +1,6 @@ package org.fdroid.database +import android.os.Build.VERSION.SDK_INT import androidx.lifecycle.LiveData import androidx.lifecycle.distinctUntilChanged import androidx.lifecycle.map @@ -30,9 +31,13 @@ internal interface AppPrefsDaoInt : AppPrefsDao { fun getAppPrefsOrNull(packageName: String): AppPrefs? fun getPreferredRepos(packageNames: List): Map { - return if (packageNames.size <= 999) getPreferredReposInternal(packageNames) - else HashMap(packageNames.size).also { map -> - packageNames.chunked(999).forEach { map.putAll(getPreferredReposInternal(it)) } + // since sqlite 3.32.0 the max variables number was increased to 32766 + return if (packageNames.size <= 999 || SDK_INT >= 31) { + getPreferredReposInternal(packageNames) + } else { + HashMap(packageNames.size).also { map -> + packageNames.chunked(999).forEach { map.putAll(getPreferredReposInternal(it)) } + } } } diff --git a/libs/database/src/main/java/org/fdroid/database/DbAppChecker.kt b/libs/database/src/main/java/org/fdroid/database/DbAppChecker.kt new file mode 100644 index 000000000..3a149abe1 --- /dev/null +++ b/libs/database/src/main/java/org/fdroid/database/DbAppChecker.kt @@ -0,0 +1,242 @@ +package org.fdroid.database + +import android.content.Context +import android.content.pm.ApplicationInfo.FLAG_SYSTEM +import android.content.pm.PackageInfo +import android.os.Build.VERSION.SDK_INT +import androidx.core.content.pm.PackageInfoCompat.getLongVersionCode +import org.fdroid.CompatibilityChecker +import org.fdroid.CompatibilityCheckerImpl +import org.fdroid.UpdateChecker +import org.fdroid.index.IndexUtils.getPackageSigner + +public class DbAppChecker( + db: FDroidDatabase, + private val context: Context, + compatibilityChecker: CompatibilityChecker = CompatibilityCheckerImpl(context.packageManager), + private val updateChecker: UpdateChecker = UpdateChecker(compatibilityChecker), +) { + private val appDao = db.getAppDao() as AppDaoInt + private val versionDao = db.getVersionDao() as VersionDaoInt + private val appPrefsDao = db.getAppPrefsDao() as AppPrefsDaoInt + + /** + * Gets all apps that somehow have a special status that warrants the user's attention. + * These can include apps that: + * * have updates available ([UpdatableApp]) + * * can not be updated, because we don't have those apps anymore ([NotAvailable]) + * * can not be updated, because all versions have incompatible signer ([NoCompatibleSigner]) + * * could get updated from another repo ([UpdateInOtherRepo]) + * * have known vulnerabilities ([KnownVulnerability]) + */ + public fun getApps(packageInfoMap: Map): AppCheckResult { + val updatableApps = ArrayList() + val appsWithIssue = ArrayList() + + // get all versions for all packages (irrespective of preferred repo) + // and make them accessible per packageName + val packageNames = packageInfoMap.keys.toList() + val versionsByPackage = HashMap>(packageNames.size) + // TODO add test for an app ignoring all updates, this won't return versions here + versionDao.getVersions(packageNames).forEach { version -> + val versions = versionsByPackage.getOrPut(version.packageName) { ArrayList() } + versions.add(version) + } + // go through all apps (packages) and check for updates + val preferredRepos = appPrefsDao.getPreferredRepos(packageNames) + packageInfoMap.forEach packages@{ (packageName, packageInfo) -> + // get versions for this app and try to find an update in them + val versions = versionsByPackage[packageName] + val flags = packageInfo.applicationInfo?.flags ?: 0 + if (versions.isNullOrEmpty() && flags and FLAG_SYSTEM == 0) { + // we have no versions and no system app, + // so check if we maybe had installed this app in the past + getUnavailableApp(packageInfo, preferredRepos)?.let { unavailableApp -> + appsWithIssue.add(unavailableApp) + } + return@packages // continue + } + // we ignore system apps without version + if (versions == null) return@packages // continue + // get all updates from the versions we found + // these can be from other repos, have incompatible signers or just are KnownVuln + val updates = updateChecker.getUpdates( + versions = versions, + allowedSignersGetter = null, // all signers are allowed + installedVersionCode = getLongVersionCode(packageInfo), + allowedReleaseChannels = null, + includeKnownVulnerabilities = true, + preferencesGetter = { appPrefsDao.getAppPrefsOrNull(packageName) }, + ).toList() + // if there are no updates available, there's nothing left to do for us + if (updates.isEmpty()) return@packages + // we have updates, so now get some data for us to judge those updates + + // get preferred repo for the current app + val preferredRepoId = preferredRepos[packageName] + ?: error("No preferred repo for $packageName") + + // get allowed signers for current app + // always gives us the oldest signer, even if they rotated certs by now + @Suppress("DEPRECATION") + val allowedSigners = packageInfo.signatures?.map { + getPackageSigner(it.toByteArray()) + }?.toSet() ?: error("Got no signatures for $packageName") + + // happy path is a preferred and compatible update, so we look for those first + // for simplicity and safety, we tell the user to make those updates first + updates.forEach { update -> + if (update.isOk(preferredRepoId, allowedSigners)) { + getUpdatableApp( + version = update, + installedVersionCode = getLongVersionCode(packageInfo), + installedVersionName = packageInfo.versionName ?: "???", + )?.let { app -> updatableApps.add(app) } + return@packages + } + } + + // we do have update(s), but there's an issue with them, find out what + // for simplicity, we only consider the issue of the most recent version + val update = updates[0] + val updateSigners = update.signer?.sha256?.toSet() + val hasCompatibleSigner = + updateSigners == null || updateSigners.intersect(allowedSigners).isNotEmpty() + val app = appDao.getAppOverviewItem(preferredRepoId, packageName) ?: return@packages + + // find out the specific issue + val appWithIssue = if (update.hasKnownVulnerability) { + AvailableAppWithIssue( + app = app, + installVersionName = packageInfo.versionName ?: "???", + installVersionCode = getLongVersionCode(packageInfo), + issue = KnownVulnerability(preferredRepoId == update.repoId), + ) + } else if (hasCompatibleSigner) { + // the signer is compatible, so the update must come from a non-preferred repo + AvailableAppWithIssue( + app = app, + installVersionName = packageInfo.versionName ?: "???", + installVersionCode = getLongVersionCode(packageInfo), + issue = UpdateInOtherRepo(update.repoId), + ) + } else { + // no update with compatible signer available, + // check if there's a compatible signer available in a non-preferred repo + val repoIdWithCompatibleSigner = updates.find { + val signers = it.signer?.sha256?.toSet() + signers == null || signers.intersect(allowedSigners).isNotEmpty() + }?.repoId + if (repoIdWithCompatibleSigner == null) { + // all updates are not compatible, we only warn about this, + // if all versions in the preferred repo aren't compatible + val allIncompatible = versions.all { version -> + version.repoId != preferredRepoId || + !version.isOk(preferredRepoId, allowedSigners) + } + if (allIncompatible) { + // most likely the wrong repo was preferred, try to find the right one + val repoId = versions.find { + // treat the current repo as preferred, so we only look at signers + it.isOk(it.repoId, allowedSigners) + }?.repoId + AvailableAppWithIssue( + app = app, + installVersionName = packageInfo.versionName ?: "???", + installVersionCode = getLongVersionCode(packageInfo), + issue = NoCompatibleSigner(repoId), + ) + } else null + } else { + AvailableAppWithIssue( + app = app, + installVersionName = packageInfo.versionName ?: "???", + installVersionCode = getLongVersionCode(packageInfo), + issue = NoCompatibleSigner(repoIdWithCompatibleSigner), + ) + } + } + appWithIssue?.let { appsWithIssue.add(it) } + } + return AppCheckResult( + updates = updatableApps, + issues = appsWithIssue, + ) + } + + /** + * Returns a [UnavailableAppWithIssue], in case the app provided with [packageInfo] + * was installed by us in the past. + */ + private fun getUnavailableApp( + packageInfo: PackageInfo, + preferredRepos: Map, + ): UnavailableAppWithIssue? { + // check if we installed the app or are the current update owner of this app + val weInstalledApp = if (SDK_INT >= 30) { + val installInfo = context.packageManager.getInstallSourceInfo(packageInfo.packageName) + context.packageName == installInfo.initiatingPackageName || + context.packageName == installInfo.installingPackageName || + (SDK_INT >= 34 && context.packageName == installInfo.updateOwnerPackageName) + } else { + @Suppress("DEPRECATION") // no other choice to use this for old API versions + val installer = context.packageManager.getInstallerPackageName(packageInfo.packageName) + context.packageName == installer + } + if (weInstalledApp) { + // we had installed this app, check if we maybe just got no versions + val app = preferredRepos[packageInfo.packageName]?.let { repoId -> + appDao.getAppOverviewItem(repoId, packageInfo.packageName) + } + // we still have the app, so we just didn't get versions for it, + // like when the user was ignoring all updates for the app + if (app != null) return null + // warn the user that this app isn't available anymore + val notAvailable = UnavailableAppWithIssue( + packageName = packageInfo.packageName, + name = packageInfo.applicationInfo?.loadLabel(context.packageManager), + installVersionName = packageInfo.versionName ?: "???", + installVersionCode = getLongVersionCode(packageInfo), + ) + return notAvailable + } + return null + } + + /** + * @return true if this version is an update from the preferred repo with a compatible signer + * and not a known vulnerable version. + */ + private fun Version.isOk(preferredRepoId: Long, signers: Set): Boolean { + val ourSigners = signer?.sha256?.toSet() + return preferredRepoId == repoId && + !hasKnownVulnerability && + (ourSigners == null || ourSigners.intersect(signers).isNotEmpty()) + } + + private fun getUpdatableApp( + version: Version, + installedVersionCode: Long, + installedVersionName: String, + ): UpdatableApp? { + val versionedStrings = versionDao.getVersionedStrings( + repoId = version.repoId, + packageName = version.packageName, + versionId = version.versionId, + ) + val appOverviewItem = + appDao.getAppOverviewItem(version.repoId, version.packageName) ?: return null + return UpdatableApp( + repoId = version.repoId, + packageName = version.packageName, + installedVersionCode = installedVersionCode, + installedVersionName = installedVersionName, + update = version.toAppVersion(versionedStrings), + isFromPreferredRepo = true, + hasKnownVulnerability = version.hasKnownVulnerability, + name = appOverviewItem.name, + summary = appOverviewItem.summary, + localizedIcon = appOverviewItem.localizedIcon, + ) + } +} diff --git a/libs/database/src/main/java/org/fdroid/database/DbUpdateChecker.kt b/libs/database/src/main/java/org/fdroid/database/DbUpdateChecker.kt index 9a04ce1cf..7af7fde9a 100644 --- a/libs/database/src/main/java/org/fdroid/database/DbUpdateChecker.kt +++ b/libs/database/src/main/java/org/fdroid/database/DbUpdateChecker.kt @@ -10,6 +10,7 @@ import org.fdroid.CompatibilityCheckerImpl import org.fdroid.PackagePreference import org.fdroid.UpdateChecker +@Deprecated("Use DbAppChecker instead") public class DbUpdateChecker @JvmOverloads constructor( db: FDroidDatabase, private val packageManager: PackageManager, diff --git a/libs/database/src/main/java/org/fdroid/database/FDroidDatabase.kt b/libs/database/src/main/java/org/fdroid/database/FDroidDatabase.kt index 04c98325e..0298cd5e8 100644 --- a/libs/database/src/main/java/org/fdroid/database/FDroidDatabase.kt +++ b/libs/database/src/main/java/org/fdroid/database/FDroidDatabase.kt @@ -16,7 +16,7 @@ import java.util.concurrent.Callable // When bumping this version, please make sure to add one (or more) migration(s) below! // Consider also providing tests for that migration. // Don't forget to commit the new schema to the git repo as well. - version = 9, + version = 10, entities = [ // repo CoreRepository::class, @@ -51,6 +51,7 @@ import java.util.concurrent.Callable AutoMigration(6, 7), AutoMigration(7, 8, CountryCodeMigration::class), // 8 to 9 is a manual migration + AutoMigration(9, 10), // add future migrations above! ], ) diff --git a/libs/database/src/main/java/org/fdroid/database/Repository.kt b/libs/database/src/main/java/org/fdroid/database/Repository.kt index a13100866..26e2486f0 100644 --- a/libs/database/src/main/java/org/fdroid/database/Repository.kt +++ b/libs/database/src/main/java/org/fdroid/database/Repository.kt @@ -2,6 +2,7 @@ package org.fdroid.database import androidx.annotation.WorkerThread import androidx.core.os.LocaleListCompat +import androidx.room.ColumnInfo import androidx.room.Embedded import androidx.room.Entity import androidx.room.ForeignKey @@ -110,6 +111,7 @@ public data class Repository internal constructor( lastUpdated: Long, username: String? = null, password: String? = null, + lastError: String? = null, ) : this( repository = CoreRepository( repoId = repoId, @@ -131,6 +133,7 @@ public data class Repository internal constructor( lastUpdated = lastUpdated, username = username, password = password, + lastError = lastError, ) ) @@ -231,6 +234,8 @@ public data class Repository internal constructor( get() { return "https://fdroid.link/#$address?fingerprint=$fingerprint" } + public val errorCount: Int get() = preferences.errorCount + public val lastError: String? get() = preferences.lastError } // Dummy repo to use in Compose Previews and in tests @@ -263,6 +268,8 @@ internal data class Mirror( val repoId: Long, val url: String, val countryCode: String? = null, + @ColumnInfo(defaultValue = "0") + val isPrimary: Boolean = false, ) { internal companion object { const val TABLE = "Mirror" @@ -271,6 +278,7 @@ internal data class Mirror( fun toDownloadMirror(): org.fdroid.download.Mirror = org.fdroid.download.Mirror( baseUrl = url, countryCode = countryCode, + // TODO add isPrimary = isPrimary, ) } @@ -278,6 +286,7 @@ internal fun MirrorV2.toMirror(repoId: Long) = Mirror( repoId = repoId, url = url, countryCode = countryCode, + // TODO add isPrimary = isPrimary, ) internal fun List.toMirrors(repoId: Long): List { @@ -417,6 +426,9 @@ internal data class RepositoryPreferences( val disabledMirrors: List? = null, val username: String? = null, val password: String? = null, + @ColumnInfo(defaultValue = "0") + val errorCount: Int = 0, + val lastError: String? = null, ) { internal companion object { const val TABLE = "RepositoryPreferences" diff --git a/libs/database/src/main/java/org/fdroid/database/RepositoryDao.kt b/libs/database/src/main/java/org/fdroid/database/RepositoryDao.kt index 816172f33..f82bfba35 100644 --- a/libs/database/src/main/java/org/fdroid/database/RepositoryDao.kt +++ b/libs/database/src/main/java/org/fdroid/database/RepositoryDao.kt @@ -442,6 +442,21 @@ internal interface RepositoryDaoInt : RepositoryDao { ) fun getArchiveRepoId(cert: String): Long? + @Query( + """UPDATE ${RepositoryPreferences.TABLE} + SET errorCount = errorCount + 1, lastError = :errorMsg + WHERE repoId = :repoId + """ + ) + fun trackRepoUpdateError(repoId: Long, errorMsg: String) + + @Query( + """UPDATE ${RepositoryPreferences.TABLE} + SET errorCount = 0, lastError = NULL WHERE repoId = :repoId + """ + ) + fun resetRepoUpdateError(repoId: Long) + @Transaction override fun deleteRepository(repoId: Long) { deleteCoreRepository(repoId) diff --git a/libs/database/src/main/java/org/fdroid/database/Version.kt b/libs/database/src/main/java/org/fdroid/database/Version.kt index d8866289b..f3021b672 100644 --- a/libs/database/src/main/java/org/fdroid/database/Version.kt +++ b/libs/database/src/main/java/org/fdroid/database/Version.kt @@ -48,6 +48,7 @@ internal data class Version( override val releaseChannels: List? = emptyList(), val antiFeatures: Map? = null, val whatsNew: LocalizedTextV2? = null, + val appLabel: LocalizedTextV2? = null, val isCompatible: Boolean, ) : PackageVersion { internal companion object { diff --git a/libs/database/src/main/java/org/fdroid/database/VersionDao.kt b/libs/database/src/main/java/org/fdroid/database/VersionDao.kt index a67493972..71be99dc3 100644 --- a/libs/database/src/main/java/org/fdroid/database/VersionDao.kt +++ b/libs/database/src/main/java/org/fdroid/database/VersionDao.kt @@ -1,5 +1,6 @@ package org.fdroid.database +import android.os.Build.VERSION.SDK_INT import androidx.lifecycle.LiveData import androidx.room.Dao import androidx.room.Insert @@ -196,7 +197,8 @@ internal interface VersionDaoInt : VersionDao { * so takes [AppPrefs.ignoreVersionCodeUpdate] into account. */ fun getVersions(packageNames: List): List { - return if (packageNames.size <= 999) getVersionsInternal(packageNames) + // since sqlite 3.32.0 (in SDK 31 the max variables number was increased to 32766 + return if (packageNames.size <= 999 || SDK_INT >= 31) getVersionsInternal(packageNames) else packageNames.chunked(999).flatMap { getVersionsInternal(it) } } diff --git a/libs/database/src/main/java/org/fdroid/index/IndexUpdater.kt b/libs/database/src/main/java/org/fdroid/index/IndexUpdater.kt index 506340bb9..8cdce3494 100644 --- a/libs/database/src/main/java/org/fdroid/index/IndexUpdater.kt +++ b/libs/database/src/main/java/org/fdroid/index/IndexUpdater.kt @@ -1,7 +1,10 @@ package org.fdroid.index import android.net.Uri +import androidx.annotation.VisibleForTesting +import androidx.annotation.WorkerThread import org.fdroid.database.Repository +import org.fdroid.database.RepositoryDaoInt import org.fdroid.download.Downloader import org.fdroid.download.NotFoundException import java.io.File @@ -20,6 +23,9 @@ public sealed class IndexUpdateResult { } public interface IndexUpdateListener { + /** + * If [totalBytes] is 0 or less, it is unknown and indeterminate progress should be shown. + */ public fun onDownloadProgress(repo: Repository, bytesRead: Long, totalBytes: Long) public fun onUpdateProgress(repo: Repository, appsProcessed: Int, totalApps: Int) } @@ -48,6 +54,8 @@ public fun interface TempFileProvider { * A class to update information of a [Repository] in the database with a new downloaded index. */ public abstract class IndexUpdater { + @VisibleForTesting + internal abstract val repoDao: RepositoryDaoInt /** * The [IndexFormatVersion] used by this updater. @@ -59,21 +67,46 @@ public abstract class IndexUpdater { /** * Updates an existing [repo] with a known [Repository.certificate]. */ - public fun update(repo: Repository): IndexUpdateResult = catchExceptions { - updateRepo(repo) + @WorkerThread + public fun update(repo: Repository): IndexUpdateResult = catchExceptions(repo) { + updateRepo(repo).also { result -> + // reset repo errors if repo updated fine again, but is still unchanged + if (repo.errorCount > 0 && result is IndexUpdateResult.Unchanged) { + repoDao.resetRepoUpdateError(repo.repoId) + } + } } - private fun catchExceptions(block: () -> IndexUpdateResult): IndexUpdateResult { + @WorkerThread + protected abstract fun updateRepo(repo: Repository): IndexUpdateResult + + @WorkerThread + private fun catchExceptions( + repo: Repository, + block: () -> IndexUpdateResult, + ): IndexUpdateResult { return try { block() } catch (e: NotFoundException) { + onError(repo.repoId, e) IndexUpdateResult.NotFound } catch (e: Exception) { + onError(repo.repoId, e) IndexUpdateResult.Error(e) } } - protected abstract fun updateRepo(repo: Repository): IndexUpdateResult + @WorkerThread + private fun onError(repoId: Long, e: Exception) { + val msg = buildString { + append(e.localizedMessage ?: e.message ?: e.javaClass.simpleName) + e.cause?.let { cause -> + append("\n") + append(cause.localizedMessage ?: cause.message ?: cause.javaClass.simpleName) + } + } + repoDao.trackRepoUpdateError(repoId, msg) + } } internal fun Downloader.setIndexUpdateListener( diff --git a/libs/database/src/main/java/org/fdroid/index/RepoManager.kt b/libs/database/src/main/java/org/fdroid/index/RepoManager.kt index 1c47a150a..5a8cdf2da 100644 --- a/libs/database/src/main/java/org/fdroid/index/RepoManager.kt +++ b/libs/database/src/main/java/org/fdroid/index/RepoManager.kt @@ -10,6 +10,7 @@ import androidx.lifecycle.asLiveData import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -86,13 +87,13 @@ public class RepoManager @JvmOverloads constructor( init { // we need to load the repositories first off the UiThread, so it doesn't deadlock GlobalScope.launch(coroutineContext) { - _repositoriesState.value = repositoryDao.getRepositories() + _repositoriesState.update { repositoryDao.getRepositories() } repoCountDownLatch.countDown() withContext(Dispatchers.Main) { // keep observing the repos from the DB // and update internal cache when changes happen db.getRepositoryDao().getLiveRepositories().observeForever { repositories -> - _repositoriesState.value = repositories + _repositoriesState.update { repositories } } } } @@ -148,7 +149,7 @@ public class RepoManager @JvmOverloads constructor( // while this will get updated automatically, getting the update may be slow, // so to speed up the UI, we emit the state change right away (deletion is unlikely to fail) _repositoriesState.update { - _repositoriesState.value.filter { repo -> + it.filter { repo -> // keep only repos that are not the deleted one repo.repoId != repoId } @@ -185,8 +186,10 @@ public class RepoManager @JvmOverloads constructor( val addedRepo = repoAdder.addFetchedRepository() // if repo was added, update state right away, so it becomes available asap if (addedRepo != null) withContext(Dispatchers.Main) { - _repositoriesState.value = _repositoriesState.value.toMutableList().apply { - add(addedRepo) + _repositoriesState.update { + it.toMutableList().apply { + add(addedRepo) + } } } } @@ -203,8 +206,8 @@ public class RepoManager @JvmOverloads constructor( } @AnyThread - public fun setPreferredRepoId(packageName: String, repoId: Long) { - GlobalScope.launch(coroutineContext) { + public fun setPreferredRepoId(packageName: String, repoId: Long): Job { + return GlobalScope.launch(coroutineContext) { db.runInTransaction { val appPrefs = appPrefsDao.getAppPrefsOrNull(packageName) ?: AppPrefs(packageName) appPrefsDao.update(appPrefs.copy(preferredRepoId = repoId)) diff --git a/libs/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt b/libs/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt index 505022730..a57dc0c36 100644 --- a/libs/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt +++ b/libs/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt @@ -8,6 +8,7 @@ import org.fdroid.database.DbV1StreamReceiver import org.fdroid.database.FDroidDatabase import org.fdroid.database.FDroidDatabaseInt import org.fdroid.database.Repository +import org.fdroid.database.RepositoryDaoInt import org.fdroid.download.DownloaderFactory import org.fdroid.index.IndexFormatVersion import org.fdroid.index.IndexFormatVersion.ONE @@ -34,6 +35,7 @@ public class IndexV1Updater( private val log = KotlinLogging.logger {} public override val formatVersion: IndexFormatVersion = ONE private val db: FDroidDatabaseInt = database as FDroidDatabaseInt + override val repoDao: RepositoryDaoInt = db.getRepositoryDao() override fun updateRepo(repo: Repository): IndexUpdateResult { // Normally, we shouldn't allow repository downgrades and assert the condition below. @@ -66,10 +68,11 @@ public class IndexV1Updater( streamProcessor.process(inputStream) } // update RepositoryPreferences with timestamp and ETag (for v1) - val repoDao = db.getRepositoryDao() val updatedPrefs = repo.preferences.copy( lastUpdated = System.currentTimeMillis(), lastETag = eTag, + errorCount = 0, + lastError = null, ) repoDao.updateRepositoryPreferences(updatedPrefs) } diff --git a/libs/database/src/main/java/org/fdroid/index/v2/IndexV2Updater.kt b/libs/database/src/main/java/org/fdroid/index/v2/IndexV2Updater.kt index 87d10f022..af8b28056 100644 --- a/libs/database/src/main/java/org/fdroid/index/v2/IndexV2Updater.kt +++ b/libs/database/src/main/java/org/fdroid/index/v2/IndexV2Updater.kt @@ -6,6 +6,7 @@ import org.fdroid.database.DbV2StreamReceiver import org.fdroid.database.FDroidDatabase import org.fdroid.database.FDroidDatabaseInt import org.fdroid.database.Repository +import org.fdroid.database.RepositoryDaoInt import org.fdroid.download.DownloaderFactory import org.fdroid.index.IndexFormatVersion import org.fdroid.index.IndexFormatVersion.ONE @@ -33,6 +34,7 @@ public class IndexV2Updater( public override val formatVersion: IndexFormatVersion = TWO private val db: FDroidDatabaseInt = database as FDroidDatabaseInt + override val repoDao: RepositoryDaoInt = db.getRepositoryDao() override fun updateRepo(repo: Repository): IndexUpdateResult { val (_, entry) = getCertAndEntry(repo, repo.certificate) @@ -61,7 +63,12 @@ public class IndexV2Updater( indexFile = FileV2.fromPath("/$SIGNED_FILE_NAME"), destFile = file, ).apply { - setIndexUpdateListener(listener, repo) + if (listener != null) setListener { bytesRead, _ -> + // don't report a total for entry.jar, + // because we'll download another file afterwards + // and progress reporting would jump to 100% two times. + listener.onDownloadProgress(repo, bytesRead, -1) + } } try { downloader.download() @@ -92,7 +99,6 @@ public class IndexV2Updater( try { downloader.download() file.inputStream().use { inputStream -> - val repoDao = db.getRepositoryDao() db.runInTransaction { // ensure somebody else hasn't updated the repo in the meantime val currentTimestamp = repoDao.getRepository(repo.repoId)?.timestamp @@ -108,6 +114,8 @@ public class IndexV2Updater( ?: error("No repo prefs for ${repo.repoId}") val updatedPrefs = repoPrefs.copy( lastUpdated = System.currentTimeMillis(), + errorCount = 0, + lastError = null, ) repoDao.updateRepositoryPreferences(updatedPrefs) } diff --git a/libs/database/src/test/java/org/fdroid/repo/RepoAdderTest.kt b/libs/database/src/test/java/org/fdroid/repo/RepoAdderTest.kt index 8f547c042..86c5e617d 100644 --- a/libs/database/src/test/java/org/fdroid/repo/RepoAdderTest.kt +++ b/libs/database/src/test/java/org/fdroid/repo/RepoAdderTest.kt @@ -190,6 +190,8 @@ internal class RepoAdderTest { assertEquals(expectedResult, fetching.fetchResult) every { newRepo.formatVersion } returns IndexFormatVersion.TWO + every { newRepo.repoId } returns 1 + every { repoDao.trackRepoUpdateError(1, any()) } just Runs repoAdder.addFetchedRepository() @@ -225,6 +227,8 @@ internal class RepoAdderTest { assertIs(fetching.fetchResult) every { newRepo.formatVersion } returns IndexFormatVersion.TWO + every { newRepo.repoId } returns 1 + every { repoDao.trackRepoUpdateError(1, any()) } just Runs repoAdder.addFetchedRepository() @@ -852,6 +856,8 @@ internal class RepoAdderTest { assertIs(awaitItem()) // still Fetching from last call every { newRepo.formatVersion } returns IndexFormatVersion.TWO + every { newRepo.repoId } returns 1 + every { repoDao.trackRepoUpdateError(1, any()) } just Runs repoAdder.addFetchedRepository() assertIs(awaitItem()) // now moved to Adding diff --git a/libs/index/api/android/index.api b/libs/index/api/android/index.api index 0ac289286..de1c69fc2 100644 --- a/libs/index/api/android/index.api +++ b/libs/index/api/android/index.api @@ -36,6 +36,8 @@ public final class org/fdroid/UpdateChecker { public final fun getUpdate (Ljava/util/List;Lkotlin/jvm/functions/Function0;JLjava/util/List;ZLkotlin/jvm/functions/Function0;)Lorg/fdroid/index/v2/PackageVersion; public static synthetic fun getUpdate$default (Lorg/fdroid/UpdateChecker;Ljava/util/List;Landroid/content/pm/PackageInfo;Ljava/util/List;ZLkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lorg/fdroid/index/v2/PackageVersion; public static synthetic fun getUpdate$default (Lorg/fdroid/UpdateChecker;Ljava/util/List;Lkotlin/jvm/functions/Function0;JLjava/util/List;ZLkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lorg/fdroid/index/v2/PackageVersion; + public final fun getUpdates (Ljava/util/List;Lkotlin/jvm/functions/Function0;JLjava/util/List;ZLkotlin/jvm/functions/Function0;)Lkotlin/sequences/Sequence; + public static synthetic fun getUpdates$default (Lorg/fdroid/UpdateChecker;Ljava/util/List;Lkotlin/jvm/functions/Function0;JLjava/util/List;ZLkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lkotlin/sequences/Sequence; } public final class org/fdroid/index/IndexConverter { diff --git a/libs/index/src/androidMain/kotlin/org/fdroid/UpdateChecker.kt b/libs/index/src/androidMain/kotlin/org/fdroid/UpdateChecker.kt index dbee9c3f3..cfb415df1 100644 --- a/libs/index/src/androidMain/kotlin/org/fdroid/UpdateChecker.kt +++ b/libs/index/src/androidMain/kotlin/org/fdroid/UpdateChecker.kt @@ -93,7 +93,29 @@ public class UpdateChecker( allowedReleaseChannels: List? = null, includeKnownVulnerabilities: Boolean = false, preferencesGetter: (() -> PackagePreference?)? = null, - ): T? { + ): T? = getUpdates( + versions = versions, + allowedSignersGetter = allowedSignersGetter, + installedVersionCode = installedVersionCode, + allowedReleaseChannels = allowedReleaseChannels, + includeKnownVulnerabilities = includeKnownVulnerabilities, + preferencesGetter = preferencesGetter, + ).firstOrNull() // just return matching update with highest version code, don't look at others + + /** + * Same as [getUpdate], but gets a list of all possible updates + * beginning from highest version code. + * + * This usually isn't useful unless you need to pick a certain update with your own criteria. + */ + public fun getUpdates( + versions: List, + allowedSignersGetter: (() -> Set?)? = null, + installedVersionCode: Long = 0, + allowedReleaseChannels: List? = null, + includeKnownVulnerabilities: Boolean = false, + preferencesGetter: (() -> PackagePreference?)? = null, + ): Sequence = sequence { // getting signers is rather expensive, so we only do that when there's update candidates val allowedSigners by lazy { allowedSignersGetter?.let { it() } } versions.iterator().forEach versions@{ version -> @@ -101,9 +123,9 @@ public class UpdateChecker( if (includeKnownVulnerabilities && version.versionCode == installedVersionCode && version.hasKnownVulnerability - ) return version + ) yield(version) // if version code is not higher than installed skip package as list is sorted - if (version.versionCode <= installedVersionCode) return null + if (version.versionCode <= installedVersionCode) return@sequence // we don't support versions that have multiple signers if (version.signer?.hasMultipleSigners == true) return@versions // skip incompatible versions @@ -115,7 +137,7 @@ public class UpdateChecker( // check if release channel of version is allowed val hasAllowedReleaseChannel = hasAllowedReleaseChannel( allowedReleaseChannels = allowedReleaseChannels?.toMutableSet() ?: LinkedHashSet(), - versionReleaseChannels = version.releaseChannels, + versionReleaseChannels = version.releaseChannels?.toSet(), packagePreference = packagePreference, ) if (!hasAllowedReleaseChannel) return@versions @@ -126,14 +148,13 @@ public class UpdateChecker( if (versionSigners.intersect(allowedSigners!!).isEmpty()) return@versions } // no need to see other versions, we got the highest version code per sorting - return version + yield(version) } - return null } private fun hasAllowedReleaseChannel( allowedReleaseChannels: MutableSet, - versionReleaseChannels: List?, + versionReleaseChannels: Set?, packagePreference: PackagePreference?, ): Boolean { // no channels (aka stable version) is always allowed diff --git a/settings.gradle b/settings.gradle index 23b740ff3..76413edd0 100644 --- a/settings.gradle +++ b/settings.gradle @@ -12,3 +12,5 @@ include ':libs:sharedTest' include ':libs:download' include ':libs:index' include ':libs:database' + +include ':legacy' diff --git a/tools/update-mirrors-in-default_repos.xml.py b/tools/update-mirrors-in-default_repos.xml.py index 870a7953e..e5a674752 100755 --- a/tools/update-mirrors-in-default_repos.xml.py +++ b/tools/update-mirrors-in-default_repos.xml.py @@ -1,28 +1,49 @@ #!/usr/bin/env python3 # -# Fetch the official mirrors from f-droid.org and update default_repos.xml. +# Fetch the official mirrors from f-droid.org and update default_repos.json. import os +import json import requests import lxml.etree as ET +REPO_URL = "https://f-droid.org/repo" +ARCHIVE_URL = "https://f-droid.org/archive" + os.chdir(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +# get mirrors from index r = requests.get('https://f-droid.org/repo/index-v2.json') mirrors = [] +archive_mirrors = [] for mirror in r.json()['repo']['mirrors']: - mirrors.append(mirror['url']) + if mirror['url'] != REPO_URL: + mirrors.append(mirror['url']) + archive_mirrors.append(mirror['url'].replace('/repo', '/archive')) -default_repos = 'app/src/main/res/values/default_repos.xml' -tree = ET.parse(default_repos) +# default_repos.json +default_repos = 'app/src/main/assets/default_repos.json' +with open(default_repos, 'r') as json_file: + data = json.load(json_file) +for repo in data: + if repo["address"] == REPO_URL: + repo["mirrors"] = mirrors + elif repo["address"] == ARCHIVE_URL: + repo["mirrors"] = archive_mirrors +with open(default_repos, 'w') as json_file: + json.dump(data, json_file, indent=2) + +# legacy XML default repos +default_repos_legacy = 'legacy/src/main/res/values/default_repos.xml' +tree = ET.parse(default_repos_legacy) root = tree.getroot() i = 0 indent = '\n ' for item in root.iter('item'): if i == 1: - item.text = indent + (indent.join(mirrors)) + '\n ' + item.text = indent + (indent.join([REPO_URL] + mirrors)) + '\n ' elif i == 8: - item.text = indent + (indent.join(mirrors).replace('/repo', '/archive')) + '\n ' + item.text = indent + (indent.join([ARCHIVE_URL] + mirrors).replace('/repo', '/archive')) + '\n ' i += 1 -tree.write(default_repos) +tree.write(default_repos_legacy)