diff --git a/README.md b/README.md index dde8d6e..6d199cb 100644 --- a/README.md +++ b/README.md @@ -19,15 +19,19 @@

Discover and replace proprietary apps with FOSS alternatives.

-GitHub Release -GitHub Downloads (all assets, all releases) - GitHub repo size - GitHub License - - CodeQL Advanced - GitHub Release Date - + + Android Platform + + Minimum SDK + GitHub Release + GitHub Downloads (all assets, all releases) + GitHub License + + CodeQL Advanced + + Last Commit

+

Get it on IzzyOnDroid @@ -38,11 +42,11 @@ alt="Get it on F-Droid" width="160"> - + Get it on GitHub - +
@@ -56,7 +60,8 @@ ## What is LibreFind? -LibreFind is a free and lightweight Android app that scans your installed packages locally and queries our database to identify proprietary software and find FOSS alternatives. +LibreFind is a free and lightweight Android app that scans your installed packages locally and +queries our database to identify proprietary software and find FOSS alternatives. ### Core Features @@ -67,10 +72,10 @@ LibreFind is a free and lightweight Android app that scans your installed packag ## Screenshots -| Dashboard | Alternative List | Submission | Profile | -|--------------------------------------------------------------------|------------------------------------------------------------------------------|----------------------------------------------------------------------|----------------------------------------------------------------| +| Dashboard | Alternative List | Submission | Profile | +|-------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------|---------------------------------------------------------------------------------| | ![Dashboard](fastlane/metadata/android/en-US/images/phoneScreenshots/dashboard.jpg) | ![Alternatives](fastlane/metadata/android/en-US/images/phoneScreenshots/alternative_list.jpg) | ![Submission](fastlane/metadata/android/en-US/images/phoneScreenshots/submission.jpg) | ![Profile](fastlane/metadata/android/en-US/images/phoneScreenshots/profile.jpg) | -| Scan your apps and view sovereignty score | Browse FOSS alternatives | Contribute new alternatives | Track your submissions | +| Scan your apps and view sovereignty score | Browse FOSS alternatives | Contribute new alternatives | Track your submissions | ### Community Contributions @@ -94,11 +99,15 @@ Please join the Telegram channel for further discussions. ## ☕ Support -LibreFind is a free and open-source project managed independently. Our goal is to promote digital privacy by making FOSS apps mainstream. Currently, LibreFind is trusted by **1,334 registered users** globally. +LibreFind is a free and open-source project managed independently. Our goal is to promote digital +privacy by making FOSS apps mainstream. Currently, LibreFind is trusted by **1,334 registered users +** globally. + +While the app is free, **infrastructure and hosting servers** cost money to keep running 24/7. -While the app is free, **infrastructure and hosting servers** cost money to keep running 24/7. +If you find this tool useful for reclaiming your digital privacy, please consider buying me a +coffee. Your support goes directly toward: -If you find this tool useful for reclaiming your digital privacy, please consider buying me a coffee. Your support goes directly toward: * Paying monthly server bills. * Keeping the database online and fast. * Development of new features. @@ -109,14 +118,29 @@ If you find this tool useful for reclaiming your digital privacy, please conside - +## Contributing + +Can't donate? You can still make a real difference: + +- **Star the repo** — helps others discover LibreFind on GitHub +- **Report bugs** — open an issue if something isn't working +- **Suggest features** — share ideas through GitHub issues +- **Test & give feedback** — usability and performance reports are valuable +- **Translate** — help bring LibreFind to more languages + via [Weblate](https://hosted.weblate.org/engage/librefind/) +- **Spread the word** — share with friends or on social media +- **Contribute code** — fix bugs or add features via pull requests + +Every small contribution helps the project grow. ## Translations + Translation status -LibreFind uses [Weblate](https://hosted.weblate.org/engage/librefind/) to manage translations. You can help translate LibreFind into your language! +LibreFind uses [Weblate](https://hosted.weblate.org/engage/librefind/) to manage translations. You +can help translate LibreFind into your language! ## Star History diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 312e612..f4af08b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,4 +1,8 @@ +import com.android.build.api.artifact.SingleArtifact +import com.android.build.api.dsl.ApplicationExtension import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import java.io.FileInputStream +import java.util.Properties plugins { alias(libs.plugins.android.application) @@ -8,13 +12,23 @@ plugins { alias(libs.plugins.kotlin.serialization) } -val versionMajor = 1 -val versionMinor = 0 -val versionPatch = 0 -val versionStage = "beta" // Change to "alpha", "beta", "rc", or "stable" -val versionBuild = 18 +// Load the version.properties file +val versionPropsFile: File = rootProject.file("version.properties") +val versionProps = Properties() +if (versionPropsFile.exists()) { + versionProps.load(FileInputStream(versionPropsFile)) +} else { + throw GradleException("version.properties file not found! Please create it in the project root.") +} + +// Safely parse the values +val vMajor = versionProps.getProperty("VERSION_MAJOR", "0").toInt() +val vMinor = versionProps.getProperty("VERSION_MINOR", "1").toInt() +val vPatch = versionProps.getProperty("VERSION_PATCH", "0").toInt() +val vStage = versionProps.getProperty("VERSION_STAGE", "beta").lowercase() +val vBuild = versionProps.getProperty("VERSION_BUILD", "1").toInt() -val stageWeight = when (versionStage.lowercase()) { +val stageWeight = when (vStage) { "alpha" -> 0 "beta" -> 1 "rc" -> 2 @@ -22,22 +36,32 @@ val stageWeight = when (versionStage.lowercase()) { else -> 0 } -val computedVersionCode = (versionMajor * 100000) + - (versionMinor * 1000) + - (versionPatch * 100) + - (stageWeight * 10) + - versionBuild +val suffix = if (vStage == "stable") "" else "-$vStage$vBuild" -// Construct the versionName dynamically -// If stable, just output "1.0.0" -// Otherwise, output "1.0.0-beta16" or "1.0.0-rc1" -val mVersionName = - "$versionMajor.$versionMinor.$versionPatch-$versionStage${ - if (versionStage.lowercase() == "stable") null else versionBuild - }" +val computedVersionCode = versionProps.getProperty("VERSION_CODE")?.toInt() ?: ((vMajor * 10000000) + + (vMinor * 100000) + + (vPatch * 1000) + + (stageWeight * 100) + + vBuild) +val computedVersionName = versionProps.getProperty("VERSION_NAME") ?: "$vMajor.$vMinor.$vPatch$suffix" -configure { +tasks.register("updateVersionProperties") { + doLast { + val calculatedCode = (vMajor * 10000000) + + (vMinor * 100000) + + (vPatch * 1000) + + (stageWeight * 100) + + vBuild + val calculatedName = "$vMajor.$vMinor.$vPatch$suffix" + + versionProps.setProperty("VERSION_CODE", calculatedCode.toString()) + versionProps.setProperty("VERSION_NAME", calculatedName) + versionProps.store(versionPropsFile.outputStream(), null) + } +} + +configure { namespace = "com.jksalcedo.librefind" compileSdk = 36 @@ -46,7 +70,7 @@ configure { minSdk = 24 targetSdk = 36 versionCode = computedVersionCode - versionName = mVersionName + versionName = computedVersionName testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -104,11 +128,11 @@ kotlin { androidComponents { onVariants { variant -> val appName = "librefind" - val versionName = mVersionName + val versionName = computedVersionName val capitalizedName = variant.name.replaceFirstChar { it.titlecase() } tasks.register("rename${capitalizedName}Apk") { - from(variant.artifacts.get(com.android.build.api.artifact.SingleArtifact.APK)) + from(variant.artifacts.get(SingleArtifact.APK)) into(layout.buildDirectory.dir("outputs/apk/${variant.name}")) rename(".*\\.apk", "$appName-v$versionName-${variant.name}.apk") } @@ -176,4 +200,4 @@ dependencies { androidTestImplementation(libs.androidx.compose.ui.test.junit4) debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.test.manifest) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/jksalcedo/librefind/MainActivity.kt b/app/src/main/java/com/jksalcedo/librefind/MainActivity.kt index 6230281..23abbea 100644 --- a/app/src/main/java/com/jksalcedo/librefind/MainActivity.kt +++ b/app/src/main/java/com/jksalcedo/librefind/MainActivity.kt @@ -5,6 +5,7 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.padding import androidx.compose.material3.Icon import androidx.compose.material3.NavigationBar @@ -46,6 +47,7 @@ class MainActivity : AppCompatActivity() { currentRoute == Route.Dashboard.route || currentRoute == Route.Discover.route Scaffold( + contentWindowInsets = WindowInsets(0), bottomBar = { if (showBottomBar) { NavigationBar { diff --git a/app/src/main/java/com/jksalcedo/librefind/data/local/InventorySource.kt b/app/src/main/java/com/jksalcedo/librefind/data/local/InventorySource.kt index 0771af5..c42121c 100644 --- a/app/src/main/java/com/jksalcedo/librefind/data/local/InventorySource.kt +++ b/app/src/main/java/com/jksalcedo/librefind/data/local/InventorySource.kt @@ -13,7 +13,8 @@ import android.os.Build * Wraps Android PackageManager API to extract installed packages. */ class InventorySource( - private val context: Context + private val context: Context, + private val preferencesManager: PreferencesManager ) { /** * Gets all user-installed apps @@ -34,16 +35,18 @@ class InventorySource( .map { it.activityInfo.packageName } .toSet() + val hideSystemPackages = preferencesManager.shouldHideSystemPackages() + pm.getInstalledPackages(PackageManager.GET_META_DATA) .filter { app -> val isSystem = (app.applicationInfo?.flags?.and(ApplicationInfo.FLAG_SYSTEM) != 0) val isUpdatedSystem = (app.applicationInfo?.flags?.and(ApplicationInfo.FLAG_UPDATED_SYSTEM_APP) != 0) - - // Filter logic: - // 1. User apps (!isSystem) - // 2. Updated System Apps (isUpdatedSystem) - // 3. System apps that are launchable (launchablePackages.contains) - !isSystem || isUpdatedSystem || launchablePackages.contains(app.packageName) + + if (hideSystemPackages) { + !isSystem || isUpdatedSystem + } else { + !isSystem || isUpdatedSystem || launchablePackages.contains(app.packageName) + } } } catch (_: Exception) { emptyList() diff --git a/app/src/main/java/com/jksalcedo/librefind/data/local/PreferencesManager.kt b/app/src/main/java/com/jksalcedo/librefind/data/local/PreferencesManager.kt index 3cd1c97..8dc886d 100644 --- a/app/src/main/java/com/jksalcedo/librefind/data/local/PreferencesManager.kt +++ b/app/src/main/java/com/jksalcedo/librefind/data/local/PreferencesManager.kt @@ -2,10 +2,13 @@ package com.jksalcedo.librefind.data.local import android.content.Context import android.content.SharedPreferences +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow import java.util.UUID class PreferencesManager(private val context: Context) { - private val prefs: SharedPreferences = + private val prefs: SharedPreferences = context.getSharedPreferences("librefind_prefs", Context.MODE_PRIVATE) fun hasSeenOnboarding(): Boolean { @@ -43,7 +46,7 @@ class PreferencesManager(private val context: Context) { fun getOrCreateDeviceId(): String { val existing = prefs.getString(KEY_DEVICE_ID, null) if (existing != null) return existing - + val newId = UUID.randomUUID().toString() prefs.edit().putString(KEY_DEVICE_ID, newId).apply() return newId @@ -57,10 +60,43 @@ class PreferencesManager(private val context: Context) { } } + // --- System package filtering preferences --- + /** + * Returns whether system/vendor packages should be hidden from the UI. + * + * Default: true (on) + */ + fun shouldHideSystemPackages(): Boolean { + return prefs.getBoolean(KEY_HIDE_SYSTEM_PACKAGES, true) + } + + /** + * Persist user preference for hiding system/vendor packages. + */ + fun setHideSystemPackages(enabled: Boolean) { + prefs.edit().putBoolean(KEY_HIDE_SYSTEM_PACKAGES, enabled).apply() + } + + fun observeHideSystemPackages(): Flow = callbackFlow { + val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if (key == KEY_HIDE_SYSTEM_PACKAGES) { + trySend(shouldHideSystemPackages()) + } + } + prefs.registerOnSharedPreferenceChangeListener(listener) + trySend(shouldHideSystemPackages()) + awaitClose { prefs.unregisterOnSharedPreferenceChangeListener(listener) } + } + + + companion object { private const val KEY_ONBOARDING_COMPLETE = "onboarding_complete" private const val KEY_LAST_VERSION = "last_seen_version" private const val KEY_TUTORIAL_COMPLETE = "tutorial_complete" private const val KEY_DEVICE_ID = "device_id" + + // New keys for system package filtering + private const val KEY_HIDE_SYSTEM_PACKAGES = "hide_system_packages" } } diff --git a/app/src/main/java/com/jksalcedo/librefind/data/remote/model/SupabaseModels.kt b/app/src/main/java/com/jksalcedo/librefind/data/remote/model/SupabaseModels.kt index 0927638..9459eac 100644 --- a/app/src/main/java/com/jksalcedo/librefind/data/remote/model/SupabaseModels.kt +++ b/app/src/main/java/com/jksalcedo/librefind/data/remote/model/SupabaseModels.kt @@ -8,7 +8,7 @@ data class SolutionDto( @SerialName("package_name") val packageName: String, val name: String, val description: String, - val category: String? = null, + val category: String = "Other", @SerialName("icon_url") val iconUrl: String? = null, @SerialName("fdroid_id") val fdroidId: String? = null, @SerialName("repo_url") val repoUrl: String? = null, @@ -103,4 +103,36 @@ data class AppReport( @SerialName("issue_type") val issueType: String, @SerialName("description") val description: String, @SerialName("status") val status: String = "pending" +) + +@Serializable +data class MatchVoteDto( + @SerialName("user_id") val userId: String, + @SerialName("target_package") val targetPackage: String, + @SerialName("solution_package") val solutionPackage: String, + val vote: Int // +1 or -1 +) + +/** Returned by the get_alternatives_with_match_votes RPC */ +@Serializable +data class AlternativeWithVoteDto( + @SerialName("package_name") val packageName: String, + val name: String, + val license: String, + @SerialName("repo_url") val repoUrl: String? = null, + @SerialName("fdroid_id") val fdroidId: String? = null, + @SerialName("icon_url") val iconUrl: String? = null, + val description: String = "", + val category: String = "Other", + val features: List? = emptyList(), + val pros: List? = emptyList(), + val cons: List? = emptyList(), + @SerialName("rating_privacy") val ratingPrivacy: Float? = 0f, + @SerialName("rating_usability") val ratingUsability: Float? = 0f, + @SerialName("rating_features") val ratingFeatures: Float? = 0f, + @SerialName("vote_count") val voteCount: Int? = 0, + @SerialName("match_upvotes") val matchUpvotes: Int = 0, + @SerialName("match_downvotes") val matchDownvotes: Int = 0, + @SerialName("match_score") val matchScore: Int = 0, + @SerialName("user_match_vote") val userMatchVote: Int? = null ) \ No newline at end of file diff --git a/app/src/main/java/com/jksalcedo/librefind/data/repository/DeviceInventoryRepoImpl.kt b/app/src/main/java/com/jksalcedo/librefind/data/repository/DeviceInventoryRepoImpl.kt index c6ef831..9c6ec63 100644 --- a/app/src/main/java/com/jksalcedo/librefind/data/repository/DeviceInventoryRepoImpl.kt +++ b/app/src/main/java/com/jksalcedo/librefind/data/repository/DeviceInventoryRepoImpl.kt @@ -1,8 +1,10 @@ package com.jksalcedo.librefind.data.repository +import android.content.pm.ApplicationInfo import android.content.pm.PackageInfo import android.util.Log import com.jksalcedo.librefind.data.local.InventorySource +import com.jksalcedo.librefind.data.local.PreferencesManager import com.jksalcedo.librefind.data.local.SafeSignatureDb import com.jksalcedo.librefind.domain.model.AppItem import com.jksalcedo.librefind.domain.model.AppStatus @@ -26,7 +28,8 @@ class DeviceInventoryRepoImpl( private val appRepository: AppRepository, private val ignoredAppsRepository: IgnoredAppsRepository, private val cacheRepository: CacheRepository, - private val reclassifiedAppsRepository: ReclassifiedAppsRepository + private val reclassifiedAppsRepository: ReclassifiedAppsRepository, + private val preferencesManager: PreferencesManager ) : DeviceInventoryRepo { companion object { @@ -125,20 +128,56 @@ class DeviceInventoryRepoImpl( val installer = localSource.getInstaller(packageName) val icon = pkg.applicationInfo?.icon + // Use standard PackageManager flags to determine if it is a system app + val isSystem = (pkg.applicationInfo?.flags?.and(ApplicationInfo.FLAG_SYSTEM)) != 0 + val isSystemPackage = isSystem + if (packageName in ignoredApps) { - return createAppItem(packageName, label, AppStatus.IGNORED, installer, icon) + return createAppItem( + packageName, + label, + AppStatus.IGNORED, + installer, + icon, + isUserReclassified = false, + isSystemPackage = isSystemPackage + ) } if (packageName in reclassifiedApps) { - return createAppItem(packageName, label, AppStatus.FOSS, installer, icon, isUserReclassified = true) + return createAppItem( + packageName, + label, + AppStatus.FOSS, + installer, + icon, + isUserReclassified = true, + isSystemPackage = isSystemPackage + ) } if (installer in FOSS_INSTALLERS) { - return createAppItem(packageName, label, AppStatus.FOSS, installer, icon) + return createAppItem( + packageName, + label, + AppStatus.FOSS, + installer, + icon, + isUserReclassified = false, + isSystemPackage = isSystemPackage + ) } if (signatureDb.isKnownFossApp(packageName)) { - return createAppItem(packageName, label, AppStatus.FOSS, installer, icon) + return createAppItem( + packageName, + label, + AppStatus.FOSS, + installer, + icon, + isUserReclassified = false, + isSystemPackage = isSystemPackage + ) } val isKnownSolution = try { @@ -148,7 +187,15 @@ class DeviceInventoryRepoImpl( } if (isKnownSolution) { - return createAppItem(packageName, label, AppStatus.FOSS, installer, icon) + return createAppItem( + packageName, + label, + AppStatus.FOSS, + installer, + icon, + isUserReclassified = false, + isSystemPackage = isSystemPackage + ) } val isProprietary = try { @@ -158,19 +205,51 @@ class DeviceInventoryRepoImpl( } if (isProprietary) { - return createAppItem(packageName, label, AppStatus.PROP, installer, icon) + return createAppItem( + packageName, + label, + AppStatus.PROP, + installer, + icon, + isUserReclassified = false, + isSystemPackage = isSystemPackage + ) } if (installer in PROPRIETARY_INSTALLERS) { - return createAppItem(packageName, label, AppStatus.PROP, installer, icon) + return createAppItem( + packageName, + label, + AppStatus.PROP, + installer, + icon, + isUserReclassified = false, + isSystemPackage = isSystemPackage + ) } // Only show PENDING if app isn't already classified if (packageName in pendingPackages) { - return createAppItem(packageName, label, AppStatus.PENDING, installer, icon) + return createAppItem( + packageName, + label, + AppStatus.PENDING, + installer, + icon, + isUserReclassified = false, + isSystemPackage = isSystemPackage + ) } - return createAppItem(packageName, label, AppStatus.UNKN, installer, icon) + return createAppItem( + packageName, + label, + AppStatus.UNKN, + installer, + icon, + isUserReclassified = false, + isSystemPackage = isSystemPackage + ) } private suspend fun createAppItem( @@ -179,7 +258,8 @@ class DeviceInventoryRepoImpl( status: AppStatus, installer: String?, icon: Int?, - isUserReclassified: Boolean = false + isUserReclassified: Boolean = false, + isSystemPackage: Boolean = false ): AppItem { val alternativesCount = if (status == AppStatus.PROP) { try { @@ -199,11 +279,12 @@ class DeviceInventoryRepoImpl( installerId = installer, knownAlternatives = alternativesCount, icon = icon, - isUserReclassified = isUserReclassified + isUserReclassified = isUserReclassified, + isSystemPackage = isSystemPackage ) } override fun getInstaller(packageName: String): String? { return localSource.getInstaller(packageName) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/jksalcedo/librefind/data/repository/SupabaseAppRepository.kt b/app/src/main/java/com/jksalcedo/librefind/data/repository/SupabaseAppRepository.kt index 53f3734..9bdca90 100644 --- a/app/src/main/java/com/jksalcedo/librefind/data/repository/SupabaseAppRepository.kt +++ b/app/src/main/java/com/jksalcedo/librefind/data/repository/SupabaseAppRepository.kt @@ -1,8 +1,10 @@ package com.jksalcedo.librefind.data.repository import android.util.Log +import com.jksalcedo.librefind.data.remote.model.AlternativeWithVoteDto import com.jksalcedo.librefind.data.remote.model.AppReport import com.jksalcedo.librefind.data.remote.model.AppScanStatsDto +import com.jksalcedo.librefind.data.remote.model.MatchVoteDto import com.jksalcedo.librefind.data.remote.model.ProfileDto import com.jksalcedo.librefind.data.remote.model.SolutionDto import com.jksalcedo.librefind.data.remote.model.UserLinkingSubmissionsDto @@ -91,29 +93,81 @@ class SupabaseAppRepository( } override suspend fun getAlternatives(packageName: String): List { + // Try the RPC first (includes match-vote aggregates). + // Falls back to direct query if the function isn't available yet. + return try { + val userId = supabase.auth.currentUserOrNull()?.id + + @Serializable + data class GetAlternativesParams( + @SerialName("target_pkg") val targetPkg: String, + @SerialName("user_id") val userId: String? + ) + + val results = supabase.postgrest.rpc( + "get_alternatives_with_match_votes", + GetAlternativesParams(packageName, userId) + ).decodeList() + + results.map { dto -> dto.toAlternative() } + } catch (rpcError: Exception) { + Log.w("SupabaseAppRepo", "get_alternatives_with_match_votes unavailable, falling back", rpcError) + getAlternativesDirect(packageName) + } + } + + private suspend fun getAlternativesDirect(packageName: String): List { return try { val target = supabase.postgrest.from("targets") .select(columns = Columns.list("alternatives")) { - filter { - eq("package_name", packageName) - } - }.decodeSingleOrNull() ?: return emptyList() + filter { eq("package_name", packageName) } + limit(1) + }.decodeSingleOrNull() - if (target.alternatives.isNullOrEmpty()) return emptyList() + val altPackageNames = target?.alternatives.orEmpty() + if (altPackageNames.isEmpty()) return emptyList() val solutions = supabase.postgrest.from("solutions") .select( columns = Columns.list( "package_name", "name", "license", "repo_url", "fdroid_id", "icon_url", "description", "features", "pros", "cons", - "rating_usability", "rating_privacy", "rating_features", "vote_count" + "rating_usability", "rating_privacy", "rating_features", + "vote_count", "category" ) ) { - filter { - isIn("package_name", target.alternatives) - } + filter { isIn("package_name", altPackageNames) } }.decodeList() + @Serializable + data class MatchVoteRowDto( + @SerialName("solution_package") val solutionPackage: String, + @SerialName("user_id") val userId: String, + val vote: Int + ) + + val allVotes: List = try { + supabase.postgrest.from("target_solution_votes") + .select(columns = Columns.list("solution_package", "user_id", "vote")) { + filter { eq("target_package", packageName) } + }.decodeList() + } catch (_: Exception) { + emptyList() + } + + val currentUserId = supabase.auth.currentUserOrNull()?.id + + data class VoteAgg(val upvotes: Int, val downvotes: Int, val userVote: Int?) + val votesByPkg = allVotes + .groupBy { it.solutionPackage } + .mapValues { (_, rows) -> + VoteAgg( + upvotes = rows.count { it.vote == 1 }, + downvotes = rows.count { it.vote == -1 }, + userVote = rows.firstOrNull { it.userId == currentUserId }?.vote + ) + } + solutions.map { dto -> val usabilityRating = dto.ratingUsability ?: 0f val privacyRating = dto.ratingPrivacy ?: 0f @@ -122,12 +176,11 @@ class SupabaseAppRepository( val ratingAvg = if (rawRatingCount > 0) { val sum = usabilityRating + privacyRating + featuresRating - val nonZeroCount = - listOf(usabilityRating, privacyRating, featuresRating).count { it > 0 } + val nonZeroCount = listOf(usabilityRating, privacyRating, featuresRating).count { it > 0 } if (nonZeroCount > 0) sum / nonZeroCount else 0f } else 0f - val ratingCount = if (ratingAvg == 0f) 0 else rawRatingCount + val agg = votesByPkg[dto.packageName] Alternative( id = dto.packageName, @@ -137,8 +190,13 @@ class SupabaseAppRepository( repoUrl = dto.repoUrl ?: "", fdroidId = dto.fdroidId ?: "", iconUrl = dto.iconUrl, + category = dto.category, + matchUpvotes = agg?.upvotes ?: 0, + matchDownvotes = agg?.downvotes ?: 0, + matchScore = (agg?.upvotes ?: 0) - (agg?.downvotes ?: 0), + userMatchVote = agg?.userVote, ratingAvg = ratingAvg, - ratingCount = ratingCount, + ratingCount = if (ratingAvg == 0f) 0 else rawRatingCount, usabilityRating = usabilityRating, privacyRating = privacyRating, featuresRating = featuresRating, @@ -149,11 +207,50 @@ class SupabaseAppRepository( ) } } catch (e: Exception) { - e.printStackTrace() + Log.e("SupabaseAppRepo", "getAlternativesDirect failed", e) emptyList() } } + private fun AlternativeWithVoteDto.toAlternative(): Alternative { + val usabilityRating = ratingUsability ?: 0f + val privacyRating = ratingPrivacy ?: 0f + val featuresRating = ratingFeatures ?: 0f + val rawRatingCount = voteCount ?: 0 + + val ratingAvg = if (rawRatingCount > 0) { + val sum = usabilityRating + privacyRating + featuresRating + val nonZeroCount = listOf(usabilityRating, privacyRating, featuresRating).count { it > 0 } + if (nonZeroCount > 0) sum / nonZeroCount else 0f + } else 0f + + val ratingCount = if (ratingAvg == 0f) 0 else rawRatingCount + + return Alternative( + id = packageName, + name = name, + packageName = packageName, + license = license, + repoUrl = repoUrl ?: "", + fdroidId = fdroidId ?: "", + iconUrl = iconUrl, + category = category, + matchScore = matchScore, + matchUpvotes = matchUpvotes, + matchDownvotes = matchDownvotes, + userMatchVote = userMatchVote, + ratingAvg = ratingAvg, + ratingCount = ratingCount, + usabilityRating = usabilityRating, + privacyRating = privacyRating, + featuresRating = featuresRating, + description = description, + features = features.orEmpty(), + pros = pros.orEmpty(), + cons = cons.orEmpty() + ) + } + override suspend fun getAlternative(packageName: String): Alternative? { return try { val dto = supabase.postgrest.from("solutions") @@ -161,7 +258,8 @@ class SupabaseAppRepository( columns = Columns.list( "package_name", "name", "license", "repo_url", "fdroid_id", "icon_url", "description", "features", "pros", "cons", - "rating_usability", "rating_privacy", "rating_features", "vote_count" + "rating_usability", "rating_privacy", "rating_features", "vote_count", + "category" ) ) { filter { @@ -191,6 +289,7 @@ class SupabaseAppRepository( repoUrl = dto.repoUrl ?: "", fdroidId = dto.fdroidId ?: "", iconUrl = dto.iconUrl, + category = dto.category, ratingAvg = ratingAvg, ratingCount = ratingCount, usabilityRating = usabilityRating, @@ -392,6 +491,37 @@ class SupabaseAppRepository( } } + override suspend fun castMatchVote( + targetPackage: String, + solutionPackage: String, + vote: Int + ): Result = runCatching { + val userId = supabase.auth.currentUserOrNull()?.id + ?: throw IllegalStateException("Not logged in") + + if (vote == 0) { + // Remove existing vote + supabase.postgrest.from("target_solution_votes").delete { + filter { + eq("user_id", userId) + eq("target_package", targetPackage) + eq("solution_package", solutionPackage) + } + } + } else { + val matchVote = MatchVoteDto( + userId = userId, + targetPackage = targetPackage, + solutionPackage = solutionPackage, + vote = vote.coerceIn(-1, 1) + ) + supabase.postgrest.from("target_solution_votes").upsert(matchVote) { + onConflict = "user_id,target_package,solution_package" + defaultToNull = false + } + } + } + override suspend fun castVote( packageName: String, voteType: String, @@ -782,6 +912,78 @@ class SupabaseAppRepository( } } + override suspend fun getSiblingAlternatives(packageName: String): List? { + return try { + // 1. Look up this solution's category + @Serializable + data class CategoryDto(val category: String = "Other") + + val categoryDto = supabase.postgrest.from("solutions") + .select(columns = Columns.list("category")) { + filter { eq("package_name", packageName) } + limit(1) + }.decodeSingleOrNull() ?: return null + + // Return null (not empty) so callers can distinguish "category unset" from "no peers" + if (categoryDto.category == "Other") return null + + // 2. Fetch all solutions in the same category, excluding self + val siblings = supabase.postgrest.from("solutions") + .select( + columns = Columns.list( + "package_name", "name", "license", "repo_url", "fdroid_id", + "icon_url", "description", "features", "pros", "cons", + "rating_usability", "rating_privacy", "rating_features", "vote_count", + "category" + ) + ) { + filter { + eq("category", categoryDto.category) + neq("package_name", packageName) + } + }.decodeList() + + siblings.map { dto -> + val usabilityRating = dto.ratingUsability ?: 0f + val privacyRating = dto.ratingPrivacy ?: 0f + val featuresRating = dto.ratingFeatures ?: 0f + val rawRatingCount = dto.voteCount ?: 0 + + val ratingAvg = if (rawRatingCount > 0) { + val sum = usabilityRating + privacyRating + featuresRating + val nonZeroCount = + listOf(usabilityRating, privacyRating, featuresRating).count { it > 0 } + if (nonZeroCount > 0) sum / nonZeroCount else 0f + } else 0f + + val ratingCount = if (ratingAvg == 0f) 0 else rawRatingCount + + Alternative( + id = dto.packageName, + name = dto.name, + packageName = dto.packageName, + license = dto.license, + repoUrl = dto.repoUrl ?: "", + fdroidId = dto.fdroidId ?: "", + iconUrl = dto.iconUrl, + category = dto.category, + ratingAvg = ratingAvg, + ratingCount = ratingCount, + usabilityRating = usabilityRating, + privacyRating = privacyRating, + featuresRating = featuresRating, + description = dto.description, + features = dto.features.orEmpty(), + pros = dto.pros.orEmpty(), + cons = dto.cons.orEmpty() + ) + }.sortedByDescending { it.ratingAvg } + } catch (e: Exception) { + Log.e("SupabaseAppRepo", "getSiblingAlternatives failed", e) + null + } + } + @Serializable private data class UserSubmissionWithProfileDto( val id: String? = null, diff --git a/app/src/main/java/com/jksalcedo/librefind/di/KoinModule.kt b/app/src/main/java/com/jksalcedo/librefind/di/KoinModule.kt index 9e96c3d..57f1530 100644 --- a/app/src/main/java/com/jksalcedo/librefind/di/KoinModule.kt +++ b/app/src/main/java/com/jksalcedo/librefind/di/KoinModule.kt @@ -40,9 +40,11 @@ val appModule = module { single { Dispatchers.Main } single { Dispatchers.Default } - single { InventorySource(androidContext()) } - single { SafeSignatureDb() } + // Provide PreferencesManager first so it can be injected into other singletons single { PreferencesManager(androidContext()) } + // InventorySource now depends on PreferencesManager; inject it via Koin's `get()` + single { InventorySource(androidContext(), get()) } + single { SafeSignatureDb() } single { AppDatabase.getInstance(androidContext()) } single { get().ignoredAppDao() } @@ -74,7 +76,17 @@ val networkModule = module { val repositoryModule = module { single { CacheRepositoryImpl(get(), get()) } - single { DeviceInventoryRepoImpl(get(), get(), get(), get(), get(), get()) } + single { + DeviceInventoryRepoImpl( + get(), + get(), + get(), + get(), + get(), + get(), + get() + ) + } } val useCaseModule = module { @@ -97,5 +109,3 @@ val viewModelModule = module { viewModel { MyReportsViewModel(get(), get()) } viewModel { DiscoverViewModel(get()) } } - - diff --git a/app/src/main/java/com/jksalcedo/librefind/domain/model/Alternative.kt b/app/src/main/java/com/jksalcedo/librefind/domain/model/Alternative.kt index 9187251..388ba38 100644 --- a/app/src/main/java/com/jksalcedo/librefind/domain/model/Alternative.kt +++ b/app/src/main/java/com/jksalcedo/librefind/domain/model/Alternative.kt @@ -10,6 +10,13 @@ data class Alternative( val repoUrl: String, val fdroidId: String, val iconUrl: String? = null, + val category: String = "Other", + // Match votes: "Is this a good replacement for the target app?" + val matchScore: Int = 0, // net score (upvotes - downvotes) + val matchUpvotes: Int = 0, + val matchDownvotes: Int = 0, + val userMatchVote: Int? = null, // +1, -1, or null (not voted) + // App quality ratings: "How good is this app on its own?" val ratingAvg: Float = 0f, val ratingCount: Int = 0, val usabilityRating: Float = 0f, diff --git a/app/src/main/java/com/jksalcedo/librefind/domain/model/AppItem.kt b/app/src/main/java/com/jksalcedo/librefind/domain/model/AppItem.kt index eb4e881..c73106a 100644 --- a/app/src/main/java/com/jksalcedo/librefind/domain/model/AppItem.kt +++ b/app/src/main/java/com/jksalcedo/librefind/domain/model/AppItem.kt @@ -10,6 +10,7 @@ package com.jksalcedo.librefind.domain.model * @param installerId Package name of installer (e.g., "com.android.vending", "org.fdroid.fdroid") * @param icon app icon (can be null) * @param knownAlternatives Number of FOSS alternatives available + * @param isSystemPackage Flag indicating if the package is a system app (based on PackageManager flags) */ data class AppItem( val packageName: String, @@ -18,5 +19,6 @@ data class AppItem( val installerId: String?, val icon: Int? = null, val knownAlternatives: Int = 0, - val isUserReclassified: Boolean = false + val isUserReclassified: Boolean = false, + val isSystemPackage: Boolean = false ) diff --git a/app/src/main/java/com/jksalcedo/librefind/domain/repository/AppRepository.kt b/app/src/main/java/com/jksalcedo/librefind/domain/repository/AppRepository.kt index dea6add..f4971ee 100644 --- a/app/src/main/java/com/jksalcedo/librefind/domain/repository/AppRepository.kt +++ b/app/src/main/java/com/jksalcedo/librefind/domain/repository/AppRepository.kt @@ -82,6 +82,17 @@ interface AppRepository { value: Int // 1-5 star rating ): Result + /** + * Cast a match vote on a target ↔ solution pairing. + * [vote] must be +1 (upvote) or -1 (downvote). + * Passing 0 removes an existing vote. + */ + suspend fun castMatchVote( + targetPackage: String, + solutionPackage: String, + vote: Int + ): Result + suspend fun getMySubmissions(userId: String): List suspend fun submitFeedback( @@ -99,6 +110,13 @@ interface AppRepository { suspend fun getAlternativesCount(packageName: String): Int + /** + * Returns other FOSS apps in the same category as [packageName], excluding itself. + * Returns null if the app has no meaningful category set (i.e. "Other"), + * as opposed to an empty list which means the category is set but has no peers yet. + */ + suspend fun getSiblingAlternatives(packageName: String): List? + suspend fun getPendingSubmissionPackages(): Set suspend fun submitScanStats( diff --git a/app/src/main/java/com/jksalcedo/librefind/domain/usecase/GetAlternativeUseCase.kt b/app/src/main/java/com/jksalcedo/librefind/domain/usecase/GetAlternativeUseCase.kt index 5bf6fe2..9e4c83d 100644 --- a/app/src/main/java/com/jksalcedo/librefind/domain/usecase/GetAlternativeUseCase.kt +++ b/app/src/main/java/com/jksalcedo/librefind/domain/usecase/GetAlternativeUseCase.kt @@ -8,7 +8,7 @@ class GetAlternativeUseCase( ) { suspend operator fun invoke(packageName: String): List { return appRepository.getAlternatives(packageName) - .sortedByDescending { it.ratingAvg } // Changed rating to ratingAvg as per Alternative model + .sortedWith(compareByDescending { it.matchScore }.thenByDescending { it.ratingAvg }) } } diff --git a/app/src/main/java/com/jksalcedo/librefind/ui/dashboard/DashboardScreen.kt b/app/src/main/java/com/jksalcedo/librefind/ui/dashboard/DashboardScreen.kt index 6bedf83..5812365 100644 --- a/app/src/main/java/com/jksalcedo/librefind/ui/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/jksalcedo/librefind/ui/dashboard/DashboardScreen.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column 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.height @@ -109,6 +110,7 @@ fun DashboardScreen( Box(modifier = Modifier.fillMaxSize()) { Scaffold( + contentWindowInsets = WindowInsets(0), topBar = { TopAppBar( title = { diff --git a/app/src/main/java/com/jksalcedo/librefind/ui/dashboard/DashboardViewModel.kt b/app/src/main/java/com/jksalcedo/librefind/ui/dashboard/DashboardViewModel.kt index b01d6b3..5c7c910 100644 --- a/app/src/main/java/com/jksalcedo/librefind/ui/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/com/jksalcedo/librefind/ui/dashboard/DashboardViewModel.kt @@ -56,8 +56,9 @@ class DashboardViewModel( combine( refreshTrigger.onStart { emit(Unit) }, ignoredAppsRepository.getIgnoredPackageNames(), - reclassifiedAppsRepository.getReclassifiedPackageNames() - ) { _, _, _ -> }.flatMapLatest { + reclassifiedAppsRepository.getReclassifiedPackageNames(), + preferencesManager.observeHideSystemPackages() + ) { _, _, _, _ -> }.flatMapLatest { _state.update { it.copy(isLoading = true, error = null) } scanInventoryUseCase() }, diff --git a/app/src/main/java/com/jksalcedo/librefind/ui/dashboard/components/ScanList.kt b/app/src/main/java/com/jksalcedo/librefind/ui/dashboard/components/ScanList.kt index 2a0946a..9be43b5 100644 --- a/app/src/main/java/com/jksalcedo/librefind/ui/dashboard/components/ScanList.kt +++ b/app/src/main/java/com/jksalcedo/librefind/ui/dashboard/components/ScanList.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -44,6 +45,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -172,13 +174,31 @@ fun AppRow( maxLines = 1, overflow = TextOverflow.Ellipsis ) - Text( - text = app.packageName, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = app.packageName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + ) + if (app.isSystemPackage) { + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "SYSTEM", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(4.dp) + ) + .padding(horizontal = 4.dp, vertical = 2.dp) + ) + } + } if (app.knownAlternatives > 0) { Text( text = "${app.knownAlternatives} alternative${if (app.knownAlternatives > 1) "s" else ""} available", @@ -253,10 +273,10 @@ internal fun AppIconAsync( modifier: Modifier = Modifier ) { val context = LocalContext.current - var iconBitmap by remember(packageName) { + var iconBitmap by remember(packageName) { mutableStateOf(AppIconCache.get(packageName)) } - var isLoading by remember(packageName) { + var isLoading by remember(packageName) { mutableStateOf(AppIconCache.get(packageName) == null) } @@ -281,8 +301,7 @@ internal fun AppIconAsync( Box( modifier = modifier - .clip(RoundedCornerShape(8.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant), + .clip(RoundedCornerShape(8.dp)), contentAlignment = Alignment.Center ) { when { @@ -299,7 +318,10 @@ internal fun AppIconAsync( Image( bitmap = iconBitmap!!.asImageBitmap(), contentDescription = "App Icon", - modifier = Modifier.size(40.dp) + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Crop ) } diff --git a/app/src/main/java/com/jksalcedo/librefind/ui/details/DetailsScreen.kt b/app/src/main/java/com/jksalcedo/librefind/ui/details/DetailsScreen.kt index 2043e0a..4b0063a 100644 --- a/app/src/main/java/com/jksalcedo/librefind/ui/details/DetailsScreen.kt +++ b/app/src/main/java/com/jksalcedo/librefind/ui/details/DetailsScreen.kt @@ -19,8 +19,12 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.ThumbDown +import androidx.compose.material.icons.filled.ThumbUp import androidx.compose.material3.Button import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator @@ -64,7 +68,7 @@ fun DetailsScreen( viewModel.loadAlternatives(packageName) } - val showFab = !state.isLoading && state.error == null && !state.isUnknown + val showFab = !state.isLoading && state.error == null && !state.isUnknown && !state.isFoss Scaffold( topBar = { @@ -132,7 +136,7 @@ fun DetailsScreen( } } - state.alternatives.isEmpty() -> { + state.alternatives.isEmpty() && state.siblingAlternatives.isEmpty() -> { Column( modifier = Modifier .fillMaxSize() @@ -155,8 +159,12 @@ fun DetailsScreen( Spacer(modifier = Modifier.height(8.dp)) Text( text = stringResource( - if (state.isUnknown) R.string.details_not_in_db - else R.string.details_no_suggested_alternatives + when { + state.isUnknown -> R.string.details_not_in_db + state.fossCategoryUnset -> R.string.details_foss_category_unset + state.isFoss -> R.string.details_no_siblings + else -> R.string.details_no_suggested_alternatives + } ), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant @@ -190,6 +198,7 @@ fun DetailsScreen( } else -> { + val displayList = if (state.isFoss) state.siblingAlternatives else state.alternatives LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(16.dp), @@ -197,17 +206,24 @@ fun DetailsScreen( ) { item { Text( - text = stringResource(R.string.details_found_format, state.alternatives.size, if (state.alternatives.size > 1) "s" else ""), + text = if (state.isFoss) { + stringResource(R.string.details_siblings_found_format, displayList.size, if (displayList.size > 1) "s" else "") + } else { + stringResource(R.string.details_found_format, displayList.size, if (displayList.size > 1) "s" else "") + }, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(bottom = 8.dp) ) } - items(state.alternatives) { alternative -> + items(displayList) { alternative -> AlternativeListItem( alternative = alternative, - onClick = { onAlternativeClick(alternative.id) } + onClick = { onAlternativeClick(alternative.id) }, + onMatchVote = if (!state.isFoss) { vote -> + viewModel.castMatchVote(alternative.packageName, vote) + } else null ) } } @@ -221,6 +237,7 @@ fun DetailsScreen( fun AlternativeListItem( alternative: Alternative, onClick: () -> Unit, + onMatchVote: ((vote: Int) -> Unit)? = null, modifier: Modifier = Modifier ) { Card( @@ -250,25 +267,72 @@ fun AlternativeListItem( ) } - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.Default.Star, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.primary - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = alternative.displayRating, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold - ) - if (alternative.ratingCount > 0) { + if (onMatchVote != null) { + // Match-vote buttons: "Is this a good replacement?" + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + val upvoted = alternative.userMatchVote == 1 + val downvoted = alternative.userMatchVote == -1 + FilledTonalIconButton( + onClick = { onMatchVote(1) }, + colors = IconButtonDefaults.filledTonalIconButtonColors( + containerColor = if (upvoted) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.surfaceVariant, + contentColor = if (upvoted) MaterialTheme.colorScheme.onPrimary + else MaterialTheme.colorScheme.onSurfaceVariant + ) + ) { + Icon( + imageVector = Icons.Default.ThumbUp, + contentDescription = stringResource(R.string.vote_upvote), + modifier = Modifier.size(18.dp) + ) + } Text( - text = " (${alternative.ratingCount})", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = alternative.matchScore.toString(), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold + ) + FilledTonalIconButton( + onClick = { onMatchVote(-1) }, + colors = IconButtonDefaults.filledTonalIconButtonColors( + containerColor = if (downvoted) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.surfaceVariant, + contentColor = if (downvoted) MaterialTheme.colorScheme.onError + else MaterialTheme.colorScheme.onSurfaceVariant + ) + ) { + Icon( + imageVector = Icons.Default.ThumbDown, + contentDescription = stringResource(R.string.vote_downvote), + modifier = Modifier.size(18.dp) + ) + } + } + } else { + // FOSS sibling context: show star rating (read-only) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Star, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = alternative.displayRating, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold + ) + if (alternative.ratingCount > 0) { + Text( + text = " (${alternative.ratingCount})", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } } } diff --git a/app/src/main/java/com/jksalcedo/librefind/ui/details/DetailsViewModel.kt b/app/src/main/java/com/jksalcedo/librefind/ui/details/DetailsViewModel.kt index 484d90e..e04cce5 100644 --- a/app/src/main/java/com/jksalcedo/librefind/ui/details/DetailsViewModel.kt +++ b/app/src/main/java/com/jksalcedo/librefind/ui/details/DetailsViewModel.kt @@ -33,7 +33,23 @@ class DetailsViewModel( } try { - val alternatives = getAlternativeUseCase(packageName) + val isFoss = cacheRepository.isSolutionCached(packageName) || + appRepository.isSolution(packageName) + + val alternatives = if (!isFoss) { + getAlternativeUseCase(packageName) + } else { + emptyList() + } + + val siblings = if (isFoss) { + appRepository.getSiblingAlternatives(packageName) + } else { + emptyList() + } + // null = category is "Other"/unset; emptyList = category set but no peers yet + val fossCategoryUnset = isFoss && siblings == null + val user = authRepository.getCurrentUser() _state.update { @@ -41,6 +57,9 @@ class DetailsViewModel( isLoading = false, packageName = packageName, alternatives = alternatives, + siblingAlternatives = siblings.orEmpty(), + isFoss = isFoss, + fossCategoryUnset = fossCategoryUnset, isSignedIn = user != null, error = null ) @@ -62,14 +81,57 @@ class DetailsViewModel( if (user != null) { appRepository.castVote(altId, "usability", stars) _state.value.packageName.let { pkg -> - if (pkg.isNotEmpty()) { - loadAlternatives(pkg) - } + if (pkg.isNotEmpty()) loadAlternatives(pkg) } } } } + fun castMatchVote(solutionPackage: String, vote: Int) { + viewModelScope.launch { + val user = authRepository.getCurrentUser() ?: return@launch + val targetPackage = _state.value.packageName + if (targetPackage.isBlank()) return@launch + + // Optimistic update: flip if same vote (toggle off), otherwise apply + _state.update { s -> + s.copy( + alternatives = s.alternatives.map { alt -> + if (alt.packageName != solutionPackage) return@map alt + val prev = alt.userMatchVote + val newVote = if (prev == vote) 0 else vote + val upDelta = when { + newVote == 1 && prev != 1 -> 1 + newVote != 1 && prev == 1 -> -1 + else -> 0 + } + val downDelta = when { + newVote == -1 && prev != -1 -> 1 + newVote != -1 && prev == -1 -> -1 + else -> 0 + } + alt.copy( + userMatchVote = newVote.takeIf { it != 0 }, + matchUpvotes = alt.matchUpvotes + upDelta, + matchDownvotes = alt.matchDownvotes + downDelta, + matchScore = alt.matchScore + upDelta - downDelta + ) + } + ) + } + + val actualVote = if (_state.value.alternatives + .find { it.packageName == solutionPackage }?.userMatchVote == null + ) 0 else vote + + appRepository.castMatchVote(targetPackage, solutionPackage, actualVote) + .onFailure { + // Revert optimistic update on failure + loadAlternatives(targetPackage) + } + } + } + fun retry(packageName: String) { loadAlternatives(packageName) } @@ -103,6 +165,9 @@ data class DetailsState( val isLoading: Boolean = false, val packageName: String = "", val alternatives: List = emptyList(), + val siblingAlternatives: List = emptyList(), + val isFoss: Boolean = false, + val fossCategoryUnset: Boolean = false, val isSignedIn: Boolean = false, val error: String? = null, val isUnknown: Boolean = false diff --git a/app/src/main/java/com/jksalcedo/librefind/ui/discover/DiscoverScreen.kt b/app/src/main/java/com/jksalcedo/librefind/ui/discover/DiscoverScreen.kt index 1272b74..78e00bd 100644 --- a/app/src/main/java/com/jksalcedo/librefind/ui/discover/DiscoverScreen.kt +++ b/app/src/main/java/com/jksalcedo/librefind/ui/discover/DiscoverScreen.kt @@ -3,6 +3,7 @@ package com.jksalcedo.librefind.ui.discover import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -47,6 +48,7 @@ fun DiscoverScreen( val state by viewModel.uiState.collectAsState() Scaffold( + contentWindowInsets = WindowInsets(0), topBar = { TopAppBar( title = { diff --git a/app/src/main/java/com/jksalcedo/librefind/ui/settings/SettingsScreen.kt b/app/src/main/java/com/jksalcedo/librefind/ui/settings/SettingsScreen.kt index 44ad075..7bd1405 100644 --- a/app/src/main/java/com/jksalcedo/librefind/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/jksalcedo/librefind/ui/settings/SettingsScreen.kt @@ -294,6 +294,40 @@ fun SettingsContent( } // 5. Account + SettingsSection(title = stringResource(R.string.settings_hide_system_packages_title)) { + // Obtain preferences manager from Koin for this section + val preferencesManager: PreferencesManager = koinInject() + val hideSystem = remember { mutableStateOf(preferencesManager.shouldHideSystemPackages()) } + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable( + interactionSource = null, + indication = LocalIndication.current, + onClick = { + // Toggle preference and update local state + val new = !hideSystem.value + hideSystem.value = new + preferencesManager.setHideSystemPackages(new) + } + ) + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.settings_hide_system_packages_label), + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = if (hideSystem.value) stringResource(R.string.settings_on) else stringResource(R.string.settings_off), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + AccountSection(onDeleteAccountRequest = onDeleteAccountRequest) } } @@ -644,4 +678,4 @@ fun SettingsScreenPreview() { onDeleteAccountErrorDismiss = {}, onAccountDeletedDismiss = {} ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/jksalcedo/librefind/ui/submit/SubmitScreen.kt b/app/src/main/java/com/jksalcedo/librefind/ui/submit/SubmitScreen.kt index 906718a..50c622d 100644 --- a/app/src/main/java/com/jksalcedo/librefind/ui/submit/SubmitScreen.kt +++ b/app/src/main/java/com/jksalcedo/librefind/ui/submit/SubmitScreen.kt @@ -160,12 +160,22 @@ fun SubmitContent( var category by remember { mutableStateOf("") } var showCategoryDropdown by remember { mutableStateOf(false) } val categories = listOf( - "Browser", "Email", "Messenger", "Social Media", - "Maps & Navigation", "Cloud Storage", "Notes", "Calendar", - "File Manager", "Music Player", "Video Player", "Photo Editor", - "Office Suite", "Keyboard", "Launcher", "Camera", - "Weather", "News Reader", "Password Manager", "VPN", - "App Store", "Fitness & Health", "Finance", "Utility", "Other" + "AI & Virtual Assistant", "App Store & Updater", "Bookmark", "Boot & Kernel", + "Browser", "Calculator", "Calendar & Agenda", "Camera", + "Cloud Storage & File Sync", "Connectivity", "Development", "DNS & Hosts", + "Draw", "Ebook Reader", "Education", "Email", + "File Encryption & Vault", "File Transfer", "Finance", "Food & Delivery", + "Forum", "Gallery", "Games", "Graphics", + "Habit Tracker", "Icon Pack", "Internet", "Keyboard & IME", + "Launcher", "Local Media Player", "Location & Navigation", "Messaging", + "Multimedia", "Music Practice Tool", "News", "Note", + "Online Media Player", "Pass Wallet", "Password & 2FA", "Phone & SMS", + "Podcast", "Public Transport", "Reading", "Recipe Manager", + "Root Management", "Security", "Shopping", "Social Network", + "Sports & Health", "Storage Management", "Streaming Service", "System", + "Task", "Text Editor", "Theming", "Time", + "Translation & Dictionary", "Unit Converter", "Voice & Video Chat", "VPN & Proxy", + "Wallpaper", "Weather", "Writing", "Other" ) LaunchedEffect(uiState.loadedSubmission) { @@ -293,7 +303,10 @@ fun SubmitContent( style = MaterialTheme.typography.titleMedium ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { FilterChip( selected = type == SubmissionType.NEW_ALTERNATIVE, onClick = { type = SubmissionType.NEW_ALTERNATIVE }, @@ -313,8 +326,8 @@ fun SubmitContent( HorizontalDivider() - // Category dropdown (proprietary apps only) - if (type == SubmissionType.NEW_PROPRIETARY) { + // Category dropdown (required for FOSS alternatives and proprietary apps) + if (type == SubmissionType.NEW_ALTERNATIVE || type == SubmissionType.NEW_PROPRIETARY) { Box(modifier = Modifier.fillMaxWidth()) { OutlinedTextField( value = category, @@ -1173,7 +1186,7 @@ fun SubmitContent( uiState.duplicateWarning == null && uiState.packageNameError == null && if (type == SubmissionType.NEW_ALTERNATIVE) { - description.isNotBlank() && repoUrl.isNotBlank() && license.isNotBlank() && uiState.repoUrlError == null + description.isNotBlank() && repoUrl.isNotBlank() && license.isNotBlank() && category.isNotBlank() && uiState.repoUrlError == null } else { true } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index b3b7e40..e547352 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -53,7 +53,7 @@ Wähle einen Benutzernamen Dieser wird mit deinen Einreichungen angezeigt Nur Buchstaben, Nummern, Unterstriche - Weiter + Fortsetzen App einreichen Einreichung bearbeiten Einreichung erhalten! @@ -146,7 +146,7 @@ Datenschutzerklärung Cache-Verwaltung Icon-Cache Größe - Löschen + Leeren Account Account löschen Icon-Cache löschen? @@ -270,7 +270,35 @@ Titel Kurze Zusammenfassung des Problems Beschreibung - Beschreibe das Problem oder den Vorschlag ausführlich ... + Beschreibe das Problem oder den Vorschlag ausführlich … Priorität Meldung einreichen + Web-Vorlagen-Seiten öffnen + Sprache + Systemvorgabe + Englisch + Arabisch + Deutsch + Griechisch + Spanisch + Estnisch + Französisch + Italienisch + Polnisch + Türkisch + Vereinfachtes Chinesisch + Bericht + Bericht %1$s + Wieso ist diese Empfehlung falsch? + Details (optional) + Bericht versendet + Bericht konnte nicht versendet werden + Kein FOSS + ohne Bezug + Defekte Verbindung + Schadsoftware + Anderes + Für diese App wurden bisher keine FOSS-Alternativen vorgeschlagen. + Schlage eine Alternative vor + Web-Vorlage diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 8da1064..2f6d945 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -281,7 +281,7 @@ Título Resumen del problema Descripción - Describe el problema o la sugerencia en detalle... + Describe el problema o la sugerencia en detalle… Prioridad Sugerir reporte diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 8328d1a..f6df31f 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -281,7 +281,7 @@ Pealkiri Lühike kokkuvõte probleemist Kirjeldus - Kirjelda probleem või pakkumine täpsemalt... + Kirjelda probleem või pakkumine täpsemalt… Prioriteet Esita teatust Selle rakenduse jaoks pole veel vabarakenduslike alternatiive pakutud. @@ -299,4 +299,6 @@ Hispaania Süsteemi Türgi + Esita veebilehekülje kaudu + Veebiesitus diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 6a1b7be..deb738c 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -32,4 +32,175 @@ Non connecté Votre compte n\'a pu être trouvé. Merci de vous connecter à nouveau ou bien contactez le support. Merci de vous connecter pour accéder à votre profil. + Modifier le profil + Se connecter + Créer un compte + Se connecter + Envoyer une alternative FOSS + Pseudonyme + Email + Mot de passe + Cacher le mot de passe + Montrer le mot de passe + OU + Se connecter avec GitHub + Déjà un compte ? Se connecter + Pas encore de compte ? S\'inscrire + Vérifier vos e-mails + Nous vous avons envoyé un lien de vérification à votre adresse e-mail. Veuillez vérifier votre adresse e-mail avant de vous connecter. + Aller à la page de connexion + Configurer le profil + Choisir un pseudonyme + Cela apparaîtra avec vos contributions + Lettres, nombres, underscore uniquement + Continuer + Envoyer l\'App + Modifier la proposition + Proposition reçue ! + Proposition mise à jour ! + Merci ! Nous avons bien reçu votre proposition concernant %1$s. + Votre proposition pour %1$s à été mise à jour. + Fait + Que proposez-vous ? + Guide des contributions + Ouvrir la page de soumission en ligne + App FOSS + App propriétaire + Lien de l\'App + Nom de l\'App + Nom du Paquet + com.exemple.app + Description + Description * + Ajouter une alternative (facultatif) + Chercher une alternative + Rechercher par nom d\'application ou par nom de paquet… + Alternatives sélectionnées : + Alternative Détails + Rechercher une application FOSS spécifique (facultatif) + Rechercher dans la base de données existante… + Intégré à une application existante + Licence et l\'URL du référentiel sont obligatoires pour les alternatives FOSS + Rechercher pour ajouter… + URL du dépôt * + https://github.com/… + ID F-Droid + Licence * + Choisissez une licence + Nom de licence personnalisé * + exemple : Ma licence personnalisée + Sélectionner une licence + Autres + Annuler + Liens vers les solutions existantes + Relier une application FOSS à une application propriétaire existante dans notre base de données. + Sélectionnez l\'application propriétaire que cette solution FOSS remplace. + Sélectionnez une application cible… + Sélectionner les solutions à associer + Rechercher des solutions + Solutions sélectionnées : + Modifier la proposition + Soumettre pour examen + Rechercher + Aucune cible trouvée dans la base de données. + Applications inconnues sur votre appareil : + Aucun résultat trouvé. + Confirmer + Nom de l\'application + Le nom d\'affichage de l\'application tel que les utilisateurs le connaissent (par exemple « Signal », « Firefox », « VLC »). + Utilisez le nom officiel figurant sur le site web de l\'application ou dans la fiche de l\'application sur la boutique en ligne. + Nom du paquet + Description + Un bref résumé de ce que fait l\'application. Limitez-vous à 1 à 3 phrases. + URL du dépôt + ID F-Droid + Le nom du paquet tel qu\'il apparaît sur F-Droid. Il correspond généralement au nom du paquet, mais certaines applications utilisent un identifiant différent pour les versions F-Droid. + Vous le trouverez sur f-droid.org — l\'identifiant figure dans l\'URL : f-droid.org/packages/<fdroid.id>/ + Licence + Paramètres + Aide + Rejoindre la communauté + À propos + Version + Voir sur GitHub + Donation pour LibreFind + Langue + Anglais + Arabe + Allemand + Grec + Espagnol + Estonien + Français + Italien + Polonais + Turc + Chinois simplifié + Effacer + Compte + Supprimer mon compte + Annuler + Supprimer mon compte ? + Supprimer + Compte supprimé + OK + Erreur + Ouvrir le site web + Voir la source + Copier le nom du paquet + Pas d\'alternative trouvée + Notations + Respect de vos données + Noter cette application + Vos notations + Description + Fonctionnalités + Avantages + Pas d\'avantages pour le moment + Désavantages + Pas de désavantages pour le moment + Ajouter un avantage + Ajouter un désavantage + Avantage + Désavantage + Vos commentaires + Envoyer + Annuler + Signaler + Signaler %1$s + Qu\'est-ce qui cloche dans cette recommandation ? + Détails (facultatif) + Signalement envoyé + Pas FOSS + Sans rapport + Lien défectueux + Maliciel + Autres + Erreur inconnue + Réessayer + Pas d\'alternatives trouvé + Suggérer comme FOSS + Suggérer comme Propriétaire + Suggérer une alternative + Mes propositions + Pas de propositions trouvées + En attente + Approuvé + Rejeté + FOSS + PROPRIÉTAIRE + INCONNUE + Ignoré + Aide pour %1$s + Passer + Fait + Suivant + Réponse : + Ouvrir + Résolue + Duplicata + Fermé + Titre + Description + Priorité diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml new file mode 100644 index 0000000..8ff9fd7 --- /dev/null +++ b/app/src/main/res/values-hr/strings.xml @@ -0,0 +1,89 @@ + + + Zatvori pretragu + Pretraži + Filter + Prikaži sve + S Alternativama + Bez Alternativa + Samo FOSS + Samo Nepoznato + Samo Na Čekanju + Samo Ignorirane + Profil + Postavke + Pošalji Aplikaciju + Pogreška + Nepoznata Pogreška + Pokušaj ponovno + Nema odgovarajućih aplikacija + Nije pronađena nijedna aplikacija + Profil & Postavke + Nepoznato Korisničko Ime + Nema Email-a + Ignorirane Aplikacije + Moje Predaje + Odjava + Zatvori + Nedostaje Profil + Niste Prijavljeni + Vaš profil nije pronađen. Pokušajte se odjaviti i ponovno prijaviti ili se obratite podršci. + Molimo prijavite se kako biste vidjeli svoj profil. + Popravi Profil + Prijava + Napravite Račun + Prijava + Pošaljite FOSS alternative + Korisničko ime + E-pošta + Lozinka + Sakrij lozinku + Prikaži lozinku + ILI + Prijavite se putem GitHuba + Već imate račun? Prijavite se + Nemate račun? Registrirajte se + Provjerite svoju e-poštu + Poslali smo vam poveznicu za potvrdu na vašu adresu e-pošte. Molimo vas da potvrdite svoju adresu e-pošte prije prijave. + Idi na Prijavu + Postavite Profil + Odaberite korisničko ime + Ovo će biti prikazano s vašim prijavama + Samo slova, brojevi, podvlake + Nastavi + Pošalji Zahtjev za Aplikaciju + Uredi Zahtjev + Zahtjev Primljen! + Zahtjev Ažuriran! + Hvala! Vaš je zahtjev za \'%1$s\' primljen. + Vaš je zahtjev za \'%1$s\' ažuriran. + Gotovo + Šta zahtijevate? + Vodič za doprinose + FOSS Aplikacija + Vlasnička Aplikacija + Poveznica Aplikacije + Naziv Aplikacije * + Naziv Paketa * + com.primjer.app + Opis + Opis * + Dodaj Alternative (neobavezno) + Pretraži aplikacije… + Sve Vlasničko + Potražite alternative + Pretraživanje po nazivu aplikacije ili paketa… + Odabrane Alternative: + Pretraži specifičnu FOSS aplikaciju (neobavezno) + Pretraži postojeću bazu podataka… + Povezano s postojećom aplikacijom + Pretraživanje za dodavanje… + URL repozitorija * + https://github.com/… + F-Droid ID + Licenca * + Odaberi licencu + Odaberi licencu + Ostalo + Otkaži + diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 5d5be30..4428044 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -190,7 +190,7 @@ Titolo Breve descrizione del problema Descrizione - Descrivi nel dettaglio il problema o il suggerimento... + Descrivi nel dettaglio il problema o il suggerimento… Priorità Invia segnalazione Le mie segnalazioni diff --git a/app/src/main/res/values-lv/strings.xml b/app/src/main/res/values-lv/strings.xml new file mode 100644 index 0000000..0b4fc3c --- /dev/null +++ b/app/src/main/res/values-lv/strings.xml @@ -0,0 +1,122 @@ + + + Meklēt + Profils + Iestatījumi + Kļūda + Mēģināt vēlreiz + Aizvērt + Lietotājvārds + E-pasts + Parole + VAI + Turpināt + lv.piemers.app + Apraksts + https://github.com/… + Atcelt + Meklēt + Apstiprināt + Apraksts + Licence + Iestatījumi + Palīdzība + Par lietotni + Versija + Valoda + Angļu + Arābu + Vācu + Grieķu + Spāņu + Igauņu + Franču + Itāļu + Poļu + Turku + Notīrīt + Konts + Atcelt + Dzēst + Labi + Kļūda + Atjaunot + Supabase + Vērtējumi + Konfidencialitāte + Lietošanas ērtums + Funkcionalitāte + Apraksts + Funkcionalitāte + Plusi + Mīnusi + Plus + Mīnuss + Iesniegt + Atcelt + Ziņot + Neatbilst + Ļaunprogrammatūra + Mēģināt vēlreiz + Gaida apstiprinājumu + Apstiprināts + Noraidīts + FOSS - atvērtā pirmkoda programmatūra + NEZINĀMS + ignorēts + Izlaist + Atvērts + Atrisināts + Virsraksts + Apraksts + Prioritāte + Lietotnes nosaukums * + Apraksts * + Ķīniešu (vienkāršotā) + Kešatmiņas pārvaldība + Dzēst kontu + Vai tiešām dzēst kontu? + Konts dzēsts + Atvērt vietni + Skatīt pirmkodu + Pilnvērtīga alternatīva + Jūsu vērtējumi + Jūsu atsauksmes + Nezināma kļūda + Noraidījuma iemesls: + Lietotnes nosaukums + Licence * + Atsauksmes un kopiena + 4. Datu drošība + 8. Sazināties ar mums + Ja jums ir kādi jautājumi par šo Konfidencialitātes politiku, lūdzu, sazinieties ar mums: + Pa e-pastu: jks.create@gmail.com + Atvērt F-Droid\'ā + Atvērt Obtainium\'ā + Pievienot plusu + Pievienot mīnusu + Īss problēmas izklāsts + Ziņot problēmu / Ieteikums + Mani ziņojumi + Ziņot par %1$s + Kas vainas šim ieteikumam? + Sīkāka informācija (pēc izvēles) + Ziņojums iesniegts + Neizdevās iesniegt ziņojumu + Tikai FOSS - atvērtā pirmkoda programmatūru + Nav FOSS - atvērtā pirmkoda programmatūra + Bojāta saite + Cits + Mani ziņojumi + Nav neviena ziņojuma + Atbilde: + Tiek risināts + Nelabos + Dublēta, jau esoša + Aizvērts + Ziņot problēmu + Kāda problēma - kategorija, tips + Detalizēti aprakstiet problēmu vai ieteikumu, priekšlikumu… + Iesniegt ziņojumu + Atlasīt + diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index e10d80a..cc67fd2 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -241,7 +241,7 @@ Название Краткий тезис проблемы Описание - Подробно опишите проблему или предложение... + Подробно опишите проблему или предложение… Приоритет Отправить жалобу Целевые проприетарные приложения @@ -299,4 +299,6 @@ Китайский (упрощённый) Для этого приложения ещё не были предложены FOSS-альтернативы. Предложить как альтернативу + Предложить через сайт + Интернет-заявки diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 4e73c84..60ac170 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -281,7 +281,7 @@ Názov Stručný prehľad problému Opis - Opíšte problém alebo návrh podrobne... + Opíšte problém alebo návrh podrobne… Priorita Odoslať správu diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index b427abf..971441b 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -1,7 +1,7 @@ LibreFind - Uygulama Ara... + Uygulama Ara… Arama Filtre Hepsini Göster @@ -72,17 +72,17 @@ Açıklama * Alternatif Ekle (opsiyonel) Alternatif Ara - Dosya veya uygulama adı ile ara... + Dosya veya uygulama adı ile ara… Seçilen Alternatifler: Alternatif Detayları Spesifik bir açık kaynak uygualama ara (opsiyonel) - Mevcut veritabanında arama yap... + Mevcut veritabanında arama yap… Mevcut Bir Uygulamaya Bağla Alternatif uygulamalar için lisans ve depo URL\'si gerekir Hedef Kapalı Kaynak Uygulamalar - Eklemek için ara... + Eklemek için ara… Depo URL\'si * - https://github.com/... + https://github.com/… F-Droid Kimliği Lisans * Lisans seçin @@ -95,7 +95,7 @@ Veritabanımızda bulunan mevcut bir açık kaynaklı uygulamayı, kapalı kaynak bir uygulamaya bağlayın. Hedef Kapalı Kaynak Uygulama Bu açık kaynaklı çözümün yerini aldığı kapalı kaynak uygulamayı seçin. - Hedef uygulama seçin... + Hedef uygulama seçin… Bağlamak İstediğiniz Çözümleri Seçin Çözümleri arayın Seçilmiş Çözümler: @@ -270,7 +270,7 @@ Başlık Sorunun kısa özeti Açıklama - Sorunu veya öneriyi ayrıntılı olarak açıklayın... + Sorunu veya öneriyi ayrıntılı olarak açıklayın… Öncelik Rapor Gönder Dil @@ -299,4 +299,6 @@ Alternatif öner Rapor Rapor %1$s + Web üzerinden gönderme sayfasını aç + Web Üzerinden Gönderme diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 853fadd..930b82a 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -270,7 +270,7 @@ 标题 问题的简要摘要 描述 - 详细描述问题或建议... + 详细描述问题或建议… 优先级 提交报告 报告 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 807d7e2..3f0662f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,6 +1,6 @@ + LibreFind - Search apps… Close search @@ -22,7 +22,6 @@ Retry No matching apps No apps found - Profile & Settings Unknown Username @@ -37,7 +36,6 @@ Please sign in to view your profile. Fix Profile Sign In - Create Account Sign In @@ -54,14 +52,12 @@ Check Your Email We\'ve sent a verification link to your email address. Please verify your email before signing in. Go to Sign In - Set Up Profile Choose a Username This will be shown with your submissions Letters, numbers, underscore only Continue - Submit App Edit Submission @@ -119,22 +115,21 @@ Suggest as target No results found. Confirm - App Name The display name of the app as users know it (e.g. \"Signal\", \"Firefox\", \"VLC\"). Use the official name from the app\'s website or store listing. Package Name The unique technical identifier for the app (e.g. \"org.mozilla.firefox\"). This is NOT the display name. - Find it on F-Droid (listed on the app page), or on the Play Store URL: play.google.com/store/apps/details?id=<package.name>. On your device: Settings → Apps → [App] → App info. + Find it on F-Droid (listed on the app page), or on the Play Store URL: play.google.com/store/apps/details?id=<package.name>. On your device: Settings → Apps → [App] → App info. Description A brief summary of what the app does. Keep it to 1-3 sentences. Repository URL The link to the app\'s public source code repository. This is required to verify the app is truly open source. - Look for it on the app\'s website, F-Droid listing, or search \"<app name> source code\" on GitHub/GitLab/Codeberg. + Look for it on the app\'s website, F-Droid listing, or search \"<app name> source code\" on GitHub/GitLab/Codeberg. F-Droid ID The package name as listed on F-Droid. Usually the same as the package name, but some apps use a different ID for F-Droid builds. - Find it at f-droid.org — the ID is in the URL: f-droid.org/packages/<fdroid.id>/ + Find it at f-droid.org — the ID is in the URL: f-droid.org/packages/<fdroid.id>/ License The open source license the app is released under (e.g. GPL-3.0-only, GPL-3.0-or-later, MIT, Apache-2.0). This is required for FOSS submissions. Check the LICENSE or COPYING file in the app\'s source code repository, or look at the F-Droid listing. @@ -143,7 +138,6 @@ Select from the list of known proprietary apps. If the target isn\'t listed, you can suggest it as a new proprietary target first. Search Alternatives Search for existing FOSS alternatives in our database to link to this proprietary app. - Settings Help @@ -185,13 +179,19 @@ Account Deleted Your account has been successfully deleted. OK + + + System package filtering + Hide vendor/system packages + On + Off + Error Ignored Apps No ignored apps. \nLong-press any app in the list → tap Ignore. Restore - Privacy Policy for LibreFind Last updated: February 10, 2026 @@ -234,7 +234,6 @@ 8. Contact Us If you have any questions about this Privacy Policy, please contact us: By email: jks.create@gmail.com - Details Open in F-Droid @@ -277,7 +276,6 @@ Broken Link Malware Other - %1$s alternatives Unknown error @@ -290,6 +288,13 @@ Suggest as Proprietary Found %1$d FOSS alternative%2$s Suggest an alternative + No other FOSS apps in the same category yet. + %1$d similar FOSS app%2$s in this category + This app hasn\'t been assigned a category yet. Similar apps can\'t be shown until it is. + + + Good replacement (upvote) + Poor replacement (downvote) My Submissions @@ -298,19 +303,16 @@ Pending Approved Rejected - FOSS PROPRIETARY UNKNOWN ignored - Help for %1$s Skip Done Next - My Reports No reports found @@ -321,15 +323,14 @@ Won\'t Fix Duplicate Closed - Report Issue Report Type Title Brief summary of the issue Description - Describe the issue or suggestion in detail... + Describe the issue or suggestion in detail… Priority Submit Report Web Submission - \ No newline at end of file + diff --git a/fastlane/metadata/android/en-US/changelogs/1900300.txt b/fastlane/metadata/android/en-US/changelogs/1900300.txt new file mode 100644 index 0000000..d23518b --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/1900300.txt @@ -0,0 +1,16 @@ +Added +- Show similar FOSS apps in the same category +- Added upvote/downvote system for rating alternative quality +- Added system apps filter + +Improved +- Expanded and improved app categories +- Category now required when submitting a FOSS alternative + +Fixed +- Submission type chips wrapping on long translated labels +- Extra padding below the list +- App icon shape inconsistencies + +Other +- Minor internal improvements and fixes diff --git a/version.properties b/version.properties new file mode 100644 index 0000000..5884b8d --- /dev/null +++ b/version.properties @@ -0,0 +1,7 @@ +VERSION_MAJOR=0 +VERSION_MINOR=19 +VERSION_PATCH=0 +VERSION_STAGE=stable +VERSION_BUILD=0 +VERSION_CODE=1900300 +VERSION_NAME=0.19.0 diff --git a/zapstore.yaml b/zapstore.yaml index 3856ec2..ab6c41d 100644 --- a/zapstore.yaml +++ b/zapstore.yaml @@ -1,6 +1,6 @@ name: LibreFind package_name: com.jksalcedo.librefind -summary: Discover and replace proprietary apps with FOSS alternatives +summary: Discover and replace proprietary apps with FOSS alternatives description: | LibreFind is a free and lightweight Android app that scans your device locally and queries our database to identify proprietary software and recommend Free and Open Source Software (FOSS) alternatives. @@ -22,10 +22,10 @@ description: | license: MIT repository: https://github.com/jksalcedo/librefind -tags: [utilities, privacy] -icon: https://raw.githubusercontent.com/jksalcedo/librefind/main/fastlane/metadata/en-US/images/icon.png +tags: [ utilities, privacy ] +icon: https://raw.githubusercontent.com/jksalcedo/librefind/main/fastlane/metadata/android/en-US/images/icon.png images: - - https://raw.githubusercontent.com/jksalcedo/librefind/main/fastlane/metadata/en-US/images/phoneScreenshots/dashboard.jpg - - https://raw.githubusercontent.com/jksalcedo/librefind/main/fastlane/metadata/en-US/images/phoneScreenshots/alternative_list.jpg - - https://raw.githubusercontent.com/jksalcedo/librefind/main/fastlane/metadata/en-US/images/phoneScreenshots/submission.jpg - - https://raw.githubusercontent.com/jksalcedo/librefind/main/fastlane/metadata/en-US/images/phoneScreenshots/profile.jpg \ No newline at end of file + - https://raw.githubusercontent.com/jksalcedo/librefind/main/fastlane/metadata/android/en-US/images/phoneScreenshots/dashboard.jpg + - https://raw.githubusercontent.com/jksalcedo/librefind/main/fastlane/metadata/android/en-US/images/phoneScreenshots/alternative_list.jpg + - https://raw.githubusercontent.com/jksalcedo/librefind/main/fastlane/metadata/android/en-US/images/phoneScreenshots/submission.jpg + - https://raw.githubusercontent.com/jksalcedo/librefind/main/fastlane/metadata/android/en-US/images/phoneScreenshots/profile.jpg \ No newline at end of file