Merge branch '2.0' into 'master'

Release 2.0-alpha8

Closes #3237

See merge request fdroid/fdroidclient!1655
This commit is contained in:
Torsten Grote committed 2026-04-16 20:16:40 +00:00
commit 128762f1d2
396 files changed
+2569 -700

No files matched your search

+1
View File
@@ -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
+1
View File
@@ -49,3 +49,4 @@ junit-report.xml
captures/
/fdroid/
/screenshots
+29
View File
@@ -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
+2 -2
View File
@@ -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"
}
+5
View File
@@ -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() =
+3 -1
View File
@@ -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,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)
@@ -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()
+9 -18
View File
@@ -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)
}
+1 -3
View File
@@ -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>
-2
View File
@@ -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>
-2
View File
@@ -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>
+4 -4
View File
@@ -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(
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:97b2a9163570a5e6f61dac969115543bcf2b5bc6040b27ce0163d1d715093158
size 155555
oid sha256:1501c756d19f294b3f71ee089a1e728b3ade04b91372617248f68cb60aecfe1f
size 158114
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:818edd986a22692191d23dfa22f835929af64628be310f7c7f32af6cd134081d
size 112062
oid sha256:619eb7e9478b45a8b1ada0b65335fad718cb728f500ab04c77bb292933df5c84
size 114633
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1f5e54e6f874f31f982de0d1889f0b6947fd78668389409e6b51f439e32d1f17
size 65274
oid sha256:037f0d24d270c8fb52ac88bff5f40c999ea11035d03e270b6fd7968ad24506a5
size 67807
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4ed3a7b54a24bcacca7dbe3a3d426e236f59958ed940f62fff651e00dd82c5fe
size 81105
oid sha256:8c6ea083fcbe5e69a47e88498ab197b4eab85ab129750290bb634815cf9166b7
size 83745
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8bcaeed6aafb9767b7d1c9e04f1ea98cdd208b9c077a4857d2e8a933bef569c8
size 46617
oid sha256:92066e2671a0fa2733feb5701d540b612714b7f8f23b69133b0e4fc06a5b4a64
size 46451
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ddc8762b99dd13e65805fa5ebdc1f14eb659ff8dc37237b0c13f1748996f8f8f
size 45990
oid sha256:b7c985dfe300bc834da2b8bfc874c79ebcb5bbfc9adc42f19391f4c719a9ee4c
size 45829
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6769563c16cb16a06752bb7aa66429ce9b3bda69fa4a7fb4232c8e837d62fe3b
size 52608
oid sha256:e535a66f33e0313195434319f11049637bdf7a89d98ec8223c5323c738bb0042
size 55346
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:406f894eba1d2dbad4b3f64f8db262b470c8d4c61f7889f7ad7110692f0438a0
size 46396
oid sha256:ee3d6bc540daebf569a1be2cb7de5c8e53478081baaddd68df7a855851703940
size 48910
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:60ae81af88e21da8a7feb43d9cbf111106c715789a93295e9c5d7dc05a627547
size 156780
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2485bf774cdbb291cac6288b617eba3bea65eeb3d6c723df526217b32e2fad57
size 155874
oid sha256:3bffaab8294637b504398af638e91187ff23640466f9775668ec00490436effe
size 151906
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:765dd2750bf6f69bd17e54f65d4954b82da43f30811efd9544532ff037f6a144
size 49240
oid sha256:28237ec38434fa636974d08cd6bc4bba31d26a90e8e1ae987116a2115e0478bb
size 49764
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:841697f4533aa01aa76fdd4ad1906276d1c4cb94eb22afb4584c56512f2f75f4
size 49333
oid sha256:e7a2d550503deb71a2fddc0436313900d8f97c2c2413018e93ff81922b63acff
size 49879
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:16486d4e51d86fcaebb8ed9890dd2a618c0899216e70e0d87f677feb7f446210
size 56722
oid sha256:0c490c338d630ee2fe39166723eaa73ee48527631422951eea68eb1067f2147d
size 57244
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:44793ef002c2dd9c81ed25810baad89b7da6b0789f17f17b638ed874b45a8d3a
size 57063
oid sha256:bb269ef10a0d4b43efb36e7ea43d62626e8bf942e7e27ac10a047aae9c9056e0
size 57639
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ab9a82e0bd93768d3c97e151d8cd2b284d670cb82c2411d0a216dce2fab56e41
size 36871
oid sha256:d06c7114be1f155ddf57352ae00ded89e27022c1c685cd2a1fce1973a990e5b7
size 36489
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6eb1c82199e7d0ace698bdc95b523bef51a761eaa22698dfefae3ff5f2419ca3
size 56180
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c97f31285c26e60b58f9aee6dd9c4517b0801f32df2e6e523753a20973d8eeda
size 19245
oid sha256:b84731273d6b914154b20cd44c6a0ef56b6d5fd0815d0585e6d39da75b75760b
size 31966
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:198d32d7d27af34fc966958db1fd033fa4fc49db294b5ba52a3919ce0a5eee4e
size 24266
oid sha256:45057a2f4a625328c888de722d85a141c87fa11a71e9b735fa8a1b1cb592387b
size 36852
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5e5810b116406d8d826620ee27d4daccfedf29d6e26b95222fdce73f48250af7
size 33494
oid sha256:ceff828eeb1900ddb11aa1e031a6429ee6d635e95d18714e96ee936b69c1f1a0
size 46075
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7ae57048f9e4f87b3eaa17ed0690f831c26a19bda18ca8a84797782912c107db
size 64975
oid sha256:70ee5992fbd5c9eb4373d0f348f6d742cc012468aa2474a263eb1dcf583b4c56
size 77590
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:203e47a0d77a97d1f1008637fde7ef493eacc36b3bb0d35880f353e4a8bf1e09
size 103221
oid sha256:ce2fd6832bf854cabb19a7eb37bd060aa42ebc19d9ff472ed30385cee8c340b7
size 115688
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0b65f5ee826e29411cfa981603de7ba1a14bf98d6f8780a96d31ab3c4395e88e
size 101535
oid sha256:c0b51b789a143543034fbd2b6c77a3433e2b50f5b57674fb8a997519a133d9ba
size 113814
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:71f406a178f4a466d27c9a1483046a249688370daeb95b15878729934ccee8af
size 235934
oid sha256:153ff37f2aab19d3ca55d158d1a0157a29981f3da8e89271fa9ca4ef3bdb5df3
size 217052
@@ -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
}
}
+1 -1
View File
@@ -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"
+10
View File
@@ -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