Merge branch '2.0' into 'master'

Release 2.0-alpha9

Closes #3262

See merge request fdroid/fdroidclient!1662
This commit is contained in:
Michael Pöhn
2026-04-30 09:00:28 +00:00
197 changed files with 3119 additions and 714 deletions

View File

@@ -103,6 +103,7 @@ app assembleRelease test:
paths:
- app/build/reports
- app/build/outputs/apk
- app/build/outputs/mapping
- libs/*/build/reports
- legacy/build/reports
- legacy/build/outputs/apk
@@ -434,16 +435,4 @@ deploy_nightly:
- rm -rf app/build/outputs/apk legacy/build/outputs/apk
- ./gradlew :app:assembleBasicNightly :legacy:assembleFullDebug
- mv app/build/outputs/apk/basicNightly/release/app-basic-nightly-release-unsigned.apk app/build/outputs/apk/basicNightly/release/app-debug.apk
# taken from fdroiddata/.gitlab-ci.yml as a tmp workaround until this is released:
# https://gitlab.com/fdroid/fdroidserver/-/merge_requests/1666
- rm -rf $fdroidserver
- mkdir $fdroidserver
- git ls-remote https://gitlab.com/fdroid/fdroidserver.git master
- curl --silent https://gitlab.com/fdroid/fdroidserver/-/archive/master/fdroidserver-master.tar.gz
| tar -xz --directory=$fdroidserver --strip-components=1
- export PATH="$fdroidserver:$PATH"
- export PYTHONPATH="$fdroidserver:$fdroidserver/examples"
- export PYTHONUNBUFFERED=true
- fdroid nightly -v

View File

@@ -18,8 +18,8 @@ android {
applicationId = "org.fdroid"
minSdk = 24
targetSdk = 36
versionCode = 2000008
versionName = "2.0-alpha8"
versionCode = 2000009
versionName = "2.0-alpha9"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

@@ -1,4 +1,5 @@
-dontobfuscate
-dontoptimize
-keepattributes SourceFile,LineNumberTable,Exceptions
# Anything less causes issues like not finding primary constructor in ReflectionDiffer

View File

@@ -9,7 +9,6 @@ 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
@@ -82,7 +81,6 @@ class DnsCacheTest {
}
@Test
@Ignore("failing") // TODO
fun preloadCacheTest() {
// test setup
settings.useDnsCache = true

View File

@@ -30,6 +30,7 @@ class MyAppsScreenshotTest(localeName: String) : LocalizedScreenshotTest(localeN
appUpdates = getUpdates(localeList),
installingApps = emptyList(),
installedApps = getInstalledApps(localeList),
showUpdatesHint = false,
showAppIssueHint = false,
networkState = NetworkState(isOnline = true, isMetered = false),
)

View File

@@ -7,6 +7,7 @@ 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.fdroid.ui.utils.getSearchInfo
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
@@ -28,14 +29,10 @@ class SearchScreenshotTest(localeName: String) : LocalizedScreenshotTest(localeN
apps = getSearchResultItems(localeList),
)
GlobalSearch(
info = getSearchInfo(results, emptyList()),
textFieldState = rememberTextFieldState("work"),
searchResults = results,
savedSearches = emptyList(),
onSearch = {},
onClearSavedSearches = {},
onNav = {},
onBack = {},
onSearchCleared = {},
)
}
}

View File

@@ -25,6 +25,12 @@
<uses-permission android:name="android.permission.ENFORCE_UPDATE_OWNERSHIP" />
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
<!-- Permission used to externally trigger repository updates -->
<permission
android:name="${applicationId}.permission.UPDATE_REPOS"
android:protectionLevel="signature|privileged" />
<uses-permission android:name="${applicationId}.permission.UPDATE_REPOS" />
<application
android:name="org.fdroid.App"
android:allowBackup="true"
@@ -115,6 +121,17 @@
android:screenOrientation="fullSensor"
tools:ignore="DiscouragedApi"
tools:replace="screenOrientation" />
<activity
android:name="org.fdroid.ui.repositories.UpdateRepoActivity"
android:excludeFromRecents="true"
android:exported="true"
android:launchMode="singleInstance"
android:permission="${applicationId}.permission.UPDATE_REPOS">
<intent-filter>
<action android:name="org.fdroid.action.UPDATE_REPOS" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<service
android:name="org.fdroid.install.AppInstallService"

View File

@@ -1,6 +1,8 @@
package org.fdroid
import android.Manifest.permission.POST_NOTIFICATIONS
import android.app.ComponentCaller
import android.content.Intent
import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.os.Build.VERSION.SDK_INT
import android.os.Bundle
@@ -14,6 +16,7 @@ import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.launch
import mu.KotlinLogging
import org.fdroid.settings.SettingsManager
import org.fdroid.ui.Main
import org.fdroid.ui.utils.launchSafe
@@ -23,12 +26,15 @@ import org.fdroid.ui.utils.launchSafe
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
private val log = KotlinLogging.logger {}
val requestPermissionLauncher = registerForActivityResult(RequestPermission()) {}
@Inject lateinit var settingsManager: SettingsManager
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
log.info { "onCreate($savedInstanceState)" }
// observe preventScreenshots setting and react to changes
lifecycleScope.launch {
// this flow doesn't change when we are paused, so we keep collecting it
@@ -44,6 +50,7 @@ class MainActivity : AppCompatActivity() {
setContent {
Main {
// inform OnNewIntentListeners about the initial intent (otherwise would be missed)
log.info { "Passing initial intent: $intent" }
if (intent != null) {
onNewIntent(intent)
// set intent to null to avoid re-processing on configuration changes
@@ -58,4 +65,17 @@ class MainActivity : AppCompatActivity() {
requestPermissionLauncher.launchSafe(POST_NOTIFICATIONS)
}
}
override fun onNewIntent(intent: Intent, caller: ComponentCaller) {
log.info {
val callerInfo = if (SDK_INT >= 35) caller.`package` else caller.toString()
"onNewIntent: $intent, caller: $callerInfo"
}
// if the app got killed and restarted via an intent,
// this may run before the IntentListener is attached, so it would miss this intent
if (SDK_INT >= 35) setIntent(intent, caller) else setIntent(intent)
// calling super seems to be needed, so the IntentListener gets informed
super.onNewIntent(intent, caller)
}
}

View File

@@ -26,8 +26,8 @@ constructor(private val settingsManager: SettingsManager, private val cache: Dns
}
/**
* these methods provide a way to pre-load the cache with values from other sources such as
* the mirror info from the index, further reducing the needs to do DNS lookups
* these methods provide a way to preload the cache with values from other sources such as the
* mirror info from the index, further reducing the needs to do DNS lookups
*/
fun populateCacheWithStrings(hostname: String, ipv4List: List<String>, ipv6List: List<String>) {
val ipv4ConvertedList = stringListToIpList(ipv4List)
@@ -35,7 +35,11 @@ constructor(private val settingsManager: SettingsManager, private val cache: Dns
populateCacheWithIps(hostname, ipv4ConvertedList, ipv6ConvertedList)
}
fun populateCacheWithIps(hostname: String, ipv4List: List<InetAddress>, ipv6List: List<InetAddress>) {
fun populateCacheWithIps(
hostname: String,
ipv4List: List<InetAddress>,
ipv6List: List<InetAddress>,
) {
// at this time, the DNS cache only supports a single collection because that's what the DNS
// lookup method returns. since these values are being inserted manually, it's possible that
// the cache might end up with values that wouldn't have been returned by the lookup method.
@@ -55,12 +59,12 @@ constructor(private val settingsManager: SettingsManager, private val cache: Dns
try {
InetAddress.getByName(it)
} catch (e: UnknownHostException) {
log.warn { "Unexpected format for IP address: $it" }
log.error(e) { "Unexpected format for IP address: $it" }
null
}
}
} catch (e: Exception) {
log.warn { "Failed to parse list of IP addresses, returning empty list" }
log.error(e) { "Failed to parse list of IP addresses, returning empty list" }
return emptyList()
}
}

View File

@@ -9,11 +9,14 @@ import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_METERED
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.update
import org.fdroid.settings.SettingsConstants.AutoUpdateValues
import kotlinx.coroutines.launch
import org.fdroid.settings.SettingsManager
import org.fdroid.utils.IoDispatcher
@Singleton
class NetworkMonitor
@@ -21,18 +24,19 @@ class NetworkMonitor
constructor(
@param:ApplicationContext private val context: Context,
private val settingsManager: SettingsManager,
@param:IoDispatcher private val coroutineScope: CoroutineScope,
) : ConnectivityManager.NetworkCallback() {
private val connectivityManager =
context.getSystemService(ConnectivityManager::class.java) as ConnectivityManager
private val neverMetered
get() = settingsManager.autoUpdateApps == AutoUpdateValues.Always
private val warnWhenMetered
get() = settingsManager.warnWhenMeteredFlow.value
private val _networkState =
MutableStateFlow(
connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)?.let {
NetworkState(it, neverMetered)
} ?: NetworkState(isOnline = false, isMetered = false)
NetworkState(it, warnWhenMetered)
} ?: NetworkState(isOnline = false, isMetered = false, warnWhenMetered = warnWhenMetered)
)
val networkState = _networkState.asStateFlow()
@@ -43,24 +47,41 @@ constructor(
* we are doing instead is simpler and seems to work better.
*/
connectivityManager.registerDefaultNetworkCallback(this)
// observe the setting for whether to warn when on a metered network
coroutineScope.launch {
// drop first emission as that is the initial value, and we only want to react to changes
settingsManager.warnWhenMeteredFlow.drop(1).collect { warnWhenMetered ->
if (!warnWhenMetered) {
_networkState.update { it.copy(warnWhenMetered = false) }
}
}
}
}
override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
_networkState.update { NetworkState(networkCapabilities, neverMetered) }
_networkState.update { NetworkState(networkCapabilities, warnWhenMetered) }
}
override fun onLost(network: Network) {
_networkState.update { NetworkState(isOnline = false, isMetered = false) }
_networkState.update {
NetworkState(isOnline = false, isMetered = false, warnWhenMetered = warnWhenMetered)
}
}
}
data class NetworkState(val isOnline: Boolean, val isMetered: Boolean) {
data class NetworkState(
val isOnline: Boolean,
private val isMetered: Boolean,
val warnWhenMetered: Boolean = true,
) {
val showWarningDialog: Boolean = isMetered && warnWhenMetered
constructor(
networkCapabilities: NetworkCapabilities,
neverMetered: Boolean,
warnWhenMetered: Boolean,
) : this(
isOnline = networkCapabilities.hasCapability(NET_CAPABILITY_INTERNET),
isMetered =
if (neverMetered) false else !networkCapabilities.hasCapability(NET_CAPABILITY_NOT_METERED),
isMetered = !networkCapabilities.hasCapability(NET_CAPABILITY_NOT_METERED),
warnWhenMetered = warnWhenMetered,
)
}

View File

@@ -25,6 +25,7 @@ import androidx.core.content.ContextCompat.registerReceiver
import androidx.core.os.LocaleListCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
@@ -122,7 +123,7 @@ constructor(
app: AppMetadata,
icon: Bitmap?,
canRequestUserConfirmationNow: Boolean,
): PreApprovalResult = suspendCancellableCoroutine { cont ->
): PreApprovalResult = safeSuspendCoroutine { cont ->
val params = getSessionParams(app.packageName)
val sessionId = installer.createSession(params)
log.info { "Opened session $sessionId for ${app.packageName}" }
@@ -133,7 +134,7 @@ constructor(
when (status) {
PackageInstaller.STATUS_SUCCESS -> {
cont.resume(PreApprovalResult.Success(sessionId))
context.unregisterReceiver(this)
unregisterReceiver(this)
}
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
val flags = FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE
@@ -149,13 +150,13 @@ constructor(
log.error(e) { "Error sending pre-approval intent: " }
val s = PreApprovalResult.UserConfirmationRequired(sessionId, pendingIntent)
cont.resume(s)
context.unregisterReceiver(this)
unregisterReceiver(this)
}
} else {
log.info { "Can not ask pre-approval for ${app.packageName}: $intent" }
val s = PreApprovalResult.UserConfirmationRequired(sessionId, pendingIntent)
cont.resume(s)
context.unregisterReceiver(this)
unregisterReceiver(this)
}
}
else -> {
@@ -166,14 +167,14 @@ constructor(
else -> PreApprovalResult.Error(msg)
}
cont.resume(result)
context.unregisterReceiver(this)
unregisterReceiver(this)
}
}
}
registerReceiver(context, receiver, IntentFilter(ACTION_INSTALL), RECEIVER_NOT_EXPORTED)
cont.invokeOnCancellation {
log.info { "Pre-approval for ${app.packageName} cancelled." }
context.unregisterReceiver(receiver)
unregisterReceiver(receiver)
}
installer.openSession(sessionId).use { session ->
@@ -197,7 +198,7 @@ constructor(
packageName: String,
state: InstallStateWithInfo,
apkFile: File,
): InstallState = suspendCancellableCoroutine { cont ->
): InstallState = safeSuspendCoroutine { cont ->
val size = apkFile.length()
log.info { "Installing ${apkFile.name} with size $size bytes" }
@@ -213,12 +214,12 @@ constructor(
log.error(e) { "Error when creating session: " }
val s = InstallState.Error("${e::class.java.simpleName} ${e.message}", state)
cont.resume(s)
return@suspendCancellableCoroutine
return@safeSuspendCoroutine
}
// set-up receiver for install result
val receiver =
receiverFactory.create(sessionId) { status, intent, msg ->
context.unregisterReceiver(this)
unregisterReceiver(this)
when (status) {
PackageInstaller.STATUS_SUCCESS -> {
val newState =
@@ -278,7 +279,7 @@ constructor(
registerReceiver(context, receiver, IntentFilter(ACTION_INSTALL), RECEIVER_NOT_EXPORTED)
cont.invokeOnCancellation {
log.info { "App installation was cancelled, unregistering broadcast receiver..." }
context.unregisterReceiver(receiver)
unregisterReceiver(receiver)
try {
installer.abandonSession(sessionId)
} catch (e: SecurityException) {
@@ -306,11 +307,11 @@ constructor(
}
suspend fun requestUserConfirmation(state: InstallConfirmationState): InstallState =
suspendCancellableCoroutine { cont ->
safeSuspendCoroutine { cont ->
val isPreApproval = state is InstallState.PreApprovalConfirmationNeeded
val receiver =
receiverFactory.create(state.sessionId) { status, _, msg ->
context.unregisterReceiver(this)
unregisterReceiver(this)
when (status) {
PackageInstaller.STATUS_SUCCESS -> {
val newState =
@@ -346,12 +347,12 @@ constructor(
}
}
registerReceiver(context, receiver, IntentFilter(ACTION_INSTALL), RECEIVER_NOT_EXPORTED)
cont.invokeOnCancellation { context.unregisterReceiver(receiver) }
cont.invokeOnCancellation { unregisterReceiver(receiver) }
try {
state.intent.send()
} catch (e: Exception) {
log.error(e) { "Error sending user confirmation intent: " }
context.unregisterReceiver(receiver)
unregisterReceiver(receiver)
cont.resume(InstallState.Error("${e::class.java.simpleName} ${e.message}", state))
}
}
@@ -423,4 +424,47 @@ constructor(
val pendingIntent = PendingIntent.getBroadcast(context, sessionId, broadcastIntent, flags)
return pendingIntent.intentSender
}
private fun unregisterReceiver(receiver: InstallBroadcastReceiver) {
try {
context.unregisterReceiver(receiver)
} catch (e: IllegalArgumentException) {
// Duplicate callbacks can race with unregistration
log.debug(e) { "Receiver already unregistered" }
}
}
/**
* A helper function to create a suspend function that can be resumed only once. This shouldn't be
* needed, but we have seen crash reports where we received [PackageInstaller.STATUS_SUCCESS] and
* then [PackageInstaller.STATUS_FAILURE_ABORTED] for the same sessionId.
*/
private suspend fun <T> safeSuspendCoroutine(block: (SafeContinuation<T>) -> Unit): T =
suspendCancellableCoroutine { continuation ->
val safeCont =
object : SafeContinuation<T> {
private val isCompleted = AtomicBoolean(false)
private var resumedValue: T? = null
override fun resume(value: T) {
if (isCompleted.compareAndSet(false, true)) {
resumedValue = value
continuation.resume(value)
} else {
log.warn { "Already resumed with $resumedValue, not trying to resume with $value" }
}
}
override fun invokeOnCancellation(handler: () -> Unit) {
continuation.invokeOnCancellation { handler() }
}
}
block(safeCont)
}
private interface SafeContinuation<T> {
fun resume(value: T)
fun invokeOnCancellation(handler: () -> Unit)
}
}

View File

@@ -127,6 +127,7 @@ constructor(
}
try {
if (canStartForegroundService(applicationContext)) setForeground(getForegroundInfo())
else log.info { "Not using foreground service" }
} catch (e: Exception) {
log.error(e) { "Error while running setForeground: " }
}

View File

@@ -6,6 +6,9 @@ import java.text.Normalizer.Form.NFKD
object SearchHelper {
private val normalizerRegex = "\\p{M}".toRegex()
fun CharSequence.isSearchable(): Boolean =
isNotBlank() && (length >= 3 || any { Character.isIdeographic(it.code) })
/** Normalizes the string by removing any diacritics that may appear. */
fun String.normalize(): String {
if (Normalizer.isNormalized(this, NFKD)) return this

View File

@@ -28,16 +28,13 @@ import org.fdroid.download.PackageName
import org.fdroid.download.getImageModel
import org.fdroid.index.RepoManager
import org.fdroid.install.InstalledAppsCache
import org.fdroid.search.SearchHelper.normalize
import org.fdroid.settings.SettingsManager
import org.fdroid.ui.categories.CategoryItem
import org.fdroid.ui.lists.AppListItem
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
@@ -55,16 +52,20 @@ constructor(
private val collator = Collator.getInstance(Locale.getDefault())
private val _searchResults = MutableStateFlow<SearchResults?>(null)
private val _savedSearches = MutableStateFlow<List<SavedSearch>?>(null)
private val categories =
private var searchJob: SearchJob? = null
val categories =
db.getRepositoryDao().getLiveCategories().asFlow().map { categories ->
categories
.map { category ->
CategoryItem(id = category.id, name = category.getName(localeList) ?: "Unknown Category")
CategoryItem(
id = category.id,
name = category.getName(localeList) ?: "Unknown Category",
description = category.getDescription(localeList),
)
}
.sortedWith { c1, c2 -> collator.compare(c1.name, c2.name) }
}
private var searchJob: SearchJob? = null
val searchResults = _searchResults.asStateFlow()
val savedSearches = _savedSearches.asStateFlow()
@@ -116,7 +117,9 @@ constructor(
val timedCategories = measureTimedValue {
categories.first().filter {
// normalization removed diacritics, so searches without them work
it.name.normalize().contains(sanitized.normalize(), ignoreCase = true)
val normalized = sanitized.normalize()
it.name.normalize().contains(normalized, ignoreCase = true) ||
(it.description?.normalize()?.contains(normalized, ignoreCase = true) ?: false)
}
}
_searchResults.value = SearchResults(timedApps.value, timedCategories.value)

View File

@@ -18,14 +18,19 @@ class OnboardingManager
constructor(@param:ApplicationContext private val context: Context) {
private companion object {
const val KEY_MY_APPS_UPDATES = "myAppsUpdates"
const val KEY_FILTER = "appFilter"
const val KEY_REPO_LIST = "repoList"
const val KEY_REPO_DETAILS = "repoDetails"
const val KEY_APP_ISSUE_HINT = "appIssueHint"
const val KEY_ANTI_FEATURES = "antiFeatures"
}
private val prefs = context.getSharedPreferences("onboarding", MODE_PRIVATE)
private val _showMyAppsUpdatesHint = Onboarding(KEY_MY_APPS_UPDATES, prefs)
val showMyAppsUpdatesHint = _showMyAppsUpdatesHint.flow
private val _showFilterOnboarding = Onboarding(KEY_FILTER, prefs)
val showFilterOnboarding = _showFilterOnboarding.flow
@@ -38,6 +43,13 @@ constructor(@param:ApplicationContext private val context: Context) {
private val _showAppIssueHint = Onboarding(KEY_APP_ISSUE_HINT, prefs)
val showAppIssueHint = _showAppIssueHint.flow
private val _showAntiFeaturesOnboarding = Onboarding(KEY_ANTI_FEATURES, prefs)
val showAntiFeaturesOnboarding = _showAntiFeaturesOnboarding.flow
fun onMyAppsUpdateHintSeen() {
_showMyAppsUpdatesHint.onSeen(prefs)
}
fun onFilterOnboardingSeen() {
_showFilterOnboarding.onSeen(prefs)
}
@@ -53,6 +65,10 @@ constructor(@param:ApplicationContext private val context: Context) {
fun onAppIssueHintSeen() {
_showAppIssueHint.onSeen(prefs)
}
fun onAntiFeaturesOnboardingSeen() {
_showAntiFeaturesOnboarding.onSeen(prefs)
}
}
private data class Onboarding(val key: String, private val _flow: MutableStateFlow<Boolean>) {

View File

@@ -55,6 +55,12 @@ object SettingsConstants {
const val PREF_KEY_PREVENT_SCREENSHOTS = "preventScreenshots"
const val PREF_DEFAULT_PREVENT_SCREENSHOTS = false
const val PREF_KEY_WARN_WHEN_METERED = "warnWhenMetered"
const val PREF_DEFAULT_WARN_WHEN_METERED = true
const val PREF_KEY_SHOW_SEARCH_KEYBOARD = "showSearchKeyboard"
const val PREF_DEFAULT_SHOW_SEARCH_KEYBOARD = false
const val PREF_KEY_SHOW_INCOMPATIBLE = "incompatibleVersions"
const val PREF_DEFAULT_SHOW_INCOMPATIBLE = true

View File

@@ -33,7 +33,9 @@ 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_SHOW_SEARCH_KEYBOARD
import org.fdroid.settings.SettingsConstants.PREF_DEFAULT_THEME
import org.fdroid.settings.SettingsConstants.PREF_DEFAULT_WARN_WHEN_METERED
import org.fdroid.settings.SettingsConstants.PREF_DNS_CACHE
import org.fdroid.settings.SettingsConstants.PREF_DNS_CACHE_DEFAULT
import org.fdroid.settings.SettingsConstants.PREF_KEY_APP_LIST_SORT_ORDER
@@ -49,7 +51,9 @@ 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_SHOW_SEARCH_KEYBOARD
import org.fdroid.settings.SettingsConstants.PREF_KEY_THEME
import org.fdroid.settings.SettingsConstants.PREF_KEY_WARN_WHEN_METERED
import org.fdroid.settings.SettingsConstants.PREF_USE_DNS_CACHE
import org.fdroid.settings.SettingsConstants.PREF_USE_DNS_CACHE_DEFAULT
import org.fdroid.settings.SettingsConstants.getAppListSortOrder
@@ -188,6 +192,14 @@ class SettingsManager @Inject constructor(@param:ApplicationContext private val
return prefs.edit { putString(PREF_DNS_CACHE, value) }
}
private val _warnWhenMeteredFlow =
MutableStateFlow(prefs.getBoolean(PREF_KEY_WARN_WHEN_METERED, PREF_DEFAULT_WARN_WHEN_METERED))
val warnWhenMeteredFlow = _warnWhenMeteredFlow.asStateFlow()
private val _showSearchKeyboardFlow =
MutableStateFlow(prefs.getBoolean(PREF_KEY_SHOW_SEARCH_KEYBOARD, PREF_DEFAULT_SHOW_SEARCH_KEYBOARD))
val showSearchKeyboardFlow = _showSearchKeyboardFlow.asStateFlow()
val filterIncompatible: Boolean
get() = !prefs.getBoolean(PREF_KEY_SHOW_INCOMPATIBLE, PREF_DEFAULT_SHOW_INCOMPATIBLE)
@@ -206,6 +218,16 @@ class SettingsManager @Inject constructor(@param:ApplicationContext private val
prefs.edit { putString(PREF_KEY_MY_APPS_SORT_ORDER, value.toSettings()) }
}
fun onDontWarnOnMeteredNetwork() {
prefs.edit { putBoolean(PREF_KEY_WARN_WHEN_METERED, false) }
_warnWhenMeteredFlow.update { false }
}
fun setShowSearchKeyboard(show: Boolean) {
prefs.edit { putBoolean(PREF_KEY_SHOW_SEARCH_KEYBOARD, show) }
_showSearchKeyboardFlow.update { show }
}
fun saveAppListFilter(sortOrder: AppListSortOrder, filterIncompatible: Boolean) {
prefs.edit {
putBoolean(PREF_KEY_SHOW_INCOMPATIBLE, !filterIncompatible)

View File

@@ -8,14 +8,19 @@ import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective
import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import org.fdroid.search.SavedSearch
import org.fdroid.ui.apps.myAppsEntry
import org.fdroid.ui.categories.CategoryItem
import org.fdroid.ui.details.NoAppSelected
import org.fdroid.ui.details.appDetailsEntry
import org.fdroid.ui.discover.discoverEntry
@@ -32,6 +37,9 @@ import org.fdroid.ui.navigation.toEntries
import org.fdroid.ui.navigation.topLevelRoutes
import org.fdroid.ui.repositories.repoEntry
import org.fdroid.ui.search.GlobalSearch
import org.fdroid.ui.search.SearchActions
import org.fdroid.ui.search.SearchInfo
import org.fdroid.ui.search.SearchResults
import org.fdroid.ui.search.SearchViewModel
import org.fdroid.ui.settings.Settings
import org.fdroid.ui.settings.SettingsViewModel
@@ -57,6 +65,7 @@ fun Main(onListeningForIntent: () -> Unit = {}) {
calculatePaneScaffoldDirective(windowAdaptiveInfo).copy(horizontalPartitionSpacerSize = 2.dp)
}
val isBigScreen = directive.maxHorizontalPartitions > 1
var showSearchKeyboard by remember { mutableStateOf(false) }
val entryProvider: (NavKey) -> NavEntry<NavKey> = entryProvider {
discoverEntry(navigator)
@@ -69,13 +78,25 @@ fun Main(onListeningForIntent: () -> Unit = {}) {
) {
val viewModel = hiltViewModel<SearchViewModel>()
GlobalSearch(
searchResults = viewModel.searchResults.collectAsStateWithLifecycle().value,
savedSearches = viewModel.savedSearchesFlow.collectAsStateWithLifecycle().value,
onSearch = viewModel::search,
onClearSavedSearches = viewModel::onClearSearchHistory,
info =
object : SearchInfo {
override val searchResults: SearchResults? =
viewModel.searchResults.collectAsStateWithLifecycle().value
override val savedSearches: List<SavedSearch>? =
viewModel.savedSearchesFlow.collectAsStateWithLifecycle().value
override val categories: List<CategoryItem>? =
viewModel.categories.collectAsStateWithLifecycle(null).value
override val autoShowKeyboard: Boolean =
viewModel.autoShowKeyboard.collectAsStateWithLifecycle().value
override val showKeyboard: Boolean = showSearchKeyboard
override val actions: SearchActions = viewModel
override fun onKeyboardShown() {
showSearchKeyboard = false
}
},
onNav = { navKey -> navigator.navigate(navKey) },
onBack = { navigator.goBack() },
onSearchCleared = viewModel::onSearchCleared,
)
}
entry(NavigationKey.Settings) {
@@ -119,7 +140,17 @@ fun Main(onListeningForIntent: () -> Unit = {}) {
isBigScreen = isBigScreen,
showBottomBar = !isBigScreen && navigator.last is MainNavKey,
currentNavKey = navigationState.topLevelRoute,
onNav = navigator::navigate,
onNav = {
// don't navigate, if we are already on the selected top level route
if (navigationState.topLevelRoute == it) {
if (navigationState.topLevelRoute == NavigationKey.Search) {
// double tapping the search brings up the keyboard
showSearchKeyboard = true
}
} else {
navigator.navigate(it)
}
},
onBack = navigator::goBack,
)
}

View File

@@ -7,9 +7,9 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ManageSearch
import androidx.compose.material.icons.automirrored.filled.Sort
import androidx.compose.material.icons.filled.AccessTime
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SortByAlpha
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
@@ -96,8 +96,8 @@ fun MyApps(
title = { Text(stringResource(R.string.menu_apps_my)) },
actions = {
TopAppBarButton(
imageVector = Icons.Filled.Search,
contentDescription = stringResource(R.string.menu_search),
imageVector = Icons.AutoMirrored.Default.ManageSearch,
contentDescription = stringResource(R.string.search_my_apps_hint),
onClick = { searchActive = true },
)
var sortByMenuExpanded by remember { mutableStateOf(false) }
@@ -186,10 +186,8 @@ fun MyAppsLoadingPreview() {
val model =
MyAppsModel(
installingApps = emptyList(),
appUpdates = null,
installedApps = null,
showUpdatesHint = false,
showAppIssueHint = false,
sortOrder = AppListSortOrder.NAME,
networkState = NetworkState(isOnline = false, isMetered = false),
)
FDroidContent {
@@ -223,11 +221,11 @@ fun MyAppsEmptyPreview() {
FDroidContent {
val model =
MyAppsModel(
installingApps = emptyList(),
appUpdates = emptyList(),
installingApps = emptyList(),
installedApps = emptyList(),
showUpdatesHint = false,
showAppIssueHint = false,
sortOrder = AppListSortOrder.NAME,
networkState = NetworkState(isOnline = false, isMetered = false),
)
MyApps(

View File

@@ -15,9 +15,11 @@ data class MyAppsModel(
val installingApps: List<InstallingAppItem>,
val appsWithIssue: List<AppWithIssueItem>? = null,
val installedApps: List<InstalledAppItem>? = null,
val showUpdatesHint: Boolean,
val showAppIssueHint: Boolean,
val sortOrder: AppListSortOrder = AppListSortOrder.NAME,
val networkState: NetworkState,
val isSearching: Boolean = false,
val appUpdatesBytes: Long? = null,
)
@@ -32,7 +34,11 @@ interface MyAppsActions {
fun ignoreAppIssue(item: AppWithIssueItem)
fun onUpdatesHintSeen()
fun onAppIssueHintSeen()
fun onNotWarnWhenMetered()
fun exportInstalledApps()
}

View File

@@ -37,6 +37,7 @@ import org.fdroid.database.NotAvailable
import org.fdroid.ui.FDroidContent
import org.fdroid.ui.utils.MeteredConnectionDialog
import org.fdroid.ui.utils.OfflineBar
import org.fdroid.ui.utils.OnboardingCard
import org.fdroid.ui.utils.getMyAppsInfo
import org.fdroid.ui.utils.myAppsModel
@@ -74,7 +75,22 @@ fun MyAppsList(
modifier.then(if (currentPackageName == null) Modifier else Modifier.selectableGroup()),
) {
// Updates header with Update all button
if (!updatableApps.isNullOrEmpty()) {
if (
updatableApps != null &&
updatableApps.isEmpty() &&
myAppsInfo.model.showUpdatesHint &&
!myAppsInfo.model.isSearching
) {
item(key = "A-hint", contentType = "hint") {
OnboardingCard(
title = stringResource(R.string.apps_updates_hint_title),
message = stringResource(R.string.apps_updates_hint_text),
buttonText = stringResource(R.string.hide),
onGotIt = { myAppsInfo.actions.onUpdatesHintSeen() },
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
} else if (!updatableApps.isNullOrEmpty()) {
if (!myAppsInfo.model.networkState.isOnline) {
item(key = "OfflineBar", contentType = "offlineBar") { OfflineBar() }
}
@@ -92,7 +108,7 @@ fun MyAppsList(
myAppsInfo.actions.updateAll()
showUpdateAllButton = false
}
if (myAppsInfo.model.networkState.isMetered) {
if (myAppsInfo.model.networkState.showWarningDialog) {
showMeteredDialog = installLambda
} else {
installLambda()
@@ -211,12 +227,8 @@ fun MyAppsList(
NotAvailableDialog(app.packageName) { showNotAvailableDialog = false }
}
}
// Installed apps header (only show when we have non-empty lists above)
val aboveNonEmpty =
installingApps.isNotEmpty() ||
!updatableApps.isNullOrEmpty() ||
!appsWithIssue.isNullOrEmpty()
if (aboveNonEmpty && !installedApps.isNullOrEmpty()) {
// List of installed apps
if (!installedApps.isNullOrEmpty()) {
item(key = "D", contentType = "header") {
Text(
text = stringResource(R.string.installed_apps__activity_title),
@@ -224,9 +236,6 @@ fun MyAppsList(
modifier = Modifier.padding(16.dp),
)
}
}
// List of installed apps
if (installedApps != null)
items(items = installedApps, key = { it.packageName }, contentType = { "D" }) { app ->
val isSelected = app.packageName == currentPackageName
val interactionModifier =
@@ -241,12 +250,16 @@ fun MyAppsList(
val modifier = Modifier.animateItem().then(interactionModifier)
InstalledAppRow(app, isSelected, modifier)
}
}
}
val meteredLambda = showMeteredDialog
if (meteredLambda != null)
MeteredConnectionDialog(
numBytes = myAppsInfo.model.appUpdatesBytes,
onConfirm = { meteredLambda() },
onConfirm = { notWarnWhenMetered ->
if (notWarnWhenMetered) myAppsInfo.actions.onNotWarnWhenMetered()
meteredLambda()
},
onDismiss = { showMeteredDialog = null },
)
}
@@ -264,3 +277,24 @@ private fun MyAppsListPreview() {
)
}
}
@Preview
@Composable
@RestrictTo(RestrictTo.Scope.TESTS)
private fun Preview() {
FDroidContent {
MyApps(
myAppsInfo =
getMyAppsInfo(
myAppsModel.copy(
appUpdates = emptyList(),
installingApps = emptyList(),
appsWithIssue = emptyList(),
)
),
currentPackageName = null,
onAppItemClick = {},
onNav = {},
)
}
}

View File

@@ -21,6 +21,7 @@ fun MyAppsPresenter(
appInstallStatesFlow: StateFlow<Map<String, InstallState>>,
appsWithIssuesFlow: StateFlow<List<AppWithIssueItem>?>,
installedAppsFlow: Flow<List<InstalledAppItem>>,
showUpdatesHintFlow: StateFlow<Boolean>,
showAppIssueHintFlow: StateFlow<Boolean>,
searchQueryFlow: StateFlow<String>,
sortOrderFlow: StateFlow<AppListSortOrder>,
@@ -36,52 +37,48 @@ fun MyAppsPresenter(
// we want to show apps currently installing/updating even if they have updates available,
// so we need to handle those first
val installingApps =
appInstallStates.mapNotNull { (packageName, state) ->
if (state is InstallStateWithInfo) {
val keep =
searchQuery.isBlank() || state.name.normalize().contains(searchQuery, ignoreCase = true)
if (keep) {
processedPackageNames.add(packageName)
InstallingAppItem(packageName, state)
} else null
} else {
null
}
}
val updates =
appUpdates?.filter {
val installingApps = appInstallStates.mapNotNull { (packageName, state) ->
if (state is InstallStateWithInfo) {
val keep =
if (searchQuery.isBlank()) {
it.packageName !in processedPackageNames
} else {
it.packageName !in processedPackageNames &&
it.name.normalize().contains(searchQuery, ignoreCase = true)
}
if (keep) processedPackageNames.add(it.packageName)
keep
searchQuery.isBlank() || state.name.normalize().contains(searchQuery, ignoreCase = true)
if (keep) {
processedPackageNames.add(packageName)
InstallingAppItem(packageName, state)
} else null
} else {
null
}
val withIssues =
appsWithIssues?.filter {
val keep =
if (searchQuery.isBlank()) {
it.packageName !in processedPackageNames
} else {
it.packageName !in processedPackageNames &&
it.name.normalize().contains(searchQuery, ignoreCase = true)
}
if (keep) processedPackageNames.add(it.packageName)
keep
}
val installed =
installedApps?.filter {
}
val updates = appUpdates?.filter {
val keep =
if (searchQuery.isBlank()) {
it.packageName !in processedPackageNames
} else {
it.packageName !in processedPackageNames &&
it.name.normalize().contains(searchQuery, ignoreCase = true)
}
if (keep) processedPackageNames.add(it.packageName)
keep
}
val withIssues = appsWithIssues?.filter {
val keep =
if (searchQuery.isBlank()) {
it.packageName !in processedPackageNames
} else {
it.packageName !in processedPackageNames &&
it.name.normalize().contains(searchQuery, ignoreCase = true)
}
if (keep) processedPackageNames.add(it.packageName)
keep
}
val installed = installedApps?.filter {
if (searchQuery.isBlank()) {
it.packageName !in processedPackageNames
} else {
it.packageName !in processedPackageNames &&
it.name.normalize().contains(searchQuery, ignoreCase = true)
}
}
var updateBytes: Long? = 0L
updates?.forEach {
val size = it.update.size
@@ -102,9 +99,13 @@ fun MyAppsPresenter(
appUpdates = updates?.sort(sortOrder),
appsWithIssue = withIssues?.sort(sortOrder),
installedApps = installed?.sort(sortOrder),
showUpdatesHint =
showUpdatesHintFlow.collectAsState().value &&
installingApps.none { it.installState.showProgress },
showAppIssueHint = showAppIssueHintFlow.collectAsState().value,
sortOrder = sortOrder,
networkState = networkStateFlow.collectAsState().value,
isSearching = searchQuery.isNotBlank(),
appUpdatesBytes = updateBytes,
)
}

View File

@@ -99,6 +99,7 @@ constructor(
appInstallStatesFlow = appInstallManager.appInstallStates,
appsWithIssuesFlow = updatesManager.appsWithIssues,
installedAppsFlow = installedAppItems,
showUpdatesHintFlow = onboardingManager.showMyAppsUpdatesHint,
showAppIssueHintFlow = onboardingManager.showAppIssueHint,
searchQueryFlow = searchQuery,
sortOrderFlow = sortOrder,
@@ -139,8 +140,14 @@ constructor(
updatesManager.loadUpdates()
}
override fun onUpdatesHintSeen() = onboardingManager.onMyAppsUpdateHintSeen()
override fun onAppIssueHintSeen() = onboardingManager.onAppIssueHintSeen()
override fun onNotWarnWhenMetered() {
settingsManager.onDontWarnOnMeteredNetwork()
}
override fun exportInstalledApps() {
scope.launch {
val stringBuilder =

View File

@@ -1,19 +1,96 @@
package org.fdroid.ui.categories
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Category
import androidx.compose.material.icons.filled.Construction
import androidx.compose.material.icons.filled.Factory
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.Forum
import androidx.compose.material.icons.filled.NetworkCheck
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.SdStorage
import androidx.compose.material.icons.filled.VideogameAsset
import androidx.compose.material.icons.filled.Wallet
import androidx.compose.ui.graphics.vector.ImageVector
import org.fdroid.R
data class CategoryGroup(val id: String, @get:StringRes val name: Int)
data class CategoryGroup(
val id: String,
@get:StringRes val name: Int,
@get:StringRes val summary: Int,
val imageVector: ImageVector,
)
object CategoryGroups {
val productivity = CategoryGroup("productivity", R.string.category_group_productivity)
val tools = CategoryGroup("tools", R.string.category_group_tools)
val wallets = CategoryGroup("wallets", R.string.category_group_wallets)
val media = CategoryGroup("media", R.string.category_group_media)
val communication = CategoryGroup("communication", R.string.category_group_communication)
val device = CategoryGroup("device", R.string.category_group_device)
val network = CategoryGroup("network", R.string.category_group_network)
val storage = CategoryGroup("storage", R.string.category_group_storage)
val interests = CategoryGroup("interests", R.string.category_group_interests)
val misc = CategoryGroup("misc", R.string.category_group_misc)
val productivity =
CategoryGroup(
id = "productivity",
name = R.string.category_group_productivity,
summary = R.string.category_group_summary_productivity,
imageVector = Icons.Default.Factory,
)
val tools =
CategoryGroup(
id = "tools",
name = R.string.category_group_tools,
summary = R.string.category_group_summary_tools,
imageVector = Icons.Default.Construction,
)
val wallets =
CategoryGroup(
id = "wallets",
name = R.string.category_group_wallets,
summary = R.string.category_group_summary_wallets,
imageVector = Icons.Default.Wallet,
)
val media =
CategoryGroup(
id = "media",
name = R.string.category_group_media,
summary = R.string.category_group_summary_media,
imageVector = Icons.Default.VideogameAsset,
)
val communication =
CategoryGroup(
id = "communication",
name = R.string.category_group_communication,
summary = R.string.category_group_summary_communication,
imageVector = Icons.Default.Forum,
)
val device =
CategoryGroup(
id = "device",
name = R.string.category_group_device,
summary = R.string.category_group_summary_device,
imageVector = Icons.Default.PhoneAndroid,
)
val network =
CategoryGroup(
id = "network",
name = R.string.category_group_network,
summary = R.string.category_group_summary_network,
imageVector = Icons.Default.NetworkCheck,
)
val storage =
CategoryGroup(
id = "storage",
name = R.string.category_group_storage,
summary = R.string.category_group_summary_storage,
imageVector = Icons.Default.SdStorage,
)
val interests =
CategoryGroup(
id = "interests",
name = R.string.category_group_interests,
summary = R.string.category_group_summary_interests,
imageVector = Icons.Default.FavoriteBorder,
)
val misc =
CategoryGroup(
id = "misc",
name = R.string.category_group_misc,
summary = R.string.category_group_summary_misc,
imageVector = Icons.Default.Category,
)
}

View File

@@ -0,0 +1,107 @@
package org.fdroid.ui.categories
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment.Companion.Center
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.navigation3.runtime.NavKey
import org.fdroid.ui.FDroidContent
import org.fdroid.ui.lists.AppListType
import org.fdroid.ui.navigation.NavigationKey
import org.fdroid.ui.utils.ExpandIconArrow
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
fun CategoryGroupRow(
group: CategoryGroup,
categories: List<CategoryItem>,
onNav: (NavKey) -> Unit,
) {
val isPreview = LocalInspectionMode.current
var expanded by rememberSaveable { mutableStateOf(isPreview) }
val onClick = { expanded = !expanded }
ListItem(
leadingContent = {
Box(
contentAlignment = Center,
modifier =
Modifier.size(40.dp)
.clip(MaterialTheme.shapes.small)
.background(MaterialTheme.colorScheme.primaryContainer),
) {
Icon(
imageVector = group.imageVector,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.semantics { hideFromAccessibility() },
)
}
},
supportingContent = {
Text(text = stringResource(group.summary), maxLines = 2, overflow = TextOverflow.Ellipsis)
},
trailingContent = { IconButton(onClick = onClick) { ExpandIconArrow(expanded) } },
onClick = onClick,
) {
Text(text = stringResource(group.name), maxLines = 2, overflow = TextOverflow.Ellipsis)
}
AnimatedVisibility(
visible = expanded,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
ChipFlowRow(modifier = Modifier.padding(8.dp)) {
categories.forEach { categoryItem ->
CategoryChip(
categoryItem = categoryItem,
onClick = {
val type = AppListType.Category(categoryItem.name, categoryItem.id)
val navKey = NavigationKey.AppList(type)
onNav(navKey)
},
)
}
}
}
}
@Composable
@PreviewLightDark
private fun Preview() {
FDroidContent {
val categories =
listOf(
CategoryItem("App Store & Updater", "App Store & Updater"),
CategoryItem("Browser", "Browser"),
CategoryItem("Calendar & Agenda", "Calendar & Agenda"),
)
Column { CategoryGroupRow(CategoryGroups.productivity, categories) {} }
}
}

View File

@@ -13,6 +13,7 @@ import androidx.compose.material.icons.filled.AppBlocking
import androidx.compose.material.icons.filled.Apps
import androidx.compose.material.icons.filled.BatteryChargingFull
import androidx.compose.material.icons.filled.Bookmarks
import androidx.compose.material.icons.filled.BrowserUpdated
import androidx.compose.material.icons.filled.Brush
import androidx.compose.material.icons.filled.Calculate
import androidx.compose.material.icons.filled.CalendarMonth
@@ -74,7 +75,7 @@ import androidx.compose.material.icons.filled.Wallpaper
import androidx.compose.material.icons.filled.WbSunny
import androidx.compose.ui.graphics.vector.ImageVector
data class CategoryItem(val id: String, val name: String) {
data class CategoryItem(val id: String, val name: String, val description: String? = null) {
val imageVector: ImageVector
get() =
when (id) {
@@ -130,6 +131,7 @@ data class CategoryItem(val id: String, val name: String) {
"Reading" -> AutoMirrored.Default.MenuBook
"Recipe Manager" -> Icons.Default.RestaurantMenu
"Religion" -> Icons.Default.Church
"Remote Access" -> Icons.Default.BrowserUpdated
"Remote Controller" -> Icons.Default.SettingsRemote
"Science & Education" -> Icons.Default.Science
"Security" -> Icons.Default.Security
@@ -208,6 +210,7 @@ data class CategoryItem(val id: String, val name: String) {
"Reading" -> CategoryGroups.media
"Recipe Manager" -> CategoryGroups.interests
"Religion" -> CategoryGroups.interests
"Remote Access" -> CategoryGroups.network
"Remote Controller" -> CategoryGroups.tools
"Science & Education" -> CategoryGroups.interests
"Security" -> CategoryGroups.device

View File

@@ -4,33 +4,37 @@ 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.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation3.runtime.NavKey
import org.fdroid.R
import org.fdroid.ui.FDroidContent
import org.fdroid.ui.lists.AppListType
import org.fdroid.ui.navigation.NavigationKey
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
fun CategoryList(
categoryMap: Map<CategoryGroup, List<CategoryItem>>?,
onNav: (NavKey) -> Unit,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(!categoryMap.isNullOrEmpty()) {
Column(modifier = modifier.padding(top = 20.dp)) {
Column(modifier = modifier) {
Text(
text = stringResource(R.string.main_menu__categories),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(bottom = 8.dp, start = 16.dp, end = 16.dp),
style = MaterialTheme.typography.titleMediumEmphasized,
fontSize = 20.sp,
modifier = Modifier.padding(horizontal = 16.dp),
)
// we'll sort the groups here, because before we didn't have the context to get names
val res = LocalResources.current
@@ -39,22 +43,12 @@ fun CategoryList(
val comparator = compareBy<CategoryGroup> { res.getString(it.name) }
categoryMap?.toSortedMap(comparator)
}
sortedMap?.forEach { (group, categories) ->
Text(
text = stringResource(group.name),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.fillMaxWidth().padding(16.dp, 2.dp),
)
ChipFlowRow(modifier = Modifier.padding(start = 16.dp, bottom = 12.dp)) {
categories.forEach { category ->
CategoryChip(
categoryItem = category,
onClick = {
val type = AppListType.Category(category.name, category.id)
val navKey = NavigationKey.AppList(type)
onNav(navKey)
},
)
OutlinedCard(modifier = Modifier.padding(16.dp)) {
// FIXME: A LazyColumn would be better, but we can't use this inside a scrollable column
Column {
sortedMap?.forEach { (group, categories) ->
CategoryGroupRow(group, categories, onNav)
HorizontalDivider(modifier = Modifier.fillMaxWidth())
}
}
}
@@ -62,8 +56,8 @@ fun CategoryList(
}
}
@Preview
@Composable
@PreviewLightDark
fun CategoryListPreview() {
FDroidContent {
val categories =

View File

@@ -37,7 +37,7 @@ fun ChipFlowRowFewItemsPreview() {
CategoryItem("doesn't exist", "Oops"),
)
FDroidContent { ChipFlowRow { categories.map { category -> CategoryChip(category, {}) } } }
FDroidContent { ChipFlowRow { categories.forEach { category -> CategoryChip(category, {}) } } }
}
@Preview
@@ -61,5 +61,5 @@ fun ChipFlowRowManyItemsPreview() {
CategoryItem("Science & Education", "Science & Education"),
)
FDroidContent { ChipFlowRow { categories.map { category -> CategoryChip(category, {}) } } }
FDroidContent { ChipFlowRow { categories.forEach { category -> CategoryChip(category, {}) } } }
}

View File

@@ -15,13 +15,23 @@ import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter.Companion.tint
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.layout.onVisibilityChanged
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.viktormykhailiv.compose.hints.HintAnchorState
import com.viktormykhailiv.compose.hints.hintAnchor
import com.viktormykhailiv.compose.hints.rememberHint
import com.viktormykhailiv.compose.hints.rememberHintAnchorState
import org.fdroid.R
import org.fdroid.ui.FDroidContent
import org.fdroid.ui.utils.AsyncShimmerImage
@@ -30,11 +40,38 @@ import org.fdroid.ui.utils.testApp
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
fun AntiFeatures(antiFeatures: List<AntiFeature>) {
fun AntiFeatures(
antiFeatures: List<AntiFeature>,
hintAnchor: HintAnchorState,
showOnboarding: Boolean,
modifier: Modifier = Modifier,
onShowOnboarding: () -> Unit,
) {
var placed by remember { mutableStateOf(false) }
var visible by remember { mutableStateOf(false) }
ElevatedCard(
colors =
CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.inverseSurface),
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 8.dp)
.hintAnchor(state = hintAnchor, shape = CardDefaults.elevatedShape)
.onPlaced {
if (visible && showOnboarding) {
onShowOnboarding()
} else {
placed = true
}
}
.onVisibilityChanged(minDurationMs = 1000) { isVisible ->
if (isVisible && placed && showOnboarding) {
onShowOnboarding()
} else if (isVisible) {
visible = true
}
}
.padding(8.dp),
) {
ExpandableSection(
icon = rememberVectorPainter(Icons.Default.WarningAmber),
@@ -81,5 +118,7 @@ fun AntiFeatures(antiFeatures: List<AntiFeature>) {
@Preview
@Composable
fun AntiFeaturesPreview() {
FDroidContent { AntiFeatures(testApp.antiFeatures!!) }
val hint = rememberHint {}
val hintAnchor = rememberHintAnchorState(hint)
FDroidContent { AntiFeatures(testApp.antiFeatures!!, hintAnchor, showOnboarding = false) {} }
}

View File

@@ -41,6 +41,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
@@ -66,6 +67,11 @@ import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.core.os.LocaleListCompat
import com.viktormykhailiv.compose.hints.HintProperties
import com.viktormykhailiv.compose.hints.rememberHint
import com.viktormykhailiv.compose.hints.rememberHintAnchorState
import com.viktormykhailiv.compose.hints.rememberHintController
import kotlinx.coroutines.launch
import org.fdroid.LocaleChooser.getBestLocale
import org.fdroid.R
import org.fdroid.install.InstallState
@@ -78,6 +84,8 @@ import org.fdroid.ui.lists.AppListType
import org.fdroid.ui.navigation.NavigationKey
import org.fdroid.ui.utils.BigLoadingIndicator
import org.fdroid.ui.utils.ExpandableSection
import org.fdroid.ui.utils.OnboardingPopupCard
import org.fdroid.ui.utils.getHintOverlayColor
import org.fdroid.ui.utils.testApp
@Composable
@@ -107,6 +115,22 @@ fun AppDetails(
showInstallError = true
}
}
// onboarding hint plumbing
val hintController = rememberHintController(overlay = getHintOverlayColor())
val hint =
rememberHint(HintProperties(dismissOnClickOutside = false)) {
OnboardingPopupCard(
title = stringResource(R.string.app_details_anti_features_title),
message = stringResource(R.string.app_details_anti_features_text),
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
onGotIt = {
item.actions.onAntiFeaturesOnboardingSeen()
hintController.dismiss()
},
)
}
val hintAnchor = rememberHintAnchorState(hint)
val coroutineScope = rememberCoroutineScope()
val scrollState = rememberScrollState()
var size by remember { mutableStateOf(IntSize.Zero) }
Column(
@@ -217,11 +241,17 @@ fun AppDetails(
}
// Anti-features
if (!item.antiFeatures.isNullOrEmpty()) {
AntiFeatures(item.antiFeatures)
AntiFeatures(
antiFeatures = item.antiFeatures,
hintAnchor = hintAnchor,
showOnboarding = item.showAntiFeaturesOnboarding,
) {
coroutineScope.launch { hintController.show(hintAnchor) }
}
}
// Screenshots
if (item.phoneScreenshots.isNotEmpty()) {
Screenshots(item.networkState.isMetered, item.phoneScreenshots)
Screenshots(item.networkState.showWarningDialog, item.phoneScreenshots)
}
// Donate card
if (item.showDonate)

View File

@@ -223,6 +223,7 @@ fun AppDetailsHeader(
Text(
text = stringResource(strRes),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
@@ -236,6 +237,7 @@ fun AppDetailsHeader(
Text(
text = stringResource(R.string.status_downloading, speedStr),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
overflow = TextOverflow.StartEllipsis,
maxLines = 1,
modifier = Modifier.padding(end = 8.dp),
@@ -243,6 +245,7 @@ fun AppDetailsHeader(
Text(
text = stringResource(R.string.status_downloading_remaining, remainingStr),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
@@ -304,7 +307,7 @@ fun AppDetailsHeader(
// button is for either installing or updating
Button(
onClick = {
if (item.networkState.isMetered) {
if (item.networkState.showWarningDialog) {
showMeteredDialog = true
} else {
require(item.suggestedVersion != null) { "suggestedVersion was null" }
@@ -325,7 +328,8 @@ fun AppDetailsHeader(
if (showMeteredDialog)
MeteredConnectionDialog(
numBytes = version?.size,
onConfirm = {
onConfirm = { notWarnWhenMetered ->
if (notWarnWhenMetered) item.actions.onNotWarnWhenMetered()
require(item.suggestedVersion != null) { "suggestedVersion was null" }
item.actions.installAction(item.app, item.suggestedVersion, item.icon)
},

View File

@@ -21,8 +21,8 @@ import org.fdroid.index.RELEASE_CHANNEL_BETA
import org.fdroid.index.v2.PackageVersion
import org.fdroid.install.InstallState
import org.fdroid.install.SessionInstallManager
import org.fdroid.ui.categories.CategoryItem
import org.fdroid.search.SearchHelper.removeZeroWhiteSpace
import org.fdroid.ui.categories.CategoryItem
data class AppDetailsItem(
val app: AppMetadata,
@@ -62,6 +62,7 @@ data class AppDetailsItem(
val appPrefs: AppPrefs? = null,
val whatsNew: String? = null,
val antiFeatures: List<AntiFeature>? = null,
val showAntiFeaturesOnboarding: Boolean = false,
val issue: AppIssue? = null,
val authorHasMoreThanOneApp: Boolean = false,
val proxy: ProxyConfig? = null,
@@ -82,6 +83,7 @@ data class AppDetailsItem(
suggestedVersion: AppVersion?,
possibleUpdate: AppVersion?,
appPrefs: AppPrefs?,
showAntiFeaturesOnboarding: Boolean,
issue: AppIssue?,
authorHasMoreThanOneApp: Boolean,
localeList: LocaleListCompat,
@@ -131,6 +133,7 @@ data class AppDetailsItem(
localeList = localeList,
proxy = proxy,
),
showAntiFeaturesOnboarding = showAntiFeaturesOnboarding,
issue = issue,
authorHasMoreThanOneApp = authorHasMoreThanOneApp,
proxy = proxy,
@@ -239,7 +242,9 @@ data class AppDetailsActions(
val onUninstallResult: (ActivityResult) -> Unit,
val onRepoChanged: (Long) -> Unit,
val onPreferredRepoChanged: (Long) -> Unit,
val onNotWarnWhenMetered: () -> Unit,
val allowBetaVersions: () -> Unit,
val onAntiFeaturesOnboardingSeen: () -> Unit,
val ignoreAllUpdates: (() -> Unit)? = null,
val ignoreThisUpdate: (() -> Unit)? = null,
val shareApk: Intent? = null,

View File

@@ -3,11 +3,10 @@ package org.fdroid.ui.details
import android.app.Application
import android.content.Intent
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.content.pm.PackageManager.GET_SIGNATURES
import androidx.activity.result.ActivityResult
import androidx.annotation.UiThread
import androidx.annotation.WorkerThread
import androidx.core.content.pm.PackageInfoCompat.getLongVersionCode
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.application
import androidx.lifecycle.viewModelScope
@@ -25,9 +24,10 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import mu.KotlinLogging
import org.fdroid.UpdateChecker
import org.fdroid.database.AppMetadata
@@ -40,7 +40,9 @@ import org.fdroid.index.RepoManager
import org.fdroid.index.v2.PackageVersion
import org.fdroid.install.AppInstallManager
import org.fdroid.install.InstallState
import org.fdroid.install.InstalledAppsCache
import org.fdroid.repo.RepoPreLoader
import org.fdroid.settings.OnboardingManager
import org.fdroid.settings.SettingsManager
import org.fdroid.updates.UpdatesManager
import org.fdroid.utils.IoDispatcher
@@ -60,7 +62,9 @@ constructor(
private val updatesManager: UpdatesManager,
private val networkMonitor: NetworkMonitor,
private val settingsManager: SettingsManager,
private val onboardingManager: OnboardingManager,
private val appInstallManager: AppInstallManager,
private val installedAppsCache: InstalledAppsCache,
) : AndroidViewModel(app) {
private val log = KotlinLogging.logger {}
private val packageInfoFlow = MutableStateFlow<AppInfo?>(null)
@@ -82,6 +86,7 @@ constructor(
viewModel = this,
packageInfoFlow = packageInfoFlow,
currentRepoIdFlow = currentRepoIdFlow,
showAntiFeaturesOnboardingFlow = onboardingManager.showAntiFeaturesOnboarding,
appsWithIssuesFlow = updatesManager.appsWithIssues,
networkStateFlow = networkMonitor.networkState,
)
@@ -89,18 +94,20 @@ constructor(
}
init {
viewModelScope.launch(dispatcher) { loadPackageInfoFlow() }
viewModelScope.launch(dispatcher) {
// Collect the cached packageInfo for this app. This way we have a central place
// to react to state changes for installs, updates and uninstalls
installedAppsCache.installedApps
.map { cacheMap -> cacheMap[packageName]?.let { getLongVersionCode(it) } }
.distinctUntilChanged()
.collect { loadPackageInfoFlow() }
}
}
@WorkerThread
private fun loadPackageInfoFlow() {
val packageManager = app.packageManager
val packageInfo =
try {
@Suppress("DEPRECATION") packageManager.getPackageInfo(packageName, GET_SIGNATURES)
} catch (_: PackageManager.NameNotFoundException) {
null
}
val packageInfo = installedAppsCache.installedApps.value[packageName]
packageInfoFlow.value =
if (packageInfo == null) {
AppInfo(packageName)
@@ -123,15 +130,15 @@ constructor(
packageName = packageName,
appMetadata = appMetadata,
version = version,
currentVersionName = packageInfoFlow.value?.packageInfo?.versionName, // TODO
currentVersionName = packageInfoFlow.value?.packageInfo?.versionName,
repo = repoManager.getRepository(appMetadata.repoId) ?: return@launch,
iconModel = iconModel,
canAskPreApprovalNow = true,
)
if (result is InstallState.Installed) {
// to reload packageInfoFlow with fresh packageInfo
loadPackageInfoFlow()
}
log.info { "Install result was: $result" }
// We are not doing anything with that result,
// because an installation could have been triggered via 'Update all' in My Apps as well.
// So updating states need to happen elsewhere.
}
}
@@ -139,11 +146,7 @@ constructor(
fun requestUserConfirmation(installState: InstallState.UserConfirmationNeeded) {
scope.launch(Dispatchers.Main) {
val result = appInstallManager.requestUserConfirmation(packageName, installState)
if (result is InstallState.Installed)
withContext(Dispatchers.Main) {
// to reload packageInfoFlow with fresh packageInfo
loadPackageInfoFlow()
}
log.info { "requestUserConfirmation result was: $result" }
}
}
@@ -164,10 +167,7 @@ constructor(
fun onUninstallResult(activityResult: ActivityResult) {
val name = appDetails.value?.name
val result = appInstallManager.onUninstallResult(packageName, name, activityResult)
if (result is InstallState.Uninstalled) {
// to reload packageInfoFlow with fresh packageInfo
viewModelScope.launch(dispatcher) { loadPackageInfoFlow() }
}
log.info { "Uninstall result was: $result" }
}
@UiThread
@@ -183,6 +183,11 @@ constructor(
}
}
@UiThread
fun onNotWarnWhenMetered() {
settingsManager.onDontWarnOnMeteredNetwork()
}
override fun onCleared() {
log.info { "App details screen left: $packageName" }
appInstallManager.cleanUp(packageName)
@@ -230,6 +235,11 @@ constructor(
}
}
@UiThread
fun onAntiFeaturesOnboardingSeen() {
onboardingManager.onAntiFeaturesOnboardingSeen()
}
@AssistedFactory
interface Factory {
fun create(packageName: String): AppDetailsViewModel

View File

@@ -47,6 +47,7 @@ fun DetailsPresenter(
viewModel: AppDetailsViewModel,
packageInfoFlow: StateFlow<AppInfo?>,
currentRepoIdFlow: StateFlow<Long?>,
showAntiFeaturesOnboardingFlow: StateFlow<Boolean>,
appsWithIssuesFlow: StateFlow<List<AppWithIssueItem>?>,
networkStateFlow: StateFlow<NetworkState>,
): AppDetailsItem? {
@@ -205,7 +206,9 @@ fun DetailsPresenter(
onUninstallResult = viewModel::onUninstallResult,
onRepoChanged = viewModel::onRepoChanged,
onPreferredRepoChanged = viewModel::onPreferredRepoChanged,
onNotWarnWhenMetered = viewModel::onNotWarnWhenMetered,
allowBetaVersions = viewModel::allowBetaUpdates,
onAntiFeaturesOnboardingSeen = viewModel::onAntiFeaturesOnboardingSeen,
ignoreAllUpdates =
if (installedVersionCode == null) {
null
@@ -265,6 +268,7 @@ fun DetailsPresenter(
suggestedVersion = suggestedVersion,
possibleUpdate = possibleUpdate,
appPrefs = appPrefs,
showAntiFeaturesOnboarding = showAntiFeaturesOnboardingFlow.collectAsState().value,
issue = issue,
authorHasMoreThanOneApp = authorHasMoreThanOneApp,
localeList = locales,

View File

@@ -6,6 +6,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
@@ -56,10 +57,11 @@ fun Versions(item: AppDetailsItem, scrollUp: suspend () -> Unit) {
item.versions?.forEach { versionItem ->
Version(
item = versionItem,
isMetered = item.networkState.isMetered,
isMetered = item.networkState.showWarningDialog,
installAction = { version: AppVersion ->
item.actions.installAction(item.app, version, item.icon)
},
onNotWarnWhenMetered = item.actions.onNotWarnWhenMetered,
scrollUp = scrollUp,
)
}
@@ -72,6 +74,7 @@ fun Version(
item: VersionItem,
isMetered: Boolean,
installAction: (AppVersion) -> Unit,
onNotWarnWhenMetered: () -> Unit,
scrollUp: suspend () -> Unit,
) {
val isPreview = LocalInspectionMode.current
@@ -83,7 +86,7 @@ fun Version(
modifier = Modifier.padding(bottom = 8.dp).clickable { expanded = !expanded },
) {
ExpandIconChevron(expanded)
Row {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.version.versionName,
@@ -103,14 +106,14 @@ fun Version(
containerColor = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 8.dp),
) {
Text(text = stringResource(R.string.app_installed), modifier = Modifier.padding(2.dp))
Text(text = stringResource(R.string.app_installed), modifier = Modifier.padding(4.dp))
}
if (item.isSuggested)
Badge(
containerColor = MaterialTheme.colorScheme.secondary,
modifier = Modifier.padding(start = 8.dp),
) {
Text(text = stringResource(R.string.app_suggested), modifier = Modifier.padding(2.dp))
Text(text = stringResource(R.string.app_suggested), modifier = Modifier.padding(4.dp))
}
}
}
@@ -120,84 +123,80 @@ fun Version(
) {
val coroutineScope = rememberCoroutineScope()
var showMeteredDialog by remember { mutableStateOf(false) }
Row(horizontalArrangement = spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
if (!item.isCompatible || !item.isSignerCompatible)
Column(modifier = Modifier.fillMaxWidth().padding(start = 40.dp)) {
if (!item.isCompatible || !item.isSignerCompatible)
Text(
text =
if (!item.isCompatible) {
stringResource(R.string.app_details_incompatible_version)
} else {
stringResource(R.string.app_details_incompatible_signer)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
item.version.size?.let { size ->
Text(
text =
stringResource(
R.string.size_colon,
Formatter.formatFileSize(LocalContext.current, size),
),
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
SelectionContainer {
Text(
text = stringResource(R.string.version_code_colon, item.version.versionCode.toString()),
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
)
}
val sdkString = buildString {
item.version.packageManifest.minSdkVersion?.let { sdk ->
append(stringResource(R.string.sdk_min_version, sdk))
}
item.version.packageManifest.targetSdkVersion?.let { sdk ->
if (isNotEmpty()) append(" ")
append(stringResource(R.string.sdk_target_version, sdk))
}
item.version.packageManifest.maxSdkVersion?.let { sdk ->
if (isNotEmpty()) append(" ")
append(stringResource(R.string.sdk_max_version, sdk))
}
}
if (sdkString.isNotEmpty())
SelectionContainer {
Text(
text =
if (!item.isCompatible) {
stringResource(R.string.app_details_incompatible_version)
} else {
stringResource(R.string.app_details_incompatible_signer)
},
text = stringResource(R.string.sdk_versions_colon, sdkString),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
maxLines = 1,
overflow = TextOverflow.StartEllipsis,
)
item.version.size?.let { size ->
}
item.version.packageManifest.nativecode?.let { nativeCode ->
if (nativeCode.isNotEmpty())
SelectionContainer {
Text(
text = stringResource(R.string.architectures_colon, nativeCode.joinToString(", ")),
style = MaterialTheme.typography.bodySmall,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
item.version.signer?.let { signer ->
SelectionContainer {
Text(
text =
stringResource(
R.string.size_colon,
Formatter.formatFileSize(LocalContext.current, size),
),
text = stringResource(R.string.signer_colon, signer.sha256[0].substring(0..15)),
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
SelectionContainer {
Text(
text =
stringResource(R.string.version_code_colon, item.version.versionCode.toString()),
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
)
}
val sdkString = buildString {
item.version.packageManifest.minSdkVersion?.let { sdk ->
append(stringResource(R.string.sdk_min_version, sdk))
}
item.version.packageManifest.targetSdkVersion?.let { sdk ->
if (isNotEmpty()) append(" ")
append(stringResource(R.string.sdk_target_version, sdk))
}
item.version.packageManifest.maxSdkVersion?.let { sdk ->
if (isNotEmpty()) append(" ")
append(stringResource(R.string.sdk_max_version, sdk))
}
}
if (sdkString.isNotEmpty())
SelectionContainer {
Text(
text = stringResource(R.string.sdk_versions_colon, sdkString),
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.StartEllipsis,
)
}
item.version.packageManifest.nativecode?.let { nativeCode ->
if (nativeCode.isNotEmpty())
SelectionContainer {
Text(
text =
stringResource(R.string.architectures_colon, nativeCode.joinToString(", ")),
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
item.version.signer?.let { signer ->
SelectionContainer {
Text(
text = stringResource(R.string.signer_colon, signer.sha256[0].substring(0..15)),
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
if (item.showInstallButton) {
val coroutineScope = rememberCoroutineScope()
@@ -211,13 +210,15 @@ fun Version(
coroutineScope.launch { scrollUp() }
}
},
modifier = Modifier.padding(top = 8.dp, end = 16.dp).fillMaxWidth(),
)
}
}
if (showMeteredDialog)
MeteredConnectionDialog(
numBytes = item.version.size,
onConfirm = {
onConfirm = { notWarnWhenMetered ->
if (notWarnWhenMetered) onNotWarnWhenMetered()
installAction(item.version as AppVersion)
coroutineScope.launch { scrollUp() }
},

View File

@@ -1,6 +1,7 @@
package org.fdroid.ui.discover
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -11,11 +12,14 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -27,6 +31,7 @@ import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.fdroid.ui.FDroidContent
import org.fdroid.ui.utils.AsyncShimmerImage
import org.fdroid.ui.utils.InstalledBadge
@@ -41,20 +46,28 @@ fun AppCarousel(
onTitleTap: () -> Unit,
onAppTap: (AppDiscoverItem) -> Unit,
) {
Column(verticalArrangement = spacedBy(8.dp), modifier = modifier) {
Column(verticalArrangement = spacedBy(10.dp), modifier = modifier) {
Row(
verticalAlignment = CenterVertically,
modifier =
Modifier.fillMaxWidth()
.clickable(onClick = onTitleTap)
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth().clickable(onClick = onTitleTap).padding(horizontal = 16.dp),
) {
Text(text = title, style = MaterialTheme.typography.headlineSmall)
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
modifier = Modifier.semantics { hideFromAccessibility() },
contentDescription = null,
)
Text(text = title, style = MaterialTheme.typography.titleMediumEmphasized, fontSize = 20.sp)
IconButton(
onClick = onTitleTap,
shape = CircleShape,
colors =
IconButtonDefaults.iconButtonColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer
),
modifier = Modifier.size(48.dp).padding(6.dp),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
modifier = Modifier.semantics { hideFromAccessibility() },
contentDescription = null,
)
}
}
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
@@ -82,7 +95,13 @@ fun AppBox(app: AppDiscoverItem, onAppTap: (AppDiscoverItem) -> Unit) {
},
)
}
Text(text = app.name, style = MaterialTheme.typography.bodySmall, minLines = 2, maxLines = 2)
Text(
text = app.name,
style = MaterialTheme.typography.bodySmall,
minLines = 2,
maxLines = 2,
lineHeight = 14.sp,
)
}
}

View File

@@ -1,19 +1,21 @@
package org.fdroid.ui.discover
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation3.runtime.NavKey
import org.fdroid.R
import org.fdroid.ui.categories.CategoryList
import org.fdroid.ui.lists.AppListType
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun DiscoverContent(
discoverModel: LoadedDiscoverModel,
onListTap: (AppListType) -> Unit,
@@ -21,7 +23,7 @@ fun DiscoverContent(
onNav: (NavKey) -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
Column(verticalArrangement = spacedBy(18.dp), modifier = modifier) {
AnimatedVisibility(discoverModel.newApps.isNotEmpty()) {
val listNew = AppListType.New(stringResource(R.string.app_list_new))
AppCarousel(

View File

@@ -2,6 +2,11 @@ package org.fdroid.ui.discover
import android.annotation.SuppressLint
import android.app.Application
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.Intent.ACTION_LOCALE_CHANGED
import android.content.IntentFilter
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.SavedStateHandle
@@ -16,10 +21,13 @@ import java.util.Locale
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
@@ -49,8 +57,8 @@ constructor(
private val log = KotlinLogging.logger {}
private val moleculeScope =
CoroutineScope(viewModelScope.coroutineContext + AndroidUiDispatcher.Main)
private val collator = Collator.getInstance(Locale.getDefault())
private val localeListFlow = MutableStateFlow(LocaleListCompat.getDefault())
private val newAppsFlow = db.getAppDao().getNewAppsFlow()
private val recentlyUpdatedAppsFlow = db.getAppDao().getRecentlyUpdatedAppsFlow()
private val mostDownloadedApps =
@@ -68,8 +76,10 @@ constructor(
db.getAppDao().getAppsFlow(packageNames).collect { apps -> emit(apps) }
}
.flowOn(Dispatchers.IO)
private val dbCategories = db.getRepositoryDao().getLiveCategories().asFlow()
private val categories =
db.getRepositoryDao().getLiveCategories().asFlow().map { categories ->
combine(localeListFlow, dbCategories) { localeList, categories ->
val collator = Collator.getInstance(Locale.getDefault())
categories
.map { category ->
CategoryItem(id = category.id, name = category.getName(localeList) ?: "Unknown Category")
@@ -81,7 +91,6 @@ constructor(
repos?.any { it.enabled && it.errorCount >= 5 } ?: false
}
val localeList = LocaleListCompat.getDefault()
val discoverModel: StateFlow<DiscoverModel> by
lazy(LazyThreadSafetyMode.NONE) {
@SuppressLint("StateFlowValueCalledInComposition") // see comment below
@@ -103,4 +112,24 @@ constructor(
)
}
}
private val localeChangedReceiver = LocaleChangedReceiver()
init {
app.registerReceiver(localeChangedReceiver, IntentFilter(ACTION_LOCALE_CHANGED))
}
override fun onCleared() {
app.unregisterReceiver(localeChangedReceiver)
}
private inner class LocaleChangedReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == ACTION_LOCALE_CHANGED) {
val localeList = LocaleListCompat.getDefault()
log.info { "Locale list has changed: $localeList" }
localeListFlow.update { localeList }
}
}
}
}

View File

@@ -52,10 +52,10 @@ fun FirstStart(
// TODO it would be nice to surface normal update errors better and also let the user retry
}
Column(verticalArrangement = Center, modifier = modifier) {
if ((!networkState.isOnline || networkState.isMetered) && !override) {
if ((!networkState.isOnline || networkState.showWarningDialog) && !override) {
// offline or metered, not overridden
val res =
if (networkState.isMetered) {
if (networkState.showWarningDialog) {
stringResource(R.string.first_start_metered)
} else {
stringResource(R.string.first_start_offline)

View File

@@ -17,8 +17,8 @@ import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ManageSearch
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -51,6 +51,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.viktormykhailiv.compose.hints.HintHost
import com.viktormykhailiv.compose.hints.HintProperties
import com.viktormykhailiv.compose.hints.hintAnchor
import com.viktormykhailiv.compose.hints.rememberHint
import com.viktormykhailiv.compose.hints.rememberHintAnchorState
@@ -61,7 +62,7 @@ import org.fdroid.ui.FDroidContent
import org.fdroid.ui.search.TopSearchBar
import org.fdroid.ui.utils.BackButton
import org.fdroid.ui.utils.BigLoadingIndicator
import org.fdroid.ui.utils.OnboardingCard
import org.fdroid.ui.utils.OnboardingPopupCard
import org.fdroid.ui.utils.TopAppBarButton
import org.fdroid.ui.utils.appListItems
import org.fdroid.ui.utils.getAppListInfo
@@ -80,22 +81,22 @@ fun AppList(
val scrollBehavior = enterAlwaysScrollBehavior(rememberTopAppBarState())
val hintController = rememberHintController(overlay = getHintOverlayColor())
val hint = rememberHint {
OnboardingCard(
title = stringResource(R.string.onboarding_app_list_filter_title),
message = stringResource(R.string.onboarding_app_list_filter_message),
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
onGotIt = {
appListInfo.actions.onOnboardingSeen()
hintController.dismiss()
},
)
}
val hint =
rememberHint(HintProperties(dismissOnClickOutside = false)) {
OnboardingPopupCard(
title = stringResource(R.string.onboarding_app_list_filter_title),
message = stringResource(R.string.onboarding_app_list_filter_message),
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
onGotIt = {
appListInfo.actions.onOnboardingSeen()
hintController.dismiss()
},
)
}
val hintAnchor = rememberHintAnchorState(hint)
LaunchedEffect(appListInfo.showOnboarding) {
if (appListInfo.showOnboarding) {
hintController.show(hintAnchor)
appListInfo.actions.onOnboardingSeen()
}
}
@@ -131,7 +132,7 @@ fun AppList(
},
actions = {
TopAppBarButton(
imageVector = Icons.Filled.Search,
imageVector = Icons.AutoMirrored.Default.ManageSearch,
contentDescription = stringResource(R.string.menu_search),
onClick = { searchActive = true },
)

View File

@@ -45,7 +45,7 @@ fun BottomBar(model: MainModel, currentNavKey: NavKey, onNav: (MainNavKey) -> Un
selectedTextColor = MaterialTheme.colorScheme.primary,
selectedIconColor = contentColorFor(MaterialTheme.colorScheme.primary),
),
onClick = { if (dest != currentNavKey) onNav(dest) },
onClick = { onNav(dest) },
modifier =
Modifier.semantics {
if (dest == NavigationKey.MyApps) {
@@ -83,7 +83,7 @@ fun NavigationRail(
selectedTextColor = MaterialTheme.colorScheme.primary,
selectedIconColor = contentColorFor(MaterialTheme.colorScheme.primary),
),
onClick = { if (dest != currentNavKey) onNav(dest) },
onClick = { onNav(dest) },
modifier =
Modifier.semantics {
if (dest == NavigationKey.MyApps) {

View File

@@ -46,6 +46,8 @@ fun EntryProviderScope<NavKey>.repoEntry(navigator: Navigator, isBigScreen: Bool
override fun onRepositoryEnabled(repoId: Long, enabled: Boolean) =
viewModel.onRepositoryEnabled(repoId, enabled)
override fun onNotWarnWhenMetered() = viewModel.onNotWarnWhenMetered()
override fun onAddRepo() {
navigator.navigate(NavigationKey.AddRepo())
}
@@ -94,6 +96,7 @@ fun EntryProviderScope<NavKey>.repoEntry(navigator: Navigator, isBigScreen: Bool
networkStateFlow = viewModel.networkState,
proxyConfig = viewModel.proxyConfig,
onFetchRepo = viewModel::onFetchRepo,
onNotWarnWhenMetered = viewModel::onNotWarnWhenMetered,
onAddRepo = viewModel::addFetchedRepository,
onExistingRepo = { repoId ->
navigator.goBack()

View File

@@ -34,8 +34,10 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import com.viktormykhailiv.compose.hints.HintHost
import com.viktormykhailiv.compose.hints.HintProperties
import com.viktormykhailiv.compose.hints.rememberHint
import com.viktormykhailiv.compose.hints.rememberHintAnchorState
import com.viktormykhailiv.compose.hints.rememberHintController
@@ -43,7 +45,7 @@ import org.fdroid.R
import org.fdroid.download.NetworkState
import org.fdroid.ui.FDroidContent
import org.fdroid.ui.utils.BigLoadingIndicator
import org.fdroid.ui.utils.OnboardingCard
import org.fdroid.ui.utils.OnboardingPopupCard
import org.fdroid.ui.utils.getHintOverlayColor
import org.fdroid.ui.utils.getRepositoriesInfo
import org.fdroid.ui.utils.repoItems
@@ -52,24 +54,24 @@ import org.fdroid.ui.utils.repoItems
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
fun Repositories(info: RepositoryInfo, isBigScreen: Boolean, onBackClicked: () -> Unit) {
val hintController = rememberHintController(overlay = getHintOverlayColor())
val hint = rememberHint {
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
OnboardingCard(
title = stringResource(R.string.repo_list_info_title),
message = stringResource(R.string.repo_list_info_text),
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
onGotIt = {
info.onOnboardingSeen()
hintController.dismiss()
},
)
val hint =
rememberHint(HintProperties(dismissOnClickOutside = false)) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
OnboardingPopupCard(
title = stringResource(R.string.repo_list_info_title),
message = stringResource(R.string.repo_list_info_text),
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
onGotIt = {
info.onOnboardingSeen()
hintController.dismiss()
},
)
}
}
}
val hintAnchor = rememberHintAnchorState(hint)
LaunchedEffect(info.model.showOnboarding) {
if (!isBigScreen && info.model.showOnboarding) {
hintController.show(hintAnchor)
info.onOnboardingSeen()
}
}
val scrollBehavior = enterAlwaysScrollBehavior(rememberTopAppBarState())
@@ -153,8 +155,8 @@ fun RepositoriesScaffoldLoadingPreview() {
}
}
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES or UI_MODE_TYPE_NORMAL)
@Composable
@PreviewLightDark
private fun RepositoriesScaffoldPreview() {
HintHost {
FDroidContent {

View File

@@ -3,8 +3,11 @@ package org.fdroid.ui.repositories
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.plus
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
@@ -12,7 +15,12 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -21,6 +29,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.graphics.shadow.Shadow
@@ -85,7 +94,7 @@ fun RepositoriesList(
isSelected = isSelected,
onRepoEnabled = { enabled ->
if (enabled) {
if (info.model.networkState.isMetered)
if (info.model.networkState.showWarningDialog)
showMeteredDialog = { info.onRepositoryEnabled(repoItem.repoId, true) }
else info.onRepositoryEnabled(repoItem.repoId, true)
} else {
@@ -108,7 +117,33 @@ fun RepositoriesList(
val repoId = showDisableRepoDialog
if (repoId != null) {
AlertDialog(
text = { Text(text = stringResource(R.string.repo_disable_warning)) },
title = { Text(text = stringResource(R.string.repo_disable_dialog_title)) },
text = {
Column(verticalArrangement = spacedBy(8.dp)) {
ElevatedCard(
colors =
CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer
)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = spacedBy(8.dp),
) {
Icon(
Icons.Default.Warning,
contentDescription = null,
modifier = Modifier.padding(start = 8.dp),
)
Text(
text = stringResource(R.string.repo_disable_dialog_warning),
modifier = Modifier.padding(8.dp),
)
}
}
Text(stringResource(R.string.repo_disable_dialog_text))
}
},
onDismissRequest = { showDisableRepoDialog = null },
confirmButton = {
TextButton(
@@ -135,7 +170,10 @@ fun RepositoriesList(
if (meteredLambda != null)
MeteredConnectionDialog(
numBytes = null,
onConfirm = { meteredLambda() },
onConfirm = { notWarnWhenMetered ->
if (notWarnWhenMetered) info.onNotWarnWhenMetered()
meteredLambda()
},
onDismiss = { showMeteredDialog = null },
)
}

View File

@@ -95,6 +95,10 @@ constructor(
}
}
fun onNotWarnWhenMetered() {
settingsManager.onDontWarnOnMeteredNetwork()
}
fun onRepositoriesMoved(fromRepoId: Long, toRepoId: Long) {
log.info { "onRepositoriesMoved($fromRepoId, $toRepoId)" }
repoSortingMap.update {

View File

@@ -12,6 +12,8 @@ interface RepositoryInfo {
fun onRepositoryEnabled(repoId: Long, enabled: Boolean)
fun onNotWarnWhenMetered()
fun onAddRepo()
fun onRepositoryMoved(fromRepoId: Long, toRepoId: Long)

View File

@@ -0,0 +1,27 @@
package org.fdroid.ui.repositories
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import org.fdroid.repo.RepoUpdateWorker
private val TAG = UpdateRepoActivity::class.java.simpleName
/**
* This activity allows OS components to trigger a repository update. One known use-case for this is
* to do an initial repository update during SetupWizard, so app data is available when needed, e.g.
* for restoring app backups.
*/
class UpdateRepoActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (intent.action != "org.fdroid.action.UPDATE_REPOS") {
Log.w(TAG, "Unknown action: ${intent.action}")
return
}
Log.i(TAG, "Intent received, updating repos...")
RepoUpdateWorker.updateNow(this)
finish()
}
}

View File

@@ -37,6 +37,7 @@ fun AddRepo(
networkStateFlow: StateFlow<NetworkState>,
proxyConfig: ProxyConfig?,
onFetchRepo: (String) -> Unit,
onNotWarnWhenMetered: () -> Unit,
onAddRepo: () -> Unit,
onExistingRepo: (Long) -> Unit,
onRepoAdded: (String, Long) -> Unit,
@@ -74,7 +75,7 @@ fun AddRepo(
when (state) {
None -> {
val networkState = networkStateFlow.collectAsStateWithLifecycle().value
AddRepoIntroContent(networkState, onFetchRepo, paddingValues)
AddRepoIntroContent(networkState, onFetchRepo, onNotWarnWhenMetered, paddingValues)
}
is Fetching -> {
if (state.receivedRepo == null) {

View File

@@ -88,6 +88,7 @@ import org.fdroid.ui.utils.startActivitySafe
fun AddRepoIntroContent(
networkState: NetworkState,
onFetchRepo: (String) -> Unit,
onNotWarnWhenMetered: () -> Unit,
paddingValues: PaddingValues = PaddingValues(),
) {
val scrollState = rememberScrollState()
@@ -150,7 +151,7 @@ fun AddRepoIntroContent(
Unit
}
}
if (networkState.isMetered) showMeteredDialog = scanLambda else scanLambda()
if (networkState.showWarningDialog) showMeteredDialog = scanLambda else scanLambda()
},
)
AnimatedVisibility(
@@ -229,7 +230,7 @@ fun AddRepoIntroContent(
FDroidButton(
text = stringResource(R.string.repo_add_add),
onClick = {
if (networkState.isMetered)
if (networkState.showWarningDialog)
showMeteredDialog = { onFetchRepo(textState.value.text) }
else onFetchRepo(textState.value.text)
},
@@ -244,7 +245,10 @@ fun AddRepoIntroContent(
if (meteredLambda != null)
MeteredConnectionDialog(
numBytes = null,
onConfirm = { meteredLambda() },
onConfirm = { notWarnWhenMetered ->
if (notWarnWhenMetered) onNotWarnWhenMetered()
meteredLambda()
},
onDismiss = { showMeteredDialog = null },
)
}
@@ -254,7 +258,7 @@ fun AddRepoIntroContent(
private fun Preview() {
FDroidContent {
val networkStateFlow = MutableStateFlow(NetworkState(isOnline = true, isMetered = false))
AddRepo(None, networkStateFlow, null, {}, {}, {}, { _, _ -> }) {}
AddRepo(None, networkStateFlow, null, {}, {}, {}, {}, { _, _ -> }, {})
}
}
@@ -263,6 +267,6 @@ private fun Preview() {
private fun PreviewNight() {
FDroidContent {
val networkStateFlow = MutableStateFlow(NetworkState(isOnline = false, isMetered = false))
AddRepo(None, networkStateFlow, null, {}, {}, {}, { _, _ -> }) {}
AddRepo(None, networkStateFlow, null, {}, {}, {}, {}, { _, _ -> }, {})
}
}

View File

@@ -28,7 +28,7 @@ class AddRepoViewModel
constructor(
app: Application,
networkMonitor: NetworkMonitor,
settingsManager: SettingsManager,
private val settingsManager: SettingsManager,
private val repoManager: RepoManager,
private val updateManager: UpdatesManager,
@param:IoDispatcher private val ioScope: CoroutineScope,
@@ -74,4 +74,8 @@ constructor(
}
}
}
fun onNotWarnWhenMetered() {
settingsManager.onDontWarnOnMeteredNetwork()
}
}

View File

@@ -30,6 +30,7 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.viktormykhailiv.compose.hints.HintHost
import com.viktormykhailiv.compose.hints.HintProperties
import com.viktormykhailiv.compose.hints.rememberHint
import com.viktormykhailiv.compose.hints.rememberHintAnchorState
import com.viktormykhailiv.compose.hints.rememberHintController
@@ -39,7 +40,7 @@ import org.fdroid.ui.FDroidContent
import org.fdroid.ui.utils.BackButton
import org.fdroid.ui.utils.BigLoadingIndicator
import org.fdroid.ui.utils.MeteredConnectionDialog
import org.fdroid.ui.utils.OnboardingCard
import org.fdroid.ui.utils.OnboardingPopupCard
import org.fdroid.ui.utils.TopAppBarButton
import org.fdroid.ui.utils.TopAppBarOverflowButton
import org.fdroid.ui.utils.getHintOverlayColor
@@ -56,24 +57,24 @@ fun RepoDetails(
val repo = info.model.repo
val hintController = rememberHintController(overlay = getHintOverlayColor())
val hint = rememberHint {
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
OnboardingCard(
title = stringResource(R.string.repo_details),
message = stringResource(R.string.repo_details_info_text),
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
onGotIt = {
info.actions.onOnboardingSeen()
hintController.dismiss()
},
)
val hint =
rememberHint(HintProperties(dismissOnClickOutside = false)) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
OnboardingPopupCard(
title = stringResource(R.string.repo_details),
message = stringResource(R.string.repo_details_info_text),
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
onGotIt = {
info.actions.onOnboardingSeen()
hintController.dismiss()
},
)
}
}
}
val hintAnchor = rememberHintAnchorState(hint)
LaunchedEffect(info.model.showOnboarding) {
if (info.model.showOnboarding) {
hintController.show(hintAnchor)
info.actions.onOnboardingSeen()
}
}
@@ -95,7 +96,10 @@ fun RepoDetails(
if (meteredLambda != null)
MeteredConnectionDialog(
numBytes = null,
onConfirm = { meteredLambda() },
onConfirm = { notWarnWhenMetered ->
if (notWarnWhenMetered) info.actions.onNotWarnWhenMetered()
meteredLambda()
},
onDismiss = { showMeteredDialog = null },
)
Scaffold(
@@ -120,7 +124,7 @@ fun RepoDetails(
contentDescription = stringResource(R.string.repo_force_update),
enabled = info.model.isUpdateButtonEnabled,
onClick = {
if (info.model.networkState.isMetered)
if (info.model.networkState.showWarningDialog)
showMeteredDialog = { RepoUpdateWorker.updateNow(context, repo.repoId) }
else RepoUpdateWorker.updateNow(context, repo.repoId)
},

View File

@@ -32,6 +32,8 @@ interface RepoDetailsActions {
fun setArchiveRepoEnabled(enabled: Boolean)
fun onNotWarnWhenMetered()
fun onOnboardingSeen()
suspend fun generateQrCode(repo: Repository): Bitmap? {

View File

@@ -154,6 +154,10 @@ constructor(
}
}
override fun onNotWarnWhenMetered() {
settingsManager.onDontWarnOnMeteredNetwork()
}
override fun onOnboardingSeen() = onboardingManager.onRepoDetailsOnboardingSeen()
private fun Repository.archiveState(): ArchiveState {

View File

@@ -23,7 +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
import org.fdroid.search.SearchHelper.isSearchable
@Composable
@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class)
@@ -41,10 +41,10 @@ fun AppSearchInputField(
snapshotFlow { textFieldState.text }
.distinctUntilChanged()
.debounce(500)
.collectLatest {
if (it.isEmpty()) {
.collectLatest { query ->
if (query.isEmpty()) {
onSearchCleared()
} else if (it.length >= SEARCH_THRESHOLD) {
} else if (query.isSearchable()) {
onSearch(textFieldState.text.toString())
}
}
@@ -54,7 +54,7 @@ fun AppSearchInputField(
searchBarState = searchBarState,
textFieldState = textFieldState,
textStyle = MaterialTheme.typography.bodyLarge,
onSearch = { scope.launch { onSearch(it) } },
onSearch = { if (it.isSearchable()) scope.launch { onSearch(it) } },
placeholder = { Text(stringResource(R.string.search_placeholder)) },
trailingIcon = {
if (textFieldState.text.isNotEmpty()) {

View File

@@ -7,42 +7,74 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
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.Keyboard
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import org.fdroid.R
import org.fdroid.search.SavedSearch
import org.fdroid.ui.FDroidContent
import org.fdroid.ui.navigation.NavigationKey
import org.fdroid.ui.utils.TopAppBarOverflowButton
import org.fdroid.ui.utils.appListItems
import org.fdroid.ui.utils.categoryItems
import org.fdroid.ui.utils.getSearchInfo
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun GlobalSearch(
info: SearchInfo,
textFieldState: TextFieldState = rememberTextFieldState(),
searchResults: SearchResults?,
savedSearches: List<SavedSearch>? = null,
onSearch: suspend (String) -> Unit,
onClearSavedSearches: () -> Unit,
onNav: (NavigationKey) -> Unit,
onBack: () -> Unit,
onSearchCleared: () -> Unit,
) {
LaunchedEffect(info.showKeyboard) {
// auto-reset showKeyboard, so it functions like an event
if (info.showKeyboard) info.onKeyboardShown()
}
val searchResults = info.searchResults
val showKeyboard =
info.showKeyboard || // either show keyboard because user double tapped the search icon
(info.autoShowKeyboard && // or auto-show is activated
(searchResults == null || // but only if no search results are shown
searchResults.apps.isEmpty() && searchResults.categories.isEmpty()))
Scaffold(
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,
shouldRequestFocus = showKeyboard,
onSearch = info.actions::onSearch,
onSearchCleared = info.actions::onSearchCleared,
onHideSearch = onBack,
actions = {
if (textFieldState.text.isEmpty()) {
TopAppBarOverflowButton { onDismiss ->
DropdownMenuItem(
leadingIcon = { Icon(Icons.Default.Keyboard, null) },
text = { Text(stringResource(R.string.search_auto_keyboard)) },
trailingIcon = {
Checkbox(checked = info.autoShowKeyboard, onCheckedChange = null)
},
onClick = {
info.actions.setAutoShowKeyboard(!info.autoShowKeyboard)
onDismiss()
},
)
}
}
},
)
}
) { paddingValues ->
@@ -59,8 +91,9 @@ fun GlobalSearch(
paddingValues = paddingValues,
searchResults = searchResults,
textFieldState = textFieldState,
savedSearches = savedSearches,
onClearSavedSearches = onClearSavedSearches,
savedSearches = info.savedSearches,
categories = info.categories,
onClearSavedSearches = info.actions::onClearSearchHistory,
onNav = onNav,
)
}
@@ -72,7 +105,9 @@ fun GlobalSearch(
private fun AppsSearchLoadingPreview() {
FDroidContent {
val textFieldState = rememberTextFieldState("foo bar")
Box(Modifier.fillMaxSize()) { GlobalSearch(textFieldState, null, null, {}, {}, {}, {}, {}) }
Box(Modifier.fillMaxSize()) {
GlobalSearch(info = getSearchInfo(), textFieldState = textFieldState, onNav = {}, onBack = {})
}
}
}
@@ -86,14 +121,10 @@ private fun AppsSearchEmptyStatePreview() {
listOf(SavedSearch(1, "foo"), SavedSearch(2, "foo bar"), SavedSearch(3, "foobar"))
Box(Modifier.fillMaxSize()) {
GlobalSearch(
info = getSearchInfo(null, savedSearches, categoryItems),
textFieldState = textFieldState,
searchResults = null,
savedSearches = savedSearches,
onSearch = {},
onClearSavedSearches = {},
onNav = {},
onBack = {},
onSearchCleared = {},
)
}
}
@@ -107,14 +138,10 @@ private fun AppsSearchNoResultsPreview() {
val textFieldState = rememberTextFieldState("foo")
Box(Modifier.fillMaxSize()) {
GlobalSearch(
info = getSearchInfo(SearchResults(emptyList(), emptyList()), emptyList(), emptyList()),
textFieldState = textFieldState,
searchResults = SearchResults(emptyList(), emptyList()),
savedSearches = emptyList(),
onSearch = {},
onClearSavedSearches = {},
onNav = {},
onBack = {},
onSearchCleared = {},
)
}
}
@@ -128,14 +155,10 @@ private fun AppsSearchOnlyCategoriesPreview() {
val textFieldState = rememberTextFieldState()
Box(Modifier.fillMaxSize()) {
GlobalSearch(
info = getSearchInfo(SearchResults(emptyList(), categoryItems.subList(3, 5)), emptyList()),
textFieldState = textFieldState,
searchResults = SearchResults(emptyList(), categoryItems.subList(3, 5)),
savedSearches = emptyList(),
onSearch = {},
onClearSavedSearches = {},
onNav = {},
onBack = {},
onSearchCleared = {},
)
}
}
@@ -149,14 +172,10 @@ private fun AppsSearchPreview() {
val textFieldState = rememberTextFieldState()
Box(Modifier.fillMaxSize()) {
GlobalSearch(
info = getSearchInfo(SearchResults(appListItems, categoryItems.subList(0, 4)), emptyList()),
textFieldState = textFieldState,
searchResults = SearchResults(appListItems, categoryItems.subList(0, 4)),
savedSearches = emptyList(),
onSearch = {},
onClearSavedSearches = {},
onNav = {},
onBack = {},
onSearchCleared = {},
)
}
}

View File

@@ -1,17 +1,11 @@
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.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
@@ -23,6 +17,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
@@ -32,51 +27,41 @@ import org.fdroid.R
import org.fdroid.search.SavedSearch
import org.fdroid.ui.FDroidContent
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
fun PastSearches(
fun LazyListScope.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),
item {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResource(R.string.search_history),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(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),
)
TextButton(
onClick = onClearSavedSearches,
modifier = Modifier.padding(horizontal = 8.dp),
) {
Text(stringResource(R.string.clear))
}
}
},
onClick = { onSearch(item.query) },
modifier = Modifier.fillMaxWidth().animateItem(),
) {
Text(item.query)
}
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)) }
}
}
@@ -86,10 +71,8 @@ private fun Preview() {
FDroidContent {
val savedSearches =
listOf(SavedSearch(1, "foo"), SavedSearch(2, "foo bar"), SavedSearch(3, "foobar"))
PastSearches(
savedSearches = savedSearches,
onClearSavedSearches = {},
onSearch = {},
)
LazyColumn {
pastSearches(savedSearches = savedSearches, onClearSavedSearches = {}, onSearch = {})
}
}
}

View File

@@ -0,0 +1,24 @@
package org.fdroid.ui.search
import org.fdroid.search.SavedSearch
import org.fdroid.ui.categories.CategoryItem
interface SearchInfo {
val searchResults: SearchResults?
val savedSearches: List<SavedSearch>?
val categories: List<CategoryItem>?
val autoShowKeyboard: Boolean
val showKeyboard: Boolean
val actions: SearchActions
fun onKeyboardShown()
}
interface SearchActions {
suspend fun onSearch(term: String)
fun onSearchCleared()
fun onClearSearchHistory()
fun setAutoShowKeyboard(autoShow: Boolean)
}

View File

@@ -23,12 +23,13 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
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.search.SearchHelper.isSearchable
import org.fdroid.ui.categories.CategoryChip
import org.fdroid.ui.categories.CategoryItem
import org.fdroid.ui.categories.ChipFlowRow
@@ -47,6 +48,7 @@ fun SearchResults(
searchResults: SearchResults?,
textFieldState: TextFieldState,
savedSearches: List<SavedSearch>?,
categories: List<CategoryItem>?,
onClearSavedSearches: () -> Unit,
onNav: (NavigationKey) -> Unit,
modifier: Modifier = Modifier,
@@ -55,20 +57,29 @@ fun SearchResults(
val listState =
rememberSaveable(searchResults, saver = LazyListState.Saver) { LazyListState(0, 0) }
if (searchResults == null) {
if (textFieldState.text.length >= SEARCH_THRESHOLD) {
if (textFieldState.text.isSearchable()) {
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,
)
val keyboardController = LocalSoftwareKeyboardController.current
LazyColumn(modifier = modifier.fillMaxSize(), contentPadding = paddingValues) {
if (!savedSearches.isNullOrEmpty()) {
pastSearches(
savedSearches = savedSearches,
onSearch = {
textFieldState.edit { replace(0, length, it) }
keyboardController?.hide()
},
onClearSavedSearches = onClearSavedSearches,
)
}
if (!categories.isNullOrEmpty()) {
item { CategoriesFlowRow(categories, onNav, modifier = Modifier.padding(top = 16.dp)) }
}
item { Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.systemBars)) }
item { Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.ime)) }
}
}
} else if (searchResults.apps.isEmpty() && textFieldState.text.length >= SEARCH_THRESHOLD) {
} else if (searchResults.apps.isEmpty() && textFieldState.text.isSearchable()) {
Column(modifier = modifier.padding(paddingValues)) {
if (searchResults.categories.isNotEmpty()) {
CategoriesFlowRow(searchResults.categories, onNav)
@@ -121,8 +132,12 @@ fun SearchResults(
}
@Composable
private fun CategoriesFlowRow(categories: List<CategoryItem>, onNav: (NavigationKey) -> Unit) {
Column(modifier = Modifier.padding(horizontal = 8.dp)) {
private fun CategoriesFlowRow(
categories: List<CategoryItem>,
onNav: (NavigationKey) -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.padding(horizontal = 8.dp)) {
Text(
text = stringResource(R.string.main_menu__categories),
style = MaterialTheme.typography.labelLarge,

View File

@@ -6,19 +6,35 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import jakarta.inject.Inject
import kotlinx.coroutines.launch
import org.fdroid.search.SearchHelper.isSearchable
import org.fdroid.search.SearchManager
import org.fdroid.settings.SettingsManager
@HiltViewModel
class SearchViewModel
@Inject
constructor(app: Application, private val searchManager: SearchManager) : AndroidViewModel(app) {
constructor(
app: Application,
private val searchManager: SearchManager,
private val settingsManager: SettingsManager,
) : AndroidViewModel(app), SearchActions {
val searchResults = searchManager.searchResults
val savedSearchesFlow = searchManager.savedSearches
val categories = searchManager.categories
val autoShowKeyboard = settingsManager.showSearchKeyboardFlow
suspend fun search(term: String) = searchManager.search(term)
override suspend fun onSearch(term: String) {
if (term.isSearchable()) searchManager.search(term)
}
fun onSearchCleared() = searchManager.onSearchCleared()
override fun onSearchCleared() = searchManager.onSearchCleared()
fun onClearSearchHistory() = viewModelScope.launch { searchManager.onClearSearchHistory() }
override fun onClearSearchHistory() {
viewModelScope.launch { searchManager.onClearSearchHistory() }
}
override fun setAutoShowKeyboard(autoShow: Boolean) {
settingsManager.setShowSearchKeyboard(autoShow)
}
}

View File

@@ -2,7 +2,9 @@ package org.fdroid.ui.settings
import android.app.Application
import android.net.Uri
import android.os.Build.VERSION.SDK_INT
import android.os.Process
import android.os.Process.myUid
import android.widget.Toast
import android.widget.Toast.LENGTH_SHORT
import androidx.annotation.StringRes
@@ -68,8 +70,14 @@ constructor(
sendToast(R.string.export_log_error)
return@launch
}
// support for --pid was introduced in SDK 24
val command = "logcat -d --pid=" + Process.myPid() + " *:V"
val command = if (SDK_INT < 30) {
// support for --pid was introduced in SDK 24
"logcat -d --pid=${Process.myPid()} *:V"
} else {
// support for --uid was introduced in SDK 30 and is better,
// because it gives logs before process death
"logcat -d --uid=${myUid()} *:V"
}
try {
application.contentResolver.openOutputStream(uri, "wt")?.use { outputStream ->
getRuntime().exec(command).inputStream.use { inputStream ->

View File

@@ -1,18 +1,34 @@
package org.fdroid.ui.utils
import android.text.format.Formatter
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.fdroid.R
@Composable
fun MeteredConnectionDialog(numBytes: Long?, onConfirm: () -> Unit, onDismiss: () -> Unit) {
fun MeteredConnectionDialog(
numBytes: Long?,
onConfirm: (Boolean) -> Unit,
onDismiss: () -> Unit,
) {
var dontShowAgain by remember { mutableStateOf(false) }
AlertDialog(
title = { Text(text = stringResource(R.string.dialog_metered_title)) },
text = {
@@ -23,14 +39,26 @@ fun MeteredConnectionDialog(numBytes: Long?, onConfirm: () -> Unit, onDismiss: (
val sizeStr = Formatter.formatFileSize(LocalContext.current, numBytes)
stringResource(R.string.dialog_metered_text, sizeStr)
}
Text(text = s)
Column {
Text(text = s)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 16.dp),
) {
Checkbox(checked = dontShowAgain, onCheckedChange = { dontShowAgain = it })
Text(
text = stringResource(R.string.dialog_metered_dont_show),
modifier = Modifier.padding(start = 8.dp),
)
}
}
},
onDismissRequest = onDismiss,
confirmButton = {
TextButton(
onClick = {
onDismiss()
onConfirm()
onConfirm(dontShowAgain)
}
) {
Text(

View File

@@ -1,5 +1,8 @@
package org.fdroid.ui.utils
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.ElevatedCard
@@ -20,16 +23,38 @@ import androidx.compose.ui.unit.dp
import org.fdroid.R
import org.fdroid.ui.FDroidContent
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun OnboardingPopupCard(
title: String,
message: String,
modifier: Modifier = Modifier,
buttonText: String = stringResource(R.string.got_it),
onGotIt: () -> Unit = {},
) {
val focusRequester = remember { FocusRequester() }
OnboardingCard(
title = title,
message = message,
modifier = modifier.widthIn(max = TooltipDefaults.richTooltipMaxWidth),
buttonText = buttonText,
buttonModifier = Modifier.focusRequester(focusRequester),
onGotIt = onGotIt,
)
LaunchedEffect(Unit) { focusRequester.requestFocus() }
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun OnboardingCard(
title: String,
message: String,
modifier: Modifier = Modifier,
buttonText: String = stringResource(R.string.got_it),
buttonModifier: Modifier = Modifier,
onGotIt: () -> Unit = {},
) {
val focusRequester = remember { FocusRequester() }
ElevatedCard(modifier = modifier.widthIn(max = TooltipDefaults.richTooltipMaxWidth)) {
ElevatedCard(modifier = modifier) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
@@ -40,15 +65,15 @@ fun OnboardingCard(
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = 16.dp),
)
TextButton(
onClick = onGotIt,
modifier =
Modifier.focusRequester(focusRequester).padding(vertical = 8.dp, horizontal = 16.dp),
) {
Text(text = stringResource(R.string.got_it))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton(
onClick = onGotIt,
modifier = buttonModifier.padding(vertical = 8.dp, horizontal = 16.dp),
) {
Text(text = buttonText)
}
}
}
LaunchedEffect(Unit) { focusRequester.requestFocus() }
}
@Preview

View File

@@ -22,6 +22,7 @@ import org.fdroid.index.v2.SignerV2
import org.fdroid.install.InstallConfirmationState
import org.fdroid.install.InstallState
import org.fdroid.repo.RepoUpdateProgress
import org.fdroid.search.SavedSearch
import org.fdroid.ui.apps.AppUpdateItem
import org.fdroid.ui.apps.AppWithIssueItem
import org.fdroid.ui.apps.InstalledAppItem
@@ -49,6 +50,9 @@ import org.fdroid.ui.repositories.details.RepoDetailsActions
import org.fdroid.ui.repositories.details.RepoDetailsInfo
import org.fdroid.ui.repositories.details.RepoDetailsModel
import org.fdroid.ui.repositories.details.UserMirrorItem
import org.fdroid.ui.search.SearchActions
import org.fdroid.ui.search.SearchInfo
import org.fdroid.ui.search.SearchResults
object Names {
val randomName: String
@@ -256,7 +260,9 @@ fun getAppDetailsActions() =
onUninstallResult = { _ -> },
onRepoChanged = {},
onPreferredRepoChanged = {},
onNotWarnWhenMetered = {},
allowBetaVersions = {},
onAntiFeaturesOnboardingSeen = {},
ignoreAllUpdates = {},
ignoreThisUpdate = {},
shareApk = Intent(),
@@ -407,8 +413,12 @@ fun getMyAppsInfo(model: MyAppsModel): MyAppsInfo =
override fun ignoreAppIssue(item: AppWithIssueItem) {}
override fun onUpdatesHintSeen() {}
override fun onAppIssueHintSeen() {}
override fun onNotWarnWhenMetered() {}
override fun exportInstalledApps() {}
}
}
@@ -495,6 +505,7 @@ internal val myAppsModel =
lastUpdated = System.currentTimeMillis() - DAYS.toMillis(3),
),
),
showUpdatesHint = true,
showAppIssueHint = true,
sortOrder = AppListSortOrder.NAME,
networkState = NetworkState(isOnline = false, isMetered = false),
@@ -549,6 +560,8 @@ fun getRepositoriesInfo(model: RepositoryModel, currentRepositoryId: Long? = nul
override fun onRepositoryEnabled(repoId: Long, enabled: Boolean) {}
override fun onNotWarnWhenMetered() {}
override fun onAddRepo() {}
override fun onRepositoryMoved(fromRepoId: Long, toRepoId: Long) {}
@@ -600,6 +613,8 @@ fun getRepoDetailsInfo(
override fun setArchiveRepoEnabled(enabled: Boolean) {}
override fun onNotWarnWhenMetered() {}
override fun onOnboardingSeen() {}
}
}
@@ -623,3 +638,31 @@ fun getRepository(
password = password,
lastError = lastError,
)
fun getSearchInfo(
searchResults: SearchResults? = null,
savedSearches: List<SavedSearch>? = null,
categories: List<CategoryItem>? = null,
) =
object : SearchInfo {
override val searchResults: SearchResults? = searchResults
override val savedSearches: List<SavedSearch>? = savedSearches
override val categories: List<CategoryItem>? = categories
override val autoShowKeyboard: Boolean = false
override val showKeyboard: Boolean = false
override val actions: SearchActions =
object : SearchActions {
override suspend fun onSearch(term: String) {}
override fun onSearchCleared() {}
override fun onClearSearchHistory() {}
override fun setAutoShowKeyboard(autoShow: Boolean) {}
}
override fun onKeyboardShown() {}
}

View File

@@ -23,7 +23,7 @@ import kotlinx.coroutines.withContext
import org.fdroid.database.Repository
@Composable
fun getHintOverlayColor() = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
fun getHintOverlayColor() = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f)
fun Context.startActivitySafe(i: Intent?) {
if (i == null) return

View File

@@ -609,7 +609,6 @@
<string name="repo_archive_toggle_description">أظهِر التطبيقات المؤرشفة والإصدارات القديمة من التطبيقات</string>
<string name="repo_disable_warning_button">عطّل</string>
<string name="app_details_repository_preferred">(مفضّل)</string>
<string name="repo_disable_warning">سيؤدي تعطيل هذا المستودع إلى إزالة حالته \"المفضلة\" من جميع التطبيقات.</string>
<string name="repo_mirror_add_info">عنوان URL الذي تحاول إضافته (%s) هو مرآة لمستودع موجود.</string>
<string name="repo_add_repo_and_mirror">أضِف المستودع والمرآة</string>
<string name="repo_view_repo">اعرض المستودع</string>

View File

@@ -529,7 +529,6 @@
<string name="status_inserting_x_apps">%3$s se app details save kiye ja rahe haì (%1$d/%2$d)</string>
<string name="global_error_updating_repos">Update karte waqt error: %s</string>
<string name="unknown">Anján</string>
<string name="repo_disable_warning">Is bhanđár ko disable kar dene par iski \"cuni hui\" sthiti sabhi apps se haţ jáyegi.</string>
<string name="repos_unchanged">Sabhi bhanđárè up-to-date haì</string>
<string name="all_other_repos_fine">Báki kisi bhi bhanđár ne error nahì diya.</string>
<string name="theme">Theme</string>

View File

@@ -571,7 +571,6 @@
<string name="repo_error_adding_archive">Архіўныя рэпазітары не могуць быць дададзены напрамую. Націсніце на рэпазітар са спісу і ўключыце ў ім архіў.</string>
<string name="repo_archive_toggle_description">Адлюстроўваць архіўныя і састарэлыя версіі праграм</string>
<string name="repo_archive_toggle">Архіў рэпазіторыя</string>
<string name="repo_disable_warning">Адключэнне гэтага рэпазітара выдаліць яго статус «пераважны» з усіх праграм.</string>
<string name="ipfsgw_title">IPFS-шлюзы</string>
<string name="ipfsgw_add_title">Дадаць шлюз IPFS</string>
<string name="ipfsgw_add_add">Дадаць</string>

View File

@@ -557,7 +557,6 @@
<string name="app_details_repository_button_prefer">Предпочитане на хранилището</string>
<string name="repo_list_info_text">Хранилището е източник на приложения. Този списък показва всички добавени за момента хранилища. Изключените хранилища не се използват.\n\nАко едно приложение е налично в повече от едно хранилище, се използва хранилището, което е по-горе в списъка. Можете да подреждате хранилищата, с дълго задържане и влачене.</string>
<string name="repo_error_adding_archive">Архив на хранилище не може да бъде добавен директно. Докоснете хранилище в списъка и го направете архивно.</string>
<string name="repo_disable_warning">Изключване на това хранилище ще премахне състоянието „предпочитано“ от всички приложения.</string>
<string name="repo_add_repo_and_mirror">Добавяне на хранилище или огледало</string>
<string name="repo_view_repo">Преглед</string>
<string name="repo_scan_qr_code">Сканиране на QR</string>

View File

@@ -485,7 +485,6 @@
<string name="repo_list_info_title">Llista de repositoris</string>
<string name="repo_archive_toggle">Arxiu del repositori</string>
<string name="repo_archive_failed">L\'arxiu del dipòsit no està disponible actualment</string>
<string name="repo_disable_warning">Desactivant aquest dipòsit traureu el seu estat de \"preferit\" a totes les aplicacions.</string>
<plurals name="details_last_update_years">
<item quantity="one">Actualitzada fa %1$d any</item>
<item quantity="many">Actualitzada fa %1$d anys</item>

View File

@@ -565,7 +565,6 @@
<string name="repo_disable_warning_button">Zakázat</string>
<string name="repo_archive_toggle_description">Zobrazit archivované a zastaralé verze aplikací</string>
<string name="repo_archive_failed">Archiv repozitáře momentálně není dostupný</string>
<string name="repo_disable_warning">Zakázáním tohoto repozitáře jej odeberete z „vybraných“ u všech aplikací.</string>
<string name="repo_list_info_text">Repozitář je zdroj aplikací. Tento seznam zobrazuje všechny aktuálně přidané repozitáře. Zakázané repozitáře nejsou používány.\n\nPokud se aplikace nachází více než v jednom repozitáři, bude automaticky vybrán repozitář výše v seznamu. Repozitáře můžete přesunout podržením a přesunutím.</string>
<string name="repositories_last_update_never">nikdy</string>
<string name="repositories_last_update">Poslední aktualizace: %s</string>

View File

@@ -545,7 +545,6 @@
<string name="repo_invalid">Ugyldigt repository.\n\nKontakt vedligeholderen og informer dem om problemet.</string>
<string name="swap_banner_message">Start dit Wi-Fi og forbind til det samme netværk som din ven for at udveksle apps.</string>
<string name="repo_list_info_text">Et repository er en kilde til apps. Denne liste viser alle aktuelt tilføjede repositories. Deaktiverede repositories bruges ikke.\n\nHvis en app findes i mere end ét repository, foretrækkes det øverste på listen automatisk. Du kan ændre rækkefølgen af repositories ved at holde og trække dem.</string>
<string name="repo_disable_warning">Deaktiverer du dette repository, fjernes dets \"foretrækkes\" status fra alle apps.</string>
<string name="copied_to_clipboard">Kopieret til udklipsholder</string>
<string name="paste">Indsæt</string>
<string name="copied_permission_to_clipboard">Tilladelsesnavn kopieret til udklipsholder</string>

View File

@@ -562,7 +562,6 @@
<string name="repo_archive_toggle_description">Zeig archivierte Apps und veraltete Versionen von Apps</string>
<string name="repo_archive_toggle">Paketquellen-Archiv</string>
<string name="repo_archive_failed">Archiv-Paketquelle derzeit nicht verfügbar</string>
<string name="repo_disable_warning">Das Deaktivieren dieser Paketquelle wird ihren »bevorzugt«-Status von allen Apps entfernen.</string>
<string name="repo_list_info_title">Paketquellen-Liste</string>
<string name="repo_list_info_text">Eine Paketquelle ist eine Quelle für Apps. Diese Liste zeigt alle derzeit hinzugefügten Paketquellen an. Deaktivierte Paketquellen werden nicht verwendet. \n \nWenn eine App in mehr als einer Paketquelle enthalten ist, wird die in der Liste weiter oben stehende Paketquelle automatisch bevorzugt. Du kannst Paketquellen durch langes Drücken und Ziehen neu anordnen.</string>
<string name="repo_error_adding_archive">Archiv-Paketquellen können nicht direkt hinzugefügt werden. Tippe auf die Paketquelle in der Liste und aktiviere das Archiv dort.</string>

View File

@@ -536,7 +536,6 @@
\nΕάν μια εφαρμογή βρίσκεται σε περισσότερα από ένα αποθετήρια, προτιμάται αυτόματα το αποθετήριο που βρίσκεται ψηλότερα στη λίστα. Μπορείτε να αναδιατάξετε τα αποθετήρια πατώντας παρατεταμένα και σύροντάς τα.</string>
<string name="repo_archive_toggle">Αρχείο αποθετηρίου</string>
<string name="repo_archive_toggle_description">Εμφάνιση αρχειοθετημένων εφαρμογών και ξεπερασμένων εκδόσεων εφαρμογών</string>
<string name="repo_disable_warning">Η απενεργοποίηση αυτού του αποθετηρίου θα αφαιρέσει την κατάσταση \"προτιμώμενη\" από όλες τις εφαρμογές.</string>
<string name="repo_disable_warning_button">Απενεργοποίηση</string>
<string name="copied_url_to_clipboard">Αντιγραφή URL στο πρόχειρο</string>
<string name="swap_banner_message">Για να ανταλλάξετε εφαρμογές, ξεκινήστε το Wi-Fi και συνδεθείτε στο ίδιο δίκτυο με τον φίλο σας.</string>

View File

@@ -561,7 +561,6 @@
<string name="repositories_last_update_never">never</string>
<string name="repo_list_info_title">Repository list</string>
<string name="repo_archive_failed">Archive repo currently not available</string>
<string name="repo_disable_warning">Disabling this repository will remove its \"preferred\" status from all apps.</string>
<string name="repo_disable_warning_button">Disable</string>
<string name="app_details_repositories">Repositories</string>
<string name="useDnsCacheSummary">Use cached results to minimise DNS queries.</string>

View File

@@ -551,7 +551,6 @@
<string name="repo_error_adding_archive">Ne eblas senpere aldoni arĥivan deponejon. Frapetu deponejon sur la listo kaj tie aktivigi arĥivon.</string>
<string name="repo_archive_failed">Arĥiva deponejo ne estas nune disponebla</string>
<string name="repo_disable_warning_button">Malaktivigi</string>
<string name="repo_disable_warning">Per malaktivigi tiun ĉi deponejon, ĝi estos forigita kiel preferata deponejo por ĉiuj ĝiaj aplikaĵoj.</string>
<string name="repositories_last_update">Lasta ĝisdatigo: %s</string>
<string name="repositories_last_update_never">neniam</string>
<string name="repo_archive_toggle">Arĥiva deponejo</string>

View File

@@ -588,7 +588,6 @@
<string name="repo_list_info_title">Lista de repositorios</string>
<string name="repo_archive_toggle">Versiones históricas</string>
<string name="repo_archive_toggle_description">Muestra aplicaciones antiguas y versiones obsoletas de aplicaciones existentes</string>
<string name="repo_disable_warning">Deshabilitar este repositorio eliminará su estado \"preferido\" de todas las aplicaciones.</string>
<string name="repo_disable_warning_button">Desactivar</string>
<string name="repo_list_info_text">Un repositorio es una fuente de aplicaciones. Esta lista muestra todos los repositorios que has añadido. Los repositorios desactivados no se utilizan.\n\nSi una aplicación está en más de un repositorio se utilizará la versión del repositorio que esté más alto en la lista. Puedes reordenar el listado manteniendo pulsado y arrastrando cada entrada.</string>
<string name="repo_mirror_add_info">La URL que está intentando añadir (%s) es una réplica de un repositorio existente.</string>

View File

@@ -567,7 +567,6 @@
<string name="repositories_last_update">Viimane uuendus: %s</string>
<string name="repo_archive_failed">Arhiiv repo praegu mitte saadaval</string>
<string name="repo_archive_toggle_description">Näita arhiivitud äppe või äppide vanasid versioone</string>
<string name="repo_disable_warning">Selle tarkvarahoidla väljalülitamine eemaldab tema \"eelistatud\" oleku kõikide rakenduste jaoks.</string>
<string name="repo_disable_warning_button">Lülita välja</string>
<string name="repositories_last_update_never">mitte kunagi</string>
<string name="app_details_repository_button_prefer">Eelista tarkvarahoidlat</string>

View File

@@ -576,7 +576,6 @@
<string name="repo_archive_toggle">Gordailuko artxiboa</string>
<string name="repo_archive_toggle_description">Erakutsi gordetako aplikazioak eta aplikazioen bertsio zaharkituak</string>
<string name="repo_archive_failed">Artxiboen gordailua une honetan ez dago erabilgarri</string>
<string name="repo_disable_warning">Gordailu hau desgaituz gero, aplikazio guztietatik \"hobetsitakoa\" egoera kenduko da.</string>
<string name="repo_disable_warning_button">Desgailu</string>
<string name="useDnsCache">Erabili DNS cachea</string>
<string name="useDnsCacheSummary">Erabili cachean gordetako emaitzak DNS kontsultak minimizatzeko.</string>

View File

@@ -551,7 +551,6 @@
<string name="repo_list_info_title">سیاههٔ مخزن‌ها</string>
<string name="repo_archive_toggle">بایگانی مخزن</string>
<string name="repo_archive_toggle_description">نمایش کاره‌های بایگانی شده و نگارش‌های از رده‌خارجشان</string>
<string name="repo_disable_warning">از کار انداختن این مخزن وضعیت «ترجیحی»اش را از همهٔ کاره‌ها برمی‌دارد.</string>
<string name="repo_archive_failed">مخزن بایگانی اکنون در دسترس نیست</string>
<string name="app_details_repository">مخزن</string>
<string name="app_details_repositories">مخزن‌ها</string>

View File

@@ -558,7 +558,6 @@
<string name="ipfsgw_explainer">Lataa sovelluksia IPFS-verkkopäätepisteistä. (Toimii vain F-Droid-ohjelmavarastoissa, joissa on IPFS-tuki.)</string>
<string name="repo_list_info_title">Ohjelmavarastoluettelo</string>
<string name="repo_archive_toggle">Ohjelmavarastoarkisto</string>
<string name="repo_disable_warning">Tämän ohjelmavaraston poistaminen käytöstä poistaa sen \"ensisijaisen\" tilan kaikista sovelluksista.</string>
<string name="ipfsgw_summary_disabled">Lataa sovelluksia IPFS-verkkopäätepisteiden avulla: pois käytöstä</string>
<plurals name="ipfsgw_summary">
<item quantity="one">Lataa sovelluksia IPFS-verkkopäätepisteen avulla: %1$d käytössä</item>

View File

@@ -576,7 +576,6 @@
<string name="repo_archive_toggle">Archive des dépôts</string>
<string name="repo_archive_toggle_description">Afficher les apps archivées et les anciennes versions des apps</string>
<string name="repositories_last_update">Dernière mise à jour : %s</string>
<string name="repo_disable_warning">Désactiver ce dépôt enlèvera son statut « préféré » de toutes les applications.</string>
<string name="repositories_last_update_never">jamais</string>
<string name="repo_list_info_text">Un dépôt est un pourvoyeur d\'applis. Cette liste affiche tous les dépôts actuellement ajoutés. Les dépôts désactivés ne sont pas utilisés. \n \nSi une appli est disponible dans plus d\'un dépôt, le dépôt le plus haut dans la liste est préféré. Vous pouvez changer l\'ordre des dépôts avec un appui long puis en les faisant glisser.</string>
<string name="repo_error_adding_archive">Les dépôts d\'archive ne peuvent pas être directement ajoutés. Appuyez sur le dépôt dans la liste puis activez l\'archive à cet emplacement.</string>

View File

@@ -437,7 +437,6 @@
<string name="repo_confirm_delete_body">Ciallaíonn scriosadh stór nach mbeidh feidhmchláir uaidh ar fáil a thuilleadh.
\n
\nNóta: Fanfaidh gach feidhmchlár a suiteáladh roimhe seo ar do ghléas.</string>
<string name="repo_disable_warning">Má dhíchumasaítear an stór seo, bainfear a stádas \"is fearr\" as gach feidhmchlár.</string>
<string name="repo_disable_warning_button">Díchumasaigh</string>
<string name="repo_added">Stór pacáiste sábháilte %1$s.</string>
<string name="repo_searching_address">Ag lorg stór pacáiste ag

View File

@@ -537,7 +537,6 @@
<string name="repo_mirror_exists">Este espello xa foi engadido (%s).</string>
<string name="repo_mirror_add_info">A URL que está tentando engadir (%s) é un espello dun repositorio existente.</string>
<string name="repo_archive_toggle">Arquivo de repositorios</string>
<string name="repo_disable_warning">Desactivar este repositorio eliminará o seu estado de \"preferente\" de todas as aplicacións.</string>
<string name="repo_disable_warning_button">Desactivar</string>
<string tools:ignore="UnusedResources" name="category_Nightly">nocturno</string>
<string name="swap_banner_message">Para compartir aplicacións, conéctate á mesma rede Wi-Fi que a túa amizade.</string>

View File

@@ -585,7 +585,6 @@
<string name="repo_list_info_text">מאגר הוא מקור של יישומונים. הרשימה הזאת מציגה את כל המאגרים שנוספו. המאגרים המושבתים אינם בשימוש.
\n
\nאם יישומון הוא יותר במאגר אחד, המאגר שנמצא יותר גבוה ברשימה מקבל עדיפות אוטומטית. אפשר לסדר את המאגרים מחדש בלחיצה ארוכה עליהם וגרירתם.</string>
<string name="repo_disable_warning">השבתת המאגר תסיר את מצב ה„העדפה” שלו מכל היישומונים.</string>
<string name="repo_archive_failed">מאגר ארכיון לא זמין כרגע</string>
<string name="ipfsgw_summary_disabled">הורדת יישומונים באמצעות נקודות גישת IPFS: מושבתת</string>
<string name="ipfsgw_add_title">הוספת שער גישת IPFS</string>

View File

@@ -605,7 +605,6 @@
<string name="repo_archive_failed">Arhivski repozitorij trenutačno nije dostupan</string>
<string name="repo_confirm_delete_mirror_title">Izbrisati zrcalni server?</string>
<string name="repo_confirm_delete_mirror_body">Možeš nastaviti instalirati aplikacije iz ovog repozitorija pomoću drugih zrcalnih servera.</string>
<string name="repo_disable_warning">Deaktiviranje ovog repozitorija će ukloniti njegov „preferirani” status iz svih aplikacija.</string>
<string name="repo_disable_warning_button">Deaktiviraj</string>
<string name="share_mirror">Dijeli zrcalni server</string>
<string name="show_repository_qr">Prikaži QR kod repozitorija</string>

View File

@@ -570,7 +570,6 @@
<string name="repositories_last_update_never">soha</string>
<string name="repo_archive_failed">Az archív tároló jelenleg nem érhető el</string>
<string name="repo_archive_toggle_description">Archivált alkalmazások és elavult verziók megjelenítése</string>
<string name="repo_disable_warning">A tároló letiltásával eltávolítja az „előnyben részesített” állapotát az összes alkalmazástól.</string>
<string name="swap_bluetooth_permissions">Adja meg a Bluetooth vagy a Közeli eszköz engedélyt!</string>
<string name="auto_update_time_past">Következő frissítés a feltételek teljesülése esetén</string>
<string name="menu_share_apk">APK-fájl megosztása</string>

View File

@@ -538,7 +538,6 @@
<string name="repo_share_not_found">Tidak dapat mencari alamat repositori dalam teks terbagi.</string>
<string name="repo_archive_toggle">Arsip repositori</string>
<string name="repo_archive_failed">Repositori arsip saat ini tidak tersedia</string>
<string name="repo_disable_warning">Menonaktifkan repositori ini akan menghilangkan status \"lebih disukai\" dari semua aplikasi.</string>
<string name="targetsdk_or_later">sasaran %s</string>
<string name="repo_disable_warning_button">Nonaktifkan</string>
<string name="ipfsgw_explainer">Unduh aplikasi dari titik akhir web IPFS. (Hanya bekerja pada repositori F-Droid dengan dukungan IPFS.)</string>

View File

@@ -557,7 +557,6 @@
<string name="repo_add_repo_and_mirror">Bæta við hugbúnaðarsafni og spegli</string>
<string name="repositories_last_update_never">aldrei</string>
<string name="repo_and_mirror_add_both_info">Slóðin sem þú ert að reyna að bæta við (%s) er spegill fyrir nýtt hugbúnaðarsafn. Bætt verður við bæði upprunalegu hugbúnaðarsafni og spegli þess.</string>
<string name="repo_disable_warning">Sé þetta hugbúnaðarsafn gert óvirkt verður staða þess sem \"ákjósanlegt\" fjarlægð af öllum forritum.</string>
<string name="app_details_repository">Hugbúnaðarsafn</string>
<string name="app_details_repository_preferred">(ákjósanlegt)</string>
<string name="app_details_repository_expand">Útvíkka lista yfir hugbúnaðarsöfn</string>

View File

@@ -591,7 +591,6 @@
<string name="repositories_last_update_never">mai</string>
<string name="repo_list_info_title">Elenco repository</string>
<string name="repo_archive_failed">Repo di archivio attualmente non disponibile</string>
<string name="repo_disable_warning">Disattivando questo repository verrà rimosso il suo stato \"preferito\" da tutte le app.</string>
<string name="repo_add_repo_and_mirror">Aggiungi repository e mirror</string>
<string name="repo_view_repo">Vedi il repository</string>
<string name="repo_scan_qr_code">Scansiona codice QR</string>

View File

@@ -559,7 +559,6 @@
</plurals>
<string name="ipfsgw_summary_disabled">IPFSウェブ終点を使用してアプリをダウンロード: 無効</string>
<string name="ipfsgw_explainer">IPFSウェブ終点からアプリをダウンロードします。 IPFSに対応するF-Droidレポジトリだけで機能します。</string>
<string name="repo_disable_warning">このリポジトリを無効にすると、すべてのアプリからそれの「優先」状態が削除されます。</string>
<string name="ipfsgw_title">IPFSゲートウェイ</string>
<string name="ipfsgw_add_title">IPFSゲートウェイを追加</string>
<string name="nearby_splach__external_storage_permission_explainer">F-DroidはSDカード上からレポジトリとミラーを検索できますが、権限が必要です。</string>

View File

@@ -501,7 +501,6 @@
<string name="swap_wifi_vpn_conflict">ಸಕ್ರಿಯ ವಿ.ಪಿ.ಎನ್ ಜೊತೆಗೆ ಸಂಘರ್ಷಿಸುತ್ತದೆ!</string>
<string name="app_name_basic">ಎಫ್-ಡ್ರಾಯ್ಡ್ ಬೇಸಿಕ್</string>
<string name="ipfsgw_caption_custom_gateways">ಕಸ್ಟಮೈಜ್ ಐ ಪಿ ಎಫ್ ಎಸ್ ಗೇಟ್ವೇಸ್</string>
<string name="repo_disable_warning">ಡಿಸೇಬ್ಲಿಂಗ್ ದಿಸ್ ರೆಪೊಸಿಟರಿ ವಿಲ್ ರಿಮೋವ್ ಇಟ್ಸ್ \"ಪ್ರಿಫರ್ಡ್\" ಸ್ಥಿತಿ ಫ್ರಾಮ್ ಆಲ್ ಅಲ್ ಅಪ್ಪ್ಸ್.</string>
<string name="share_apk_error">ಅಪ್ಲಿಕೇಶನ್ ಫೈಲ್ ಹಂಚಿಸಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ</string>
<string name="warning_target_sdk">ಈ ಅಪ್ಲಿಕೇಶನ್ ಹಳಈ ಅಪ್ಲಿಕೇಶನ್‌ನು ಹಳೆಯ ಆಂಡ್ರಾಯ್ಡ್ ಆವೃತ್ತಿಗಾಗಿ ನಿರ್ಮಿಸಲಾಗಿದೆ ಮತ್ತು ಸ್ವಯಂಮತ್ತಾಯವಾಗಿ ನವೀಕರಿಸಲು ಸಾಧ್ಯವಿಲ್ಲ</string>
<string name="swap_connection_indexv0_error">ನೀವು ಹಳೆಯ ಆವೃತ್ತಿಯ F-Droid ಅನ್ನು ಹೊಂದಿರುವ ಯಾರಾದರು ಸಂಪರ್ಕ ಸ್ಥಾಪಿಸಲು ಪ್ರಯತ್ನಿಸುತ್ತಿದ್ದೀರಿ. ಅವರು ತಮ್ಮ F-Droid ಅಪ್ಲಿಕೇಶನ್‌ನನ್ನು ನವೀಕರಿಸಬೇಕಾಗಿರುತ್ತದೆ. ಅವರು ನಿಮ್ಮಿಂದ F-Droid ಅಪ್ಲಿಕೇಶನ್‌ನನ್ನು ನವೀಕರಿಸಲು ಸ್ವಾಪ್ ಬಳಸಬಹುದು.</string>

View File

@@ -548,7 +548,6 @@
<string name="app_list_no_suggested_version">설치에 권장되는 버전이 없습니다.</string>
<string name="repo_exists">이 저장소는 이미 추가되었습니다.</string>
<string name="repo_error_adding_archive">Archive 저장소는 직접 추가할 수 없습니다. 목록에서 저장소를 탭하고 거기 아카이브를 활성화.</string>
<string name="repo_disable_warning">이 저장소를 비활성화하면 모든 앱의 \"preferred\"상태를 제거합니다.</string>
<string name="targetsdk_or_later">대상 %s</string>
<string name="repositories_last_update">마지막 업데이트: %s</string>
<string name="repositories_last_update_never">한 적 없음</string>

View File

@@ -575,7 +575,6 @@
</plurals>
<string name="repo_and_mirror_add_both_info">URL, ko tiek mēģināts pievienot (%s), ir jaunas glabātavas spoguļserveris. Tiks pievienota gan sākotnējā glabātava, gan spoguļserveris.</string>
<string name="repo_archive_toggle_description">Rādīt arhivētās lietotnes un novecojušas lietotņu versijas</string>
<string name="repo_disable_warning">Šīs glabātavas atspējošana noņems tās stāvokli \"vēlama\" no visām lietotnēm.</string>
<string name="auto_update_time">Tuvākais nākamais atjauninājums: %s</string>
<string name="auto_update_time_past">Nākamais atjauninājums, kad ir izpildījušies nosacījumi</string>
<string name="antitetherednet">Piesaistīti tīkla pakalpojumi</string>

View File

@@ -581,7 +581,6 @@
<string name="repo_archive_failed">Pakkebrønnarkivet er ikke tilgjengelig nå</string>
<string name="repo_confirm_delete_mirror_title">Slett speilet?</string>
<string name="repo_confirm_delete_mirror_body">Du kan fortsette å installere apper fra denne pakkebrønnen ved å bruke andre speil.</string>
<string name="repo_disable_warning">Å skru av denne pakkebrønnen vil fjerne den som «foretrukket» fra alle apper.</string>
<string name="repo_disable_warning_button">Skru av</string>
<string name="share_mirror">Del speil</string>
<string name="show_repository_qr">Vis QR-kode til pakkebrønn</string>

View File

@@ -570,7 +570,6 @@
\n
\nAls een app in meerdere gegevensbanken voorkomt dan wordt de eerste gegevensbank in de lijst gebruikt. U kunt de volgorde veranderen door een gegevensbank ingedrukt te houden en te verplaatsen.</string>
<string name="repo_archive_toggle_description">Toon gearchiveerde apps en verouderde versies van apps</string>
<string name="repo_disable_warning">Uitschakelen van deze gegevensbank zal de \"geprefereerde\" status verwijderen van alle apps.</string>
<plurals name="ipfsgw_summary">
<item quantity="one">Download apps van IPFS web eindpunten: %1$d ingeschakeld</item>
<item quantity="other">Download apps van IPFS web eindpunten: %1$d ingeschakeld</item>

View File

@@ -596,7 +596,6 @@
<string name="app_details_repository_button_prefer">Preferuj repozytorium</string>
<string name="repo_error_adding_archive">Archiwalne repozytoria nie mogą być dodawane bezpośrednio. Naciśnij repozytorium na liście i włącz tam archiwum.</string>
<string name="repositories_last_update">Ostatnia aktualizacja: %s</string>
<string name="repo_disable_warning">Wyłączenie tego repozytorium spowoduje usunięcie jego statusu „preferowane” ze wszystkich aplikacji.</string>
<string name="repo_add_repo_and_mirror">Dodaj repozytorium i serwer lustrzany</string>
<string name="repo_view_repo">Wyświetl repozytorium</string>
<string name="repo_scan_qr_code">Zeskanuj kod QR</string>

View File

@@ -568,7 +568,6 @@
<string name="repo_archive_toggle">Arquivo do repositório</string>
<string name="repo_archive_toggle_description">Mostrar apps arquivados e versões desatualizadas de apps</string>
<string name="repo_archive_failed">O arquivo do repositório não está disponível no momento</string>
<string name="repo_disable_warning">Desativar este repositório removerá o estado de \"preferido\" dele de todos os apps.</string>
<string name="repo_disable_warning_button">Desativar</string>
<string name="app_details_repository_preferred">(preferido)</string>
<string name="repo_add_repo_and_mirror">Adicionar repositório e espelho</string>

View File

@@ -543,7 +543,6 @@
<string name="repo_list_info_text">Um repositório é uma fonte de aplicações. Esta lista mostra todos os repositórios atualmente adicionados. Os repositórios desativados não são utilizados.\n\nSe uma aplicação estiver em mais do que um repositório, o repositório no topo da lista é automaticamente preferido. Pode reordenar os repositórios premindo longamente e arrastando-os.</string>
<string name="repo_archive_toggle">Arquivo do repositório</string>
<string name="repo_archive_failed">O repositório de arquivos não está atualmente disponível</string>
<string name="repo_disable_warning">A desativação deste repositório removerá o seu estatdo de \"preferido\" de todas as apps.</string>
<string name="repo_disable_warning_button">Desativar</string>
<string name="nearby_splach__external_storage_permission_explainer">F-Droid pode procurar por repositórios e espelhos no seu Cartão de Memória, mas precisa da sua permissão para fazer isso.</string>
<string name="swap_banner_message">Para trocar aplicações, inicie o seu Wi-Fi e ligue-se à mesma rede que o seu amigo.</string>

View File

@@ -571,7 +571,6 @@
<string name="repo_archive_toggle_description">Mostrar aplicações arquivadas e versões desatualizadas de aplicações</string>
<string name="repo_archive_failed">O repositório de arquivos não está atualmente disponível</string>
<string name="repo_disable_warning_button">Desativar</string>
<string name="repo_disable_warning">A desativação deste repositório removerá o seu estatuto de \"preferido\" de todas as aplicações.</string>
<string name="ipfsgw_title">Gateways IPFS</string>
<string name="app_details_repository">Repositório</string>
<string name="app_details_repositories">Repositórios</string>

View File

@@ -570,7 +570,6 @@
<string name="repo_list_info_title">Lista de depozite</string>
<string name="repo_archive_toggle">Arhivă depozit</string>
<string name="repo_archive_failed">Arhivă depozit momentan indisponibilă</string>
<string name="repo_disable_warning">Dezactivarea acestui depozit îl va elimina ca fiind \"preferat\" pentru toate aplicațiile.</string>
<string name="repositories_last_update">Ultima actualizare: %s</string>
<string name="repositories_last_update_never">niciodată</string>
<string name="repo_list_info_text">Un depozit este o sursă de aplicații. Această listă afișează toate depozitele adăugate în prezent. Depozitele dezactivate nu sunt utilizate.

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