mirror of
https://github.com/f-droid/fdroidclient.git
synced 2026-10-05 18:43:24 -04:00
Merge branch '2.0' into 'master'
Release 2.0-alpha8 Closes #3237 See merge request fdroid/fdroidclient!1655
This commit is contained in:
commit
128762f1d2
396 files changed
+2569
-700
No files matched your search
@@ -1,3 +1,4 @@
|
||||
*.gpg binary
|
||||
app/src/screenshotTestFullDefaultDebug/reference/** filter=lfs diff=lfs merge=lfs -text
|
||||
app/src/screenshotTestBasicDefaultDebug/reference/** filter=lfs diff=lfs merge=lfs -text
|
||||
src/basic/fastlane/metadata/android/*/images/** filter=lfs diff=lfs merge=lfs -text
|
||||
@@ -49,3 +49,4 @@ junit-report.xml
|
||||
captures/
|
||||
|
||||
/fdroid/
|
||||
/screenshots
|
||||
@@ -238,11 +238,21 @@ app screenshots:
|
||||
- app/**/*
|
||||
script:
|
||||
- ./gradlew :app:validateBasicDefaultDebugScreenshotTest
|
||||
after_script:
|
||||
- |
|
||||
if [ "$CI_JOB_STATUS" == "failed" ]; then
|
||||
url="https://${CI_PROJECT_NAMESPACE}.${CI_PAGES_DOMAIN}/-/${CI_PROJECT_NAME}/-/jobs/${CI_JOB_ID}/artifacts/app/build/reports/screenshotTest/preview/debug/basicDefault/index.html"
|
||||
echo "Report at ${url}"
|
||||
echo '[{"location":{"path":".","lines":{"begin":1}},"description":"Screenshot test report needs copy and paste: '"${url}"'","check_name":"screenshot_test_report","fingerprint":"screenshot_test_report","severity":"blocker"}]' > codequality-warnings.json
|
||||
fi
|
||||
artifacts:
|
||||
name: "${CI_PROJECT_PATH}_${CI_JOB_STAGE}_${CI_COMMIT_REF_NAME}_${CI_COMMIT_SHA}"
|
||||
when: always
|
||||
paths:
|
||||
- app/build/reports/screenshotTest
|
||||
- codequality-warnings.json
|
||||
reports:
|
||||
codequality: codequality-warnings.json
|
||||
expire_in: 1 week
|
||||
|
||||
app tools scripts:
|
||||
@@ -332,6 +342,8 @@ libs database schema:
|
||||
image: briar/ci-image-android-emulator:trixie # TODO change to latest once available
|
||||
tags:
|
||||
- kvm
|
||||
rules:
|
||||
- if: '$RUN_KVM_JOBS == "1"'
|
||||
# Cache hits the storage limits in kvm runners quickly
|
||||
cache: []
|
||||
script:
|
||||
@@ -359,6 +371,23 @@ libs database schema:
|
||||
kvm 24 default x86:
|
||||
<<: [ *test-template, *kvm-connected-template ]
|
||||
|
||||
no kvm warning:
|
||||
image: debian:trixie-slim
|
||||
rules:
|
||||
- if: '$RUN_KVM_JOBS != "1"'
|
||||
cache: []
|
||||
script:
|
||||
- |
|
||||
echo "WARNING: RUN_KVM_JOBS environment variable is not set."
|
||||
echo "This job requires a special KVM runner. Skipping with warning."
|
||||
echo '[{"location":{"path":".gitlab-ci.yml","lines":{"begin":237}},"description":"KVM job was skipped: RUN_KVM_JOBS env variable is not set. Set it to 1 to enable this job.","check_name":"skipped_kvm_job","fingerprint":"skipped_kvm_job","severity":"major"}]' > codequality-warnings.json
|
||||
exit 42
|
||||
artifacts:
|
||||
reports:
|
||||
codequality: codequality-warnings.json
|
||||
allow_failure:
|
||||
exit_codes: 42
|
||||
|
||||
pages:
|
||||
extends: .base
|
||||
stage: deploy
|
||||
|
||||
@@ -18,8 +18,8 @@ android {
|
||||
applicationId = "org.fdroid"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 2000007
|
||||
versionName = "2.0-alpha7"
|
||||
versionCode = 2000008
|
||||
versionName = "2.0-alpha8"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
Vendored
+5
@@ -13,5 +13,10 @@
|
||||
-keep class kotlin.LazyKt
|
||||
-keep class kotlin.collections.CollectionsKt
|
||||
|
||||
# Used for full's nearby feature
|
||||
-keep class kellinwood.security.zipsigner.** {*;}
|
||||
-keep class org.bouncycastle.jcajce.provider.** { *; }
|
||||
-keep class org.bouncycastle.jce.provider.** { *; }
|
||||
|
||||
# for debugging (comment in when needed)
|
||||
#-printconfiguration build/outputs/logs/r8-configuration.txt
|
||||
@@ -9,6 +9,7 @@ import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import org.fdroid.settings.SettingsManager
|
||||
import org.junit.Ignore
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@@ -81,6 +82,7 @@ class DnsCacheTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@Ignore("failing") // TODO
|
||||
fun preloadCacheTest() {
|
||||
// test setup
|
||||
settings.useDnsCache = true
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package org.fdroid.ui.screenshots
|
||||
|
||||
import androidx.compose.ui.platform.LocalLocaleList
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import org.fdroid.LocaleChooser.getBestLocale
|
||||
import org.fdroid.download.NetworkState
|
||||
import org.fdroid.install.InstallState
|
||||
@@ -23,11 +21,7 @@ class DetailsScreenshotTest(localeName: String) : LocalizedScreenshotTest(locale
|
||||
|
||||
@Test
|
||||
fun appDetails() =
|
||||
screenshotTest("3_Details", showBottomBar = false) {
|
||||
val localeList =
|
||||
LocaleListCompat.forLanguageTags(
|
||||
LocalLocaleList.current.localeList.map { it.toLanguageTag() }.joinToString(",")
|
||||
)
|
||||
screenshotTest("4_Details", showBottomBar = false, dark = true) { localeList ->
|
||||
val item =
|
||||
AppDetailsItem(
|
||||
app = appMetadata,
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
package org.fdroid.ui.screenshots
|
||||
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import org.fdroid.ui.discover.Discover
|
||||
import org.fdroid.ui.discover.LoadedDiscoverModel
|
||||
import org.junit.Ignore
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.junit.runners.Parameterized
|
||||
@@ -18,15 +15,13 @@ class DiscoverScreenshotTest(localeName: String) : LocalizedScreenshotTest(local
|
||||
|
||||
@Test
|
||||
fun appDetails() =
|
||||
screenshotTest("1_Discover") {
|
||||
val localeList = LocaleListCompat.getDefault()
|
||||
screenshotTest("1_Discover") { localeList ->
|
||||
val model =
|
||||
LoadedDiscoverModel(
|
||||
newApps = getNewApps(localeList),
|
||||
recentlyUpdatedApps = getRecentlyUpdatedApps(localeList),
|
||||
mostDownloadedApps = getMostDownloadedApps(localeList),
|
||||
categories = getCategoryItems(localeList).groupBy { it.group },
|
||||
searchTextFieldState = rememberTextFieldState(),
|
||||
hasRepoIssues = false,
|
||||
)
|
||||
Discover(discoverModel = model, onListTap = {}, onAppTap = {}, onNav = {})
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
package org.fdroid.ui.screenshots
|
||||
|
||||
import android.app.LocaleConfig
|
||||
import android.os.Build.VERSION.SDK_INT
|
||||
import androidx.compose.material3.adaptive.layout.PaneScaffoldDirective
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.DarkMode
|
||||
import androidx.compose.ui.test.DeviceConfigurationOverride
|
||||
import androidx.compose.ui.test.Locales
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.then
|
||||
import androidx.compose.ui.text.intl.LocaleList
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.navigation3.runtime.NavEntry
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.uiautomator.UiDevice
|
||||
@@ -22,14 +28,30 @@ import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
|
||||
private const val ENABLED = false
|
||||
|
||||
@OptIn(DelicateCoilApi::class)
|
||||
abstract class LocalizedScreenshotTest(val localeName: String) {
|
||||
@get:Rule val composeRule = createComposeRule()
|
||||
|
||||
companion object {
|
||||
val locales = listOf("en-US", "de-DE", "ar-SA", "he", "zh-CN")
|
||||
val locales: List<String> by lazy {
|
||||
if (!enabled) return@lazy emptyList()
|
||||
val fallback = listOf("en-US", "de-DE", "ar-SA", "he", "zh-CN")
|
||||
if (SDK_INT >= 33) {
|
||||
val localeConfig = LocaleConfig(InstrumentationRegistry.getInstrumentation().targetContext)
|
||||
mutableListOf<String>().apply {
|
||||
localeConfig.supportedLocales?.let { localeList ->
|
||||
for (i in 0 until localeList.size()) {
|
||||
val locale = localeList.get(i)
|
||||
add(locale.toLanguageTag())
|
||||
}
|
||||
} ?: addAll(fallback)
|
||||
}
|
||||
} else {
|
||||
fallback
|
||||
}
|
||||
}
|
||||
val enabled: Boolean
|
||||
get() = InstrumentationRegistry.getArguments().getString("fdroid_screenshots") == "true"
|
||||
}
|
||||
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
@@ -45,7 +67,7 @@ abstract class LocalizedScreenshotTest(val localeName: String) {
|
||||
|
||||
@Before
|
||||
fun before() {
|
||||
assumeTrue(ENABLED)
|
||||
assumeTrue(enabled)
|
||||
}
|
||||
|
||||
protected fun screenshotTest(
|
||||
@@ -54,26 +76,29 @@ abstract class LocalizedScreenshotTest(val localeName: String) {
|
||||
currentNavKey: NavKey = NavigationKey.Discover,
|
||||
numUpdates: Int = 3,
|
||||
hasAppIssues: Boolean = true,
|
||||
content: @Composable (Modifier) -> Unit,
|
||||
dark: Boolean = false,
|
||||
content: @Composable (LocaleListCompat) -> Unit,
|
||||
) {
|
||||
val localeList = LocaleList(localeName)
|
||||
composeRule.setContent {
|
||||
DeviceConfigurationOverride(
|
||||
override = DeviceConfigurationOverride.Locales(locales = localeList)
|
||||
override =
|
||||
DeviceConfigurationOverride.Locales(locales = localeList) then
|
||||
DeviceConfigurationOverride.DarkMode(dark)
|
||||
) {
|
||||
MainContent(
|
||||
model =
|
||||
MainModel(
|
||||
dynamicColors = false,
|
||||
smallBottomBar = false,
|
||||
numUpdates = numUpdates,
|
||||
hasAppIssues = hasAppIssues,
|
||||
MainModel(dynamicColors = false, numUpdates = numUpdates, hasAppIssues = hasAppIssues),
|
||||
navEntries =
|
||||
listOf(
|
||||
NavEntry(currentNavKey) { content(LocaleListCompat.forLanguageTags(localeName)) }
|
||||
),
|
||||
directive = PaneScaffoldDirective.Default,
|
||||
isBigScreen = false,
|
||||
showBottomBar = showBottomBar,
|
||||
currentNavKey = currentNavKey,
|
||||
onNav = {},
|
||||
content = content,
|
||||
onBack = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -82,11 +107,12 @@ abstract class LocalizedScreenshotTest(val localeName: String) {
|
||||
val dir = context.getExternalFilesDir("screenshots") ?: fail("Could not create screenshots dir")
|
||||
assertTrue(dir.isDirectory)
|
||||
val subDir =
|
||||
File(dir, "${FLAVOR_variant}/fastlane/metadata/$localeName/images/phoneScreenshots")
|
||||
File(dir, "${FLAVOR_variant}/fastlane/metadata/android/$localeName/images/phoneScreenshots")
|
||||
subDir.mkdirs()
|
||||
assertTrue(subDir.isDirectory)
|
||||
|
||||
val file = File(subDir, "${screenName}.png")
|
||||
println("Saving screenshot to ${file.absolutePath}")
|
||||
uiDevice.takeScreenshot(file)
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import androidx.core.os.LocaleListCompat
|
||||
import org.fdroid.download.NetworkState
|
||||
import org.fdroid.ui.apps.MyApps
|
||||
import org.fdroid.ui.apps.MyAppsModel
|
||||
import org.fdroid.ui.navigation.NavigationKey
|
||||
import org.fdroid.ui.utils.getMyAppsInfo
|
||||
import org.junit.Ignore
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.junit.runners.Parameterized
|
||||
@@ -19,8 +19,12 @@ class MyAppsScreenshotTest(localeName: String) : LocalizedScreenshotTest(localeN
|
||||
|
||||
@Test
|
||||
fun myApps() =
|
||||
screenshotTest("2_My_Apps", numUpdates = getUpdates(LocaleListCompat.getDefault()).size) {
|
||||
val localeList = LocaleListCompat.getDefault()
|
||||
screenshotTest(
|
||||
"3_My_Apps",
|
||||
currentNavKey = NavigationKey.MyApps,
|
||||
numUpdates = getUpdates(LocaleListCompat.getDefault()).size,
|
||||
dark = true,
|
||||
) { localeList ->
|
||||
val model =
|
||||
MyAppsModel(
|
||||
appUpdates = getUpdates(localeList),
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
package org.fdroid.ui.screenshots
|
||||
|
||||
import org.fdroid.index.v2.FileV2
|
||||
import org.fdroid.index.v2.MetadataV2
|
||||
|
||||
val searchResultApps =
|
||||
listOf(
|
||||
"com.github.yeriomin.workoutlog" to
|
||||
MetadataV2(
|
||||
name = mapOf("en-US" to "WorkoutLog"),
|
||||
summary =
|
||||
mapOf(
|
||||
"ar" to "تطبيق بسيط لتتبع التدريبات الخاصة بك",
|
||||
"ba" to "Күнекмәләрегеҙҙе күҙәтеү өсөн минималистик ҡушымта",
|
||||
"ca" to "Aplicació minimalista per a seguir els entrenaments",
|
||||
"cs" to "Minimalistická aplikace pro sledování vašeho cvičení",
|
||||
"de" to "Minimalistische Anwendung, um Workouts zu erfassen",
|
||||
"en-US" to "A minimalistic app for tracking your workouts",
|
||||
"es" to "Una aplicación minimalista para el seguimiento de tus entrenamientos",
|
||||
"fr" to "App minimaliste pour suivre vos entraînements",
|
||||
"ga" to "Aip íostach chun do chuid oibre a rianú",
|
||||
"he" to "יישומון מזערי למעקב אחר האימונים שלך",
|
||||
"id" to "Aplikasi minimalis untuk melacak latihan Anda",
|
||||
"it" to "Un'app minimalista per monitorare i tuoi allenamenti",
|
||||
"ja" to "ワークアウト追跡用の最小主義なアプリ",
|
||||
"nb" to "En minimalistisk app for å spore treningene dine",
|
||||
"pl" to "Aplikacja do śledzenia treningów",
|
||||
"pt" to "Um app minimalista para rastrear seus treinos",
|
||||
"pt-BR" to "Um aplicativo minimalista para monitorar seus treinos",
|
||||
"pt-PT" to "Um app minimalista para rastrear seus treinos",
|
||||
"ro" to "O aplicație minimalistă pentru a vă urmări antrenamentele",
|
||||
"ru" to "Минималистичное приложение для отслеживания ваших тренировок",
|
||||
"sr" to "Минималистичка апликација за праћење тренинга",
|
||||
"sw" to "Apu ya minimalistic ya kufuatilia mazoezi yako",
|
||||
"ta" to "உங்கள் உடற்பயிற்சிகளைக் கண்காணிப்பதற்கான மிகச்சிறிய பயன்பாடு",
|
||||
"tr" to "Egzersizlerinizi izlemek için sadelikten yana bir uygulama",
|
||||
"uk" to "Мінімалістичний застосунок для відстеження тренувань",
|
||||
"zh-CN" to "用于跟踪锻炼的简约应用",
|
||||
),
|
||||
icon = mapOf("en-US" to FileV2(name = "/icons/com.github.yeriomin.workoutlog.2.png")),
|
||||
added = 23,
|
||||
lastUpdated = 42,
|
||||
),
|
||||
"com.gk.simpleworkoutjournal" to
|
||||
MetadataV2(
|
||||
name = mapOf("en-US" to "SWJournal"),
|
||||
summary =
|
||||
mapOf(
|
||||
"ar" to "تتبع تمارينك",
|
||||
"ba" to "Күнекмәләрегеҙҙе күҙәтегеҙ",
|
||||
"ca" to "Fes un seguiment dels teus entrenaments",
|
||||
"cs" to "Sledování tréninků",
|
||||
"de" to "Workouts aufzeichnen",
|
||||
"en-US" to "Track your workouts",
|
||||
"es" to "Monitorea tus entrenamientos",
|
||||
"fr" to "Suivre vos entraînements",
|
||||
"ga" to "Rianaigh do workouts",
|
||||
"he" to "מעקב אחר האימונים שלך",
|
||||
"id" to "Lacak latihan Anda",
|
||||
"it" to "Monitora i tuoi allenamenti",
|
||||
"ja" to "ワークアウトを記録",
|
||||
"pl" to "Monitoruj swoje treningi",
|
||||
"pt" to "Rastreie seus treinos",
|
||||
"pt-BR" to "Monitore seus treinos",
|
||||
"pt-PT" to "Rastreie seus treinos",
|
||||
"ro" to "Urmăriți-vă antrenamentele",
|
||||
"ru" to "Следите за своими тренировками",
|
||||
"sr" to "Прати своје тренинге",
|
||||
"sw" to "Fuatilia mazoezi yako",
|
||||
"ta" to "உங்கள் உடற்பயிற்சிகளையும் கண்காணிக்கவும்",
|
||||
"tr" to "Egzersizlerinizi izleyin",
|
||||
"uk" to "Відстежуйте свої тренування",
|
||||
"zh-CN" to "跟踪你的锻炼",
|
||||
),
|
||||
icon = mapOf("en-US" to FileV2(name = "/icons/com.gk.simpleworkoutjournal.3.png")),
|
||||
added = 23,
|
||||
lastUpdated = 42,
|
||||
),
|
||||
"com.mcsnowflake.worktimer" to
|
||||
MetadataV2(
|
||||
name = mapOf("en-US" to "WorkTimer"),
|
||||
summary =
|
||||
mapOf(
|
||||
"fr" to
|
||||
"WorkTimer est un simple minuteur qui vous aide à structurer votre temps de trava",
|
||||
"ru" to "Таймер продуктивности для техники управления временем по «методу помидора».",
|
||||
"de" to
|
||||
"WorkTimer ist ein einfacher Timer, der Ihnen hilft, Ihre Arbeitszeit zu struktur",
|
||||
"en" to
|
||||
"WorkTimer is a simple timer that helps you to structure your work time and to st",
|
||||
"es" to
|
||||
"WorkTimer es un temporizador simple que te ayuda a estructurar tu tiempo de trab",
|
||||
),
|
||||
icon =
|
||||
mapOf(
|
||||
"en-US" to
|
||||
FileV2(
|
||||
name =
|
||||
"/com.mcsnowflake.worktimer/en-US/icon_2ulxYoOyPvurF6rw-fHZdyx3Ys-_1ohNUVVvyih_wEM=.png"
|
||||
)
|
||||
),
|
||||
added = 23,
|
||||
lastUpdated = 42,
|
||||
),
|
||||
"de.wger.flutter" to
|
||||
MetadataV2(
|
||||
name = mapOf("en-US" to "wger Workout Manager"),
|
||||
summary =
|
||||
mapOf(
|
||||
"en-US" to "Fitness/workout, nutrition and weight tracker",
|
||||
"ar" to "متتبع التمارين الرياضية، التغذية، ومراقبة الوزن",
|
||||
"ca" to "Registre de la forma física, entrenament, nutrició i pes corporal",
|
||||
"cs-CZ" to "Sledování fitness/tréninků, výživy a hmotnosti",
|
||||
"de-DE" to "Fitness-, Ernährungs- und Gewichtstracker",
|
||||
"el-GR" to "Καταγραφή φυσικής κατάστασης/προπόνησης, διατροφής και βάρους",
|
||||
"es-ES" to "Monitor de entrenamiento, peso y nutrición",
|
||||
"fa-IR" to "شمارنده تناسب اندام/ورزش، تغذیه و وزن",
|
||||
"fr-FR" to "Fitness/entraînement, nutrition et traqueur de poids",
|
||||
"hi-IN" to "फिटनेस/वर्कआउट, नुट्रिशन और वज़न ट्रैकर",
|
||||
"hr" to "Praćenje fitnesa/treninga, prehrane i težine",
|
||||
"it-IT" to "Monitoraggio fitness/workout, dieta e peso",
|
||||
"iw-IL" to "מעקב אחר כושר גופני, תזונה ומשקל",
|
||||
"ko-KR" to "피트니스/운동, 영양소, 몸무게 추적기, 영양소, 몸무게 추적기",
|
||||
"nb-NO" to "Fitness/treningsøkt, ernæring og vektlogging",
|
||||
"nl-NL" to "Fitness/workout, voeding- en gewichts tracker",
|
||||
"pl-PL" to "Monitorowanie kondycji/treningu, odżywiania i wagi",
|
||||
"pt-BR" to "Fitness/treino, nutrição e controle de peso",
|
||||
"pt-PT" to "Gestor de treinos, nutrição e peso",
|
||||
"ru-RU" to "Трекер фитнеса/тренировок, питания и веса",
|
||||
"sr" to "Фитнес/тренинг, исхрана и праћење тежине",
|
||||
"ta-IN" to "உடற்தகுதி/உடற்பயிற்சி, ஊட்டச்சத்து மற்றும் எடை கண்காணிப்பு",
|
||||
"tr-TR" to "Fitness/egzersiz, beslenme ve kilo takibi",
|
||||
"uk" to "Фітнес/тренування, харчування та відстеження ваги",
|
||||
"zh-CN" to "健身/锻炼、营养和体重追踪器",
|
||||
"zh-TW" to "健身、營養和體重紀錄器",
|
||||
),
|
||||
icon =
|
||||
mapOf(
|
||||
"en-US" to
|
||||
FileV2(
|
||||
name =
|
||||
"/de.wger.flutter/en-US/icon_yXwYQPVpjYOb_9wLe4BDR9YY9FCYY29RuuvMfGdIW68=.png",
|
||||
sha256 = "c97c1840f5698d839bffdc0b7b804347d618f45098636f51baebcc7c67485baf",
|
||||
size = 12181,
|
||||
)
|
||||
),
|
||||
added = 23,
|
||||
lastUpdated = 42,
|
||||
),
|
||||
"es.ideotec.workouttime" to
|
||||
MetadataV2(
|
||||
name = mapOf("de-DE" to "Workout Time!"),
|
||||
summary =
|
||||
mapOf(
|
||||
"de-DE" to "Eine App, um Trainings zu verwalten und durchzuführen",
|
||||
"en-US" to "An app for managing and performing timed workouts",
|
||||
"es-ES" to "App para gestionar y realizar entrenamientos cronometrados",
|
||||
"fr-FR" to "Une application pour gérer et réaliser des entraînements chronométrés",
|
||||
),
|
||||
icon =
|
||||
mapOf(
|
||||
"en-US" to
|
||||
FileV2(
|
||||
name =
|
||||
"/es.ideotec.workouttime/en-US/icon_GmCBd-0I_HjZNdIMk10t7ceQ5yMtFnn_SrHYgnEsKvI=.png",
|
||||
sha256 = "1a608177ed08fc78d935d20c935d2dedc790e7232d1679ff4ab1d882712c2af2",
|
||||
size = 24900,
|
||||
)
|
||||
),
|
||||
added = 23,
|
||||
lastUpdated = 42,
|
||||
),
|
||||
"me.ahmetcetinkaya.whph" to
|
||||
MetadataV2(
|
||||
name = mapOf("en-US" to "WHPH • Work Hard Play Hard"),
|
||||
summary =
|
||||
mapOf(
|
||||
"cs-CZ" to "Aplikace pro správu úkolů, rozvoj návyků a optimalizaci vašeho času.",
|
||||
"da-DK" to "Produktivitetsapp til opgavestyring, vaneudvikling og tidsoptimering.",
|
||||
"de-DE" to
|
||||
"Produktivitäts-App für Aufgabenmanagement, Gewohnheiten und Zeitoptimierung.",
|
||||
"el-GR" to
|
||||
"Εφαρμογή για διαχείριση εργασιών, ανάπτυξη συνηθειών και βελτιστοποίηση χρόνου.",
|
||||
"en-GB" to
|
||||
"A productivity app to manage tasks, develop new habits, and optimize your time.",
|
||||
"en-US" to
|
||||
"A productivity app to manage tasks, develop new habits, and optimize your time.",
|
||||
"es-ES" to "App de productividad para gestionar tareas, hábitos y optimizar el tiempo.",
|
||||
"fi-FI" to
|
||||
"Tuottavuussovellus tehtävien hallintaan, tapoihin ja ajankäytön optimointiin.",
|
||||
"fr-FR" to
|
||||
"Une app de productivité pour gérer tâches, habitudes et optimiser votre temps.",
|
||||
"it-IT" to
|
||||
"App di produttività per gestire attività, abitudini e ottimizzare il tempo.",
|
||||
"ja-JP" to "タスク管理、習慣形成、時間最適化のための包括的な生産性アプリ。",
|
||||
"ko-KR" to "작업 관리, 습관 형성 및 시간 최적화를 위한 종합 생산성 앱.",
|
||||
"nl-NL" to
|
||||
"Productiviteitsapp voor taakbeheer, gewoonteontwikkeling en tijdoptimalisatie.",
|
||||
"no-NO" to "Produktivitetsapp for oppgavestyring, vaneutvikling og tidsoptimalisering.",
|
||||
"pl-PL" to "Aplikacja do zarządzania zadaniami, nawykami i optymalizacji czasu.",
|
||||
"pt-PT" to "App de produtividade para gerir tarefas, criar hábitos e otimizar o tempo.",
|
||||
"ro" to
|
||||
"Aplicație pentru gestionarea sarcinilor, obiceiurilor și optimizarea timpului.",
|
||||
"ru-RU" to "Приложение для управления задачами, привычками и оптимизации времени.",
|
||||
"sl" to "Aplikacija za upravljanje nalog, razvoj navad in optimizacijo časa.",
|
||||
"sv-SE" to "Produktivitetsapp för uppgiftshantering, vanor och tidsoptimering.",
|
||||
"tr-TR" to
|
||||
"Görev, alışkanlık geliştirme ve zaman optimizasyonu için verimlilik uygulaması.",
|
||||
"uk" to "Додаток для управління завданнями, звичками та оптимізації вашого часу.",
|
||||
"zh-CN" to "用于管理任务、培养习惯和优化时间的综合生产力应用。",
|
||||
),
|
||||
icon =
|
||||
mapOf(
|
||||
"en-US" to
|
||||
FileV2(
|
||||
name =
|
||||
"/me.ahmetcetinkaya.whph/en-US/icon_tpPTqqEGIdreAhCa15py36S-PiU0HDONw1S8SrfdG1s=.png"
|
||||
)
|
||||
),
|
||||
added = 23,
|
||||
lastUpdated = 42,
|
||||
),
|
||||
"org.runnerup.free" to
|
||||
MetadataV2(
|
||||
name = mapOf("en-US" to "RunnerUp"),
|
||||
summary =
|
||||
mapOf(
|
||||
"ar" to "تتبع نشاطاتك الرياضية باستخدام خدمة تحديد المواقع (GPS)",
|
||||
"cs" to "Sledování sportovních aktivit pomocí GPS",
|
||||
"de" to "Sportliche Aktivitäten mittels GPS aufzeichnen",
|
||||
"en-US" to
|
||||
"RunnerUp - an open source GPS tracker with advanced workout and audio cues.",
|
||||
"es" to "Realiza un seguimiento de tus actividades deportivas usando GPS",
|
||||
"fr" to "Suivez vos activités sportives à l'aide du GPS",
|
||||
"he" to "מעקב אחר הפעילות הספורטיבית באמצעות GPS",
|
||||
"it" to "Traccia le tue attività sportive con il GPS",
|
||||
"ja" to "GPSを使用してあなたのスポーツ活動を追跡",
|
||||
"pl" to "Śledzenie aktywności sportowej za pomocą GPS",
|
||||
"pt" to "Rastreie as suas atividades desportivas usando GPS",
|
||||
"pt-BR" to "Rastreie as suas atividades esportivas usando o GPS",
|
||||
"ro" to "Urmăriți-vă activitățile sportive cu ajutorul GPS",
|
||||
"ru" to "Отслеживайте свои занятия спортом с помощью GPS",
|
||||
"tr" to "GPS kullanarak spor etkinliklerinizi izleyin",
|
||||
"uk" to "Відстежуйте свої спортивні заняття за допомогою GPS",
|
||||
"zh-CN" to "使用 GPS 跟踪您的体育活动",
|
||||
),
|
||||
icon =
|
||||
mapOf(
|
||||
"en-US" to
|
||||
FileV2(
|
||||
name =
|
||||
"/org.runnerup.free/en-US/icon_kN3i52l3-KVxE-yMinNc6g8JQTsMQeRFjWA2fW3M1uE=.png"
|
||||
)
|
||||
),
|
||||
added = 23,
|
||||
lastUpdated = 42,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
package org.fdroid.ui.screenshots
|
||||
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import org.fdroid.LocaleChooser.getBestLocale
|
||||
import org.fdroid.ui.lists.AppListItem
|
||||
import org.fdroid.ui.navigation.NavigationKey
|
||||
import org.fdroid.ui.search.GlobalSearch
|
||||
import org.fdroid.ui.search.SearchResults
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.junit.runners.Parameterized
|
||||
|
||||
@RunWith(Parameterized::class)
|
||||
class SearchScreenshotTest(localeName: String) : LocalizedScreenshotTest(localeName) {
|
||||
|
||||
companion object {
|
||||
@JvmStatic @Parameterized.Parameters(name = "{0}") fun locales() = locales
|
||||
}
|
||||
|
||||
@Test
|
||||
fun search() =
|
||||
screenshotTest("2_Search", currentNavKey = NavigationKey.Search, numUpdates = 0) { localeList ->
|
||||
val categories = setOf("Network Analyzer", "Social Network", "Workout")
|
||||
val results =
|
||||
SearchResults(
|
||||
categories = getCategoryItems(localeList).filter { it.id in categories },
|
||||
apps = getSearchResultItems(localeList),
|
||||
)
|
||||
GlobalSearch(
|
||||
textFieldState = rememberTextFieldState("work"),
|
||||
searchResults = results,
|
||||
savedSearches = emptyList(),
|
||||
onSearch = {},
|
||||
onClearSavedSearches = {},
|
||||
onNav = {},
|
||||
onBack = {},
|
||||
onSearchCleared = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSearchResultItems(localeList: LocaleListCompat) =
|
||||
searchResultApps.map { (packageName, metadata) ->
|
||||
AppListItem(
|
||||
repoId = 1L,
|
||||
packageName = packageName,
|
||||
name = metadata.name?.getBestLocale(localeList) ?: "Unknown",
|
||||
summary = metadata.summary?.getBestLocale(localeList) ?: "",
|
||||
lastUpdated = metadata.lastUpdated,
|
||||
isInstalled = packageName.startsWith("de"),
|
||||
isCompatible = true,
|
||||
iconModel = "https://f-droid.org/repo${metadata.icon.getBestLocale(localeList)?.name}",
|
||||
)
|
||||
}
|
||||
@@ -24,8 +24,8 @@ import org.fdroid.R
|
||||
import org.fdroid.install.InstallState
|
||||
import org.fdroid.install.InstallStateWithInfo
|
||||
import org.fdroid.ui.FDroidContent
|
||||
import org.fdroid.ui.apps.VersionLine
|
||||
import org.fdroid.ui.utils.AsyncShimmerImage
|
||||
import org.fdroid.ui.utils.VersionLine
|
||||
|
||||
@Composable
|
||||
fun SwapSuccessAppRow(
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.application
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import info.guardianproject.panic.Panic
|
||||
import info.guardianproject.panic.PanicResponder
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -59,7 +60,7 @@ constructor(
|
||||
PanicSettingsState(
|
||||
panicApps = apps,
|
||||
selectedPanicApp =
|
||||
if (selected == null) {
|
||||
if (selected == null || selected == Panic.PACKAGE_NAME_NONE) {
|
||||
null
|
||||
} else {
|
||||
getPanicApp(selected)
|
||||
@@ -75,18 +76,22 @@ constructor(
|
||||
}
|
||||
|
||||
fun resetDb() {
|
||||
val job =
|
||||
ioScope.launch {
|
||||
db.getRepositoryDao().clearAll()
|
||||
repoPreLoader.addPreloadedRepositories(db)
|
||||
}
|
||||
val job = ioScope.launch {
|
||||
db.getRepositoryDao().clearAll()
|
||||
repoPreLoader.addPreloadedRepositories(db)
|
||||
}
|
||||
// hard wait for data to be cleared
|
||||
runBlocking { job.join() }
|
||||
}
|
||||
|
||||
private fun getPanicApp(packageName: String?): PanicApp? {
|
||||
if (packageName == null) return null
|
||||
return pm.getPackageInfo(packageName, 0)?.applicationInfo?.toPanicApp()
|
||||
return try {
|
||||
pm.getPackageInfo(packageName, 0)?.applicationInfo?.toPanicApp()
|
||||
} catch (e: Exception) {
|
||||
log.error(e) { "Failed to get package info for $packageName" }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun ApplicationInfo.toPanicApp() =
|
||||
|
||||
@@ -19,8 +19,8 @@ import coil3.request.crossfade
|
||||
import coil3.util.DebugLogger
|
||||
import coil3.util.Logger
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import org.acra.ACRA
|
||||
import javax.inject.Inject
|
||||
import org.acra.ACRA
|
||||
import org.acra.ReportField
|
||||
import org.acra.config.dialog
|
||||
import org.acra.config.mailSender
|
||||
@@ -71,10 +71,12 @@ class App : Application(), Configuration.Provider, SingletonImageLoader.Factory
|
||||
ReportField.DISPLAY,
|
||||
ReportField.TOTAL_MEM_SIZE,
|
||||
ReportField.AVAILABLE_MEM_SIZE,
|
||||
ReportField.LOGCAT,
|
||||
ReportField.CUSTOM_DATA,
|
||||
ReportField.STACK_TRACE_HASH,
|
||||
ReportField.STACK_TRACE,
|
||||
)
|
||||
logcatArguments = listOf("-t", "750")
|
||||
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
|
||||
|
||||
@@ -16,6 +16,7 @@ import javax.inject.Inject
|
||||
import kotlinx.coroutines.launch
|
||||
import org.fdroid.settings.SettingsManager
|
||||
import org.fdroid.ui.Main
|
||||
import org.fdroid.ui.utils.launchSafe
|
||||
|
||||
// Using [AppCompatActivity] and not [ComponentActivity] seems to be needed
|
||||
// for automatic theme changes when calling AppCompatDelegate.setDefaultNightMode()
|
||||
@@ -54,7 +55,7 @@ class MainActivity : AppCompatActivity() {
|
||||
SDK_INT >= 33 &&
|
||||
ContextCompat.checkSelfPermission(this, POST_NOTIFICATIONS) != PERMISSION_GRANTED
|
||||
) {
|
||||
requestPermissionLauncher.launch(POST_NOTIFICATIONS)
|
||||
requestPermissionLauncher.launchSafe(POST_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,7 +143,14 @@ constructor(
|
||||
// so fire up intent here and now.
|
||||
if (canRequestUserConfirmationNow) {
|
||||
log.info { "Sending pre-approval intent for ${app.packageName}: $intent" }
|
||||
pendingIntent.send()
|
||||
try {
|
||||
pendingIntent.send()
|
||||
} catch (e: Exception) {
|
||||
log.error(e) { "Error sending pre-approval intent: " }
|
||||
val s = PreApprovalResult.UserConfirmationRequired(sessionId, pendingIntent)
|
||||
cont.resume(s)
|
||||
context.unregisterReceiver(this)
|
||||
}
|
||||
} else {
|
||||
log.info { "Can not ask pre-approval for ${app.packageName}: $intent" }
|
||||
val s = PreApprovalResult.UserConfirmationRequired(sessionId, pendingIntent)
|
||||
@@ -340,7 +347,13 @@ constructor(
|
||||
}
|
||||
registerReceiver(context, receiver, IntentFilter(ACTION_INSTALL), RECEIVER_NOT_EXPORTED)
|
||||
cont.invokeOnCancellation { context.unregisterReceiver(receiver) }
|
||||
state.intent.send()
|
||||
try {
|
||||
state.intent.send()
|
||||
} catch (e: Exception) {
|
||||
log.error(e) { "Error sending user confirmation intent: " }
|
||||
context.unregisterReceiver(receiver)
|
||||
cont.resume(InstallState.Error("${e::class.java.simpleName} ${e.message}", state))
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSessionParams(packageName: String, size: Long? = null): SessionParams {
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package org.fdroid.ui.search
|
||||
package org.fdroid.search
|
||||
|
||||
import java.text.Normalizer
|
||||
import java.text.Normalizer.Form.NFKD
|
||||
@@ -0,0 +1,86 @@
|
||||
package org.fdroid.search
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Context.MODE_PRIVATE
|
||||
import androidx.annotation.WorkerThread
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.io.FileNotFoundException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.decodeFromStream
|
||||
import mu.KotlinLogging
|
||||
|
||||
private const val HISTORY_FILE = "search_history.json"
|
||||
private const val MAX_SEARCHES = 10
|
||||
|
||||
@Singleton
|
||||
class SearchHistoryManager(
|
||||
private val context: Context,
|
||||
private val maxNumSearches: Int = MAX_SEARCHES,
|
||||
) {
|
||||
private val log = KotlinLogging.logger {}
|
||||
|
||||
@Inject constructor(@ApplicationContext context: Context) : this(context, MAX_SEARCHES)
|
||||
|
||||
@Synchronized
|
||||
@WorkerThread
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
fun getSavedSearches(): List<SavedSearch> {
|
||||
return try {
|
||||
context.openFileInput(HISTORY_FILE).use { inputStream ->
|
||||
Json.decodeFromStream<List<SavedSearch>>(inputStream).sortedByDescending { it.time }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e !is FileNotFoundException) {
|
||||
log.error(e) { "Error getting saved searches: " }
|
||||
clearAll()
|
||||
}
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/** Saved the given [query] and returns the updated list of saved searches. */
|
||||
@Synchronized
|
||||
@WorkerThread
|
||||
fun saveSearchQuery(query: String): List<SavedSearch> {
|
||||
log.info { "Saving search query \"$query\"" }
|
||||
// get existing searches, remove any with the same query
|
||||
val savedSearches = getSavedSearches().toMutableList()
|
||||
savedSearches.removeAll { it.query == query }
|
||||
// keep only the most recent maxNumSearches - 1 searches, so we can add the new one
|
||||
val searchesToSave =
|
||||
if (savedSearches.size > maxNumSearches - 1) {
|
||||
savedSearches.subList(0, maxNumSearches - 1)
|
||||
} else {
|
||||
savedSearches
|
||||
}
|
||||
// add the new search with the current time
|
||||
val savedSearch = SavedSearch(System.currentTimeMillis(), query)
|
||||
searchesToSave.add(savedSearch)
|
||||
// save searches to disk
|
||||
try {
|
||||
context.openFileOutput(HISTORY_FILE, MODE_PRIVATE).use { outputStream ->
|
||||
outputStream.write(Json.encodeToString(searchesToSave).encodeToByteArray())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
log.error(e) { "Error saving $savedSearch: " }
|
||||
}
|
||||
return searchesToSave.sortedByDescending { it.time }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@WorkerThread
|
||||
fun clearAll(): Boolean {
|
||||
return try {
|
||||
context.deleteFile(HISTORY_FILE)
|
||||
} catch (e: Exception) {
|
||||
log.error(e) { "Error deleting file: " }
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable data class SavedSearch(val time: Long, val query: String)
|
||||
+48
-9
@@ -1,7 +1,6 @@
|
||||
package org.fdroid.ui.search
|
||||
package org.fdroid.search
|
||||
|
||||
import android.database.sqlite.SQLiteException
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.lifecycle.asFlow
|
||||
import java.text.Collator
|
||||
@@ -10,15 +9,20 @@ import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.measureTimedValue
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import mu.KotlinLogging
|
||||
import org.fdroid.LocaleChooser.getBestLocale
|
||||
import org.fdroid.database.FDroidDatabase
|
||||
import org.fdroid.database.SearchQueryRewriter.rewriteQuery
|
||||
import org.fdroid.database.SearchQueryRewriter
|
||||
import org.fdroid.download.DownloadRequest
|
||||
import org.fdroid.download.PackageName
|
||||
import org.fdroid.download.getImageModel
|
||||
@@ -27,9 +31,13 @@ import org.fdroid.install.InstalledAppsCache
|
||||
import org.fdroid.settings.SettingsManager
|
||||
import org.fdroid.ui.categories.CategoryItem
|
||||
import org.fdroid.ui.lists.AppListItem
|
||||
import org.fdroid.ui.search.SearchHelper.normalize
|
||||
import org.fdroid.search.SearchHelper.normalize
|
||||
import org.fdroid.ui.search.SearchResults
|
||||
import org.fdroid.utils.IoDispatcher
|
||||
|
||||
/** The minimum amount of characters we start auto-searching for. */
|
||||
const val SEARCH_THRESHOLD = 2
|
||||
|
||||
@Singleton
|
||||
class SearchManager
|
||||
@Inject
|
||||
@@ -38,6 +46,7 @@ constructor(
|
||||
private val repoManager: RepoManager,
|
||||
private val settingsManager: SettingsManager,
|
||||
private val installedAppsCache: InstalledAppsCache,
|
||||
private val searchHistoryManager: SearchHistoryManager,
|
||||
@param:IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
) {
|
||||
|
||||
@@ -45,9 +54,7 @@ constructor(
|
||||
private val localeList = LocaleListCompat.getDefault()
|
||||
private val collator = Collator.getInstance(Locale.getDefault())
|
||||
private val _searchResults = MutableStateFlow<SearchResults?>(null)
|
||||
val textFieldState = TextFieldState()
|
||||
val searchResults = _searchResults.asStateFlow()
|
||||
|
||||
private val _savedSearches = MutableStateFlow<List<SavedSearch>?>(null)
|
||||
private val categories =
|
||||
db.getRepositoryDao().getLiveCategories().asFlow().map { categories ->
|
||||
categories
|
||||
@@ -56,14 +63,25 @@ constructor(
|
||||
}
|
||||
.sortedWith { c1, c2 -> collator.compare(c1.name, c2.name) }
|
||||
}
|
||||
private var searchJob: SearchJob? = null
|
||||
|
||||
suspend fun search(term: String) =
|
||||
val searchResults = _searchResults.asStateFlow()
|
||||
val savedSearches = _savedSearches.asStateFlow()
|
||||
|
||||
init {
|
||||
// load saved searches on initialization
|
||||
CoroutineScope(ioDispatcher).launch {
|
||||
_savedSearches.value = searchHistoryManager.getSavedSearches()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun search(term: String) {
|
||||
withContext(ioDispatcher) {
|
||||
// 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 query = rewriteQuery(sanitized)
|
||||
val query = SearchQueryRewriter.rewriteQuery(sanitized)
|
||||
log.info { "Searching for: $query" }
|
||||
val timedApps = measureTimedValue {
|
||||
try {
|
||||
@@ -108,8 +126,29 @@ constructor(
|
||||
"and took ${timedApps.duration} and ${timedCategories.duration}"
|
||||
}
|
||||
}
|
||||
coroutineScope {
|
||||
// cancel previous search job if it's still running, so we don't save search as you type
|
||||
// incomplete queries
|
||||
searchJob?.job?.cancel()
|
||||
val job =
|
||||
launch(ioDispatcher) {
|
||||
// debounce, so we don't save every single search if the user is typing quickly
|
||||
delay(1500)
|
||||
_savedSearches.value = searchHistoryManager.saveSearchQuery(term)
|
||||
}
|
||||
searchJob = SearchJob(job, term)
|
||||
}
|
||||
}
|
||||
|
||||
fun onSearchCleared() {
|
||||
_searchResults.value = null
|
||||
}
|
||||
|
||||
suspend fun onClearSearchHistory() {
|
||||
withContext(ioDispatcher) {
|
||||
if (searchHistoryManager.clearAll()) _savedSearches.value = emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SearchJob(val job: Job, val query: String)
|
||||
@@ -17,9 +17,6 @@ object SettingsConstants {
|
||||
const val PREF_KEY_DYNAMIC_COLORS = "dynamicColors"
|
||||
const val PREF_DEFAULT_DYNAMIC_COLORS = false
|
||||
|
||||
const val PREF_KEY_SMALL_BOTTOM_BAR = "smallBottomBar"
|
||||
const val PREF_DEFAULT_SMALL_BOTTOM_BAR = false
|
||||
|
||||
enum class AutoUpdateValues {
|
||||
OnlyWifi,
|
||||
Always,
|
||||
|
||||
@@ -33,7 +33,6 @@ import org.fdroid.settings.SettingsConstants.PREF_DEFAULT_PREVENT_SCREENSHOTS
|
||||
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_SMALL_BOTTOM_BAR
|
||||
import org.fdroid.settings.SettingsConstants.PREF_DEFAULT_THEME
|
||||
import org.fdroid.settings.SettingsConstants.PREF_DNS_CACHE
|
||||
import org.fdroid.settings.SettingsConstants.PREF_DNS_CACHE_DEFAULT
|
||||
@@ -50,7 +49,6 @@ import org.fdroid.settings.SettingsConstants.PREF_KEY_PREVENT_SCREENSHOTS
|
||||
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_SMALL_BOTTOM_BAR
|
||||
import org.fdroid.settings.SettingsConstants.PREF_KEY_THEME
|
||||
import org.fdroid.settings.SettingsConstants.PREF_USE_DNS_CACHE
|
||||
import org.fdroid.settings.SettingsConstants.PREF_USE_DNS_CACHE_DEFAULT
|
||||
@@ -79,10 +77,6 @@ class SettingsManager @Inject constructor(@param:ApplicationContext private val
|
||||
prefsFlow
|
||||
.map { it.get<Boolean>(PREF_KEY_DYNAMIC_COLORS) ?: PREF_DEFAULT_DYNAMIC_COLORS }
|
||||
.distinctUntilChanged()
|
||||
val smallBottomBarFlow: Flow<Boolean> =
|
||||
prefsFlow
|
||||
.map { it.get<Boolean>(PREF_KEY_SMALL_BOTTOM_BAR) ?: PREF_DEFAULT_SMALL_BOTTOM_BAR }
|
||||
.distinctUntilChanged()
|
||||
val repoUpdates
|
||||
get() = prefs.getString(PREF_KEY_REPO_UPDATES, PREF_DEFAULT_REPO_UPDATES).toAutoUpdateValue()
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ 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
|
||||
@@ -16,7 +15,6 @@ 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 org.fdroid.ui.apps.myAppsEntry
|
||||
import org.fdroid.ui.details.NoAppSelected
|
||||
import org.fdroid.ui.details.appDetailsEntry
|
||||
@@ -59,7 +57,6 @@ fun Main(onListeningForIntent: () -> Unit = {}) {
|
||||
calculatePaneScaffoldDirective(windowAdaptiveInfo).copy(horizontalPartitionSpacerSize = 2.dp)
|
||||
}
|
||||
val isBigScreen = directive.maxHorizontalPartitions > 1
|
||||
val listDetailStrategy = rememberListDetailSceneStrategy<NavKey>(directive = directive)
|
||||
|
||||
val entryProvider: (NavKey) -> NavEntry<NavKey> = entryProvider {
|
||||
discoverEntry(navigator)
|
||||
@@ -72,9 +69,10 @@ fun Main(onListeningForIntent: () -> Unit = {}) {
|
||||
) {
|
||||
val viewModel = hiltViewModel<SearchViewModel>()
|
||||
GlobalSearch(
|
||||
textFieldState = viewModel.textFieldState,
|
||||
searchResults = viewModel.searchResults.collectAsStateWithLifecycle().value,
|
||||
savedSearches = viewModel.savedSearchesFlow.collectAsStateWithLifecycle().value,
|
||||
onSearch = viewModel::search,
|
||||
onClearSavedSearches = viewModel::onClearSearchHistory,
|
||||
onNav = { navKey -> navigator.navigate(navKey) },
|
||||
onBack = { navigator.goBack() },
|
||||
onSearchCleared = viewModel::onSearchCleared,
|
||||
@@ -84,7 +82,6 @@ fun Main(onListeningForIntent: () -> Unit = {}) {
|
||||
val viewModel = hiltViewModel<SettingsViewModel>()
|
||||
Settings(
|
||||
model = viewModel.model,
|
||||
isBigScreen = isBigScreen,
|
||||
onSaveLogcat = {
|
||||
viewModel.onSaveLogcat(it)
|
||||
navigator.goBack()
|
||||
@@ -114,21 +111,15 @@ fun Main(onListeningForIntent: () -> Unit = {}) {
|
||||
// flavor specific navigation destinations go here
|
||||
extraNavigationEntries(navigator)
|
||||
}
|
||||
val showBottomBar = !isBigScreen && navigator.last is MainNavKey
|
||||
val viewModel = hiltViewModel<MainViewModel>()
|
||||
val mainModel = viewModel.mainModel.collectAsStateWithLifecycle().value
|
||||
MainContent(
|
||||
model = mainModel,
|
||||
model = viewModel.mainModel.collectAsStateWithLifecycle().value,
|
||||
navEntries = navigationState.toEntries(entryProvider),
|
||||
directive = directive,
|
||||
isBigScreen = isBigScreen,
|
||||
showBottomBar = showBottomBar,
|
||||
showBottomBar = !isBigScreen && navigator.last is MainNavKey,
|
||||
currentNavKey = navigationState.topLevelRoute,
|
||||
onNav = { navKey -> navigator.navigate(navKey) },
|
||||
) { modifier ->
|
||||
NavDisplay(
|
||||
entries = navigationState.toEntries(entryProvider),
|
||||
sceneStrategy = listDetailStrategy,
|
||||
onBack = { navigator.goBack() },
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
onNav = navigator::navigate,
|
||||
onBack = navigator::goBack,
|
||||
)
|
||||
}
|
||||
@@ -1,68 +1,61 @@
|
||||
package org.fdroid.ui
|
||||
|
||||
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.animation.SharedTransitionLayout
|
||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||
import androidx.compose.material3.adaptive.layout.PaneScaffoldDirective
|
||||
import androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneStrategy
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation3.runtime.NavEntry
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import com.viktormykhailiv.compose.hints.HintHost
|
||||
import org.fdroid.ui.navigation.BottomBar
|
||||
import org.fdroid.ui.navigation.MainNavKey
|
||||
import org.fdroid.ui.navigation.NavigationRail
|
||||
import org.fdroid.ui.navigation.rememberResponsiveNavigationSceneDecoratorStrategy
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
fun MainContent(
|
||||
model: MainModel,
|
||||
navEntries: List<NavEntry<NavKey>>,
|
||||
directive: PaneScaffoldDirective,
|
||||
isBigScreen: Boolean,
|
||||
showBottomBar: Boolean,
|
||||
currentNavKey: NavKey,
|
||||
onNav: (MainNavKey) -> Unit,
|
||||
content: @Composable (Modifier) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) =
|
||||
FDroidContent(dynamicColors = model.dynamicColors) {
|
||||
HintHost {
|
||||
Scaffold(
|
||||
bottomBar =
|
||||
if (showBottomBar) {
|
||||
{ BottomBar(model = model, currentNavKey = currentNavKey, onNav = onNav) }
|
||||
} else {
|
||||
{}
|
||||
}
|
||||
) { paddingValues ->
|
||||
Row {
|
||||
// show nav rail only on big screen (at least two partitions)
|
||||
if (isBigScreen)
|
||||
NavigationRail(
|
||||
numUpdates = model.numUpdates,
|
||||
hasIssues = model.hasAppIssues,
|
||||
currentNavKey = currentNavKey,
|
||||
onNav = onNav,
|
||||
)
|
||||
val modifier =
|
||||
if (isBigScreen) {
|
||||
// need to consume start insets or some phones leave a lot of space there
|
||||
Modifier.consumeWindowInsets(PaddingValues(start = 64.dp))
|
||||
} else if (showBottomBar) {
|
||||
// we only apply the bottom padding here, so content stays above bottom bar,
|
||||
// but we need to consume the navigation bar height manually
|
||||
val bottom =
|
||||
with(LocalDensity.current) { WindowInsets.navigationBars.getBottom(this).toDp() }
|
||||
Modifier.consumeWindowInsets(PaddingValues(bottom = bottom))
|
||||
.padding(bottom = paddingValues.calculateBottomPadding())
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
// this needs to a have a fixed place or state saving breaks,
|
||||
// so all moving pieces with conditionals are above
|
||||
content(modifier)
|
||||
}
|
||||
val listDetailStrategy = rememberListDetailSceneStrategy<NavKey>(directive = directive)
|
||||
SharedTransitionLayout {
|
||||
HintHost {
|
||||
val responsiveNavigationSceneDecoratorStrategy =
|
||||
rememberResponsiveNavigationSceneDecoratorStrategy<NavKey>(
|
||||
isBigScreen = isBigScreen,
|
||||
navBar = {
|
||||
if (showBottomBar) {
|
||||
BottomBar(model = model, currentNavKey = currentNavKey, onNav = onNav)
|
||||
}
|
||||
},
|
||||
navRail = {
|
||||
NavigationRail(
|
||||
numUpdates = model.numUpdates,
|
||||
hasIssues = model.hasAppIssues,
|
||||
currentNavKey = currentNavKey,
|
||||
onNav = onNav,
|
||||
)
|
||||
},
|
||||
sharedTransitionScope = this,
|
||||
)
|
||||
NavDisplay(
|
||||
entries = navEntries,
|
||||
sceneDecoratorStrategies = listOf(responsiveNavigationSceneDecoratorStrategy),
|
||||
sceneStrategies = listOf(listDetailStrategy),
|
||||
onBack = onBack,
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,10 @@ import androidx.compose.runtime.collectAsState
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.fdroid.settings.SettingsConstants.PREF_DEFAULT_DYNAMIC_COLORS
|
||||
import org.fdroid.settings.SettingsConstants.PREF_DEFAULT_SMALL_BOTTOM_BAR
|
||||
import org.fdroid.ui.apps.AppWithIssueItem
|
||||
|
||||
data class MainModel(
|
||||
val dynamicColors: Boolean,
|
||||
val smallBottomBar: Boolean,
|
||||
val numUpdates: Int,
|
||||
val hasAppIssues: Boolean,
|
||||
)
|
||||
@@ -18,14 +16,11 @@ data class MainModel(
|
||||
@Composable
|
||||
fun MainPresenter(
|
||||
dynamicColorsFlow: Flow<Boolean>,
|
||||
smallBottomBarFlow: Flow<Boolean>,
|
||||
numUpdatesFlow: StateFlow<Int>,
|
||||
appsWithIssuesFlow: StateFlow<List<AppWithIssueItem>?>,
|
||||
): MainModel {
|
||||
return MainModel(
|
||||
dynamicColors = dynamicColorsFlow.collectAsState(initial = PREF_DEFAULT_DYNAMIC_COLORS).value,
|
||||
smallBottomBar =
|
||||
smallBottomBarFlow.collectAsState(initial = PREF_DEFAULT_SMALL_BOTTOM_BAR).value,
|
||||
numUpdates = numUpdatesFlow.collectAsState().value,
|
||||
hasAppIssues = !appsWithIssuesFlow.collectAsState().value.isNullOrEmpty(),
|
||||
)
|
||||
|
||||
@@ -37,7 +37,6 @@ constructor(
|
||||
moleculeScope.launchMolecule(mode = ContextClock) {
|
||||
MainPresenter(
|
||||
dynamicColorsFlow = settingsManager.dynamicColorFlow,
|
||||
smallBottomBarFlow = settingsManager.smallBottomBarFlow,
|
||||
numUpdatesFlow = updatesManager.numUpdates,
|
||||
appsWithIssuesFlow = updatesManager.appsWithIssues,
|
||||
)
|
||||
|
||||
@@ -23,6 +23,7 @@ import org.fdroid.R
|
||||
import org.fdroid.install.InstallState
|
||||
import org.fdroid.ui.FDroidContent
|
||||
import org.fdroid.ui.utils.AsyncShimmerImage
|
||||
import org.fdroid.ui.utils.VersionLine
|
||||
|
||||
@Composable
|
||||
fun InstallingAppRow(app: InstallingAppItem, isSelected: Boolean, modifier: Modifier = Modifier) {
|
||||
|
||||
@@ -11,8 +11,8 @@ import org.fdroid.download.NetworkState
|
||||
import org.fdroid.install.InstallConfirmationState
|
||||
import org.fdroid.install.InstallState
|
||||
import org.fdroid.install.InstallStateWithInfo
|
||||
import org.fdroid.ui.search.SearchHelper.fixQuery
|
||||
import org.fdroid.ui.search.SearchHelper.normalize
|
||||
import org.fdroid.search.SearchHelper.fixQuery
|
||||
import org.fdroid.search.SearchHelper.normalize
|
||||
|
||||
// TODO add tests for this, similar to DetailsPresenter
|
||||
@Composable
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
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.foundation.text.InlineTextContent
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowRightAlt
|
||||
import androidx.compose.material.icons.filled.NewReleases
|
||||
import androidx.compose.material3.BadgedBox
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
@@ -26,26 +21,20 @@ 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.platform.LocalLayoutDirection
|
||||
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.text.Placeholder
|
||||
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
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.VersionLine
|
||||
import org.fdroid.ui.utils.getPreviewVersion
|
||||
|
||||
@Composable
|
||||
@@ -103,61 +92,6 @@ fun UpdatableAppRow(app: AppUpdateItem, isSelected: Boolean, modifier: Modifier
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows which is the installed version of the app and to which version it will upgrade. Takes into
|
||||
* account the layout direction to show the correct order of versions and the arrow.
|
||||
*/
|
||||
@Composable
|
||||
fun VersionLine(app: AppUpdateItem) {
|
||||
VersionLineWithSize(app.installedVersionName, app.update.versionName, app.update.size)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VersionLineWithSize(fromVersion: String?, toVersion: String, numBytes: Long? = null) {
|
||||
val size = numBytes?.let { Formatter.formatFileSize(LocalContext.current, it) }
|
||||
VersionLine(fromVersion, toVersion, size)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VersionLine(fromVersion: String?, toVersion: String, extraText: String? = null) {
|
||||
val test = buildAnnotatedString {
|
||||
if (LocalLayoutDirection.current == LayoutDirection.Ltr) {
|
||||
if (fromVersion != null) {
|
||||
append(fromVersion)
|
||||
}
|
||||
} else {
|
||||
append("\u202A${toVersion}\u202C")
|
||||
}
|
||||
if (fromVersion != null) appendInlineContent("arrowId", " → ")
|
||||
if (LocalLayoutDirection.current == LayoutDirection.Ltr) {
|
||||
append("\u202A${toVersion}\u202C")
|
||||
} else if (fromVersion != null) {
|
||||
append("\u202A${fromVersion}\u202C")
|
||||
}
|
||||
if (extraText != null) append(" • $extraText")
|
||||
}
|
||||
val inlineContent =
|
||||
mapOf(
|
||||
Pair(
|
||||
"arrowId",
|
||||
InlineTextContent(
|
||||
Placeholder(
|
||||
width = 24.sp,
|
||||
height = 20.sp,
|
||||
placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter,
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Default.ArrowRightAlt,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(horizontal = 2.dp),
|
||||
)
|
||||
},
|
||||
)
|
||||
)
|
||||
Text(test, inlineContent = if (fromVersion == null) mapOf() else inlineContent)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun UpdatableAppRowPreview() {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package org.fdroid.ui.crash
|
||||
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import android.widget.Toast.LENGTH_SHORT
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -24,6 +26,7 @@ import kotlinx.coroutines.launch
|
||||
import org.fdroid.R
|
||||
import org.fdroid.ui.FDroidContent
|
||||
import org.fdroid.ui.utils.TopAppBarButton
|
||||
import org.fdroid.ui.utils.launchSafe
|
||||
import org.fdroid.utils.getLogName
|
||||
|
||||
@Composable
|
||||
@@ -56,7 +59,11 @@ fun Crash(
|
||||
TopAppBarButton(
|
||||
imageVector = Icons.Default.Save,
|
||||
contentDescription = stringResource(R.string.crash_report_save),
|
||||
onClick = { launcher.launch("${getLogName(context)}.json") },
|
||||
onClick = {
|
||||
if (!launcher.launchSafe("${getLogName(context)}.json")) {
|
||||
Toast.makeText(context, R.string.no_handler_app_generic, LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package org.fdroid.ui.crash
|
||||
|
||||
import android.content.Context
|
||||
import kotlin.math.min
|
||||
import org.acra.ReportField
|
||||
import org.acra.builder.ReportBuilder
|
||||
import org.acra.collector.BaseReportFieldCollector
|
||||
import org.acra.config.CoreConfiguration
|
||||
import org.acra.data.CrashReportData
|
||||
|
||||
class ShortHashCollector : BaseReportFieldCollector(ReportField.CUSTOM_DATA) {
|
||||
|
||||
override fun collect(
|
||||
reportField: ReportField,
|
||||
context: Context,
|
||||
config: CoreConfiguration,
|
||||
reportBuilder: ReportBuilder,
|
||||
target: CrashReportData,
|
||||
) {
|
||||
val hash = getStackTraceHash(reportBuilder.exception)
|
||||
if (hash != null) {
|
||||
target.put("STACK_TRACE_SHORT_HASH", hash)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getStackTraceHash(th: Throwable?): String? {
|
||||
if (th == null) return null
|
||||
val res = StringBuilder()
|
||||
var cause = th
|
||||
while (cause != null) {
|
||||
// we only consider the first 3 stack trace elements of each cause,
|
||||
// because we found the bottom often includes irrelevant changes
|
||||
val numElements = min(3, cause.stackTrace.size)
|
||||
val stackTraceElements = cause.stackTrace.toMutableList().subList(0, numElements)
|
||||
stackTraceElements.forEachIndexed { i, e ->
|
||||
res.append(e.className)
|
||||
res.append(e.methodName)
|
||||
// first element also includes line number to differentiate between crashes in same method
|
||||
if (i == 0) res.append(e.lineNumber)
|
||||
}
|
||||
cause = cause.cause
|
||||
}
|
||||
return Integer.toHexString(res.toString().hashCode())
|
||||
}
|
||||
}
|
||||
@@ -230,7 +230,7 @@ fun AppDetailsHeader(
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = SpaceBetween) {
|
||||
val secondsPassed = ((now - state.startMillis) / 1000f).takeIf { it > 0 } ?: 1f
|
||||
val averageSpeed = (state.downloadedBytes / secondsPassed).roundToLong()
|
||||
val speedStr = Formatter.formatFileSize(context, averageSpeed) + "/s"
|
||||
val speedStr = Formatter.formatFileSize(context, averageSpeed)
|
||||
val remainingStr =
|
||||
Formatter.formatFileSize(context, state.totalBytes - state.downloadedBytes)
|
||||
Text(
|
||||
|
||||
@@ -22,7 +22,7 @@ import org.fdroid.index.v2.PackageVersion
|
||||
import org.fdroid.install.InstallState
|
||||
import org.fdroid.install.SessionInstallManager
|
||||
import org.fdroid.ui.categories.CategoryItem
|
||||
import org.fdroid.ui.search.SearchHelper.removeZeroWhiteSpace
|
||||
import org.fdroid.search.SearchHelper.removeZeroWhiteSpace
|
||||
|
||||
data class AppDetailsItem(
|
||||
val app: AppMetadata,
|
||||
|
||||
@@ -22,6 +22,7 @@ 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.launchSafe
|
||||
import org.fdroid.ui.utils.startActivitySafe
|
||||
import org.fdroid.ui.utils.testApp
|
||||
|
||||
@@ -93,7 +94,7 @@ fun AppDetailsMenu(
|
||||
leadingIcon = { Icon(Icons.Default.Delete, null) },
|
||||
text = { Text(stringResource(R.string.menu_uninstall)) },
|
||||
onClick = {
|
||||
uninstallLauncher.launch(item.actions.uninstallIntent)
|
||||
uninstallLauncher.launchSafe(item.actions.uninstallIntent)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
package org.fdroid.ui.discover
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
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.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
|
||||
import org.fdroid.ui.search.AppsSearch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -27,17 +22,6 @@ fun DiscoverContent(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
AppsSearch(
|
||||
onNav = onNav,
|
||||
textFieldState = discoverModel.searchTextFieldState,
|
||||
modifier =
|
||||
Modifier
|
||||
// focusable is a workaround for https://issuetracker.google.com/issues/445720462
|
||||
.focusable()
|
||||
.padding(top = 16.dp, bottom = 4.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
.align(Alignment.CenterHorizontally),
|
||||
)
|
||||
AnimatedVisibility(discoverModel.newApps.isNotEmpty()) {
|
||||
val listNew = AppListType.New(stringResource(R.string.app_list_new))
|
||||
AppCarousel(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.fdroid.ui.discover
|
||||
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import org.fdroid.download.NetworkState
|
||||
import org.fdroid.repo.RepoUpdateState
|
||||
import org.fdroid.ui.categories.CategoryGroup
|
||||
@@ -22,6 +21,5 @@ data class LoadedDiscoverModel(
|
||||
val recentlyUpdatedApps: List<AppDiscoverItem>,
|
||||
val mostDownloadedApps: List<AppDiscoverItem>?,
|
||||
val categories: Map<CategoryGroup, List<CategoryItem>>?,
|
||||
val searchTextFieldState: TextFieldState,
|
||||
val hasRepoIssues: Boolean,
|
||||
) : DiscoverModel()
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.fdroid.ui.discover
|
||||
|
||||
import android.content.pm.PackageInfo
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.core.os.LocaleListCompat
|
||||
@@ -26,7 +25,6 @@ fun DiscoverPresenter(
|
||||
mostDownloadedAppsFlow: Flow<List<AppOverviewItem>>,
|
||||
categoriesFlow: Flow<List<CategoryItem>>,
|
||||
installedAppsFlow: StateFlow<Map<String, PackageInfo>>,
|
||||
searchTextFieldState: TextFieldState,
|
||||
isFirstStart: Boolean,
|
||||
networkState: NetworkState,
|
||||
repoUpdateStateFlow: StateFlow<RepoUpdateState?>,
|
||||
@@ -42,7 +40,7 @@ fun DiscoverPresenter(
|
||||
proxyConfig: ProxyConfig?,
|
||||
): AppDiscoverItem {
|
||||
val isInstalled = installedApps.contains(packageName)
|
||||
val imageModel = getIcon(localeList)?.getImageModel(repository, proxyConfig) as? DownloadRequest
|
||||
val imageModel = getIcon(localeList)?.getImageModel(repository, proxyConfig)
|
||||
return AppDiscoverItem(
|
||||
packageName = packageName,
|
||||
name = getName(localeList) ?: "Unknown App",
|
||||
@@ -50,7 +48,7 @@ fun DiscoverPresenter(
|
||||
isInstalled = isInstalled,
|
||||
imageModel =
|
||||
if (isInstalled) {
|
||||
PackageName(packageName, imageModel)
|
||||
PackageName(packageName, imageModel as? DownloadRequest)
|
||||
} else {
|
||||
imageModel
|
||||
},
|
||||
@@ -91,7 +89,6 @@ fun DiscoverPresenter(
|
||||
recentlyUpdatedApps = recentlyUpdatedApps ?: emptyList(),
|
||||
mostDownloadedApps = mostDownloadedApps,
|
||||
categories = categories?.groupBy { it.group },
|
||||
searchTextFieldState = searchTextFieldState,
|
||||
hasRepoIssues = hasRepoIssuesFlow.collectAsState(false).value,
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -31,7 +31,6 @@ 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.search.SearchManager
|
||||
|
||||
@HiltViewModel
|
||||
class DiscoverViewModel
|
||||
@@ -42,7 +41,6 @@ constructor(
|
||||
private val db: FDroidDatabase,
|
||||
networkMonitor: NetworkMonitor,
|
||||
private val settingsManager: SettingsManager,
|
||||
private val searchManager: SearchManager,
|
||||
private val repoManager: RepoManager,
|
||||
private val repoUpdateManager: RepoUpdateManager,
|
||||
private val installedAppsCache: InstalledAppsCache,
|
||||
@@ -94,7 +92,6 @@ constructor(
|
||||
mostDownloadedAppsFlow = mostDownloadedApps,
|
||||
categoriesFlow = categories,
|
||||
installedAppsFlow = installedAppsCache.installedApps,
|
||||
searchTextFieldState = searchManager.textFieldState,
|
||||
isFirstStart = settingsManager.isFirstStart,
|
||||
// not observing the flow, but just taking the current value,
|
||||
// because we kick off repo updates from the UI depending on this state
|
||||
|
||||
@@ -33,9 +33,9 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.fdroid.R
|
||||
import org.fdroid.history.InstallEvent
|
||||
import org.fdroid.ui.apps.VersionLine
|
||||
import org.fdroid.ui.utils.AsyncShimmerImage
|
||||
import org.fdroid.ui.utils.BadgeIcon
|
||||
import org.fdroid.ui.utils.VersionLine
|
||||
import org.fdroid.ui.utils.asRelativeTimeString
|
||||
|
||||
@Composable
|
||||
@@ -55,7 +55,11 @@ fun HistoryList(
|
||||
modifier = Modifier.padding(16.dp).fillMaxWidth().clickable { onEnabled(!enabled) },
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(16.dp)) {
|
||||
Text(text = "Use install history", fontSize = 19.sp, modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
text = stringResource(R.string.install_history_switch),
|
||||
fontSize = 19.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Switch(enabled, onCheckedChange = onEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,10 @@ import java.util.Locale
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.fdroid.database.AppListSortOrder
|
||||
import org.fdroid.search.SearchHelper.fixQuery
|
||||
import org.fdroid.search.SearchHelper.normalize
|
||||
import org.fdroid.ui.categories.CategoryItem
|
||||
import org.fdroid.ui.repositories.RepositoryItem
|
||||
import org.fdroid.ui.search.SearchHelper.fixQuery
|
||||
import org.fdroid.ui.search.SearchHelper.normalize
|
||||
|
||||
@Composable
|
||||
fun AppListPresenter(
|
||||
|
||||
@@ -3,9 +3,6 @@ package org.fdroid.ui.navigation
|
||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Error
|
||||
import androidx.compose.material3.Badge
|
||||
@@ -22,7 +19,6 @@ 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.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
@@ -37,19 +33,11 @@ import org.fdroid.ui.MainModel
|
||||
@Composable
|
||||
fun BottomBar(model: MainModel, currentNavKey: NavKey, onNav: (MainNavKey) -> Unit) {
|
||||
val res = LocalResources.current
|
||||
val bottom = with(LocalDensity.current) { WindowInsets.navigationBars.getBottom(this).toDp() }
|
||||
NavigationBar(
|
||||
modifier = if (model.smallBottomBar) Modifier.heightIn(max = 56.dp + bottom) else Modifier
|
||||
) {
|
||||
NavigationBar {
|
||||
topLevelRoutes.forEach { dest ->
|
||||
NavigationBarItem(
|
||||
icon = { NavIcon(dest, model.numUpdates, model.hasAppIssues) },
|
||||
label =
|
||||
if (model.smallBottomBar) {
|
||||
null
|
||||
} else {
|
||||
{ Text(stringResource(dest.label)) }
|
||||
},
|
||||
label ={ Text(stringResource(dest.label)) },
|
||||
selected = dest == currentNavKey,
|
||||
colors =
|
||||
NavigationBarItemDefaults.colors(
|
||||
@@ -148,16 +136,10 @@ private fun Preview() {
|
||||
val model =
|
||||
MainModel(
|
||||
dynamicColors = false,
|
||||
smallBottomBar = false,
|
||||
numUpdates = 3,
|
||||
hasAppIssues = false,
|
||||
)
|
||||
BottomBar(model = model, currentNavKey = NavigationKey.Discover, onNav = {})
|
||||
BottomBar(
|
||||
model = model.copy(smallBottomBar = true),
|
||||
currentNavKey = NavigationKey.Discover,
|
||||
onNav = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,16 +160,10 @@ private fun PreviewIssues() {
|
||||
val model =
|
||||
MainModel(
|
||||
dynamicColors = true,
|
||||
smallBottomBar = false,
|
||||
numUpdates = 0,
|
||||
hasAppIssues = true,
|
||||
)
|
||||
BottomBar(model = model, currentNavKey = NavigationKey.MyApps, onNav = {})
|
||||
BottomBar(
|
||||
model = model.copy(smallBottomBar = true),
|
||||
currentNavKey = NavigationKey.MyApps,
|
||||
onNav = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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.Search
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.SwapHorizontalCircle
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
@@ -22,14 +23,18 @@ sealed interface NavigationKey : NavKey {
|
||||
override val icon: ImageVector = Icons.Filled.Explore
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data object Search : NavigationKey, MainNavKey {
|
||||
override val label: Int = R.string.menu_search
|
||||
override val icon: ImageVector = Icons.Filled.Search
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data object MyApps : NavigationKey, MainNavKey {
|
||||
override val label: Int = R.string.menu_apps_my
|
||||
override val icon: ImageVector = Icons.Filled.Apps
|
||||
}
|
||||
|
||||
@Serializable data object Search : NavigationKey
|
||||
|
||||
@Serializable data class AppDetails(val packageName: String) : NavigationKey
|
||||
|
||||
@Serializable data class AppList(val type: AppListType) : NavigationKey
|
||||
@@ -56,7 +61,8 @@ sealed interface MainNavKey : NavKey {
|
||||
val icon: ImageVector
|
||||
}
|
||||
|
||||
val topLevelRoutes = listOf<MainNavKey>(NavigationKey.Discover, NavigationKey.MyApps)
|
||||
val topLevelRoutes =
|
||||
listOf<MainNavKey>(NavigationKey.Discover, NavigationKey.Search, NavigationKey.MyApps)
|
||||
|
||||
sealed class NavDestinations(
|
||||
val id: NavigationKey,
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Copyright 2026 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.
|
||||
*
|
||||
* Mostly copied from https://github.com/android/nav3-recipes
|
||||
*/
|
||||
|
||||
package org.fdroid.ui.navigation
|
||||
|
||||
import androidx.compose.animation.EnterExitState
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.movableContentOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation3.scene.Scene
|
||||
import androidx.navigation3.scene.SceneDecoratorStrategy
|
||||
import androidx.navigation3.scene.SceneDecoratorStrategyScope
|
||||
import androidx.navigation3.ui.LocalNavAnimatedContentScope
|
||||
|
||||
class ResponsiveNavigationScene<T : Any>(
|
||||
scene: Scene<T>,
|
||||
isBigScreen: Boolean,
|
||||
sharedTransitionScope: SharedTransitionScope,
|
||||
navBarContent: @Composable (() -> Unit),
|
||||
navRailContent: @Composable (() -> Unit),
|
||||
) : Scene<T> by scene {
|
||||
override val key = scene::class to scene.key
|
||||
|
||||
override val content =
|
||||
@Composable {
|
||||
val animatedContentScope = LocalNavAnimatedContentScope.current
|
||||
val isMovableContentCaller =
|
||||
animatedContentScope.transition.targetState == EnterExitState.Visible
|
||||
|
||||
with(sharedTransitionScope) {
|
||||
if (isBigScreen) {
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.sharedElement(rememberSharedContentState("nav-rail"), animatedContentScope)
|
||||
) {
|
||||
if (isMovableContentCaller) {
|
||||
navRailContent()
|
||||
}
|
||||
}
|
||||
Box(modifier = Modifier.weight(1f)) { scene.content() }
|
||||
}
|
||||
} else {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Box(modifier = Modifier.weight(1f)) { scene.content() }
|
||||
navBarContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T : Any> rememberResponsiveNavigationSceneDecoratorStrategy(
|
||||
isBigScreen: Boolean,
|
||||
navBar: @Composable (() -> Unit)?,
|
||||
navRail: @Composable () -> Unit,
|
||||
sharedTransitionScope: SharedTransitionScope,
|
||||
): ResponsiveNavigationSceneDecoratorStrategy<T> {
|
||||
val currentNavBar by rememberUpdatedState(navBar)
|
||||
val currentNavRail by rememberUpdatedState(navRail)
|
||||
|
||||
val movableNavBar = remember(navBar) { movableContentOf { currentNavBar?.invoke() } }
|
||||
val movableNavRail = remember { movableContentOf { currentNavRail() } }
|
||||
|
||||
return remember(isBigScreen, sharedTransitionScope) {
|
||||
ResponsiveNavigationSceneDecoratorStrategy(
|
||||
isBigScreen = isBigScreen,
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
navBarContent = movableNavBar,
|
||||
navRailContent = movableNavRail,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class ResponsiveNavigationSceneDecoratorStrategy<T : Any>(
|
||||
private val isBigScreen: Boolean,
|
||||
private val sharedTransitionScope: SharedTransitionScope,
|
||||
private val navBarContent: @Composable (() -> Unit),
|
||||
private val navRailContent: @Composable () -> Unit,
|
||||
) : SceneDecoratorStrategy<T> {
|
||||
|
||||
override fun SceneDecoratorStrategyScope<T>.decorateScene(scene: Scene<T>): Scene<T> {
|
||||
return ResponsiveNavigationScene(
|
||||
scene = scene,
|
||||
isBigScreen = isBigScreen,
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
navBarContent = navBarContent,
|
||||
navRailContent = navRailContent,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -72,9 +72,12 @@ constructor(
|
||||
private fun onRepositoriesChanged(repositories: List<Repository>) {
|
||||
log.info("onRepositoriesChanged(${repositories.size})")
|
||||
repos.update {
|
||||
repositories.mapNotNull {
|
||||
if (it.isArchiveRepo) null else RepositoryItem(it, localeList, settingsManager.proxyConfig)
|
||||
}
|
||||
repositories
|
||||
.mapNotNull {
|
||||
if (it.isArchiveRepo) null
|
||||
else RepositoryItem(it, localeList, settingsManager.proxyConfig)
|
||||
}
|
||||
.also { repos -> log.info { "Adding repos ${repos.map { it.address }}" } }
|
||||
}
|
||||
repoSortingMap.update {
|
||||
// just add repos to sortingMap, because they are already pre-sorted by weight
|
||||
|
||||
@@ -46,8 +46,10 @@ fun AddRepo(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBackClicked) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back))
|
||||
if (state !is Adding) {
|
||||
IconButton(onClick = onBackClicked) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back))
|
||||
}
|
||||
}
|
||||
},
|
||||
title = {
|
||||
@@ -93,6 +95,7 @@ fun AddRepo(
|
||||
Adding ->
|
||||
AddRepoProgressScreen(
|
||||
text = stringResource(R.string.repo_state_adding),
|
||||
backAllowed = false,
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
)
|
||||
is Added -> {
|
||||
|
||||
@@ -81,6 +81,7 @@ 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.launchSafe
|
||||
import org.fdroid.ui.utils.startActivitySafe
|
||||
|
||||
@Composable
|
||||
@@ -145,7 +146,8 @@ fun AddRepoIntroContent(
|
||||
if (checkSelfPermission(context, CAMERA) == PERMISSION_GRANTED) {
|
||||
startScanning()
|
||||
} else {
|
||||
permissionLauncher.launch(CAMERA)
|
||||
permissionLauncher.launchSafe(CAMERA)
|
||||
Unit
|
||||
}
|
||||
}
|
||||
if (networkState.isMetered) showMeteredDialog = scanLambda else scanLambda()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.fdroid.ui.repositories.add
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -21,7 +22,12 @@ import org.fdroid.ui.FDroidContent
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
fun AddRepoProgressScreen(text: String, modifier: Modifier = Modifier) {
|
||||
fun AddRepoProgressScreen(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
backAllowed: Boolean = true,
|
||||
) {
|
||||
BackHandler(!backAllowed) {}
|
||||
Column(
|
||||
verticalArrangement = spacedBy(16.dp, CenterVertically),
|
||||
horizontalAlignment = CenterHorizontally,
|
||||
|
||||
@@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import org.fdroid.R
|
||||
import org.fdroid.search.SEARCH_THRESHOLD
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class)
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
package org.fdroid.ui.search
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.SearchBar
|
||||
import androidx.compose.material3.SearchBarDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberSearchBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
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 org.fdroid.R
|
||||
import org.fdroid.ui.FDroidContent
|
||||
import org.fdroid.ui.navigation.NavigationKey
|
||||
|
||||
/** The minimum amount of characters we start auto-searching for. */
|
||||
const val SEARCH_THRESHOLD = 2
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
fun AppsSearch(
|
||||
textFieldState: TextFieldState,
|
||||
onNav: (NavigationKey) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val searchBarState = rememberSearchBarState()
|
||||
SearchBar(
|
||||
state = searchBarState,
|
||||
inputField = {
|
||||
// InputField is different from ExpandedFullScreenSearchBar to separate onSearch()
|
||||
SearchBarDefaults.InputField(
|
||||
searchBarState = searchBarState,
|
||||
textFieldState = textFieldState,
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.search_placeholder),
|
||||
// we hide the placeholder, because TalkBack is already saying "Search"
|
||||
modifier = Modifier.semantics { hideFromAccessibility() },
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.semantics { hideFromAccessibility() },
|
||||
)
|
||||
},
|
||||
onSearch = {},
|
||||
modifier = Modifier.onFocusChanged { if (it.isFocused) onNav(NavigationKey.Search) },
|
||||
)
|
||||
},
|
||||
modifier = modifier.clickable { onNav(NavigationKey.Search) },
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun Preview() {
|
||||
FDroidContent {
|
||||
val textFieldState = rememberTextFieldState()
|
||||
Box(Modifier.fillMaxSize()) { AppsSearch(textFieldState, {}) }
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import org.fdroid.search.SavedSearch
|
||||
import org.fdroid.ui.FDroidContent
|
||||
import org.fdroid.ui.navigation.NavigationKey
|
||||
import org.fdroid.ui.utils.appListItems
|
||||
@@ -23,9 +24,11 @@ import org.fdroid.ui.utils.categoryItems
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
fun GlobalSearch(
|
||||
textFieldState: TextFieldState,
|
||||
textFieldState: TextFieldState = rememberTextFieldState(),
|
||||
searchResults: SearchResults?,
|
||||
savedSearches: List<SavedSearch>? = null,
|
||||
onSearch: suspend (String) -> Unit,
|
||||
onClearSavedSearches: () -> Unit,
|
||||
onNav: (NavigationKey) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onSearchCleared: () -> Unit,
|
||||
@@ -34,26 +37,31 @@ fun GlobalSearch(
|
||||
topBar = {
|
||||
TopSearchBar(
|
||||
searchFieldState = textFieldState,
|
||||
shouldRequestFocus = // only show keyboard if there are no results to show
|
||||
searchResults == null ||
|
||||
searchResults.apps.isEmpty() && searchResults.categories.isEmpty(),
|
||||
onSearch = onSearch,
|
||||
onSearchCleared = onSearchCleared,
|
||||
onHideSearch = onBack,
|
||||
)
|
||||
},
|
||||
}
|
||||
) { paddingValues ->
|
||||
HorizontalDivider(
|
||||
color = SearchBarDefaults.colors().dividerColor,
|
||||
modifier = Modifier.padding(
|
||||
start = paddingValues.calculateStartPadding(LocalLayoutDirection.current),
|
||||
end = paddingValues.calculateEndPadding(LocalLayoutDirection.current),
|
||||
top = paddingValues.calculateTopPadding(),
|
||||
),
|
||||
modifier =
|
||||
Modifier.padding(
|
||||
start = paddingValues.calculateStartPadding(LocalLayoutDirection.current),
|
||||
end = paddingValues.calculateEndPadding(LocalLayoutDirection.current),
|
||||
top = paddingValues.calculateTopPadding(),
|
||||
),
|
||||
)
|
||||
SearchResults(
|
||||
paddingValues = paddingValues,
|
||||
searchResults = searchResults,
|
||||
textFieldState = textFieldState,
|
||||
savedSearches = savedSearches,
|
||||
onClearSavedSearches = onClearSavedSearches,
|
||||
onNav = onNav,
|
||||
paddingValues = paddingValues,
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -64,18 +72,50 @@ fun GlobalSearch(
|
||||
private fun AppsSearchLoadingPreview() {
|
||||
FDroidContent {
|
||||
val textFieldState = rememberTextFieldState("foo bar")
|
||||
Box(Modifier.fillMaxSize()) { GlobalSearch(textFieldState, null, {}, {}, {}, {}) }
|
||||
Box(Modifier.fillMaxSize()) { GlobalSearch(textFieldState, null, null, {}, {}, {}, {}, {}) }
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
private fun AppsSearchEmptyPreview() {
|
||||
private fun AppsSearchEmptyStatePreview() {
|
||||
FDroidContent {
|
||||
val textFieldState = rememberTextFieldState("f")
|
||||
val savedSearches =
|
||||
listOf(SavedSearch(1, "foo"), SavedSearch(2, "foo bar"), SavedSearch(3, "foobar"))
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
GlobalSearch(
|
||||
textFieldState = textFieldState,
|
||||
searchResults = null,
|
||||
savedSearches = savedSearches,
|
||||
onSearch = {},
|
||||
onClearSavedSearches = {},
|
||||
onNav = {},
|
||||
onBack = {},
|
||||
onSearchCleared = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
private fun AppsSearchNoResultsPreview() {
|
||||
FDroidContent {
|
||||
val textFieldState = rememberTextFieldState("foo")
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
GlobalSearch(textFieldState, SearchResults(emptyList(), emptyList()), {}, {}, {}, {})
|
||||
GlobalSearch(
|
||||
textFieldState = textFieldState,
|
||||
searchResults = SearchResults(emptyList(), emptyList()),
|
||||
savedSearches = emptyList(),
|
||||
onSearch = {},
|
||||
onClearSavedSearches = {},
|
||||
onNav = {},
|
||||
onBack = {},
|
||||
onSearchCleared = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,7 +130,9 @@ private fun AppsSearchOnlyCategoriesPreview() {
|
||||
GlobalSearch(
|
||||
textFieldState = textFieldState,
|
||||
searchResults = SearchResults(emptyList(), categoryItems.subList(3, 5)),
|
||||
savedSearches = emptyList(),
|
||||
onSearch = {},
|
||||
onClearSavedSearches = {},
|
||||
onNav = {},
|
||||
onBack = {},
|
||||
onSearchCleared = {},
|
||||
@@ -109,7 +151,9 @@ private fun AppsSearchPreview() {
|
||||
GlobalSearch(
|
||||
textFieldState = textFieldState,
|
||||
searchResults = SearchResults(appListItems, categoryItems.subList(0, 4)),
|
||||
savedSearches = emptyList(),
|
||||
onSearch = {},
|
||||
onClearSavedSearches = {},
|
||||
onNav = {},
|
||||
onBack = {},
|
||||
onSearchCleared = {},
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package org.fdroid.ui.search
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.windowInsetsBottomHeight
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
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.search.SavedSearch
|
||||
import org.fdroid.ui.FDroidContent
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
fun PastSearches(
|
||||
savedSearches: List<SavedSearch>,
|
||||
onSearch: (String) -> Unit,
|
||||
onClearSavedSearches: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
paddingValues: PaddingValues = PaddingValues(),
|
||||
) {
|
||||
LazyColumn(modifier = modifier.fillMaxSize(), contentPadding = paddingValues) {
|
||||
item {
|
||||
Row {
|
||||
Text(
|
||||
text = stringResource(R.string.search_history),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp).weight(1f),
|
||||
)
|
||||
TextButton(
|
||||
onClick = onClearSavedSearches,
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.clear))
|
||||
}
|
||||
}
|
||||
}
|
||||
items(savedSearches) { item ->
|
||||
ListItem(
|
||||
leadingContent = {
|
||||
Icon(
|
||||
Icons.Default.History,
|
||||
contentDescription = null,
|
||||
modifier =
|
||||
Modifier.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.padding(8.dp),
|
||||
)
|
||||
},
|
||||
onClick = { onSearch(item.query) },
|
||||
modifier = Modifier.fillMaxWidth().animateItem(),
|
||||
) {
|
||||
Text(item.query)
|
||||
}
|
||||
}
|
||||
item { Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.systemBars)) }
|
||||
item { Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.ime)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun Preview() {
|
||||
FDroidContent {
|
||||
val savedSearches =
|
||||
listOf(SavedSearch(1, "foo"), SavedSearch(2, "foo bar"), SavedSearch(3, "foobar"))
|
||||
PastSearches(
|
||||
savedSearches = savedSearches,
|
||||
onClearSavedSearches = {},
|
||||
onSearch = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,20 @@ package org.fdroid.ui.search
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.windowInsetsBottomHeight
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -21,6 +27,8 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.fdroid.R
|
||||
import org.fdroid.search.SEARCH_THRESHOLD
|
||||
import org.fdroid.search.SavedSearch
|
||||
import org.fdroid.ui.categories.CategoryChip
|
||||
import org.fdroid.ui.categories.CategoryItem
|
||||
import org.fdroid.ui.categories.ChipFlowRow
|
||||
@@ -33,12 +41,15 @@ import org.fdroid.ui.utils.BigLoadingIndicator
|
||||
data class SearchResults(val apps: List<AppListItem>, val categories: List<CategoryItem>)
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
fun SearchResults(
|
||||
paddingValues: PaddingValues,
|
||||
searchResults: SearchResults?,
|
||||
textFieldState: TextFieldState,
|
||||
savedSearches: List<SavedSearch>?,
|
||||
onClearSavedSearches: () -> Unit,
|
||||
onNav: (NavigationKey) -> Unit,
|
||||
paddingValues: PaddingValues,
|
||||
modifier: Modifier,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// rememberLazyListState done differently, so it refreshes for different searchResults
|
||||
val listState =
|
||||
@@ -46,9 +57,19 @@ fun SearchResults(
|
||||
if (searchResults == null) {
|
||||
if (textFieldState.text.length >= SEARCH_THRESHOLD) {
|
||||
BigLoadingIndicator(modifier.padding(paddingValues).imePadding())
|
||||
} else {
|
||||
if (!savedSearches.isNullOrEmpty()) {
|
||||
PastSearches(
|
||||
savedSearches = savedSearches,
|
||||
onSearch = { textFieldState.edit { replace(0, length, it) } },
|
||||
onClearSavedSearches = onClearSavedSearches,
|
||||
modifier = modifier,
|
||||
paddingValues = paddingValues,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (searchResults.apps.isEmpty() && textFieldState.text.length >= SEARCH_THRESHOLD) {
|
||||
Column(modifier = modifier.padding(paddingValues).imePadding()) {
|
||||
Column(modifier = modifier.padding(paddingValues)) {
|
||||
if (searchResults.categories.isNotEmpty()) {
|
||||
CategoriesFlowRow(searchResults.categories, onNav)
|
||||
}
|
||||
@@ -62,7 +83,7 @@ fun SearchResults(
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
contentPadding = paddingValues,
|
||||
modifier = modifier.fillMaxSize().imePadding(),
|
||||
modifier = modifier.fillMaxSize(),
|
||||
) {
|
||||
if (searchResults.categories.isNotEmpty()) {
|
||||
item(key = "categories", contentType = "category") {
|
||||
@@ -93,6 +114,8 @@ fun SearchResults(
|
||||
},
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.systemBars)) }
|
||||
item { Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.ime)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,18 +2,23 @@ package org.fdroid.ui.search
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import jakarta.inject.Inject
|
||||
import kotlinx.coroutines.launch
|
||||
import org.fdroid.search.SearchManager
|
||||
|
||||
@HiltViewModel
|
||||
class SearchViewModel
|
||||
@Inject
|
||||
constructor(app: Application, private val searchManager: SearchManager) : AndroidViewModel(app) {
|
||||
|
||||
val textFieldState = searchManager.textFieldState
|
||||
val searchResults = searchManager.searchResults
|
||||
val savedSearchesFlow = searchManager.savedSearches
|
||||
|
||||
suspend fun search(term: String) = searchManager.search(term)
|
||||
|
||||
fun onSearchCleared() = searchManager.onSearchCleared()
|
||||
|
||||
fun onClearSearchHistory() = viewModelScope.launch { searchManager.onClearSearchHistory() }
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import org.fdroid.ui.utils.BackButton
|
||||
@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class)
|
||||
fun TopSearchBar(
|
||||
searchFieldState: TextFieldState = rememberTextFieldState(),
|
||||
shouldRequestFocus: Boolean = true,
|
||||
actions: @Composable (RowScope.() -> Unit) = {},
|
||||
onSearch: suspend (String) -> Unit,
|
||||
onSearchCleared: () -> Unit,
|
||||
@@ -49,8 +50,10 @@ fun TopSearchBar(
|
||||
},
|
||||
actions = actions,
|
||||
)
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
keyboardController?.show()
|
||||
LaunchedEffect(shouldRequestFocus) {
|
||||
if (shouldRequestFocus) {
|
||||
focusRequester.requestFocus()
|
||||
keyboardController?.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ 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 android.widget.Toast
|
||||
import android.widget.Toast.LENGTH_SHORT
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -65,7 +67,6 @@ import org.fdroid.settings.SettingsConstants.PREF_DEFAULT_MIRROR_CHOOSER
|
||||
import org.fdroid.settings.SettingsConstants.PREF_DEFAULT_PREVENT_SCREENSHOTS
|
||||
import org.fdroid.settings.SettingsConstants.PREF_DEFAULT_PROXY
|
||||
import org.fdroid.settings.SettingsConstants.PREF_DEFAULT_REPO_UPDATES
|
||||
import org.fdroid.settings.SettingsConstants.PREF_DEFAULT_SMALL_BOTTOM_BAR
|
||||
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
|
||||
@@ -73,7 +74,6 @@ import org.fdroid.settings.SettingsConstants.PREF_KEY_MIRROR_CHOOSER
|
||||
import org.fdroid.settings.SettingsConstants.PREF_KEY_PREVENT_SCREENSHOTS
|
||||
import org.fdroid.settings.SettingsConstants.PREF_KEY_PROXY
|
||||
import org.fdroid.settings.SettingsConstants.PREF_KEY_REPO_UPDATES
|
||||
import org.fdroid.settings.SettingsConstants.PREF_KEY_SMALL_BOTTOM_BAR
|
||||
import org.fdroid.settings.SettingsConstants.PREF_KEY_THEME
|
||||
import org.fdroid.settings.SettingsConstants.PREF_USE_DNS_CACHE
|
||||
import org.fdroid.settings.SettingsConstants.PREF_USE_DNS_CACHE_DEFAULT
|
||||
@@ -82,17 +82,13 @@ import org.fdroid.settings.toMirrorChooserValue
|
||||
import org.fdroid.ui.FDroidContent
|
||||
import org.fdroid.ui.utils.BackButton
|
||||
import org.fdroid.ui.utils.asRelativeTimeString
|
||||
import org.fdroid.ui.utils.launchSafe
|
||||
import org.fdroid.ui.utils.startActivitySafe
|
||||
import org.fdroid.utils.getLogName
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
fun Settings(
|
||||
model: SettingsModel,
|
||||
isBigScreen: Boolean,
|
||||
onSaveLogcat: (Uri?) -> Unit,
|
||||
onBackClicked: () -> Unit,
|
||||
) {
|
||||
fun Settings(model: SettingsModel, onSaveLogcat: (Uri?) -> Unit, onBackClicked: () -> Unit) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -118,6 +114,7 @@ fun Settings(
|
||||
AnnotatedString(
|
||||
when (value) {
|
||||
"light" -> res.getString(R.string.theme_light)
|
||||
"night", // night was added because we had a crash with that
|
||||
"dark" -> res.getString(R.string.theme_dark)
|
||||
"followSystem" -> res.getString(R.string.theme_follow_system)
|
||||
else -> error("Unknown value: $value")
|
||||
@@ -193,14 +190,6 @@ fun Settings(
|
||||
context.startActivitySafe(intent)
|
||||
},
|
||||
)
|
||||
if (!isBigScreen)
|
||||
switchPreference(
|
||||
key = PREF_KEY_SMALL_BOTTOM_BAR,
|
||||
defaultValue = PREF_DEFAULT_SMALL_BOTTOM_BAR,
|
||||
icon = {},
|
||||
title = { Text(stringResource(R.string.pref_compact_bottom_bar_title)) },
|
||||
summary = { Text(stringResource(R.string.pref_compact_bottom_bar_summary)) },
|
||||
)
|
||||
preferenceCategory(
|
||||
key = "pref_category_updates",
|
||||
title = { Text(stringResource(R.string.updates)) },
|
||||
@@ -387,7 +376,11 @@ fun Settings(
|
||||
},
|
||||
title = { Text(stringResource(R.string.pref_export_log_title)) },
|
||||
summary = { Text(stringResource(R.string.pref_export_log_summary)) },
|
||||
onClick = { launcher.launch("${getLogName(context)}.txt") },
|
||||
onClick = {
|
||||
if (!launcher.launchSafe("${getLogName(context)}.txt")) {
|
||||
Toast.makeText(context, R.string.no_handler_app_generic, LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -404,6 +397,6 @@ fun SettingsPreview() {
|
||||
nextRepoUpdateFlow = MutableStateFlow(Long.MAX_VALUE),
|
||||
nextAppUpdateFlow = MutableStateFlow(currentTimeMillis() - HOURS.toMillis(12)),
|
||||
)
|
||||
Settings(model, true, {}, {})
|
||||
Settings(model, {}, {})
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import android.graphics.Color
|
||||
import android.os.PowerManager
|
||||
import android.text.format.DateUtils
|
||||
import android.util.Log
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -33,6 +34,16 @@ fun Context.startActivitySafe(i: Intent?) {
|
||||
}
|
||||
}
|
||||
|
||||
fun <I> ActivityResultLauncher<I>.launchSafe(input: I): Boolean {
|
||||
return try {
|
||||
launch(input)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e("ActivityResultLauncher", "Error launching $input ", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun applyNewTheme(theme: String) {
|
||||
val mode =
|
||||
when (theme) {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package org.fdroid.ui.utils
|
||||
|
||||
import android.text.format.Formatter
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowRightAlt
|
||||
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.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.text.Placeholder
|
||||
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.fdroid.ui.apps.AppUpdateItem
|
||||
|
||||
/**
|
||||
* Shows which is the installed version of the app and to which version it will upgrade. Takes into
|
||||
* account the layout direction to show the correct order of versions and the arrow.
|
||||
*/
|
||||
@Composable
|
||||
fun VersionLine(app: AppUpdateItem) {
|
||||
VersionLineWithSize(app.installedVersionName, app.update.versionName, app.update.size)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VersionLineWithSize(fromVersion: String?, toVersion: String, numBytes: Long? = null) {
|
||||
val size = numBytes?.let { Formatter.formatFileSize(LocalContext.current, it) }
|
||||
VersionLine(fromVersion, toVersion, size)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VersionLine(fromVersion: String?, toVersion: String, extraText: String? = null) {
|
||||
val test = buildAnnotatedString {
|
||||
if (LocalLayoutDirection.current == LayoutDirection.Ltr) {
|
||||
if (fromVersion != null) {
|
||||
append(fromVersion)
|
||||
}
|
||||
} else {
|
||||
append("\u202A${toVersion}\u202C")
|
||||
}
|
||||
if (fromVersion != null) appendInlineContent("arrowId", " → ")
|
||||
if (LocalLayoutDirection.current == LayoutDirection.Ltr) {
|
||||
append("\u202A${toVersion}\u202C")
|
||||
} else if (fromVersion != null) {
|
||||
append("\u202A${fromVersion}\u202C")
|
||||
}
|
||||
if (extraText != null) append(" • $extraText")
|
||||
}
|
||||
val inlineContent =
|
||||
mapOf(
|
||||
Pair(
|
||||
"arrowId",
|
||||
InlineTextContent(
|
||||
Placeholder(
|
||||
width = 24.sp,
|
||||
height = 20.sp,
|
||||
placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter,
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Default.ArrowRightAlt,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(horizontal = 2.dp),
|
||||
)
|
||||
},
|
||||
)
|
||||
)
|
||||
Text(test, inlineContent = if (fromVersion == null) mapOf() else inlineContent)
|
||||
}
|
||||
@@ -851,8 +851,6 @@
|
||||
<item quantity="many">حُدِّث %1$d تطبيقًا</item>
|
||||
<item quantity="other">حُدِّث %1$d تطبيق</item>
|
||||
</plurals>
|
||||
<string name="pref_compact_bottom_bar_title">شريط تنقل مضغوط</string>
|
||||
<string name="pref_compact_bottom_bar_summary">يقلل حجم شريط التنقل السفلي ويخفي تسمياته</string>
|
||||
<string name="status_downloading">ينزل ب %1$s</string>
|
||||
W <string name="status_downloading">ينزل ب %1$s</string>
|
||||
<string name="status_downloading_remaining">%1$s متبقي</string>
|
||||
</resources>
|
||||
@@ -714,8 +714,6 @@
|
||||
<string name="pref_dyn_colors_title">Usa i colori di sistema</string>
|
||||
<string name="pref_dyn_colors_summary">Applica colori dinamici dalle impostazioni di sistema</string>
|
||||
<string name="pref_language_summary">Apri le impostazioni di lingua del sistema</string>
|
||||
<string name="pref_compact_bottom_bar_title">Barra di navigazione compatta</string>
|
||||
<string name="pref_compact_bottom_bar_summary">Riduci la dimensione e nascondi le etichette della barra inferiore di navigazione</string>
|
||||
<string name="pref_auto_updates_only_wifi">Solo con Wi-Fi</string>
|
||||
<string name="pref_auto_updates_only_always">Sempre (anche con connessione dati mobile)</string>
|
||||
<string name="pref_auto_updates_only_never">Mai</string>
|
||||
|
||||
@@ -826,8 +826,6 @@
|
||||
<string name="install_history_disabled_state">Historia instalacji jest wyłączona\n\nInstalacje, aktualizacje i odinstalowania nie będą rejestrowane</string>
|
||||
<string name="install_history_delete_ally">Usuń historię instalacji</string>
|
||||
<string name="install_history_delete_text">Czy usunąć historię instalacji?</string>
|
||||
<string name="pref_compact_bottom_bar_title">Kompaktowy pasek nawigacyjny</string>
|
||||
<string name="pref_compact_bottom_bar_summary">Zmniejsz rozmiar i ukryj etykiety dolnego paska nawigacyjnego</string>
|
||||
<string name="status_downloading">Pobieranie z prędkością %1$s</string>
|
||||
<string name="status_downloading_remaining">Pozostało %1$s</string>
|
||||
</resources>
|
||||
@@ -768,8 +768,6 @@
|
||||
<string name="install_history_disabled_state">禁用了安装历史记录\n\n不会记录安装、更新和卸载</string>
|
||||
<string name="install_history_delete_ally">删除安装历史记录</string>
|
||||
<string name="install_history_delete_text">要删除安装历史记录吗?</string>
|
||||
<string name="pref_compact_bottom_bar_title">紧凑式导航栏</string>
|
||||
<string name="pref_compact_bottom_bar_summary">缩小尺寸并隐藏底部导航栏的标签</string>
|
||||
<string name="status_downloading">实时下载速度 %1$s</string>
|
||||
<string name="status_downloading_remaining">剩余 %1$s</string>
|
||||
</resources>
|
||||
@@ -34,6 +34,7 @@
|
||||
<string name="app_list_author">Apps by %s</string>
|
||||
|
||||
<string name="search_placeholder">Search…</string>
|
||||
<string name="search_history">Recent searches</string>
|
||||
<string name="search_no_results">No apps found\n\nTry using fewer search terms or add more repositories</string>
|
||||
<string name="search_filter_no_results">No matching apps\n\nTry using fewer search terms or remove filters</string>
|
||||
<string name="search_my_apps_no_results">No matching apps\n\nTry using fewer search terms</string>
|
||||
@@ -166,8 +167,6 @@
|
||||
<string name="pref_dyn_colors_title">Use system colors</string>
|
||||
<string name="pref_dyn_colors_summary">Apply dynamic colors from your system settings</string>
|
||||
<string name="pref_language_summary">Open system language settings</string>
|
||||
<string name="pref_compact_bottom_bar_title">Compact navigation bar</string>
|
||||
<string name="pref_compact_bottom_bar_summary">Reduces size and hides labels of navigation bottom bar</string>
|
||||
<string name="pref_auto_updates_only_wifi">Only on Wi-Fi</string>
|
||||
<string name="pref_auto_updates_only_always">Always (even on mobile data)</string>
|
||||
<string name="pref_auto_updates_only_never">Never</string>
|
||||
@@ -569,8 +568,8 @@ This often occurs with apps installed via Google Play or other sources, if they
|
||||
- Percentage complete (int between 0-100)
|
||||
-->
|
||||
<string name="status_download">Downloading\n%2$s / %3$s (%4$d%%) from\n%1$s</string>
|
||||
<!-- placeholder is for download speed. Example: Downloading with 2.3 MB/s -->
|
||||
<string name="status_downloading">Downloading with %1$s</string>
|
||||
<!-- placeholder is for download speed. Note that %s is the placeholder that needs to stay and /s is part of the speed unit and doesn't need translating in most languages. Example: Downloading at 2.3 MB/s -->
|
||||
<string name="status_downloading">Downloading at %s/s</string>
|
||||
<!-- placeholder is for how much is left to download. Example: 23 MB remaining -->
|
||||
<string name="status_downloading_remaining">%1$s remaining</string>
|
||||
|
||||
@@ -1009,6 +1008,7 @@ This often occurs with apps installed via Google Play or other sources, if they
|
||||
<string name="ipfsgw_caption_official_gateways">Official IPFS gateways</string>
|
||||
<string name="ipfsgw_caption_custom_gateways">Custom IPFS gateways</string>
|
||||
|
||||
<string name="install_history_switch">Use installation history</string>
|
||||
<string name="install_history_empty_state">This list is still empty\n\nWhen you install, update or uninstall apps, a record will appear here</string>
|
||||
<string name="install_history_disabled_state">Installation history is disabled\n\nInstallations, updates and uninstalls will not be recorded</string>
|
||||
<string name="install_history_delete_ally">Delete installation history</string>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
org.fdroid.ui.crash.ShortHashCollector
|
||||
@@ -1,31 +1,33 @@
|
||||
package org.fdroid.ui
|
||||
|
||||
import androidx.compose.material3.adaptive.layout.PaneScaffoldDirective
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation3.runtime.NavEntry
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import org.fdroid.ui.navigation.MainNavKey
|
||||
import org.fdroid.ui.navigation.NavigationKey
|
||||
|
||||
@Composable
|
||||
fun ScreenshotTest(
|
||||
showBottomBar: Boolean = true,
|
||||
smallBottomBar: Boolean = false,
|
||||
currentNavKey: NavKey = NavigationKey.Discover,
|
||||
showBottomBar: Boolean = currentNavKey is MainNavKey,
|
||||
numUpdates: Int = 3,
|
||||
hasAppIssues: Boolean = true,
|
||||
content: @Composable (Modifier) -> Unit,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
MainContent(
|
||||
model =
|
||||
MainModel(
|
||||
dynamicColors = false,
|
||||
smallBottomBar = smallBottomBar,
|
||||
numUpdates = numUpdates,
|
||||
hasAppIssues = hasAppIssues,
|
||||
),
|
||||
navEntries = listOf(NavEntry(currentNavKey) { content() }),
|
||||
directive = PaneScaffoldDirective.Default,
|
||||
isBigScreen = false,
|
||||
showBottomBar = showBottomBar,
|
||||
currentNavKey = currentNavKey,
|
||||
onNav = {},
|
||||
content = content,
|
||||
onBack = {},
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
package org.fdroid.ui.discover
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.android.tools.screenshot.PreviewTest
|
||||
@@ -43,24 +41,6 @@ private fun DiscoverTest() {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@PreviewTest
|
||||
@Preview(
|
||||
showBackground = true,
|
||||
showSystemUi = true,
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL,
|
||||
)
|
||||
private fun DiscoverSmallBottomBarTest() {
|
||||
ScreenshotTest(smallBottomBar = true) {
|
||||
Discover(
|
||||
discoverModel = getLoadedModel().copy(hasRepoIssues = false),
|
||||
onListTap = {},
|
||||
onAppTap = {},
|
||||
onNav = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getLoadedModel(): LoadedDiscoverModel {
|
||||
val newApps =
|
||||
listOf(
|
||||
@@ -132,7 +112,6 @@ private fun getLoadedModel(): LoadedDiscoverModel {
|
||||
recentlyUpdatedApps = recentlyUpdatedApps,
|
||||
mostDownloadedApps = mostDownloadedApps,
|
||||
categories = categories,
|
||||
searchTextFieldState = TextFieldState(),
|
||||
hasRepoIssues = true,
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.android.tools.screenshot.PreviewTest
|
||||
import org.fdroid.search.SavedSearch
|
||||
import org.fdroid.ui.ScreenshotTest
|
||||
import org.fdroid.ui.utils.appListItems
|
||||
import org.fdroid.ui.utils.categoryItems
|
||||
@@ -13,12 +14,34 @@ import org.fdroid.ui.utils.categoryItems
|
||||
@PreviewTest
|
||||
@Preview(showBackground = true, showSystemUi = true)
|
||||
fun GlobalSearchEmptyTest() =
|
||||
ScreenshotTest(showBottomBar = false) {
|
||||
ScreenshotTest {
|
||||
val textFieldState = rememberTextFieldState()
|
||||
GlobalSearch(
|
||||
textFieldState,
|
||||
null,
|
||||
textFieldState = textFieldState,
|
||||
searchResults = null,
|
||||
savedSearches = emptyList(),
|
||||
onSearch = {},
|
||||
onClearSavedSearches = {},
|
||||
onNav = {},
|
||||
onBack = {},
|
||||
onSearchCleared = {},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@PreviewTest
|
||||
@Preview(showBackground = true, showSystemUi = true)
|
||||
fun GlobalSearchEmptyStateTest() =
|
||||
ScreenshotTest {
|
||||
val savedSearches =
|
||||
listOf(SavedSearch(1, "Browser"), SavedSearch(2, "media player"), SavedSearch(3, "email app"))
|
||||
val textFieldState = rememberTextFieldState()
|
||||
GlobalSearch(
|
||||
textFieldState = textFieldState,
|
||||
searchResults = null,
|
||||
savedSearches = savedSearches,
|
||||
onSearch = {},
|
||||
onClearSavedSearches = {},
|
||||
onNav = {},
|
||||
onBack = {},
|
||||
onSearchCleared = {},
|
||||
@@ -29,12 +52,14 @@ fun GlobalSearchEmptyTest() =
|
||||
@PreviewTest
|
||||
@Preview(showBackground = true, showSystemUi = true)
|
||||
fun GlobalSearchLoadingTest() =
|
||||
ScreenshotTest(showBottomBar = false) {
|
||||
ScreenshotTest {
|
||||
val textFieldState = rememberTextFieldState("foo bar")
|
||||
GlobalSearch(
|
||||
textFieldState,
|
||||
null,
|
||||
textFieldState = textFieldState,
|
||||
searchResults = null,
|
||||
savedSearches = emptyList(),
|
||||
onSearch = {},
|
||||
onClearSavedSearches = {},
|
||||
onNav = {},
|
||||
onBack = {},
|
||||
onSearchCleared = {},
|
||||
@@ -45,12 +70,14 @@ fun GlobalSearchLoadingTest() =
|
||||
@PreviewTest
|
||||
@Preview(showBackground = true, showSystemUi = true)
|
||||
fun GlobalSearchNoResultsTest() =
|
||||
ScreenshotTest(showBottomBar = false) {
|
||||
ScreenshotTest {
|
||||
val textFieldState = rememberTextFieldState("foo")
|
||||
GlobalSearch(
|
||||
textFieldState,
|
||||
SearchResults(emptyList(), emptyList()),
|
||||
textFieldState = textFieldState,
|
||||
searchResults = SearchResults(emptyList(), emptyList()),
|
||||
savedSearches = emptyList(),
|
||||
onSearch = {},
|
||||
onClearSavedSearches = {},
|
||||
onNav = {},
|
||||
onBack = {},
|
||||
onSearchCleared = {},
|
||||
@@ -61,12 +88,14 @@ fun GlobalSearchNoResultsTest() =
|
||||
@PreviewTest
|
||||
@Preview(showBackground = true, showSystemUi = true)
|
||||
fun GlobalSearchResultsTest() =
|
||||
ScreenshotTest(showBottomBar = false) {
|
||||
ScreenshotTest {
|
||||
val textFieldState = rememberTextFieldState("foo bar")
|
||||
GlobalSearch(
|
||||
textFieldState,
|
||||
SearchResults(appListItems, categoryItems.subList(0, 4)),
|
||||
textFieldState = textFieldState,
|
||||
searchResults = SearchResults(appListItems, categoryItems.subList(0, 4)),
|
||||
savedSearches = emptyList(),
|
||||
onSearch = {},
|
||||
onClearSavedSearches = {},
|
||||
onNav = {},
|
||||
onBack = {},
|
||||
onSearchCleared = {},
|
||||
@@ -77,12 +106,14 @@ fun GlobalSearchResultsTest() =
|
||||
@PreviewTest
|
||||
@Preview(showBackground = true, showSystemUi = true)
|
||||
fun GlobalSearchOnlyCategoriesTest() =
|
||||
ScreenshotTest(showBottomBar = false) {
|
||||
ScreenshotTest {
|
||||
val textFieldState = rememberTextFieldState("foo bar")
|
||||
GlobalSearch(
|
||||
textFieldState,
|
||||
SearchResults(emptyList(), categoryItems.subList(5, 9)),
|
||||
textFieldState = textFieldState,
|
||||
searchResults = SearchResults(emptyList(), categoryItems.subList(5, 9)),
|
||||
savedSearches = emptyList(),
|
||||
onSearch = {},
|
||||
onClearSavedSearches = {},
|
||||
onNav = {},
|
||||
onBack = {},
|
||||
onSearchCleared = {},
|
||||
@@ -97,12 +128,14 @@ fun GlobalSearchOnlyCategoriesTest() =
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL,
|
||||
)
|
||||
fun GlobalSearchResultsNightTest() =
|
||||
ScreenshotTest(showBottomBar = false) {
|
||||
ScreenshotTest {
|
||||
val textFieldState = rememberTextFieldState("foo bar")
|
||||
GlobalSearch(
|
||||
textFieldState,
|
||||
SearchResults(appListItems, categoryItems.subList(0, 4)),
|
||||
textFieldState = textFieldState,
|
||||
searchResults = SearchResults(appListItems, categoryItems.subList(0, 4)),
|
||||
savedSearches = emptyList(),
|
||||
onSearch = {},
|
||||
onClearSavedSearches = {},
|
||||
onNav = {},
|
||||
onBack = {},
|
||||
onSearchCleared = {},
|
||||
|
||||
@@ -15,7 +15,7 @@ import org.fdroid.ui.ScreenshotTest
|
||||
@Preview(showBackground = true, showSystemUi = true, heightDp = 1400)
|
||||
fun SettingsTest() =
|
||||
ScreenshotTest(showBottomBar = false) {
|
||||
Settings(model = getSettingsModel(), isBigScreen = false, onSaveLogcat = {}, onBackClicked = {})
|
||||
Settings(model = getSettingsModel(), onSaveLogcat = {}, onBackClicked = {})
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -28,7 +28,7 @@ fun SettingsTest() =
|
||||
)
|
||||
fun SettingsNightTest() =
|
||||
ScreenshotTest(showBottomBar = false) {
|
||||
Settings(model = getSettingsModel(), isBigScreen = false, onSaveLogcat = {}, onBackClicked = {})
|
||||
Settings(model = getSettingsModel(), onSaveLogcat = {}, onBackClicked = {})
|
||||
}
|
||||
|
||||
private fun getSettingsModel(
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:97b2a9163570a5e6f61dac969115543bcf2b5bc6040b27ce0163d1d715093158
|
||||
size 155555
|
||||
oid sha256:1501c756d19f294b3f71ee089a1e728b3ade04b91372617248f68cb60aecfe1f
|
||||
size 158114
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:818edd986a22692191d23dfa22f835929af64628be310f7c7f32af6cd134081d
|
||||
size 112062
|
||||
oid sha256:619eb7e9478b45a8b1ada0b65335fad718cb728f500ab04c77bb292933df5c84
|
||||
size 114633
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1f5e54e6f874f31f982de0d1889f0b6947fd78668389409e6b51f439e32d1f17
|
||||
size 65274
|
||||
oid sha256:037f0d24d270c8fb52ac88bff5f40c999ea11035d03e270b6fd7968ad24506a5
|
||||
size 67807
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4ed3a7b54a24bcacca7dbe3a3d426e236f59958ed940f62fff651e00dd82c5fe
|
||||
size 81105
|
||||
oid sha256:8c6ea083fcbe5e69a47e88498ab197b4eab85ab129750290bb634815cf9166b7
|
||||
size 83745
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8bcaeed6aafb9767b7d1c9e04f1ea98cdd208b9c077a4857d2e8a933bef569c8
|
||||
size 46617
|
||||
oid sha256:92066e2671a0fa2733feb5701d540b612714b7f8f23b69133b0e4fc06a5b4a64
|
||||
size 46451
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ddc8762b99dd13e65805fa5ebdc1f14eb659ff8dc37237b0c13f1748996f8f8f
|
||||
size 45990
|
||||
oid sha256:b7c985dfe300bc834da2b8bfc874c79ebcb5bbfc9adc42f19391f4c719a9ee4c
|
||||
size 45829
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6769563c16cb16a06752bb7aa66429ce9b3bda69fa4a7fb4232c8e837d62fe3b
|
||||
size 52608
|
||||
oid sha256:e535a66f33e0313195434319f11049637bdf7a89d98ec8223c5323c738bb0042
|
||||
size 55346
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:406f894eba1d2dbad4b3f64f8db262b470c8d4c61f7889f7ad7110692f0438a0
|
||||
size 46396
|
||||
oid sha256:ee3d6bc540daebf569a1be2cb7de5c8e53478081baaddd68df7a855851703940
|
||||
size 48910
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:60ae81af88e21da8a7feb43d9cbf111106c715789a93295e9c5d7dc05a627547
|
||||
size 156780
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2485bf774cdbb291cac6288b617eba3bea65eeb3d6c723df526217b32e2fad57
|
||||
size 155874
|
||||
oid sha256:3bffaab8294637b504398af638e91187ff23640466f9775668ec00490436effe
|
||||
size 151906
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:765dd2750bf6f69bd17e54f65d4954b82da43f30811efd9544532ff037f6a144
|
||||
size 49240
|
||||
oid sha256:28237ec38434fa636974d08cd6bc4bba31d26a90e8e1ae987116a2115e0478bb
|
||||
size 49764
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:841697f4533aa01aa76fdd4ad1906276d1c4cb94eb22afb4584c56512f2f75f4
|
||||
size 49333
|
||||
oid sha256:e7a2d550503deb71a2fddc0436313900d8f97c2c2413018e93ff81922b63acff
|
||||
size 49879
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:16486d4e51d86fcaebb8ed9890dd2a618c0899216e70e0d87f677feb7f446210
|
||||
size 56722
|
||||
oid sha256:0c490c338d630ee2fe39166723eaa73ee48527631422951eea68eb1067f2147d
|
||||
size 57244
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:44793ef002c2dd9c81ed25810baad89b7da6b0789f17f17b638ed874b45a8d3a
|
||||
size 57063
|
||||
oid sha256:bb269ef10a0d4b43efb36e7ea43d62626e8bf942e7e27ac10a047aae9c9056e0
|
||||
size 57639
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ab9a82e0bd93768d3c97e151d8cd2b284d670cb82c2411d0a216dce2fab56e41
|
||||
size 36871
|
||||
oid sha256:d06c7114be1f155ddf57352ae00ded89e27022c1c685cd2a1fce1973a990e5b7
|
||||
size 36489
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6eb1c82199e7d0ace698bdc95b523bef51a761eaa22698dfefae3ff5f2419ca3
|
||||
size 56180
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c97f31285c26e60b58f9aee6dd9c4517b0801f32df2e6e523753a20973d8eeda
|
||||
size 19245
|
||||
oid sha256:b84731273d6b914154b20cd44c6a0ef56b6d5fd0815d0585e6d39da75b75760b
|
||||
size 31966
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:198d32d7d27af34fc966958db1fd033fa4fc49db294b5ba52a3919ce0a5eee4e
|
||||
size 24266
|
||||
oid sha256:45057a2f4a625328c888de722d85a141c87fa11a71e9b735fa8a1b1cb592387b
|
||||
size 36852
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5e5810b116406d8d826620ee27d4daccfedf29d6e26b95222fdce73f48250af7
|
||||
size 33494
|
||||
oid sha256:ceff828eeb1900ddb11aa1e031a6429ee6d635e95d18714e96ee936b69c1f1a0
|
||||
size 46075
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7ae57048f9e4f87b3eaa17ed0690f831c26a19bda18ca8a84797782912c107db
|
||||
size 64975
|
||||
oid sha256:70ee5992fbd5c9eb4373d0f348f6d742cc012468aa2474a263eb1dcf583b4c56
|
||||
size 77590
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:203e47a0d77a97d1f1008637fde7ef493eacc36b3bb0d35880f353e4a8bf1e09
|
||||
size 103221
|
||||
oid sha256:ce2fd6832bf854cabb19a7eb37bd060aa42ebc19d9ff472ed30385cee8c340b7
|
||||
size 115688
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0b65f5ee826e29411cfa981603de7ba1a14bf98d6f8780a96d31ab3c4395e88e
|
||||
size 101535
|
||||
oid sha256:c0b51b789a143543034fbd2b6c77a3433e2b50f5b57674fb8a997519a133d9ba
|
||||
size 113814
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:71f406a178f4a466d27c9a1483046a249688370daeb95b15878729934ccee8af
|
||||
size 235934
|
||||
oid sha256:153ff37f2aab19d3ca55d158d1a0157a29981f3da8e89271fa9ca4ef3bdb5df3
|
||||
size 217052
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8ab1c8e0bdc1c1a148a287020d299b08d060dc8315068d3f9ca5fd412f21227e
|
||||
size 235866
|
||||
oid sha256:081a2b6280f74393aae70752da97ce3e0629505d98dee69425095ec018140c27
|
||||
size 216908
|
||||
@@ -0,0 +1,133 @@
|
||||
package org.fdroid.search
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Context.MODE_PRIVATE
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.FileOutputStream
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
|
||||
internal class SearchHistoryManagerTest {
|
||||
|
||||
@get:Rule val tempFolder = TemporaryFolder()
|
||||
|
||||
private val context: Context = mockk()
|
||||
private val manager = SearchHistoryManager(context)
|
||||
|
||||
@Test
|
||||
fun testGetSavedSearchesSortedByNewest() {
|
||||
val file = tempFolder.newFile()
|
||||
val unsorted =
|
||||
listOf(
|
||||
SavedSearch(time = 10L, query = "a"),
|
||||
SavedSearch(time = 30L, query = "c"),
|
||||
SavedSearch(time = 20L, query = "b"),
|
||||
)
|
||||
file.writeText(Json.encodeToString(unsorted))
|
||||
every { context.openFileInput(any()) } answers { file.inputStream() }
|
||||
|
||||
val saved = manager.getSavedSearches()
|
||||
|
||||
assertEquals(listOf(30L, 20L, 10L), saved.map { it.time })
|
||||
assertEquals(listOf("c", "b", "a"), saved.map { it.query })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSaveGetAndClear() {
|
||||
val file = tempFolder.newFile()
|
||||
file.writeText("[]")
|
||||
every { context.openFileInput(any()) } answers { file.inputStream() }
|
||||
every { context.openFileOutput(any(), MODE_PRIVATE) } answers { FileOutputStream(file, false) }
|
||||
every { context.deleteFile(any()) } returns true
|
||||
|
||||
val savedFromSave = manager.saveSearchQuery("foo")
|
||||
val savedFromDisk = manager.getSavedSearches()
|
||||
|
||||
assertEquals(1, savedFromSave.size)
|
||||
assertEquals("foo", savedFromSave.first().query)
|
||||
assertEquals(1, savedFromDisk.size)
|
||||
assertEquals("foo", savedFromDisk.first().query)
|
||||
|
||||
assertTrue(manager.clearAll())
|
||||
verify { context.deleteFile(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSaveSearchQueryDeduplicatesExistingQuery() {
|
||||
val file = tempFolder.newFile()
|
||||
val existing =
|
||||
listOf(SavedSearch(time = 200L, query = "foo"), SavedSearch(time = 100L, query = "bar"))
|
||||
file.writeText(Json.encodeToString(existing))
|
||||
every { context.openFileInput(any()) } answers { file.inputStream() }
|
||||
every { context.openFileOutput(any(), MODE_PRIVATE) } answers { FileOutputStream(file, false) }
|
||||
|
||||
val saved = manager.saveSearchQuery("foo")
|
||||
|
||||
assertEquals(2, saved.size)
|
||||
assertEquals(1, saved.count { it.query == "foo" })
|
||||
assertEquals(1, saved.count { it.query == "bar" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSaveSearchQueryCapsSavedEntriesAtMax() {
|
||||
val maxSearches = 3
|
||||
val cappedManager = SearchHistoryManager(context, maxSearches)
|
||||
val file = tempFolder.newFile()
|
||||
val existing =
|
||||
listOf(
|
||||
SavedSearch(time = 400L, query = "q4"),
|
||||
SavedSearch(time = 300L, query = "q3"),
|
||||
SavedSearch(time = 200L, query = "q2"),
|
||||
SavedSearch(time = 100L, query = "q1"),
|
||||
)
|
||||
file.writeText(Json.encodeToString(existing))
|
||||
every { context.openFileInput(any()) } answers { file.inputStream() }
|
||||
every { context.openFileOutput(any(), MODE_PRIVATE) } answers { FileOutputStream(file, false) }
|
||||
|
||||
val savedFromSave = cappedManager.saveSearchQuery("new-query")
|
||||
val savedFromDisk = cappedManager.getSavedSearches()
|
||||
|
||||
assertEquals(maxSearches, savedFromSave.size)
|
||||
assertEquals(maxSearches, savedFromDisk.size)
|
||||
assertTrue(savedFromSave.any { it.query == "new-query" })
|
||||
assertTrue(savedFromDisk.any { it.query == "new-query" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetSavedSearchesReturnsEmptyForMissingFile() {
|
||||
every { context.openFileInput(any()) } throws FileNotFoundException()
|
||||
|
||||
val saved = manager.getSavedSearches()
|
||||
|
||||
assertTrue(saved.isEmpty())
|
||||
verify(exactly = 0) { context.deleteFile(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetSavedSearchesCorruptFileClearsAndReturnsEmpty() {
|
||||
val file = tempFolder.newFile()
|
||||
file.writeText("not valid json")
|
||||
every { context.openFileInput(any()) } answers { file.inputStream() }
|
||||
every { context.deleteFile(any()) } returns true
|
||||
|
||||
val saved = manager.getSavedSearches()
|
||||
|
||||
assertTrue(saved.isEmpty())
|
||||
verify(exactly = 1) { context.deleteFile(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testClearAllReturnsFalseWhenDeleteFails() {
|
||||
every { context.deleteFile(any()) } returns false
|
||||
|
||||
assertEquals(false, manager.clearAll())
|
||||
verify(exactly = 1) { context.deleteFile(any()) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package org.fdroid.search
|
||||
|
||||
import android.database.sqlite.SQLiteException
|
||||
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import io.mockk.verify
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.fdroid.database.AppDao
|
||||
import org.fdroid.database.AppSearchItem
|
||||
import org.fdroid.database.Category
|
||||
import org.fdroid.database.FDroidDatabase
|
||||
import org.fdroid.database.Repository
|
||||
import org.fdroid.database.RepositoryDao
|
||||
import org.fdroid.download.PackageName
|
||||
import org.fdroid.index.RepoManager
|
||||
import org.fdroid.install.InstalledAppsCache
|
||||
import org.fdroid.settings.SettingsManager
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
internal class SearchManagerTest {
|
||||
|
||||
@get:Rule
|
||||
val instantTaskExecutorRule = InstantTaskExecutorRule()
|
||||
|
||||
private val db: FDroidDatabase = mockk()
|
||||
private val appDao: AppDao = mockk()
|
||||
private val repositoryDao: RepositoryDao = mockk()
|
||||
private val repoManager: RepoManager = mockk()
|
||||
private val settingsManager: SettingsManager = mockk()
|
||||
private val installedAppsCache: InstalledAppsCache = mockk()
|
||||
private val searchHistoryManager: SearchHistoryManager = mockk()
|
||||
private val categoriesLiveData = MutableLiveData<List<Category>>(emptyList())
|
||||
|
||||
private val repo: Repository = mockk(relaxed = true)
|
||||
|
||||
init {
|
||||
Dispatchers.setMain(Dispatchers.Unconfined)
|
||||
|
||||
every { db.getAppDao() } returns appDao
|
||||
every { db.getRepositoryDao() } returns repositoryDao
|
||||
every { repositoryDao.getLiveCategories() } returns categoriesLiveData
|
||||
every { settingsManager.proxyConfig } returns null
|
||||
every { repoManager.getRepository(any()) } returns repo
|
||||
every { installedAppsCache.isInstalled(any()) } returns false
|
||||
every { searchHistoryManager.getSavedSearches() } returns emptyList()
|
||||
every { searchHistoryManager.saveSearchQuery(any()) } answers
|
||||
{
|
||||
listOf(SavedSearch(time = 1L, query = firstArg()))
|
||||
}
|
||||
every { searchHistoryManager.clearAll() } returns true
|
||||
}
|
||||
|
||||
private val searchManager =
|
||||
SearchManager(
|
||||
db = db,
|
||||
repoManager = repoManager,
|
||||
settingsManager = settingsManager,
|
||||
installedAppsCache = installedAppsCache,
|
||||
searchHistoryManager = searchHistoryManager,
|
||||
ioDispatcher = Dispatchers.Unconfined,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun searchCrashMeThrows() = runTest {
|
||||
assertFailsWith<IllegalStateException> { searchManager.search("CrashMe") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchBuildsPrefixQueryForSingleWord() = runTest {
|
||||
val querySlot = slot<String>()
|
||||
coEvery { appDao.getAppSearchItems(capture(querySlot)) } returns emptyList()
|
||||
|
||||
searchManager.search("foo")
|
||||
|
||||
assertEquals("foo*", querySlot.captured)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchBuildsComplexQueryForMultipleWordsAndSanitizesQuotes() = runTest {
|
||||
val querySlot = slot<String>()
|
||||
coEvery { appDao.getAppSearchItems(capture(querySlot)) } returns emptyList()
|
||||
|
||||
searchManager.search("foo \"bar\"")
|
||||
|
||||
assertEquals("foo* bar* OR foobar* OR \"foo* bar*\"", querySlot.captured)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchBuildsCjkQueryBySplittingIdeographicCharacters() = runTest {
|
||||
val querySlot = slot<String>()
|
||||
coEvery { appDao.getAppSearchItems(capture(querySlot)) } returns emptyList()
|
||||
|
||||
searchManager.search("測試")
|
||||
|
||||
assertEquals("測* 試* OR \"測\u200B試*\" OR 測試*", querySlot.captured)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchBuildsMultiWordCjkQuery() = runTest {
|
||||
val querySlot = slot<String>()
|
||||
coEvery { appDao.getAppSearchItems(capture(querySlot)) } returns emptyList()
|
||||
|
||||
searchManager.search("測試 艾星")
|
||||
|
||||
assertEquals(
|
||||
"測* 試* 艾* 星* OR \"測\u200B試*\" \"艾\u200B星*\" OR 測試* 艾星*",
|
||||
querySlot.captured
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchUsesLocalIconForInstalledApps() = runTest {
|
||||
val item = buildSearchItem(packageName = "com.example.installed", repoId = 1L)
|
||||
coEvery { appDao.getAppSearchItems(any()) } returns listOf(item)
|
||||
every { installedAppsCache.isInstalled("com.example.installed") } returns true
|
||||
|
||||
searchManager.search("installed")
|
||||
|
||||
val results = searchManager.searchResults.value
|
||||
assertNotNull(results)
|
||||
assertEquals(1, results.apps.size)
|
||||
val listItem = results.apps.first()
|
||||
assertTrue(listItem.isInstalled)
|
||||
assertIs<PackageName>(listItem.iconModel)
|
||||
assertEquals("com.example.installed", listItem.iconModel.packageName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchIsEmptyWhenThrowsSQLiteException() = runTest {
|
||||
coEvery { appDao.getAppSearchItems(any()) } throws SQLiteException("boom")
|
||||
|
||||
searchManager.search("boom")
|
||||
|
||||
val results = searchManager.searchResults.value
|
||||
assertNotNull(results)
|
||||
assertTrue(results.apps.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchFiltersCategoriesUsingNormalizedMatching() = runTest {
|
||||
val category = Category(repoId = 1L, id = "coffee", name = mapOf("en-US" to "Café"))
|
||||
categoriesLiveData.value = listOf(category)
|
||||
coEvery { appDao.getAppSearchItems(any()) } returns emptyList()
|
||||
|
||||
searchManager.search("cafe")
|
||||
|
||||
val results = searchManager.searchResults.value
|
||||
assertNotNull(results)
|
||||
assertEquals(1, results.categories.size)
|
||||
assertEquals("Café", results.categories.first().name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onSearchClearedResetsResultsToNull() = runTest {
|
||||
coEvery { appDao.getAppSearchItems(any()) } returns emptyList()
|
||||
|
||||
searchManager.search("foo")
|
||||
assertNotNull(searchManager.searchResults.value)
|
||||
|
||||
searchManager.onSearchCleared()
|
||||
|
||||
assertNull(searchManager.searchResults.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchSavesQueryInHistory() = runTest {
|
||||
val expected = listOf(SavedSearch(time = 999L, query = "foo"))
|
||||
every { searchHistoryManager.saveSearchQuery("foo") } returns expected
|
||||
coEvery { appDao.getAppSearchItems(any()) } returns emptyList()
|
||||
|
||||
advanceUntilIdle()
|
||||
searchManager.search("foo")
|
||||
|
||||
verify(exactly = 1) { searchHistoryManager.saveSearchQuery("foo") }
|
||||
assertEquals(expected, searchManager.savedSearches.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchDebouncesAndSavesOnlyAfterDelay() = runTest {
|
||||
val searchManager = SearchManager(
|
||||
db = db,
|
||||
repoManager = repoManager,
|
||||
settingsManager = settingsManager,
|
||||
installedAppsCache = installedAppsCache,
|
||||
searchHistoryManager = searchHistoryManager,
|
||||
ioDispatcher = StandardTestDispatcher(testScheduler),
|
||||
)
|
||||
|
||||
val expected = listOf(SavedSearch(time = 999L, query = "foo"))
|
||||
every { searchHistoryManager.saveSearchQuery("foo") } returns expected
|
||||
coEvery { appDao.getAppSearchItems(any()) } returns emptyList()
|
||||
|
||||
advanceUntilIdle()
|
||||
|
||||
val searchJob = launch { searchManager.search("foo") }
|
||||
runCurrent()
|
||||
verify(exactly = 0) { searchHistoryManager.saveSearchQuery(any()) }
|
||||
|
||||
advanceTimeBy(1499)
|
||||
runCurrent()
|
||||
verify(exactly = 0) { searchHistoryManager.saveSearchQuery(any()) }
|
||||
|
||||
advanceTimeBy(1)
|
||||
runCurrent()
|
||||
verify(exactly = 1) { searchHistoryManager.saveSearchQuery("foo") }
|
||||
assertEquals(expected, searchManager.savedSearches.value)
|
||||
|
||||
searchJob.join()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onClearSearchHistoryClearsAfterDelete() = runTest {
|
||||
val initial = listOf(SavedSearch(time = 123L, query = "first"))
|
||||
every { searchHistoryManager.getSavedSearches() } returns initial
|
||||
every { searchHistoryManager.clearAll() } returns true
|
||||
|
||||
searchManager.onClearSearchHistory()
|
||||
|
||||
verify(exactly = 1) { searchHistoryManager.clearAll() }
|
||||
assertEquals(emptyList(), searchManager.savedSearches.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onClearSearchHistoryKeepsSavedSearchesWhenDeleteFails() = runTest {
|
||||
val searchManager = SearchManager(
|
||||
db = db,
|
||||
repoManager = repoManager,
|
||||
settingsManager = settingsManager,
|
||||
installedAppsCache = installedAppsCache,
|
||||
searchHistoryManager = searchHistoryManager,
|
||||
ioDispatcher = StandardTestDispatcher(testScheduler),
|
||||
)
|
||||
|
||||
val initial = listOf(SavedSearch(time = 123L, query = "first"))
|
||||
every { searchHistoryManager.getSavedSearches() } returns initial
|
||||
every { searchHistoryManager.clearAll() } returns false
|
||||
|
||||
advanceUntilIdle()
|
||||
searchManager.onClearSearchHistory()
|
||||
|
||||
verify(exactly = 1) { searchHistoryManager.clearAll() }
|
||||
assertEquals(initial, searchManager.savedSearches.value)
|
||||
}
|
||||
|
||||
private fun buildSearchItem(
|
||||
packageName: String,
|
||||
repoId: Long = 1L,
|
||||
appName: String = "Test App",
|
||||
summary: String = "Test Summary",
|
||||
lastUpdated: Long = 1234L,
|
||||
categories: List<String>? = null,
|
||||
): AppSearchItem {
|
||||
val item: AppSearchItem = mockk()
|
||||
every { item.repoId } returns repoId
|
||||
every { item.packageName } returns packageName
|
||||
every { item.lastUpdated } returns lastUpdated
|
||||
every { item.name } returns mapOf("en-US" to appName)
|
||||
every { item.summary } returns mapOf("en-US" to summary)
|
||||
every { item.categories } returns categories
|
||||
every { item.getIcon(any()) } returns null
|
||||
return item
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ import org.robolectric.RobolectricTestRunner
|
||||
* This is the in-memory [String.contains]-based counterpart of AppSearchItemsTest, which covers the
|
||||
* DB FTS4 path.
|
||||
*
|
||||
* [AppListPresenter] uses [org.fdroid.ui.search.SearchHelper.fixQuery] on the incoming query before
|
||||
* [AppListPresenter] uses [org.fdroid.search.SearchHelper.fixQuery] on the incoming query before
|
||||
* matching, which:
|
||||
* - strips diacritics via NFKD normalization (enables diacritic-insensitive search), and
|
||||
* - inserts zero-width spaces (U+200B) after each ideographic character (enables CJK matching
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
package org.fdroid.ui.search
|
||||
|
||||
import android.database.sqlite.SQLiteException
|
||||
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.fdroid.database.AppDao
|
||||
import org.fdroid.database.AppSearchItem
|
||||
import org.fdroid.database.Category
|
||||
import org.fdroid.database.FDroidDatabase
|
||||
import org.fdroid.database.Repository
|
||||
import org.fdroid.database.RepositoryDao
|
||||
import org.fdroid.download.PackageName
|
||||
import org.fdroid.index.RepoManager
|
||||
import org.fdroid.install.InstalledAppsCache
|
||||
import org.fdroid.settings.SettingsManager
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
internal class SearchManagerTest {
|
||||
|
||||
@get:Rule val instantTaskExecutorRule = InstantTaskExecutorRule()
|
||||
|
||||
private val db: FDroidDatabase = mockk()
|
||||
private val appDao: AppDao = mockk()
|
||||
private val repositoryDao: RepositoryDao = mockk()
|
||||
private val repoManager: RepoManager = mockk()
|
||||
private val settingsManager: SettingsManager = mockk()
|
||||
private val installedAppsCache: InstalledAppsCache = mockk()
|
||||
private val categoriesLiveData = MutableLiveData<List<Category>>(emptyList())
|
||||
|
||||
private val repo: Repository = mockk(relaxed = true)
|
||||
|
||||
init {
|
||||
Dispatchers.setMain(Dispatchers.Unconfined)
|
||||
|
||||
every { db.getAppDao() } returns appDao
|
||||
every { db.getRepositoryDao() } returns repositoryDao
|
||||
every { repositoryDao.getLiveCategories() } returns categoriesLiveData
|
||||
every { settingsManager.proxyConfig } returns null
|
||||
every { repoManager.getRepository(any()) } returns repo
|
||||
every { installedAppsCache.isInstalled(any()) } returns false
|
||||
}
|
||||
|
||||
private val searchManager =
|
||||
SearchManager(
|
||||
db = db,
|
||||
repoManager = repoManager,
|
||||
settingsManager = settingsManager,
|
||||
installedAppsCache = installedAppsCache,
|
||||
ioDispatcher = Dispatchers.Unconfined,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun searchCrashMeThrows() = runTest {
|
||||
assertFailsWith<IllegalStateException> { searchManager.search("CrashMe") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchBuildsPrefixQueryForSingleWord() = runTest {
|
||||
val querySlot = slot<String>()
|
||||
coEvery { appDao.getAppSearchItems(capture(querySlot)) } returns emptyList()
|
||||
|
||||
searchManager.search("foo")
|
||||
|
||||
assertEquals("foo*", querySlot.captured)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchBuildsComplexQueryForMultipleWordsAndSanitizesQuotes() = runTest {
|
||||
val querySlot = slot<String>()
|
||||
coEvery { appDao.getAppSearchItems(capture(querySlot)) } returns emptyList()
|
||||
|
||||
searchManager.search("foo \"bar\"")
|
||||
|
||||
assertEquals("foo* bar* OR foobar* OR \"foo* bar*\"", querySlot.captured)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchBuildsCjkQueryBySplittingIdeographicCharacters() = runTest {
|
||||
val querySlot = slot<String>()
|
||||
coEvery { appDao.getAppSearchItems(capture(querySlot)) } returns emptyList()
|
||||
|
||||
searchManager.search("測試")
|
||||
|
||||
assertEquals("測* 試* OR \"測\u200B試*\" OR 測試*", querySlot.captured)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchBuildsMultiWordCjkQuery() = runTest {
|
||||
val querySlot = slot<String>()
|
||||
coEvery { appDao.getAppSearchItems(capture(querySlot)) } returns emptyList()
|
||||
|
||||
searchManager.search("測試 艾星")
|
||||
|
||||
assertEquals("測* 試* 艾* 星* OR \"測\u200B試*\" \"艾\u200B星*\" OR 測試* 艾星*", querySlot.captured)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchUsesLocalIconForInstalledApps() = runTest {
|
||||
val item = buildSearchItem(packageName = "com.example.installed", repoId = 1L)
|
||||
coEvery { appDao.getAppSearchItems(any()) } returns listOf(item)
|
||||
every { installedAppsCache.isInstalled("com.example.installed") } returns true
|
||||
|
||||
searchManager.search("installed")
|
||||
|
||||
val results = searchManager.searchResults.value
|
||||
assertNotNull(results)
|
||||
assertEquals(1, results.apps.size)
|
||||
val listItem = results.apps.first()
|
||||
assertTrue(listItem.isInstalled)
|
||||
assertIs<PackageName>(listItem.iconModel)
|
||||
assertEquals("com.example.installed", listItem.iconModel.packageName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchIsEmptyWhenThrowsSQLiteException() = runTest {
|
||||
coEvery { appDao.getAppSearchItems(any()) } throws SQLiteException("boom")
|
||||
|
||||
searchManager.search("boom")
|
||||
|
||||
val results = searchManager.searchResults.value
|
||||
assertNotNull(results)
|
||||
assertTrue(results.apps.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchFiltersCategoriesUsingNormalizedMatching() = runTest {
|
||||
val category = Category(repoId = 1L, id = "coffee", name = mapOf("en-US" to "Café"))
|
||||
categoriesLiveData.value = listOf(category)
|
||||
coEvery { appDao.getAppSearchItems(any()) } returns emptyList()
|
||||
|
||||
searchManager.search("cafe")
|
||||
|
||||
val results = searchManager.searchResults.value
|
||||
assertNotNull(results)
|
||||
assertEquals(1, results.categories.size)
|
||||
assertEquals("Café", results.categories.first().name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onSearchClearedResetsResultsToNull() = runTest {
|
||||
coEvery { appDao.getAppSearchItems(any()) } returns emptyList()
|
||||
|
||||
searchManager.search("foo")
|
||||
assertNotNull(searchManager.searchResults.value)
|
||||
|
||||
searchManager.onSearchCleared()
|
||||
|
||||
assertNull(searchManager.searchResults.value)
|
||||
}
|
||||
|
||||
private fun buildSearchItem(
|
||||
packageName: String,
|
||||
repoId: Long = 1L,
|
||||
appName: String = "Test App",
|
||||
summary: String = "Test Summary",
|
||||
lastUpdated: Long = 1234L,
|
||||
categories: List<String>? = null,
|
||||
): AppSearchItem {
|
||||
val item: AppSearchItem = mockk()
|
||||
every { item.repoId } returns repoId
|
||||
every { item.packageName } returns packageName
|
||||
every { item.lastUpdated } returns lastUpdated
|
||||
every { item.name } returns mapOf("en-US" to appName)
|
||||
every { item.summary } returns mapOf("en-US" to summary)
|
||||
every { item.categories } returns categories
|
||||
every { item.getIcon(any()) } returns null
|
||||
return item
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ androidxActivityCompose = "1.13.0"
|
||||
accompanistDrawablepainter = "0.37.3"
|
||||
|
||||
# navigation3
|
||||
nav3Core = "1.0.1"
|
||||
nav3Core = "1.1.0-rc01"
|
||||
lifecycleViewmodelNav3 = "2.10.0"
|
||||
material3AdaptiveNav3 = "1.0.0-alpha03"
|
||||
|
||||
|
||||
@@ -1203,11 +1203,21 @@
|
||||
<sha256 value="0a9963328acbde74dd8695534faed2df3a72b69a9ff74c7a7b0a33c4333d4716" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.navigation3" name="navigation3-runtime-android" version="1.1.0-rc01">
|
||||
<artifact name="navigation3-runtime.aar">
|
||||
<sha256 value="db9d3fe3084fbebbf97396ec4a903953bddb02ee5476b78139dbdf906e4a0973" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.navigation3" name="navigation3-ui-android" version="1.0.1">
|
||||
<artifact name="navigation3-ui.aar">
|
||||
<sha256 value="55f3b7e93532d2f17ec490d3b3f284a230b3e450c03f6bc219856fd972d7d26e" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.navigation3" name="navigation3-ui-android" version="1.1.0-rc01">
|
||||
<artifact name="navigation3-ui.aar">
|
||||
<sha256 value="eeb89460906f0fcd1bb612e9a2aee51c168d8debe7e73df99a31d23f4cc5b2b6" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.navigationevent" name="navigationevent-android" version="1.0.0">
|
||||
<artifact name="navigationevent.aar">
|
||||
<sha256 value="cda4bb83d1c892a208732e99c2d59e4f247ab5c23a1d2524fc004db2e4e9a780" origin="Generated by Gradle"/>
|
||||
|
||||
@@ -188,7 +188,13 @@ constructor(
|
||||
if (addedRepo != null)
|
||||
withContext(Dispatchers.Main) {
|
||||
// as soon as the list has loaded, it should never be null
|
||||
_repositoriesState.update { it!!.toMutableList().apply { add(addedRepo) } }
|
||||
_repositoriesState.update { repos ->
|
||||
if (repos!!.none { it.repoId == addedRepo.repoId }) {
|
||||
repos.toMutableList().apply { add(addedRepo) }
|
||||
} else {
|
||||
repos
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -151,6 +152,7 @@ internal class RepoAdder(
|
||||
var receivedRepo: Repository? = null
|
||||
val apps = ArrayList<AppOverviewItem>()
|
||||
var fetchResult: FetchResult? = null
|
||||
val activeContext = currentCoroutineContext()
|
||||
|
||||
val receiver =
|
||||
object : RepoPreviewReceiver {
|
||||
@@ -164,18 +166,18 @@ internal class RepoAdder(
|
||||
)
|
||||
}
|
||||
fetchResult = getFetchResult(fetchUrl, repo)
|
||||
coroutineContext.ensureActive() // ensure active before updating state
|
||||
activeContext.ensureActive() // ensure active before updating state
|
||||
addRepoState.value = Fetching(fetchUrl, receivedRepo, apps.toList(), fetchResult)
|
||||
}
|
||||
|
||||
override fun onAppReceived(app: AppOverviewItem) {
|
||||
apps.add(app)
|
||||
coroutineContext.ensureActive() // ensure active before updating state
|
||||
activeContext.ensureActive() // ensure active before updating state
|
||||
addRepoState.value = Fetching(fetchUrl, receivedRepo, apps.toList(), fetchResult)
|
||||
}
|
||||
}
|
||||
// set a state early, so the ui can show progress animation
|
||||
coroutineContext.ensureActive() // ensure active before updating state
|
||||
activeContext.ensureActive() // ensure active before updating state
|
||||
addRepoState.value = Fetching(fetchUrl, receivedRepo, apps, fetchResult)
|
||||
|
||||
// try fetching repo with v2 format first and fallback to v1
|
||||
@@ -201,7 +203,7 @@ internal class RepoAdder(
|
||||
}
|
||||
// set final result
|
||||
val finalRepo = receivedRepo
|
||||
coroutineContext.ensureActive() // ensure active before updating state
|
||||
activeContext.ensureActive() // ensure active before updating state
|
||||
if (finalRepo == null) {
|
||||
onError(AddRepoError(INVALID_INDEX))
|
||||
} else {
|
||||
@@ -280,18 +282,29 @@ internal class RepoAdder(
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
internal fun addFetchedRepository(): Repository? {
|
||||
internal suspend fun addFetchedRepository(): Repository? {
|
||||
return try {
|
||||
addFetchedRepositoryInt()
|
||||
} catch (e: Exception) {
|
||||
addRepoState.value = AddRepoError(IO_ERROR, e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private suspend fun addFetchedRepositoryInt(): Repository? {
|
||||
// first cancel fetch preview job, so it stops emitting new states,
|
||||
// screwing up the atomicity of getAndUpdate() below.
|
||||
fetchJob?.cancel()
|
||||
fetchJob?.cancelAndJoin()
|
||||
|
||||
// get current state before changing it
|
||||
// prevent double calls (e.g. caused by double tapping a UI button)
|
||||
val state =
|
||||
addRepoState.getAndUpdate {
|
||||
log.info { "Previous state was $it" }
|
||||
Adding
|
||||
} as? Fetching ?: error("Unexpected previous state")
|
||||
val state = addRepoState.getAndUpdate { Adding }
|
||||
if (state is Adding) {
|
||||
log.warn { "Already adding a repo, ignoring call to addFetchedRepository()." }
|
||||
return null
|
||||
}
|
||||
state as? Fetching ?: error("Unexpected previous state: $state")
|
||||
log.info { "Moved to state ${addRepoState.value}, cancelling preview job..." }
|
||||
|
||||
val repo = state.receivedRepo ?: throw IllegalStateException("No repo: ${addRepoState.value}")
|
||||
|
||||
@@ -24,7 +24,6 @@ import java.security.DigestInputStream
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.Callable
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNull
|
||||
@@ -429,7 +428,9 @@ internal class RepoAdderTest {
|
||||
|
||||
expectMinRepoPreview(repoName, url, expectedFetchResult)
|
||||
|
||||
assertFailsWith<IllegalStateException> { repoAdder.addFetchedRepository() }
|
||||
assertNull(repoAdder.addFetchedRepository())
|
||||
assertIs<AddRepoError>(repoAdder.addRepoState.value)
|
||||
assertIs<IllegalStateException>((repoAdder.addRepoState.value as AddRepoError).exception)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Loaded 100 of 396 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user