From 4cecfd6631c50d653eb8b4681506061e760dabaf Mon Sep 17 00:00:00 2001 From: Jonathan Klee Date: Wed, 22 Apr 2026 15:21:42 +0200 Subject: [PATCH 1/2] tests: fix unit tests warnings --- .../e/apps/data/install/updates/UpdatesWorkerTest.kt | 8 +++++++- .../apps/data/login/playstore/OauthAuthDataBuilderTest.kt | 6 ++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkerTest.kt b/app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkerTest.kt index 5826fe3cf..0931f17fd 100644 --- a/app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkerTest.kt +++ b/app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkerTest.kt @@ -75,9 +75,13 @@ import kotlin.test.assertFalse @Config(sdk = [Build.VERSION_CODES.R]) class UpdatesWorkerTest { + private var workManagerInitialized = false + @After fun teardown() { - WorkManagerTestInitHelper.closeWorkDatabase() + if (workManagerInitialized) { + WorkManagerTestInitHelper.closeWorkDatabase() + } } @Test @@ -277,6 +281,7 @@ class UpdatesWorkerTest { fun insure_checkManualUpdateRunning_return_fails_when_there_is_no_worker() = runTest { val appContext = ApplicationProvider.getApplicationContext() WorkManagerTestInitHelper.initializeTestWorkManager(appContext) + workManagerInitialized = true val params = mock() val inputData = Data.Builder() @@ -370,6 +375,7 @@ class UpdatesWorkerTest { fun checkManualUpdateRunning_returnsTrue_whenUserTaggedWorkIsBlocked() = runTest { val appContext = ApplicationProvider.getApplicationContext() WorkManagerTestInitHelper.initializeTestWorkManager(appContext) + workManagerInitialized = true val request = OneTimeWorkRequestBuilder() .setConstraints( diff --git a/app/src/test/java/foundation/e/apps/data/login/playstore/OauthAuthDataBuilderTest.kt b/app/src/test/java/foundation/e/apps/data/login/playstore/OauthAuthDataBuilderTest.kt index be2d274d2..0b64848e5 100644 --- a/app/src/test/java/foundation/e/apps/data/login/playstore/OauthAuthDataBuilderTest.kt +++ b/app/src/test/java/foundation/e/apps/data/login/playstore/OauthAuthDataBuilderTest.kt @@ -1,5 +1,6 @@ package foundation.e.apps.data.login.playstore +import android.os.Build import com.aurora.gplayapi.data.models.AuthData import com.aurora.gplayapi.helpers.AuthHelper import com.google.common.truth.Truth.assertThat @@ -13,8 +14,13 @@ import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config import java.util.Properties +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.R]) class OauthAuthDataBuilderTest { @Before -- GitLab From 5839b0bf2e07df0e1ec377bdc91bbb9a123050cb Mon Sep 17 00:00:00 2001 From: Jonathan Klee Date: Thu, 16 Apr 2026 09:22:55 +0200 Subject: [PATCH 2/2] feat: introduce F-Droid store --- app/build.gradle | 5 + .../foundation/e/apps/AppLoungeApplication.kt | 16 + .../java/foundation/e/apps/data/Constants.kt | 1 + .../java/foundation/e/apps/data/Stores.kt | 11 + .../application/ApplicationDataManager.kt | 4 +- .../data/application/ApplicationRepository.kt | 5 + .../apps/data/application/apps/AppsApiImpl.kt | 15 + .../data/application/category/CategoryApi.kt | 4 + .../application/category/CategoryApiImpl.kt | 66 +++- .../apps/data/application/data/Application.kt | 2 +- .../downloadInfo/DownloadInfoApiImpl.kt | 15 +- .../search/SearchRepositoryImpl.kt | 56 +++- .../apps/data/cleanapk/ApkSignatureManager.kt | 59 ++++ .../e/apps/data/di/network/NetworkModule.kt | 18 + .../apps/data/di/network/RetrofitApiModule.kt | 13 + .../foundation/e/apps/data/enums/AppTag.kt | 1 + .../foundation/e/apps/data/enums/Source.kt | 1 + .../download/FdroidDownloadInfoProvider.kt | 47 +++ .../apps/data/fdroid/index/FdroidIndexApi.kt | 30 ++ .../data/fdroid/index/FdroidIndexModels.kt | 166 +++++++++ .../fdroid/index/FdroidIndexRepository.kt | 314 ++++++++++++++++++ .../search/FdroidSearchPagingRepository.kt | 47 +++ .../fdroid/search/FdroidSearchPagingSource.kt | 70 ++++ .../apps/data/fdroid/store/FdroidAppMapper.kt | 137 ++++++++ .../fdroid/store/FdroidStoreRepository.kt | 75 +++++ .../e/apps/data/install/AppInstallMappings.kt | 2 + .../e/apps/data/install/AppManagerImpl.kt | 23 +- .../install/download/DownloadManagerUtils.kt | 11 +- .../utils/NativeDeviceInfoProviderModule.kt | 6 + .../apps/domain/ValidateAppAgeLimitUseCase.kt | 4 +- .../domain/install/GetAppDetailsUseCase.kt | 18 + .../search/FdroidSearchPagingUseCase.kt | 52 +++ .../e/apps/services/InstallAppService.kt | 1 + .../model/ApplicationScreenshotsRVAdapter.kt | 3 + .../application/model/ScreenshotRVAdapter.kt | 5 + .../ApplicationListRVAdapter.kt | 1 + .../categories/model/CategoriesRVAdapter.kt | 1 + .../components/SearchResultsContent.kt | 14 +- .../apps/ui/compose/components/SearchTabs.kt | 2 + .../e/apps/ui/compose/screens/SearchScreen.kt | 3 + .../e/apps/ui/compose/screens/SearchTopBar.kt | 1 + .../e/apps/ui/search/v2/SearchFragmentV2.kt | 1 + .../e/apps/ui/search/v2/SearchViewModelV2.kt | 10 + .../e/apps/ui/settings/SettingsFragment.kt | 5 +- app/src/main/res/values/strings.xml | 5 + app/src/main/res/xml/settings_preferences.xml | 7 + .../e/apps/FakeAppLoungePreference.kt | 13 + .../e/apps/category/CategoryApiTest.kt | 19 +- .../foundation/e/apps/data/StoreConfigTest.kt | 14 +- .../java/foundation/e/apps/data/StoresTest.kt | 13 +- .../ApplicationRepositoryHomeTest.kt | 7 + .../downloadInfo/DownloadInfoApiImplTest.kt | 5 +- .../data/install/updates/UpdatesWorkerTest.kt | 6 + .../cleanapk/CleanApkAuthenticatorTest.kt | 3 + .../playstore/PlayStoreAuthenticatorTest.kt | 3 + .../install/GetAppDetailsUseCaseTest.kt | 3 + .../e/apps/fused/SearchRepositoryImplTest.kt | 5 + .../compose/state/InstallStatusStreamTest.kt | 11 +- .../ui/search/v2/SearchViewModelV2Test.kt | 15 + .../installation/model/InstallationSource.kt | 3 +- .../data/preference/AppLoungePreference.kt | 17 +- .../preference/AppLoungePreferenceTest.kt | 1 + .../preferences/AppPreferencesRepository.kt | 3 + .../e/apps/installapp/SeachableSources.kt | 3 +- 64 files changed, 1461 insertions(+), 36 deletions(-) create mode 100644 app/src/main/java/foundation/e/apps/data/fdroid/download/FdroidDownloadInfoProvider.kt create mode 100644 app/src/main/java/foundation/e/apps/data/fdroid/index/FdroidIndexApi.kt create mode 100644 app/src/main/java/foundation/e/apps/data/fdroid/index/FdroidIndexModels.kt create mode 100644 app/src/main/java/foundation/e/apps/data/fdroid/index/FdroidIndexRepository.kt create mode 100644 app/src/main/java/foundation/e/apps/data/fdroid/search/FdroidSearchPagingRepository.kt create mode 100644 app/src/main/java/foundation/e/apps/data/fdroid/search/FdroidSearchPagingSource.kt create mode 100644 app/src/main/java/foundation/e/apps/data/fdroid/store/FdroidAppMapper.kt create mode 100644 app/src/main/java/foundation/e/apps/data/fdroid/store/FdroidStoreRepository.kt create mode 100644 app/src/main/java/foundation/e/apps/domain/search/FdroidSearchPagingUseCase.kt diff --git a/app/build.gradle b/app/build.gradle index c72a56209..842a54ee1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -123,6 +123,11 @@ android { buildConfigField "String", "USER_AGENT", "\"${retrieveKey("user_agent", "Dalvik/2.1.0 (Linux; U; Android %s)")}\"" buildConfigField "String", "FDROID_HOST", "\"${fdroidHost}\"" buildConfigField "String", "FDROID_REPO_BASE_URL", "\"https://${fdroidHost}/repo/\"" + buildConfigField( + "String", + "FDROID_DEFAULT_ICON_URL", + "\"https://${fdroidHost}/assets/ic_repo_app_default_KNN008Z2K7VNPZOFLMTry3JkfFYPxVGDopS1iwWe5wo=.png\"" + ) def parentalControlPkgName = "foundation.e.parentalcontrol" diff --git a/app/src/main/java/foundation/e/apps/AppLoungeApplication.kt b/app/src/main/java/foundation/e/apps/AppLoungeApplication.kt index ed694ba5b..1aefdf062 100644 --- a/app/src/main/java/foundation/e/apps/AppLoungeApplication.kt +++ b/app/src/main/java/foundation/e/apps/AppLoungeApplication.kt @@ -28,7 +28,10 @@ import androidx.work.ExistingPeriodicWorkPolicy import dagger.hilt.android.HiltAndroidApp import foundation.e.apps.data.Constants.TAG_APP_INSTALL_STATE import foundation.e.apps.data.Constants.TAG_AUTHDATA_DUMP +import foundation.e.apps.data.Stores import foundation.e.apps.data.di.qualifiers.IoCoroutineScope +import foundation.e.apps.data.enums.Source +import foundation.e.apps.data.fdroid.index.FdroidIndexRepository import foundation.e.apps.data.install.pkg.AppLoungePackageManager import foundation.e.apps.data.install.pkg.PkgManagerBR import foundation.e.apps.data.install.updates.UpdatesWorkManager @@ -83,6 +86,12 @@ class AppLoungeApplication : Application(), Configuration.Provider { @Inject lateinit var installOrchestrator: InstallOrchestrator + @Inject + lateinit var fdroidIndexRepository: FdroidIndexRepository + + @Inject + lateinit var stores: Stores + @RequiresApi(Build.VERSION_CODES.TIRAMISU) override fun onCreate() { super.onCreate() @@ -131,6 +140,13 @@ class AppLoungeApplication : Application(), Configuration.Provider { ) } + if (stores.isStoreEnabled(Source.FDROID)) { + coroutineScope.launch { + runCatching { fdroidIndexRepository.getIndex() } + .onFailure { Timber.w(it, "Failed to prefetch F-Droid index") } + } + } + removeStalledInstallationFromDb() installOrchestrator.init() } diff --git a/app/src/main/java/foundation/e/apps/data/Constants.kt b/app/src/main/java/foundation/e/apps/data/Constants.kt index 63fb6553e..e593dffcc 100644 --- a/app/src/main/java/foundation/e/apps/data/Constants.kt +++ b/app/src/main/java/foundation/e/apps/data/Constants.kt @@ -26,6 +26,7 @@ object Constants { const val PREFERENCE_SHOW_FOSS = "showFOSSApplications" const val PREFERENCE_SHOW_PWA = "showPWAApplications" const val PREFERENCE_SHOW_GPLAY = "showAllApplications" + const val PREFERENCE_SHOW_FDROID = "showFDroidApplications" const val ACTION_AUTHDATA_DUMP = "foundation.e.apps.action.DUMP_GACCOUNT_INFO" const val TAG_AUTHDATA_DUMP = "AUTHDATA_DUMP" diff --git a/app/src/main/java/foundation/e/apps/data/Stores.kt b/app/src/main/java/foundation/e/apps/data/Stores.kt index 40f8b9c23..08c38f101 100644 --- a/app/src/main/java/foundation/e/apps/data/Stores.kt +++ b/app/src/main/java/foundation/e/apps/data/Stores.kt @@ -22,9 +22,11 @@ package foundation.e.apps.data import foundation.e.apps.data.cleanapk.repositories.CleanApkAppsRepository import foundation.e.apps.data.cleanapk.repositories.CleanApkPwaRepository import foundation.e.apps.data.enums.Source +import foundation.e.apps.data.enums.Source.FDROID import foundation.e.apps.data.enums.Source.OPEN_SOURCE import foundation.e.apps.data.enums.Source.PLAY_STORE import foundation.e.apps.data.enums.Source.PWA +import foundation.e.apps.data.fdroid.store.FdroidStoreRepository import foundation.e.apps.data.playstore.PlayStoreRepository import foundation.e.apps.domain.preferences.AppPreferencesRepository import kotlinx.coroutines.flow.MutableStateFlow @@ -37,6 +39,7 @@ import javax.inject.Singleton @Singleton class Stores @Inject constructor( playStoreRepository: PlayStoreRepository, + fdroidStoreRepository: FdroidStoreRepository, cleanApkAppsRepository: CleanApkAppsRepository, cleanApkPwaRepository: CleanApkPwaRepository, appPreferencesRepository: AppPreferencesRepository @@ -44,6 +47,7 @@ class Stores @Inject constructor( private val storeConfigs: Map = buildStoreConfigs( playStoreRepository, + fdroidStoreRepository, cleanApkAppsRepository, cleanApkPwaRepository, appPreferencesRepository @@ -105,6 +109,7 @@ internal data class StoreConfig( internal fun buildStoreConfigs( playStoreRepository: PlayStoreRepository, + fdroidStoreRepository: FdroidStoreRepository, cleanApkAppsRepository: CleanApkAppsRepository, cleanApkPwaRepository: CleanApkPwaRepository, appPreferencesRepository: AppPreferencesRepository @@ -115,6 +120,12 @@ internal fun buildStoreConfigs( enable = { appPreferencesRepository.enablePlayStore() }, disable = { appPreferencesRepository.disablePlayStore() }, ), + FDROID to StoreConfig( + repository = fdroidStoreRepository, + isEnabled = { appPreferencesRepository.isFdroidSelected() }, + enable = { appPreferencesRepository.enableFdroid() }, + disable = { appPreferencesRepository.disableFdroid() }, + ), OPEN_SOURCE to StoreConfig( repository = cleanApkAppsRepository, isEnabled = { appPreferencesRepository.isOpenSourceSelected() }, diff --git a/app/src/main/java/foundation/e/apps/data/application/ApplicationDataManager.kt b/app/src/main/java/foundation/e/apps/data/application/ApplicationDataManager.kt index 29cf2f51c..ab57040ee 100644 --- a/app/src/main/java/foundation/e/apps/data/application/ApplicationDataManager.kt +++ b/app/src/main/java/foundation/e/apps/data/application/ApplicationDataManager.kt @@ -59,7 +59,9 @@ class ApplicationDataManager @Inject constructor( return when { application.package_name.isBlank() -> FilterLevel.UNKNOWN !application.isFree && application.price.isBlank() -> FilterLevel.UI - application.source == Source.PWA || application.source == Source.OPEN_SOURCE -> FilterLevel.NONE + application.source == Source.PWA || + application.source == Source.OPEN_SOURCE || + application.source == Source.FDROID -> FilterLevel.NONE application.source == Source.SYSTEM_APP -> FilterLevel.NONE !isRestricted(application) -> FilterLevel.NONE !isApplicationVisible(application) -> FilterLevel.DATA diff --git a/app/src/main/java/foundation/e/apps/data/application/ApplicationRepository.kt b/app/src/main/java/foundation/e/apps/data/application/ApplicationRepository.kt index 5c6d31168..4ac6bf3c5 100644 --- a/app/src/main/java/foundation/e/apps/data/application/ApplicationRepository.kt +++ b/app/src/main/java/foundation/e/apps/data/application/ApplicationRepository.kt @@ -115,6 +115,7 @@ class ApplicationRepository @Inject constructor( private fun setHomeErrorMessage(apiStatus: ResultStatus, source: Source) { if (apiStatus != ResultStatus.OK) { apiStatus.message = when (source) { + Source.FDROID -> ("F-Droid home loading error\n" + apiStatus.message).trim() Source.PLAY_STORE -> ("GPlay home loading error\n" + apiStatus.message).trim() Source.SYSTEM_APP -> ("Gitlab home not allowed\n" + apiStatus.message).trim() Source.OPEN_SOURCE -> ("Open Source home loading error\n" + apiStatus.message).trim() @@ -139,6 +140,9 @@ class ApplicationRepository @Inject constructor( val selectedAppTypes = mutableListOf() if (stores.isStoreEnabled(Source.PLAY_STORE)) selectedAppTypes.add(APP_TYPE_ANY) if (stores.isStoreEnabled(Source.OPEN_SOURCE)) selectedAppTypes.add(APP_TYPE_OPEN) + if (stores.isStoreEnabled(Source.FDROID) && !selectedAppTypes.contains(APP_TYPE_OPEN)) { + selectedAppTypes.add(APP_TYPE_OPEN) + } if (stores.isStoreEnabled(Source.PWA)) selectedAppTypes.add(APP_TYPE_PWA) return selectedAppTypes @@ -188,6 +192,7 @@ class ApplicationRepository @Inject constructor( source: Source ): ResultSupreme, String>> { return when (source) { + Source.FDROID -> categoryApi.getFdroidAppsByCategory(category) Source.OPEN_SOURCE -> categoryApi.getCleanApkAppsByCategory(category, Source.OPEN_SOURCE) Source.PWA -> categoryApi.getCleanApkAppsByCategory(category, Source.PWA) else -> categoryApi.getGplayAppsByCategory(category, pageUrl) diff --git a/app/src/main/java/foundation/e/apps/data/application/apps/AppsApiImpl.kt b/app/src/main/java/foundation/e/apps/data/application/apps/AppsApiImpl.kt index 9b514e4e5..5a1076c42 100644 --- a/app/src/main/java/foundation/e/apps/data/application/apps/AppsApiImpl.kt +++ b/app/src/main/java/foundation/e/apps/data/application/apps/AppsApiImpl.kt @@ -63,6 +63,8 @@ class AppsApiImpl @Inject constructor( val response: Pair, ResultStatus> = if (source == Source.OPEN_SOURCE || source == Source.PWA) { getAppDetailsListFromCleanApk(packageNameList) + } else if (source == Source.FDROID) { + getAppDetailsListFromFdroid(packageNameList) } else { getAppDetailsListFromGPlay(packageNameList) } @@ -97,6 +99,19 @@ class AppsApiImpl @Inject constructor( return Pair(applicationList, status) } + private suspend fun getAppDetailsListFromFdroid( + packageNameList: List, + ): Pair, ResultStatus> { + val status = ResultStatus.OK + val applicationList = mutableListOf() + + for (packageName in packageNameList) { + applicationList.add(stores.getStore(Source.FDROID)?.getAppDetails(packageName) ?: Application()) + } + + return Pair(applicationList, status) + } + private suspend fun getAppDetailsListFromGPlay( packageNameList: List, ): Pair, ResultStatus> { diff --git a/app/src/main/java/foundation/e/apps/data/application/category/CategoryApi.kt b/app/src/main/java/foundation/e/apps/data/application/category/CategoryApi.kt index e67b4e886..f1790566a 100644 --- a/app/src/main/java/foundation/e/apps/data/application/category/CategoryApi.kt +++ b/app/src/main/java/foundation/e/apps/data/application/category/CategoryApi.kt @@ -38,4 +38,8 @@ interface CategoryApi { category: String, source: Source ): ResultSupreme, String>> + + suspend fun getFdroidAppsByCategory( + category: String, + ): ResultSupreme, String>> } diff --git a/app/src/main/java/foundation/e/apps/data/application/category/CategoryApiImpl.kt b/app/src/main/java/foundation/e/apps/data/application/category/CategoryApiImpl.kt index 7b5e18f2c..dd5a1ebde 100644 --- a/app/src/main/java/foundation/e/apps/data/application/category/CategoryApiImpl.kt +++ b/app/src/main/java/foundation/e/apps/data/application/category/CategoryApiImpl.kt @@ -37,6 +37,8 @@ import foundation.e.apps.data.enums.AppTag import foundation.e.apps.data.enums.ResultStatus import foundation.e.apps.data.enums.Source import foundation.e.apps.data.enums.isUnFiltered +import foundation.e.apps.data.fdroid.index.FdroidIndexRepository +import foundation.e.apps.data.fdroid.store.FdroidAppMapper import foundation.e.apps.data.handleNetworkResult import javax.inject.Inject @@ -44,7 +46,8 @@ class CategoryApiImpl @Inject constructor( @ApplicationContext private val context: Context, private val appSources: AppSourcesContainer, private val stores: Stores, - private val applicationDataManager: ApplicationDataManager + private val applicationDataManager: ApplicationDataManager, + private val fdroidIndexRepository: FdroidIndexRepository, ) : CategoryApi { override suspend fun getCategoriesList(type: CategoryType): List { val categoryResponses = mutableListOf() @@ -63,6 +66,10 @@ class CategoryApiImpl @Inject constructor( source: Source ): ResultStatus { val categoryResult = when (source) { + Source.FDROID -> { + fetchFdroidCategories(type) + } + Source.OPEN_SOURCE -> { fetchCleanApkCategories(type, Source.OPEN_SOURCE) } @@ -134,6 +141,46 @@ class CategoryApiImpl @Inject constructor( return Pair(categoryList, result.getResultStatus()) } + private suspend fun fetchFdroidCategories( + type: CategoryType, + ): Pair, ResultStatus> { + val categoryList = mutableListOf() + val tag = AppTag.FDroid(context.getString(R.string.fdroid)) + val result = handleNetworkResult { + val index = fdroidIndexRepository.getIndex() + val categoryMap = linkedMapOf() + index.packages.values.forEach { pkg -> + pkg.metadata?.categories.orEmpty().forEach { raw -> + val normalized = fdroidIndexRepository.normalizeCategory(raw) + if (normalized.isBlank()) return@forEach + if (shouldIncludeFdroidCategory(type, normalized)) { + categoryMap.putIfAbsent(normalized, raw.trim()) + } + } + } + categoryMap.forEach { (id, title) -> + categoryList.add( + Category( + id = id, + title = title, + drawable = CategoryUtils.provideAppsCategoryIconResource(id), + tag = tag + ) + ) + } + categoryList + } + return Pair(result.data ?: emptyList(), result.getResultStatus()) + } + + private fun shouldIncludeFdroidCategory(type: CategoryType, normalizedCategory: String): Boolean { + val isGame = normalizedCategory == "games" || normalizedCategory.startsWith("game_") + return when (type) { + CategoryType.APPLICATION -> !isGame + CategoryType.GAMES -> isGame + } + } + private fun getFusedCategoryBasedOnCategoryType( categories: Categories, categoryType: CategoryType, @@ -218,6 +265,23 @@ class CategoryApiImpl @Inject constructor( return ResultSupreme.create(result.getResultStatus(), Pair(list, "")) } + override suspend fun getFdroidAppsByCategory( + category: String + ): ResultSupreme, String>> { + val list = mutableListOf() + val result = handleNetworkResult { + val entries = fdroidIndexRepository.appsByCategory(category) + entries.forEach { entry -> + val app = FdroidAppMapper.toApplication(entry, fdroidIndexRepository) ?: return@forEach + applicationDataManager.updateStatus(app) + applicationDataManager.updateFilterLevel(app) + list.add(app) + } + list + } + return ResultSupreme.create(result.getResultStatus(), Pair(list, "")) + } + private suspend fun getCleanApkAppsResponse( source: Source, category: String diff --git a/app/src/main/java/foundation/e/apps/data/application/data/Application.kt b/app/src/main/java/foundation/e/apps/data/application/data/Application.kt index 97b50477c..244268852 100644 --- a/app/src/main/java/foundation/e/apps/data/application/data/Application.kt +++ b/app/src/main/java/foundation/e/apps/data/application/data/Application.kt @@ -120,7 +120,7 @@ data class Application( CleanApkRetrofit.ASSET_URL + icon_image_path } } - Source.SYSTEM_APP, Source.PLAY_STORE -> icon_image_path + Source.SYSTEM_APP, Source.PLAY_STORE, Source.FDROID -> icon_image_path } } diff --git a/app/src/main/java/foundation/e/apps/data/application/downloadInfo/DownloadInfoApiImpl.kt b/app/src/main/java/foundation/e/apps/data/application/downloadInfo/DownloadInfoApiImpl.kt index 88e91c31c..3e40f0b00 100644 --- a/app/src/main/java/foundation/e/apps/data/application/downloadInfo/DownloadInfoApiImpl.kt +++ b/app/src/main/java/foundation/e/apps/data/application/downloadInfo/DownloadInfoApiImpl.kt @@ -21,12 +21,14 @@ package foundation.e.apps.data.application.downloadInfo import foundation.e.apps.data.AppSourcesContainer import foundation.e.apps.data.cleanapk.CleanApkDownloadInfoFetcher import foundation.e.apps.data.enums.Source +import foundation.e.apps.data.fdroid.download.FdroidDownloadInfoProvider import foundation.e.apps.data.handleNetworkResult import foundation.e.apps.data.installation.model.AppInstall import javax.inject.Inject class DownloadInfoApiImpl @Inject constructor( - private val appSources: AppSourcesContainer + private val appSources: AppSourcesContainer, + private val fdroidDownloadInfoProvider: FdroidDownloadInfoProvider, ) : DownloadInfoApi { override suspend fun getOnDemandModule( @@ -61,6 +63,10 @@ class DownloadInfoApiImpl @Inject constructor( ) { val list = mutableListOf() when (source) { + Source.FDROID -> { + updateDownloadInfoFromFdroid(appInstall, list) + } + Source.OPEN_SOURCE -> { updateDownloadInfoFromCleanApk(appInstall, list) } @@ -126,6 +132,13 @@ class DownloadInfoApiImpl @Inject constructor( appInstall.signature = downloadInfo?.download_data?.signature ?: "" } + private suspend fun updateDownloadInfoFromFdroid( + appInstall: AppInstall, + list: MutableList + ) { + fdroidDownloadInfoProvider.updateDownloadInfo(appInstall, list) + } + override suspend fun getOSSDownloadInfo(id: String, version: String?) = (appSources.cleanApkAppsRepo as CleanApkDownloadInfoFetcher) .getDownloadInfo(id, version) diff --git a/app/src/main/java/foundation/e/apps/data/application/search/SearchRepositoryImpl.kt b/app/src/main/java/foundation/e/apps/data/application/search/SearchRepositoryImpl.kt index ade1e278d..c0d1b6a85 100644 --- a/app/src/main/java/foundation/e/apps/data/application/search/SearchRepositoryImpl.kt +++ b/app/src/main/java/foundation/e/apps/data/application/search/SearchRepositoryImpl.kt @@ -51,6 +51,9 @@ class SearchRepositoryImpl @Inject constructor( } val searchResultsByKeyword = buildList { + if (stores.isStoreEnabled(Source.FDROID)) { + addAll(fetchFdroidSearchResult(query, searchResultsByPackageName)) + } if (stores.isStoreEnabled(Source.OPEN_SOURCE)) { addAll(fetchOpenSourceSearchResult(query, searchResultsByPackageName)) } @@ -127,11 +130,33 @@ class SearchRepositoryImpl @Inject constructor( } } + private suspend fun fetchFdroidSearchResult( + query: String, + packageSpecificResults: List, + ): List { + val result = handleNetworkResult { + stores.getStore(Source.FDROID)?.getSearchResults(query).orEmpty().map { + applicationDataManager.updateStatus(it) + it.updateType() + it + } + } + + return if (result.isSuccess()) { + result.data.orEmpty().apply { + filterWithKeywordSearch(this, packageSpecificResults) + } + } else { + emptyList() + } + } + private suspend fun searchAppsByPackageName(query: String): SearchResult { val apps = mutableListOf() var playStoreApp: Application? = null var cleanApkApp: Application? = null + var fdroidApp: Application? = null val result = handleNetworkResult { if (stores.isStoreEnabled(Source.PLAY_STORE)) { @@ -141,6 +166,10 @@ class SearchRepositoryImpl @Inject constructor( if (stores.isStoreEnabled(Source.OPEN_SOURCE)) { cleanApkApp = getCleanApkApp(query) } + + if (stores.isStoreEnabled(Source.FDROID)) { + fdroidApp = getFdroidApp(query) + } } val resultStatus = result.getResultStatus() @@ -151,6 +180,7 @@ class SearchRepositoryImpl @Inject constructor( // Choose only open source app if it exists both in F-Droid and Play Store. // Example: com.fsck.k9 when { + fdroidApp != null -> apps.add(fdroidApp) cleanApkApp != null -> apps.add(cleanApkApp) playStoreApp != null -> apps.add(playStoreApp) } @@ -200,6 +230,16 @@ class SearchRepositoryImpl @Inject constructor( return null } + private suspend fun getFdroidApp(query: String): Application? { + getFdroidSearchResult(query).let { + if (it.isSuccess() && it.data!!.package_name.isNotBlank()) { + return it.data!! + } + } + + return null + } + private suspend fun getPlayStoreApp(query: String): Application? { val storeRepository = stores.getStore(Source.PLAY_STORE) as? PlayStoreRepository return storeRepository?.getAppDetailsWeb(query) @@ -217,10 +257,20 @@ class SearchRepositoryImpl @Inject constructor( val result = handleNetworkResult { val results = stores.getStore(Source.OPEN_SOURCE)?.getSearchResults(packageName).orEmpty() + application = results.firstOrNull { it.package_name == packageName } + ?: if (results.size == 1) results[0] else Application() + } - if (results.isNotEmpty() && results.size == 1) { - application = results[0] - } + return ResultSupreme.create(result.getResultStatus(), application) + } + + private suspend fun getFdroidSearchResult(packageName: String): ResultSupreme { + var application = Application() + val result = handleNetworkResult { + val results = + stores.getStore(Source.FDROID)?.getSearchResults(packageName).orEmpty() + application = results.firstOrNull { it.package_name == packageName } + ?: if (results.size == 1) results[0] else Application() } return ResultSupreme.create(result.getResultStatus(), application) diff --git a/app/src/main/java/foundation/e/apps/data/cleanapk/ApkSignatureManager.kt b/app/src/main/java/foundation/e/apps/data/cleanapk/ApkSignatureManager.kt index 017394e7c..7f3c3c845 100644 --- a/app/src/main/java/foundation/e/apps/data/cleanapk/ApkSignatureManager.kt +++ b/app/src/main/java/foundation/e/apps/data/cleanapk/ApkSignatureManager.kt @@ -18,6 +18,7 @@ package foundation.e.apps.data.cleanapk import android.content.Context +import android.content.pm.PackageManager import foundation.e.apps.data.install.FileManager import org.bouncycastle.jce.provider.BouncyCastleProvider import org.bouncycastle.openpgp.PGPCompressedData @@ -32,9 +33,13 @@ import timber.log.Timber import java.io.BufferedInputStream import java.io.FileInputStream import java.io.InputStream +import java.security.MessageDigest import java.security.Security object ApkSignatureManager { + private const val HEX_CHARS_PER_BYTE = 2 + private const val HEX_RADIX = 16 + private const val BYTE_MASK = 0xFF fun verifyFdroidSignature(context: Context, apkFilePath: String, signature: String, packageName: String): Boolean { Security.addProvider(BouncyCastleProvider()) @@ -51,6 +56,23 @@ object ApkSignatureManager { return false } + fun verifyFdroidSignerDigest( + context: Context, + apkFilePath: String, + expectedSigner: String, + packageName: String + ): Boolean { + if (expectedSigner.isBlank()) return false + return try { + val normalizedExpected = normalizeDigest(expectedSigner) + val digests = getApkSignerDigests(context, apkFilePath) + digests.any { normalizeDigest(it) == normalizedExpected } + } catch (e: Exception) { + Timber.e(e, "Signer digest verification failed for: $packageName") + false + } + } + private fun verifyAPKSignature( apkInputStream: BufferedInputStream, apkSignatureInputStream: InputStream, @@ -80,6 +102,43 @@ object ApkSignatureManager { return false } + private fun getApkSignerDigests(context: Context, apkFilePath: String): List { + val packageManager = context.packageManager + val packageInfo = packageManager.getPackageArchiveInfo( + apkFilePath, + PackageManager.GET_SIGNING_CERTIFICATES + ) + packageInfo?.applicationInfo?.apply { + sourceDir = apkFilePath + publicSourceDir = apkFilePath + } + + val signingInfo = packageInfo?.signingInfo ?: return emptyList() + val signatures = if (signingInfo.hasMultipleSigners()) { + signingInfo.apkContentsSigners + } else { + signingInfo.signingCertificateHistory + } + + return signatures.mapNotNull { signature -> + val digest = MessageDigest.getInstance("SHA-256").digest(signature.toByteArray()) + digest.toHex() + } + } + + private fun ByteArray.toHex(): String { + val builder = StringBuilder(size * HEX_CHARS_PER_BYTE) + for (byte in this) { + val value = byte.toInt() and BYTE_MASK + builder.append(value.toString(HEX_RADIX).padStart(HEX_CHARS_PER_BYTE, '0')) + } + return builder.toString() + } + + private fun normalizeDigest(value: String): String { + return value.filter { it.isDigit() || it.lowercaseChar() in 'a'..'f' }.lowercase() + } + private fun extractSignature(apkSignatureInputStream: InputStream): PGPSignature? { var jcaPGPObjectFactory = JcaPGPObjectFactory(PGPUtil.getDecoderStream(apkSignatureInputStream)) diff --git a/app/src/main/java/foundation/e/apps/data/di/network/NetworkModule.kt b/app/src/main/java/foundation/e/apps/data/di/network/NetworkModule.kt index 077da8db4..c0570cebd 100644 --- a/app/src/main/java/foundation/e/apps/data/di/network/NetworkModule.kt +++ b/app/src/main/java/foundation/e/apps/data/di/network/NetworkModule.kt @@ -42,6 +42,7 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object NetworkModule { + private const val FDROID_INDEX_TIMEOUT_SECONDS = 60L private const val HTTP_TIMEOUT_IN_SECOND = 10L @@ -88,4 +89,21 @@ object NetworkModule { .cache(cache) .build() } + + @Singleton + @Provides + @Named("fdroidIndexOkHttpClient") + fun provideFdroidIndexOkHttpClient( + cache: Cache, + interceptor: Interceptor, + httpLoggingInterceptor: HttpLoggingInterceptor + ): OkHttpClient { + return OkHttpClient.Builder() + .addInterceptor(interceptor) + .addInterceptor(httpLoggingInterceptor) + .callTimeout(FDROID_INDEX_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(FDROID_INDEX_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .cache(cache) + .build() + } } diff --git a/app/src/main/java/foundation/e/apps/data/di/network/RetrofitApiModule.kt b/app/src/main/java/foundation/e/apps/data/di/network/RetrofitApiModule.kt index f4f579daf..cc52b7dbf 100644 --- a/app/src/main/java/foundation/e/apps/data/di/network/RetrofitApiModule.kt +++ b/app/src/main/java/foundation/e/apps/data/di/network/RetrofitApiModule.kt @@ -29,6 +29,7 @@ import foundation.e.apps.data.cleanapk.CleanApkRetrofit import foundation.e.apps.data.di.network.NetworkModule.getYamlFactory import foundation.e.apps.data.ecloud.EcloudApiInterface import foundation.e.apps.data.fdroid.FdroidApiInterface +import foundation.e.apps.data.fdroid.index.FdroidIndexApi import foundation.e.apps.data.gitlab.ReleaseInfoApi import foundation.e.apps.data.gitlab.SystemAppDefinitionApi import foundation.e.apps.data.gitlab.UpdatableSystemAppsApi @@ -78,6 +79,18 @@ class RetrofitApiModule { .create(FdroidApiInterface::class.java) } + @Singleton + @Provides + fun provideFdroidIndexApi( + @Named("fdroidIndexOkHttpClient") okHttpClient: OkHttpClient + ): FdroidIndexApi { + return Retrofit.Builder() + .baseUrl(BuildConfig.FDROID_REPO_BASE_URL) + .client(okHttpClient) + .build() + .create(FdroidIndexApi::class.java) + } + @Singleton @Provides fun provideEcloudApi(okHttpClient: OkHttpClient, moshi: Moshi): EcloudApiInterface { diff --git a/app/src/main/java/foundation/e/apps/data/enums/AppTag.kt b/app/src/main/java/foundation/e/apps/data/enums/AppTag.kt index a6e3f49ec..d90bbf3d0 100644 --- a/app/src/main/java/foundation/e/apps/data/enums/AppTag.kt +++ b/app/src/main/java/foundation/e/apps/data/enums/AppTag.kt @@ -28,6 +28,7 @@ package foundation.e.apps.data.enums sealed class AppTag(val displayTag: String) { class OpenSource(displayTag: String) : AppTag(displayTag) class PWA(displayTag: String) : AppTag(displayTag) + class FDroid(displayTag: String) : AppTag(displayTag) class GPlay(displayTag: String = "") : AppTag(displayTag) /** diff --git a/app/src/main/java/foundation/e/apps/data/enums/Source.kt b/app/src/main/java/foundation/e/apps/data/enums/Source.kt index fb1d51a1b..f765c2c69 100644 --- a/app/src/main/java/foundation/e/apps/data/enums/Source.kt +++ b/app/src/main/java/foundation/e/apps/data/enums/Source.kt @@ -21,6 +21,7 @@ import androidx.annotation.StringRes import foundation.e.apps.R enum class Source(@param:StringRes val stringResId: Int?) { + FDROID(R.string.fdroid), OPEN_SOURCE(R.string.open_source), PWA(R.string.pwa), SYSTEM_APP(R.string.system_app), diff --git a/app/src/main/java/foundation/e/apps/data/fdroid/download/FdroidDownloadInfoProvider.kt b/app/src/main/java/foundation/e/apps/data/fdroid/download/FdroidDownloadInfoProvider.kt new file mode 100644 index 000000000..1addc48e6 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/fdroid/download/FdroidDownloadInfoProvider.kt @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2026 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package foundation.e.apps.data.fdroid.download + +import foundation.e.apps.BuildConfig +import foundation.e.apps.data.fdroid.index.FdroidIndexRepository +import foundation.e.apps.data.installation.model.AppInstall +import foundation.e.apps.data.system.SystemInfoProvider +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class FdroidDownloadInfoProvider @Inject constructor( + private val indexRepository: FdroidIndexRepository, +) { + + suspend fun updateDownloadInfo(appInstall: AppInstall, downloadUrls: MutableList) { + val entry = indexRepository.getEntry(appInstall.packageName) ?: return + val version = indexRepository.selectPreferredVersion( + entry, + SystemInfoProvider.getSupportedArchitectureList() + ) ?: return + val apkName = version.apkName ?: return + val apkUrl = if (apkName.startsWith("http")) { + apkName + } else { + BuildConfig.FDROID_REPO_BASE_URL + apkName.trimStart('/') + } + downloadUrls.add(apkUrl) + appInstall.signature = version.signer?.ifBlank { version.sig.orEmpty() } ?: version.sig.orEmpty() + } +} diff --git a/app/src/main/java/foundation/e/apps/data/fdroid/index/FdroidIndexApi.kt b/app/src/main/java/foundation/e/apps/data/fdroid/index/FdroidIndexApi.kt new file mode 100644 index 000000000..e1cede4b6 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/fdroid/index/FdroidIndexApi.kt @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2026 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package foundation.e.apps.data.fdroid.index + +import okhttp3.ResponseBody +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Streaming + +interface FdroidIndexApi { + + @Streaming + @GET("index-v2.json") + suspend fun downloadIndexJson(): Response +} diff --git a/app/src/main/java/foundation/e/apps/data/fdroid/index/FdroidIndexModels.kt b/app/src/main/java/foundation/e/apps/data/fdroid/index/FdroidIndexModels.kt new file mode 100644 index 000000000..b83e459c3 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/fdroid/index/FdroidIndexModels.kt @@ -0,0 +1,166 @@ +/* + * Copyright (C) 2026 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package foundation.e.apps.data.fdroid.index + +import java.util.Locale + +data class FdroidIndexV2( + val repo: FdroidRepoV2? = null, + val packages: Map = emptyMap(), +) + +data class FdroidRepoV2( + val timestamp: Long? = null, +) + +data class FdroidApp( + val name: String? = null, + val summary: String? = null, + val description: String? = null, + val license: String? = null, + val categories: List? = null, + val icon: String? = null, + val featureGraphic: String? = null, + val promoGraphic: String? = null, + val tvBanner: String? = null, + val phoneScreenshots: List? = null, + val sevenInchScreenshots: List? = null, + val tenInchScreenshots: List? = null, + val tvScreenshots: List? = null, + val wearScreenshots: List? = null, + val localizedLocale: String? = null, + val suggestedVersionCode: Long? = null, + val lastUpdated: Long? = null, + val added: Long? = null, + val antiFeatures: List? = null, + val authorName: String? = null, + val authorEmail: String? = null, + val authorWebSite: String? = null, +) + +data class FdroidPackageVersion( + val versionCode: Long? = null, + val versionName: String? = null, + val apkName: String? = null, + val hash: String? = null, + val hashType: String? = null, + val size: Long? = null, + val sig: String? = null, + val signer: String? = null, + val nativecode: List? = null, +) + +data class FdroidAppEntry( + val packageName: String, + val app: FdroidApp, + val versions: List, +) + +data class FdroidPackageV2( + val metadata: FdroidMetadataV2? = null, + val versions: Map = emptyMap(), +) + +data class FdroidMetadataV2( + val name: Map? = null, + val summary: Map? = null, + val description: Map? = null, + val added: Long? = null, + val lastUpdated: Long? = null, + val license: String? = null, + val categories: List? = null, + val authorName: String? = null, + val authorEmail: String? = null, + val authorWebSite: String? = null, + val icon: Map? = null, + val featureGraphic: Map? = null, + val promoGraphic: Map? = null, + val tvBanner: Map? = null, + val screenshots: FdroidScreenshotsV2? = null, +) + +data class FdroidScreenshotsV2( + val phone: Map>? = null, + val sevenInch: Map>? = null, + val tenInch: Map>? = null, + val wear: Map>? = null, + val tv: Map>? = null, +) + +data class FdroidPackageVersionV2( + val added: Long? = null, + val file: FdroidApkFileV2? = null, + val manifest: FdroidManifestV2? = null, + val antiFeatures: Map>? = null, +) + +data class FdroidApkFileV2( + val name: String? = null, + val sha256: String? = null, + val size: Long? = null, +) + +data class FdroidManifestV2( + val versionName: String? = null, + val versionCode: Long? = null, + val signer: FdroidSignerV2? = null, + val nativecode: List? = null, +) + +data class FdroidSignerV2( + val sha256: List? = null, + val hasMultipleSigners: Boolean? = null, +) + +data class FdroidFileV2( + val name: String? = null, + val sha256: String? = null, + val size: Long? = null, +) + +fun selectBestLocaleKey(locale: Locale, keys: Set): String? { + if (keys.isEmpty()) { + return null + } + val tags = buildList { + val tag = locale.toLanguageTag() + if (tag.isNotBlank()) add(tag) + if (locale.language.isNotBlank()) add(locale.language) + add("en-US") + add("en") + } + var selected: String? = null + for (tag in tags) { + val match = keys.firstOrNull { it.equals(tag, ignoreCase = true) } + if (match != null) { + selected = match + break + } + } + if (selected == null && locale.language.isNotBlank()) { + val prefix = locale.language.lowercase() + val match = keys.firstOrNull { it.lowercase().startsWith(prefix) } + if (match != null) { + selected = match + } + } + if (selected == null) { + selected = keys.firstOrNull() + } + return selected +} diff --git a/app/src/main/java/foundation/e/apps/data/fdroid/index/FdroidIndexRepository.kt b/app/src/main/java/foundation/e/apps/data/fdroid/index/FdroidIndexRepository.kt new file mode 100644 index 000000000..0b5428279 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/fdroid/index/FdroidIndexRepository.kt @@ -0,0 +1,314 @@ +/* + * Copyright (C) 2026 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package foundation.e.apps.data.fdroid.index + +import android.content.Context +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonDataException +import com.squareup.moshi.Moshi +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import timber.log.Timber +import java.io.File +import java.io.IOException +import java.util.Locale +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class FdroidIndexRepository @Inject constructor( + @ApplicationContext private val context: Context, + private val indexApi: FdroidIndexApi, + moshi: Moshi, +) { + + companion object { + private const val CACHE_TTL_MS = 30 * 60 * 1000L + private const val DISK_CACHE_TTL_MS = 24 * 60 * 60 * 1000L + private const val INDEX_CACHE_FILE = "fdroid-index-v2.json" + } + + private val indexAdapter: JsonAdapter = + moshi.adapter(FdroidIndexV2::class.java) + + private val cacheLock = Mutex() + private var cachedIndex: FdroidIndexV2? = null + private var cachedAtMs: Long = 0L + + suspend fun getIndex(): FdroidIndexV2 { + val now = System.currentTimeMillis() + val cached = cachedIndex + if (cached != null && now - cachedAtMs < CACHE_TTL_MS) { + return cached + } + + return cacheLock.withLock { + val refreshedCached = cachedIndex + val refreshedAt = cachedAtMs + val refreshedNow = System.currentTimeMillis() + if (refreshedCached != null && refreshedNow - refreshedAt < CACHE_TTL_MS) { + return@withLock refreshedCached + } + + val index = loadFromDiskIfFresh(refreshedNow) + ?: downloadAndCacheIndex() + cachedIndex = index + cachedAtMs = refreshedNow + index + } + } + + suspend fun getEntry(packageName: String): FdroidAppEntry? { + val index = getIndex() + val pkg = index.packages[packageName] ?: return null + return buildEntry(packageName, pkg) + } + + suspend fun searchApps(query: String): List { + val normalizedQuery = query.trim().lowercase() + if (normalizedQuery.isEmpty()) return emptyList() + val index = getIndex() + return index.packages.mapNotNull { (packageName, pkg) -> + val app = buildApp(pkg) ?: return@mapNotNull null + val match = packageName.lowercase().contains(normalizedQuery) || + app.name.orEmpty().lowercase().contains(normalizedQuery) || + app.summary.orEmpty().lowercase().contains(normalizedQuery) + if (!match) return@mapNotNull null + val versions = buildVersions(pkg) + if (versions.isEmpty()) return@mapNotNull null + FdroidAppEntry(packageName, app, versions) + } + } + + suspend fun topApps(limit: Int): List { + val index = getIndex() + return index.packages.mapNotNull { (packageName, pkg) -> + val app = buildApp(pkg) ?: return@mapNotNull null + val versions = buildVersions(pkg) + if (versions.isEmpty()) return@mapNotNull null + FdroidAppEntry(packageName, app, versions) + } + .sortedByDescending { it.app.lastUpdated ?: 0L } + .take(limit) + } + + suspend fun appsByCategory(categoryId: String): List { + val normalizedId = normalizeCategory(categoryId) + if (normalizedId.isBlank()) return emptyList() + val index = getIndex() + return index.packages.mapNotNull { (packageName, pkg) -> + val app = buildApp(pkg) ?: return@mapNotNull null + val categories = app.categories.orEmpty() + val matches = categories.any { normalizeCategory(it) == normalizedId } + if (!matches) return@mapNotNull null + val versions = buildVersions(pkg) + if (versions.isEmpty()) return@mapNotNull null + FdroidAppEntry(packageName, app, versions) + } + } + + fun normalizeCategory(category: String): String { + return category.trim() + .lowercase() + .replace("&", "and") + .replace('-', '_') + .replace(' ', '_') + } + + fun selectPreferredVersion( + entry: FdroidAppEntry, + supportedAbis: List, + ): FdroidPackageVersion? { + if (entry.versions.isEmpty()) return null + val supportedSet = supportedAbis.map { it.lowercase() }.toSet() + val sorted = entry.versions.sortedByDescending { it.versionCode ?: 0L } + return sorted.firstOrNull { version -> + val native = version.nativecode.orEmpty() + if (native.isEmpty()) return@firstOrNull true + native.any { it.lowercase() in supportedSet } + } ?: sorted.firstOrNull() + } + + private fun loadFromDiskIfFresh(nowMs: Long): FdroidIndexV2? { + val cacheFile = getIndexCacheFile() + val ageMs = nowMs - cacheFile.lastModified() + return if (!cacheFile.exists() || ageMs > DISK_CACHE_TTL_MS) { + null + } else { + try { + parseIndexFromFile(cacheFile) + } catch (e: IOException) { + Timber.w(e, "Failed to read cached F-Droid index") + cacheFile.delete() + null + } catch (e: JsonDataException) { + Timber.w(e, "Failed to read cached F-Droid index") + cacheFile.delete() + null + } + } + } + + private suspend fun downloadAndCacheIndex(): FdroidIndexV2 { + val response = indexApi.downloadIndexJson() + require(response.isSuccessful) { "Failed to download F-Droid index: ${response.code()}" } + val body = requireNotNull(response.body()) { "Empty F-Droid index response" } + val json = body.string() + val cacheFile = getIndexCacheFile() + cacheFile.writeText(json) + return parseIndexFromString(json) + } + + private fun parseIndexFromFile(cacheFile: File): FdroidIndexV2 { + val json = cacheFile.readText() + return parseIndexFromString(json) + } + + private fun parseIndexFromString(json: String): FdroidIndexV2 { + return indexAdapter.fromJson(json) + ?: throw IOException("Failed to parse F-Droid index JSON") + } + + private fun getIndexCacheFile(): File { + return File(context.cacheDir, INDEX_CACHE_FILE) + } + + private fun buildEntry( + packageName: String, + pkg: FdroidPackageV2, + ): FdroidAppEntry? { + val app = buildApp(pkg) ?: return null + val versions = buildVersions(pkg) + if (versions.isEmpty()) return null + return FdroidAppEntry(packageName, app, versions) + } + + private fun buildApp( + pkg: FdroidPackageV2, + ): FdroidApp? { + val metadata = pkg.metadata ?: return null + val locale = Locale.getDefault() + val localeKey = selectLocaleKey(metadata, locale) + val screenshots = metadata.screenshots + val phoneScreens = selectLocalizedFileList(screenshots?.phone, localeKey) + val sevenScreens = selectLocalizedFileList(screenshots?.sevenInch, localeKey) + val tenScreens = selectLocalizedFileList(screenshots?.tenInch, localeKey) + val tvScreens = selectLocalizedFileList(screenshots?.tv, localeKey) + val wearScreens = selectLocalizedFileList(screenshots?.wear, localeKey) + val antiFeatures = selectAntiFeatures(pkg) + + return FdroidApp( + name = selectLocalizedText(metadata.name, localeKey), + summary = selectLocalizedText(metadata.summary, localeKey), + description = selectLocalizedText(metadata.description, localeKey), + license = metadata.license, + categories = metadata.categories, + icon = selectLocalizedFileName(metadata.icon, localeKey), + featureGraphic = selectLocalizedFileName(metadata.featureGraphic, localeKey), + promoGraphic = selectLocalizedFileName(metadata.promoGraphic, localeKey), + tvBanner = selectLocalizedFileName(metadata.tvBanner, localeKey), + phoneScreenshots = phoneScreens, + sevenInchScreenshots = sevenScreens, + tenInchScreenshots = tenScreens, + tvScreenshots = tvScreens, + wearScreenshots = wearScreens, + localizedLocale = localeKey, + lastUpdated = metadata.lastUpdated, + added = metadata.added, + antiFeatures = antiFeatures, + authorName = metadata.authorName, + authorEmail = metadata.authorEmail, + authorWebSite = metadata.authorWebSite, + ) + } + + private fun buildVersions(pkg: FdroidPackageV2): List { + return pkg.versions.values.mapNotNull { version -> + val file = version.file ?: return@mapNotNull null + val manifest = version.manifest ?: return@mapNotNull null + FdroidPackageVersion( + versionCode = manifest.versionCode, + versionName = manifest.versionName, + apkName = file.name, + hash = file.sha256, + size = file.size, + signer = manifest.signer?.sha256?.firstOrNull(), + nativecode = manifest.nativecode, + ) + } + } + + private fun selectAntiFeatures(pkg: FdroidPackageV2): List? { + val versions = pkg.versions.values + val latest = versions.maxByOrNull { it.manifest?.versionCode ?: 0L } + return latest?.antiFeatures?.keys?.toList() + } + + private fun selectLocaleKey(metadata: FdroidMetadataV2, locale: Locale): String? { + val keys = mutableSetOf() + listOfNotNull( + metadata.name?.keys, + metadata.summary?.keys, + metadata.description?.keys, + metadata.icon?.keys, + metadata.featureGraphic?.keys, + metadata.promoGraphic?.keys, + metadata.tvBanner?.keys, + metadata.screenshots?.phone?.keys, + metadata.screenshots?.sevenInch?.keys, + metadata.screenshots?.tenInch?.keys, + metadata.screenshots?.tv?.keys, + metadata.screenshots?.wear?.keys, + ).forEach { keys.addAll(it) } + return selectBestLocaleKey(locale, keys) + } + + private fun selectLocalizedText( + localized: Map?, + localeKey: String?, + ): String? { + if (localized.isNullOrEmpty()) return null + val value = localeKey?.let { key -> + localized.entries.firstOrNull { it.key.equals(key, ignoreCase = true) }?.value + } + return value ?: localized.values.firstOrNull() + } + + private fun selectLocalizedFileName( + localized: Map?, + localeKey: String?, + ): String? { + if (localized.isNullOrEmpty()) return null + val file = localeKey?.let { key -> + localized.entries.firstOrNull { it.key.equals(key, ignoreCase = true) }?.value + } ?: localized.values.firstOrNull() + return file?.name + } + + private fun selectLocalizedFileList( + localized: Map>?, + localeKey: String?, + ): List? { + if (localized.isNullOrEmpty()) return null + val list = localeKey?.let { key -> + localized.entries.firstOrNull { it.key.equals(key, ignoreCase = true) }?.value + } ?: localized.values.firstOrNull() + return list?.mapNotNull { it.name } + } +} diff --git a/app/src/main/java/foundation/e/apps/data/fdroid/search/FdroidSearchPagingRepository.kt b/app/src/main/java/foundation/e/apps/data/fdroid/search/FdroidSearchPagingRepository.kt new file mode 100644 index 000000000..19567243e --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/fdroid/search/FdroidSearchPagingRepository.kt @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2026 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package foundation.e.apps.data.fdroid.search + +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.fdroid.index.FdroidIndexRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class FdroidSearchPagingRepository @Inject constructor( + private val indexRepository: FdroidIndexRepository, +) { + fun search(query: String, pageSize: Int): Flow> { + return Pager( + config = PagingConfig( + pageSize = pageSize, + enablePlaceholders = false, + prefetchDistance = 2, + ), + pagingSourceFactory = { + FdroidSearchPagingSource( + indexRepository = indexRepository, + query = query, + pageSize = pageSize, + ) + } + ).flow + } +} diff --git a/app/src/main/java/foundation/e/apps/data/fdroid/search/FdroidSearchPagingSource.kt b/app/src/main/java/foundation/e/apps/data/fdroid/search/FdroidSearchPagingSource.kt new file mode 100644 index 000000000..f95619e6c --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/fdroid/search/FdroidSearchPagingSource.kt @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2026 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package foundation.e.apps.data.fdroid.search + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.squareup.moshi.JsonDataException +import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.fdroid.index.FdroidAppEntry +import foundation.e.apps.data.fdroid.index.FdroidIndexRepository +import foundation.e.apps.data.fdroid.store.FdroidAppMapper +import java.io.IOException +import kotlin.math.min + +class FdroidSearchPagingSource( + private val indexRepository: FdroidIndexRepository, + private val query: String, + private val pageSize: Int, +) : PagingSource() { + + private var allResults: List? = null + + override suspend fun load(params: LoadParams): LoadResult { + return try { + val page = params.key ?: 0 + val all = allResults ?: indexRepository.searchApps(query).also { allResults = it } + val start = page * pageSize + if (start >= all.size) { + return LoadResult.Page( + data = emptyList(), + prevKey = if (page == 0) null else page - 1, + nextKey = null, + ) + } + val end = min(start + pageSize, all.size) + val slice = all.subList(start, end) + val apps = slice.mapNotNull { FdroidAppMapper.toApplication(it, indexRepository) } + LoadResult.Page( + data = apps, + prevKey = if (page == 0) null else page - 1, + nextKey = if (end < all.size) page + 1 else null, + ) + } catch (e: IOException) { + LoadResult.Error(e) + } catch (e: JsonDataException) { + LoadResult.Error(e) + } + } + + override fun getRefreshKey(state: PagingState): Int? { + val anchorPosition = state.anchorPosition ?: return null + val anchorPage = state.closestPageToPosition(anchorPosition) ?: return null + return anchorPage.prevKey?.plus(1) ?: anchorPage.nextKey?.minus(1) + } +} diff --git a/app/src/main/java/foundation/e/apps/data/fdroid/store/FdroidAppMapper.kt b/app/src/main/java/foundation/e/apps/data/fdroid/store/FdroidAppMapper.kt new file mode 100644 index 000000000..5a1a2c35f --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/fdroid/store/FdroidAppMapper.kt @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2026 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package foundation.e.apps.data.fdroid.store + +import foundation.e.apps.BuildConfig +import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.enums.FilterLevel +import foundation.e.apps.data.enums.Source +import foundation.e.apps.data.fdroid.index.FdroidAppEntry +import foundation.e.apps.data.fdroid.index.FdroidIndexRepository +import foundation.e.apps.data.system.SystemInfoProvider + +object FdroidAppMapper { + fun toApplication( + entry: FdroidAppEntry, + indexRepository: FdroidIndexRepository, + ): Application? { + val supportedAbis = SystemInfoProvider.getSupportedArchitectureList() + val version = indexRepository.selectPreferredVersion(entry, supportedAbis) ?: return null + val resolvedIconUrl = entry.app.icon.toFdroidUrl(entry.packageName, entry.app.localizedLocale) + val iconUrl = if (resolvedIconUrl.isBlank()) { + BuildConfig.FDROID_DEFAULT_ICON_URL + } else { + resolvedIconUrl + } + val screenshotUrls = buildScreenshotUrls(entry) + + return Application( + _id = entry.packageName, + author = entry.app.authorName.orEmpty(), + category = entry.app.categories?.firstOrNull().orEmpty(), + description = entry.app.description.orEmpty(), + icon_image_path = iconUrl, + icon_url = "", + last_modified = "", + latest_version_code = version.versionCode ?: -1, + latest_version_number = version.versionName.orEmpty(), + latest_downloaded_version = "", + licence = entry.app.license.orEmpty(), + name = entry.app.name ?: entry.packageName, + other_images_path = screenshotUrls, + package_name = entry.packageName, + shareUrl = "", + originalSize = version.size ?: 0L, + appSize = "", + source = Source.FDROID, + price = "", + isFree = true, + is_pwa = false, + url = "", + privacyScore = -1, + updatedOn = "", + isFDroidApp = true, + antiFeatures = entry.app.antiFeatures + ?.map { feature -> mapOf("name" to feature) } + .orEmpty(), + filterLevel = FilterLevel.NONE, + ) + } + + private fun buildScreenshotUrls(entry: FdroidAppEntry): List { + val app = entry.app + val packageName = entry.packageName + val locale = app.localizedLocale + val screenshotCandidates = listOf( + "phoneScreenshots" to app.phoneScreenshots, + "sevenInchScreenshots" to app.sevenInchScreenshots, + "tenInchScreenshots" to app.tenInchScreenshots, + "tvScreenshots" to app.tvScreenshots, + "wearScreenshots" to app.wearScreenshots, + ) + val selected = screenshotCandidates.firstOrNull { !it.second.isNullOrEmpty() } + val screenshots = selected?.second.orEmpty() + val screenshotUrls = screenshots.mapNotNull { path -> + path.normalizeScreenshotPath(selected?.first) + .toFdroidUrlOrNull(packageName, locale) + } + if (screenshotUrls.isNotEmpty()) return screenshotUrls + val graphics = listOf(app.featureGraphic, app.promoGraphic, app.tvBanner) + return graphics.mapNotNull { it.toFdroidUrlOrNull(packageName, locale) } + } + + private fun String?.toFdroidUrl(packageName: String, locale: String?): String { + return toFdroidUrlOrNull(packageName, locale).orEmpty() + } + + private fun String?.toFdroidUrlOrNull(packageName: String, locale: String?): String? { + val path = this?.trim().orEmpty() + if (path.isBlank()) return null + return if (path.startsWith("http")) { + path + } else { + val normalized = path.trimStart('/') + when { + normalized.startsWith(packageName + "/") -> { + BuildConfig.FDROID_REPO_BASE_URL + normalized + } + !locale.isNullOrBlank() && normalized.startsWith(locale.trim('/') + "/") -> { + BuildConfig.FDROID_REPO_BASE_URL + packageName.trimStart('/') + "/" + normalized + } + !locale.isNullOrBlank() -> { + BuildConfig.FDROID_REPO_BASE_URL + packageName.trimStart('/') + "/" + + locale.trim('/') + "/" + normalized + } + else -> { + BuildConfig.FDROID_REPO_BASE_URL + normalized + } + } + } + } + + private fun String?.normalizeScreenshotPath(folder: String?): String? { + val path = this?.trim().orEmpty() + val normalizedFolder = folder?.trim('/').orEmpty() + return when { + path.isBlank() -> null + path.contains('/') -> path + normalizedFolder.isBlank() -> path + else -> normalizedFolder + "/" + path + } + } +} diff --git a/app/src/main/java/foundation/e/apps/data/fdroid/store/FdroidStoreRepository.kt b/app/src/main/java/foundation/e/apps/data/fdroid/store/FdroidStoreRepository.kt new file mode 100644 index 000000000..3d49bbbbe --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/fdroid/store/FdroidStoreRepository.kt @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2026 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package foundation.e.apps.data.fdroid.store + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import foundation.e.apps.R +import foundation.e.apps.data.StoreRepository +import foundation.e.apps.data.application.ApplicationRepository +import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.application.data.Home +import foundation.e.apps.data.application.search.SearchSuggestion +import foundation.e.apps.data.enums.Source +import foundation.e.apps.data.fdroid.index.FdroidIndexRepository +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class FdroidStoreRepository @Inject constructor( + @ApplicationContext private val context: Context, + private val indexRepository: FdroidIndexRepository, +) : StoreRepository { + companion object { + private const val TOP_APPS_LIMIT = 20 + private const val SEARCH_SUGGESTIONS_LIMIT = 5 + } + + override suspend fun getHomeScreenData(list: MutableList): List { + val apps = indexRepository.topApps(limit = TOP_APPS_LIMIT).mapNotNull { entry -> + FdroidAppMapper.toApplication(entry, indexRepository) + } + if (apps.isNotEmpty()) { + list.add( + Home( + title = context.getString(R.string.fdroid), + list = apps, + source = ApplicationRepository.APP_TYPE_OPEN, + ) + ) + } + return list + } + + override suspend fun getAppDetails(packageName: String): Application { + val entry = indexRepository.getEntry(packageName) ?: return Application() + return FdroidAppMapper.toApplication(entry, indexRepository) ?: Application() + } + + override suspend fun getSearchResults(pattern: String): List { + return indexRepository.searchApps(pattern) + .mapNotNull { FdroidAppMapper.toApplication(it, indexRepository) } + } + + override suspend fun getSearchSuggestions(pattern: String): List { + if (pattern.isBlank()) return emptyList() + return indexRepository.searchApps(pattern) + .take(SEARCH_SUGGESTIONS_LIMIT) + .map { SearchSuggestion(it.app.name ?: it.packageName, Source.FDROID) } + } +} diff --git a/app/src/main/java/foundation/e/apps/data/install/AppInstallMappings.kt b/app/src/main/java/foundation/e/apps/data/install/AppInstallMappings.kt index 3cd9bac81..e9bb2c1d6 100644 --- a/app/src/main/java/foundation/e/apps/data/install/AppInstallMappings.kt +++ b/app/src/main/java/foundation/e/apps/data/install/AppInstallMappings.kt @@ -25,6 +25,7 @@ import foundation.e.apps.data.installation.model.InstallationType fun Source.toInstallationSource(): InstallationSource { return when (this) { + Source.FDROID -> InstallationSource.FDROID Source.OPEN_SOURCE -> InstallationSource.OPEN_SOURCE Source.PWA -> InstallationSource.PWA Source.SYSTEM_APP -> InstallationSource.SYSTEM_APP @@ -41,6 +42,7 @@ fun Type.toInstallationType(): InstallationType { fun InstallationSource.toAppSource(): Source { return when (this) { + InstallationSource.FDROID -> Source.FDROID InstallationSource.OPEN_SOURCE -> Source.OPEN_SOURCE InstallationSource.PWA -> Source.PWA InstallationSource.SYSTEM_APP -> Source.SYSTEM_APP diff --git a/app/src/main/java/foundation/e/apps/data/install/AppManagerImpl.kt b/app/src/main/java/foundation/e/apps/data/install/AppManagerImpl.kt index 34aed8ea7..60165eb21 100644 --- a/app/src/main/java/foundation/e/apps/data/install/AppManagerImpl.kt +++ b/app/src/main/java/foundation/e/apps/data/install/AppManagerImpl.kt @@ -29,6 +29,7 @@ import androidx.lifecycle.LiveData import dagger.hilt.android.qualifiers.ApplicationContext import foundation.e.apps.R import foundation.e.apps.data.application.AppManager +import foundation.e.apps.data.cleanapk.ApkSignatureManager import foundation.e.apps.data.fdroid.FDroidRepository import foundation.e.apps.data.install.download.data.DownloadProgressLD import foundation.e.apps.data.install.pkg.AppLoungePackageManager @@ -36,6 +37,7 @@ import foundation.e.apps.data.install.pkg.PwaManager import foundation.e.apps.data.install.sharedlib.SharedLibraryManager import foundation.e.apps.data.install.workmanager.InstallWorkManager import foundation.e.apps.data.installation.model.AppInstall +import foundation.e.apps.data.installation.model.InstallationSource import foundation.e.apps.data.installation.model.InstallationType import foundation.e.apps.data.installation.repository.AppInstallRepository import foundation.e.apps.data.parentalcontrol.ContentRatingDao @@ -123,12 +125,21 @@ class AppManagerImpl @Inject constructor( appInstall: AppInstall ): Boolean { val apkFilePath = getBaseApkPath(appInstall) - return fDroidRepository.isFDroidApplicationSigned( - context, - appInstall.packageName, - apkFilePath, - appInstall.signature - ) + return if (appInstall.source == InstallationSource.FDROID) { + ApkSignatureManager.verifyFdroidSignerDigest( + context, + apkFilePath, + appInstall.signature, + appInstall.packageName + ) + } else { + fDroidRepository.isFDroidApplicationSigned( + context, + appInstall.packageName, + apkFilePath, + appInstall.signature + ) + } } override suspend fun getDownloadById(appInstall: AppInstall): AppInstall? { diff --git a/app/src/main/java/foundation/e/apps/data/install/download/DownloadManagerUtils.kt b/app/src/main/java/foundation/e/apps/data/install/download/DownloadManagerUtils.kt index ab4772a49..7348f95f1 100644 --- a/app/src/main/java/foundation/e/apps/data/install/download/DownloadManagerUtils.kt +++ b/app/src/main/java/foundation/e/apps/data/install/download/DownloadManagerUtils.kt @@ -179,17 +179,18 @@ class DownloadManagerUtils @Inject constructor( } private suspend fun checkCleanApkSignatureOK(appInstall: AppInstall): Boolean { - val isNonOpenSource = - appInstall.source != InstallationSource.PWA && - appInstall.source != InstallationSource.OPEN_SOURCE + val requiresFdroidSignature = + appInstall.source == InstallationSource.OPEN_SOURCE || + appInstall.source == InstallationSource.PWA || + appInstall.source == InstallationSource.FDROID val isSigned = appManager.isFDroidApplicationSigned(context, appInstall) - if (isNonOpenSource || isSigned) { + if (!requiresFdroidSignature || isSigned) { Timber.d("Apk signature is OK") return true } appInstall.status = Status.INSTALLATION_ISSUE appManager.updateAppInstall(appInstall) - Timber.w("CleanApk signature is Wrong!") + Timber.w("APK signature is wrong!") return false } diff --git a/app/src/main/java/foundation/e/apps/data/playstore/utils/NativeDeviceInfoProviderModule.kt b/app/src/main/java/foundation/e/apps/data/playstore/utils/NativeDeviceInfoProviderModule.kt index 787071b9d..8f1fba2b6 100644 --- a/app/src/main/java/foundation/e/apps/data/playstore/utils/NativeDeviceInfoProviderModule.kt +++ b/app/src/main/java/foundation/e/apps/data/playstore/utils/NativeDeviceInfoProviderModule.kt @@ -76,12 +76,18 @@ object NativeDeviceInfoProviderModule { "GL.Version", activityManager.deviceConfigurationInfo.reqGlEsVersion.toString() ) + if (isRunningUnderRobolectric()) { + setProperty("GL.Extensions", "") + return + } setProperty( "GL.Extensions", EglExtensionProvider.eglExtensions.joinToString(separator = ",") ) } + private fun isRunningUnderRobolectric(): Boolean = Build.FINGERPRINT == "robolectric" + private fun Properties.setDisplayMetrics(context: Context) { val metrics = context.resources.displayMetrics setProperty("Screen.Density", "${metrics.densityDpi}") diff --git a/app/src/main/java/foundation/e/apps/domain/ValidateAppAgeLimitUseCase.kt b/app/src/main/java/foundation/e/apps/domain/ValidateAppAgeLimitUseCase.kt index 2b450ab80..0849a7654 100644 --- a/app/src/main/java/foundation/e/apps/domain/ValidateAppAgeLimitUseCase.kt +++ b/app/src/main/java/foundation/e/apps/domain/ValidateAppAgeLimitUseCase.kt @@ -101,7 +101,9 @@ class ValidateAppAgeLimitUseCase @Inject constructor( } private fun isWhiteListedCleanApkApp(app: AppInstall): Boolean { - return app.source == InstallationSource.OPEN_SOURCE || app.source == InstallationSource.PWA + return app.source == InstallationSource.OPEN_SOURCE || + app.source == InstallationSource.PWA || + app.source == InstallationSource.FDROID } private suspend fun isNsfwAppByCleanApkApi(app: AppInstall): Boolean { diff --git a/app/src/main/java/foundation/e/apps/domain/install/GetAppDetailsUseCase.kt b/app/src/main/java/foundation/e/apps/domain/install/GetAppDetailsUseCase.kt index d05be50bf..bc58d693a 100644 --- a/app/src/main/java/foundation/e/apps/domain/install/GetAppDetailsUseCase.kt +++ b/app/src/main/java/foundation/e/apps/domain/install/GetAppDetailsUseCase.kt @@ -24,6 +24,7 @@ import foundation.e.apps.data.blockedApps.BlockedAppRepository import foundation.e.apps.data.cleanapk.repositories.CleanApkAppsRepository import foundation.e.apps.data.cleanapk.repositories.CleanApkPwaRepository import foundation.e.apps.data.enums.Source +import foundation.e.apps.data.fdroid.store.FdroidStoreRepository import foundation.e.apps.data.playstore.PlayStoreRepository import foundation.e.apps.domain.model.LoginState import foundation.e.apps.domain.model.User @@ -35,6 +36,7 @@ class GetAppDetailsUseCase @Inject constructor( private val blockedAppRepository: BlockedAppRepository, private val cleanApkAppsRepository: CleanApkAppsRepository, private val cleanApkPwaRepository: CleanApkPwaRepository, + private val fdroidStoreRepository: FdroidStoreRepository, private val playStoreRepository: PlayStoreRepository, private val getEnabledSearchSourcesUseCase: GetEnabledSearchSourcesUseCase, private val sessionRepository: SessionRepository, @@ -91,6 +93,7 @@ class GetAppDetailsUseCase @Inject constructor( ) Source.SYSTEM_APP -> false + Source.FDROID -> true Source.PWA, Source.OPEN_SOURCE -> loginState == LoginState.AVAILABLE } } @@ -117,6 +120,10 @@ class GetAppDetailsUseCase @Inject constructor( private suspend fun getAppDetailsUnchecked(packageName: String, source: Source) = when (source) { + Source.FDROID -> { + getFdroidAppDetails(packageName) + } + Source.OPEN_SOURCE -> { getOpenSourceAppDetails(packageName) } @@ -155,6 +162,17 @@ class GetAppDetailsUseCase @Inject constructor( return application } + private suspend fun getFdroidAppDetails(packageName: String): Application { + val application = fdroidStoreRepository.getAppDetails(packageName) + if (application._id.isNullOrBlank()) { + throw UnavailableAppException( + "$packageName wasn't found in F-Droid", + sources = listOf(Source.FDROID) + ) + } + return application + } + private suspend fun getPWAAppDetails(packageName: String): Application { val application = cleanApkPwaRepository.getAppDetails(packageName) if (application._id.isNullOrBlank()) { diff --git a/app/src/main/java/foundation/e/apps/domain/search/FdroidSearchPagingUseCase.kt b/app/src/main/java/foundation/e/apps/domain/search/FdroidSearchPagingUseCase.kt new file mode 100644 index 000000000..4c417d5e6 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/domain/search/FdroidSearchPagingUseCase.kt @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2026 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package foundation.e.apps.domain.search + +import androidx.paging.PagingData +import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.enums.Source +import foundation.e.apps.data.fdroid.search.FdroidSearchPagingRepository +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.mapLatest +import javax.inject.Inject + +class FdroidSearchPagingUseCase @Inject constructor( + private val fdroidSearchPagingRepository: FdroidSearchPagingRepository, +) { + @OptIn(ExperimentalCoroutinesApi::class) + operator fun invoke( + requests: Flow, + source: Source, + pageSize: Int = 20, + ): Flow> { + return requests + .filterNotNull() + .mapLatest { request -> + if (!request.enabledSources.contains(source) || request.query.isBlank()) { + flowOf(PagingData.empty()) + } else { + fdroidSearchPagingRepository.search(request.query, pageSize) + } + } + .flatMapLatest { it } + } +} diff --git a/app/src/main/java/foundation/e/apps/services/InstallAppService.kt b/app/src/main/java/foundation/e/apps/services/InstallAppService.kt index 36e61a306..90648e49f 100644 --- a/app/src/main/java/foundation/e/apps/services/InstallAppService.kt +++ b/app/src/main/java/foundation/e/apps/services/InstallAppService.kt @@ -132,6 +132,7 @@ fun serializeUserForInstallAppLib(userType: User?): String { fun serializeSourcesForInstallAppLib(sources: List): List { return sources.mapNotNull { source -> when (source) { + Source.FDROID -> SearchableSources.FDROID Source.OPEN_SOURCE -> SearchableSources.OPEN_SOURCE Source.PWA -> SearchableSources.PWA Source.SYSTEM_APP -> null diff --git a/app/src/main/java/foundation/e/apps/ui/application/model/ApplicationScreenshotsRVAdapter.kt b/app/src/main/java/foundation/e/apps/ui/application/model/ApplicationScreenshotsRVAdapter.kt index d26e55340..a4ee1daa3 100644 --- a/app/src/main/java/foundation/e/apps/ui/application/model/ApplicationScreenshotsRVAdapter.kt +++ b/app/src/main/java/foundation/e/apps/ui/application/model/ApplicationScreenshotsRVAdapter.kt @@ -58,6 +58,9 @@ class ApplicationScreenshotsRVAdapter( Source.OPEN_SOURCE -> { imageView.load(CleanApkRetrofit.ASSET_URL + oldList[position]) } + Source.FDROID -> { + imageView.load(oldList[position]) + } Source.PLAY_STORE -> { imageView.load(oldList[position]) } diff --git a/app/src/main/java/foundation/e/apps/ui/application/model/ScreenshotRVAdapter.kt b/app/src/main/java/foundation/e/apps/ui/application/model/ScreenshotRVAdapter.kt index 670c65fc9..9feb59d88 100644 --- a/app/src/main/java/foundation/e/apps/ui/application/model/ScreenshotRVAdapter.kt +++ b/app/src/main/java/foundation/e/apps/ui/application/model/ScreenshotRVAdapter.kt @@ -66,6 +66,11 @@ class ScreenshotRVAdapter(private val list: List, private val source: So placeholder(circularProgressDrawable) } } + Source.FDROID -> { + imageView.load(list[position]) { + placeholder(circularProgressDrawable) + } + } Source.PLAY_STORE -> { imageView.load(list[position]) { placeholder(circularProgressDrawable) diff --git a/app/src/main/java/foundation/e/apps/ui/applicationlist/ApplicationListRVAdapter.kt b/app/src/main/java/foundation/e/apps/ui/applicationlist/ApplicationListRVAdapter.kt index 4ac4c7060..7415a4624 100644 --- a/app/src/main/java/foundation/e/apps/ui/applicationlist/ApplicationListRVAdapter.kt +++ b/app/src/main/java/foundation/e/apps/ui/applicationlist/ApplicationListRVAdapter.kt @@ -181,6 +181,7 @@ class ApplicationListRVAdapter( ) { when (searchApp.source) { Source.PLAY_STORE, + Source.FDROID, Source.PWA, Source.OPEN_SOURCE -> { appIcon.load(searchApp.iconUrl) { diff --git a/app/src/main/java/foundation/e/apps/ui/categories/model/CategoriesRVAdapter.kt b/app/src/main/java/foundation/e/apps/ui/categories/model/CategoriesRVAdapter.kt index f4c304efb..e4382931d 100644 --- a/app/src/main/java/foundation/e/apps/ui/categories/model/CategoriesRVAdapter.kt +++ b/app/src/main/java/foundation/e/apps/ui/categories/model/CategoriesRVAdapter.kt @@ -90,6 +90,7 @@ class CategoriesRVAdapter : return when (tag) { is AppTag.OpenSource -> Source.OPEN_SOURCE.stringResId is AppTag.PWA -> Source.PWA.stringResId + is AppTag.FDroid -> Source.FDROID.stringResId is AppTag.GPlay -> Source.PLAY_STORE.stringResId } } diff --git a/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultsContent.kt b/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultsContent.kt index 030ee01e1..66277ebbf 100644 --- a/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultsContent.kt +++ b/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultsContent.kt @@ -70,6 +70,7 @@ fun SearchResultsContent( tabs: List, selectedTab: SearchTabType, fossItems: LazyPagingItems?, + fdroidItems: LazyPagingItems?, pwaItems: LazyPagingItems?, playStoreItems: LazyPagingItems?, searchVersion: Int, @@ -92,6 +93,7 @@ fun SearchResultsContent( SearchTabPage( tab = selectedTab, fossItems = fossItems, + fdroidItems = fdroidItems, pwaItems = pwaItems, playStoreItems = playStoreItems, searchVersion = searchVersion, @@ -155,6 +157,7 @@ fun SearchResultsContent( SearchTabPage( tab = tab, fossItems = fossItems, + fdroidItems = fdroidItems, pwaItems = pwaItems, playStoreItems = playStoreItems, searchVersion = searchVersion, @@ -176,6 +179,7 @@ fun SearchResultsContent( private fun SearchTabPage( tab: SearchTabType, fossItems: LazyPagingItems?, + fdroidItems: LazyPagingItems?, pwaItems: LazyPagingItems?, playStoreItems: LazyPagingItems?, searchVersion: Int, @@ -198,6 +202,12 @@ private fun SearchTabPage( errorTitleStringResource = R.string.search_error_title_playstore } + SearchTabType.FDROID -> { + items = fdroidItems + emptyResultsStringResource = R.string.search_empty_results_title_fdroid + errorTitleStringResource = R.string.search_error_title_fdroid + } + SearchTabType.OPEN_SOURCE -> { items = fossItems emptyResultsStringResource = R.string.search_empty_results_title_open_source @@ -212,7 +222,7 @@ private fun SearchTabPage( } when (tab) { - SearchTabType.OPEN_SOURCE, SearchTabType.PWA -> { + SearchTabType.OPEN_SOURCE, SearchTabType.PWA, SearchTabType.FDROID -> { PagingSearchResultList( items = items, searchVersion = searchVersion, @@ -575,7 +585,7 @@ private fun Application.toSearchResultUiState( } val ratingText = when { - source == Source.OPEN_SOURCE || source == Source.PWA || isSystemApp -> "" + source == Source.OPEN_SOURCE || source == Source.PWA || source == Source.FDROID || isSystemApp -> "" else -> formatRating?.invoke(ratings.usageQualityScore) ?: stringResource(R.string.not_available) } diff --git a/app/src/main/java/foundation/e/apps/ui/compose/components/SearchTabs.kt b/app/src/main/java/foundation/e/apps/ui/compose/components/SearchTabs.kt index cef3cc0d6..f09c7dcae 100644 --- a/app/src/main/java/foundation/e/apps/ui/compose/components/SearchTabs.kt +++ b/app/src/main/java/foundation/e/apps/ui/compose/components/SearchTabs.kt @@ -88,6 +88,7 @@ private fun SearchTabsPreview() { SearchTabs( tabs = listOf( SearchTabType.COMMON_APPS, + SearchTabType.FDROID, SearchTabType.OPEN_SOURCE, SearchTabType.PWA, ), @@ -100,6 +101,7 @@ private fun SearchTabsPreview() { @StringRes private fun SearchTabType.toLabelRes(): Int = when (this) { SearchTabType.COMMON_APPS -> R.string.search_tab_standard_apps + SearchTabType.FDROID -> R.string.search_tab_fdroid SearchTabType.OPEN_SOURCE -> R.string.search_tab_open_source SearchTabType.PWA -> R.string.search_tab_web_apps } diff --git a/app/src/main/java/foundation/e/apps/ui/compose/screens/SearchScreen.kt b/app/src/main/java/foundation/e/apps/ui/compose/screens/SearchScreen.kt index 9f7898a4f..5dd5b086d 100644 --- a/app/src/main/java/foundation/e/apps/ui/compose/screens/SearchScreen.kt +++ b/app/src/main/java/foundation/e/apps/ui/compose/screens/SearchScreen.kt @@ -70,6 +70,7 @@ fun SearchScreen( onTabSelect: (SearchTabType) -> Unit, modifier: Modifier = Modifier, fossPaging: Flow>? = null, + fdroidPaging: Flow>? = null, pwaPaging: Flow>? = null, playStorePaging: Flow>? = null, searchVersion: Int = 0, @@ -131,6 +132,7 @@ fun SearchScreen( .padding(innerPadding) ) { val fossItems = fossPaging?.collectAsLazyPagingItems() + val fdroidItems = fdroidPaging?.collectAsLazyPagingItems() val pwaItems = pwaPaging?.collectAsLazyPagingItems() val playStoreItems = playStorePaging?.collectAsLazyPagingItems() @@ -145,6 +147,7 @@ fun SearchScreen( tabs = uiState.availableTabs, selectedTab = uiState.selectedTab!!, fossItems = fossItems, + fdroidItems = fdroidItems, pwaItems = pwaItems, playStoreItems = playStoreItems, searchVersion = searchVersion, diff --git a/app/src/main/java/foundation/e/apps/ui/compose/screens/SearchTopBar.kt b/app/src/main/java/foundation/e/apps/ui/compose/screens/SearchTopBar.kt index 4b9baea6b..a439c1a34 100644 --- a/app/src/main/java/foundation/e/apps/ui/compose/screens/SearchTopBar.kt +++ b/app/src/main/java/foundation/e/apps/ui/compose/screens/SearchTopBar.kt @@ -152,6 +152,7 @@ private fun SearchTopBarPreview() { isSuggestionVisible = true, availableTabs = listOf( SearchTabType.COMMON_APPS, + SearchTabType.FDROID, SearchTabType.OPEN_SOURCE, SearchTabType.PWA, ), diff --git a/app/src/main/java/foundation/e/apps/ui/search/v2/SearchFragmentV2.kt b/app/src/main/java/foundation/e/apps/ui/search/v2/SearchFragmentV2.kt index 86e771f0e..ad152324f 100644 --- a/app/src/main/java/foundation/e/apps/ui/search/v2/SearchFragmentV2.kt +++ b/app/src/main/java/foundation/e/apps/ui/search/v2/SearchFragmentV2.kt @@ -134,6 +134,7 @@ class SearchFragmentV2 : Fragment(R.layout.fragment_search_v2) { onSuggestionSelect = searchViewModel::onSuggestionSelected, onTabSelect = searchViewModel::onTabSelected, fossPaging = searchViewModel.fossPagingFlow, + fdroidPaging = searchViewModel.fdroidPagingFlow, pwaPaging = searchViewModel.pwaPagingFlow, playStorePaging = searchViewModel.playStorePagingFlow, searchVersion = uiState.searchVersion, diff --git a/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt b/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt index 7eba81b12..dc9f6f6a2 100644 --- a/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt +++ b/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt @@ -32,6 +32,7 @@ import foundation.e.apps.data.install.download.data.DownloadProgress import foundation.e.apps.domain.model.install.Status import foundation.e.apps.domain.preferences.AppPreferencesRepository import foundation.e.apps.domain.search.CleanApkSearchPagingUseCase +import foundation.e.apps.domain.search.FdroidSearchPagingUseCase import foundation.e.apps.domain.search.FetchSearchSuggestionsUseCase import foundation.e.apps.domain.search.PlayStoreSearchPagingUseCase import foundation.e.apps.domain.search.PrepareSearchSubmissionUseCase @@ -55,6 +56,7 @@ private const val SUGGESTION_DEBOUNCE_MS = 500L enum class SearchTabType { COMMON_APPS, + FDROID, OPEN_SOURCE, PWA, } @@ -82,6 +84,7 @@ data class ScrollPosition( @Suppress("LongParameterList", "TooManyFunctions") class SearchViewModelV2 @Inject constructor( cleanApkSearchPagingUseCase: CleanApkSearchPagingUseCase, + fdroidSearchPagingUseCase: FdroidSearchPagingUseCase, playStoreSearchPagingUseCase: PlayStoreSearchPagingUseCase, private val appPreferencesRepository: AppPreferencesRepository, private val fetchSearchSuggestionsUseCase: FetchSearchSuggestionsUseCase, @@ -121,6 +124,11 @@ class SearchViewModelV2 @Inject constructor( appType = CleanApkRetrofit.APP_TYPE_NATIVE, ).cachedIn(viewModelScope).withStatus() + val fdroidPagingFlow = fdroidSearchPagingUseCase( + requests = searchRequests, + source = Source.FDROID, + ).cachedIn(viewModelScope).withStatus() + val pwaPagingFlow = cleanApkSearchPagingUseCase( requests = searchRequests, source = Source.PWA, @@ -383,6 +391,7 @@ internal fun List.toSearchTabTypes(): List = mapNotNull { internal fun Source.toSearchTabTypeOrNull(): SearchTabType? { return when (this) { Source.PLAY_STORE -> SearchTabType.COMMON_APPS + Source.FDROID -> SearchTabType.FDROID Source.OPEN_SOURCE -> SearchTabType.OPEN_SOURCE Source.PWA -> SearchTabType.PWA else -> null @@ -392,6 +401,7 @@ internal fun Source.toSearchTabTypeOrNull(): SearchTabType? { internal fun SearchTabType.toSource(): Source { return when (this) { SearchTabType.COMMON_APPS -> Source.PLAY_STORE + SearchTabType.FDROID -> Source.FDROID SearchTabType.OPEN_SOURCE -> Source.OPEN_SOURCE SearchTabType.PWA -> Source.PWA } diff --git a/app/src/main/java/foundation/e/apps/ui/settings/SettingsFragment.kt b/app/src/main/java/foundation/e/apps/ui/settings/SettingsFragment.kt index f2ca68226..4490e7028 100644 --- a/app/src/main/java/foundation/e/apps/ui/settings/SettingsFragment.kt +++ b/app/src/main/java/foundation/e/apps/ui/settings/SettingsFragment.kt @@ -62,6 +62,7 @@ class SettingsFragment : PreferenceFragmentCompat() { private var _binding: CustomPreferenceBinding? = null private val binding get() = _binding!! private var showAllApplications: CheckBoxPreference? = null + private var showFdroidApplications: CheckBoxPreference? = null private var showFOSSApplications: CheckBoxPreference? = null private var showPWAApplications: CheckBoxPreference? = null private var troubleShootPreference: Preference? = null @@ -85,7 +86,7 @@ class SettingsFragment : PreferenceFragmentCompat() { lateinit var playStoreAuthStore: PlayStoreAuthStore private val allSourceCheckboxes by lazy { - listOf(showAllApplications, showFOSSApplications, showPWAApplications) + listOf(showAllApplications, showFdroidApplications, showFOSSApplications, showPWAApplications) } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { @@ -93,6 +94,7 @@ class SettingsFragment : PreferenceFragmentCompat() { // Show applications preferences showAllApplications = findPreference("showAllApplications") + showFdroidApplications = findPreference("showFDroidApplications") showFOSSApplications = findPreference("showFOSSApplications") showPWAApplications = findPreference("showPWAApplications") troubleShootPreference = findPreference(getString(R.string.having_troubles)) @@ -184,6 +186,7 @@ class SettingsFragment : PreferenceFragmentCompat() { private fun applySourceChange(preference: Preference, isEnabled: Boolean) { val sourceMap = mapOf( Constants.PREFERENCE_SHOW_GPLAY to Source.PLAY_STORE, + Constants.PREFERENCE_SHOW_FDROID to Source.FDROID, Constants.PREFERENCE_SHOW_FOSS to Source.OPEN_SOURCE, Constants.PREFERENCE_SHOW_PWA to Source.PWA ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 266e5c51e..5b97f1181 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -32,14 +32,17 @@ Back Clear search APPS + F-DROID OPEN SOURCE WEB APPS No apps found… Apps temporarily unavailable + F-Droid temporarily unavailable Open Source temporarily unavailable Web Apps temporarily unavailable We can’t reach this catalog right now. Other app sources may still work. No results in Apps + No results in F-Droid No results in Open Source No results in Web Apps Other app sources may be able to provide results, please check the other tabs/your settings. @@ -48,6 +51,7 @@ Applications Games Category Icon + F-Droid Open Source Web Apps System app @@ -88,6 +92,7 @@ Show a notification when app updates are available Show applications: Show Apps + Show F-Droid apps Show Open Source apps Show Web Apps About diff --git a/app/src/main/res/xml/settings_preferences.xml b/app/src/main/res/xml/settings_preferences.xml index d368da48e..89a213c34 100644 --- a/app/src/main/res/xml/settings_preferences.xml +++ b/app/src/main/res/xml/settings_preferences.xml @@ -100,6 +100,13 @@ app:iconSpaceReserved="false" app:singleLineTitle="false" /> + + , String>>() override suspend fun getCleanApkAppsByCategory(category: String, source: Source) = expected + override suspend fun getFdroidAppsByCategory(category: String) = + ResultSupreme.Error, String>>() }) val result = repository.getAppsListBasedOnCategory("cat", null, Source.OPEN_SOURCE) @@ -298,6 +301,8 @@ class ApplicationRepositoryHomeTest { override suspend fun getGplayAppsByCategory(category: String, pageUrl: String?) = ResultSupreme.Error, String>>() override suspend fun getCleanApkAppsByCategory(category: String, source: Source) = expected + override suspend fun getFdroidAppsByCategory(category: String) = + ResultSupreme.Error, String>>() }) val result = repository.getAppsListBasedOnCategory("cat", null, Source.PWA) @@ -313,6 +318,8 @@ class ApplicationRepositoryHomeTest { override suspend fun getGplayAppsByCategory(category: String, pageUrl: String?) = expected override suspend fun getCleanApkAppsByCategory(category: String, source: Source) = ResultSupreme.Error, String>>() + override suspend fun getFdroidAppsByCategory(category: String) = + ResultSupreme.Error, String>>() }) val result = repository.getAppsListBasedOnCategory("cat", "next", Source.PLAY_STORE) diff --git a/app/src/test/java/foundation/e/apps/data/application/downloadInfo/DownloadInfoApiImplTest.kt b/app/src/test/java/foundation/e/apps/data/application/downloadInfo/DownloadInfoApiImplTest.kt index 53e201483..a341fc649 100644 --- a/app/src/test/java/foundation/e/apps/data/application/downloadInfo/DownloadInfoApiImplTest.kt +++ b/app/src/test/java/foundation/e/apps/data/application/downloadInfo/DownloadInfoApiImplTest.kt @@ -23,6 +23,7 @@ import foundation.e.apps.data.AppSourcesContainer import foundation.e.apps.data.cleanapk.repositories.CleanApkAppsRepository import foundation.e.apps.data.cleanapk.repositories.CleanApkPwaRepository import foundation.e.apps.data.enums.Source +import foundation.e.apps.data.fdroid.download.FdroidDownloadInfoProvider import foundation.e.apps.data.installation.model.AppInstall import foundation.e.apps.data.installation.model.SharedLib import foundation.e.apps.data.playstore.PlayStoreRepository @@ -42,13 +43,15 @@ class DownloadInfoApiImplTest { private val gplayRepo = mockk() private val cleanApkAppsRepo = mockk() private val cleanApkPwaRepo = mockk() + private val fdroidDownloadInfoProvider = mockk(relaxed = true) private lateinit var downloadInfoApi: DownloadInfoApiImpl @Before fun setUp() { downloadInfoApi = DownloadInfoApiImpl( - AppSourcesContainer(gplayRepo, cleanApkAppsRepo, cleanApkPwaRepo) + AppSourcesContainer(gplayRepo, cleanApkAppsRepo, cleanApkPwaRepo), + fdroidDownloadInfoProvider ) } diff --git a/app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkerTest.kt b/app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkerTest.kt index 0931f17fd..7b1774e6e 100644 --- a/app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkerTest.kt +++ b/app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkerTest.kt @@ -976,6 +976,8 @@ class UpdatesWorkerTest { return object : AppPreferencesRepository { override fun preferredApplicationType(): String = "any" + override fun isFdroidSelected(): Boolean = true + override fun isOpenSourceSelected(): Boolean = true override fun isPWASelected(): Boolean = true @@ -988,12 +990,16 @@ class UpdatesWorkerTest { override fun disablePlayStore() = Unit + override fun disableFdroid() = Unit + override fun disableOpenSource() = Unit override fun disablePwa() = Unit override fun enablePlayStore() = Unit + override fun enableFdroid() = Unit + override fun enableOpenSource() = Unit override fun enablePwa() = Unit diff --git a/app/src/test/java/foundation/e/apps/data/login/cleanapk/CleanApkAuthenticatorTest.kt b/app/src/test/java/foundation/e/apps/data/login/cleanapk/CleanApkAuthenticatorTest.kt index 8406e3b2d..2b48e9fb3 100644 --- a/app/src/test/java/foundation/e/apps/data/login/cleanapk/CleanApkAuthenticatorTest.kt +++ b/app/src/test/java/foundation/e/apps/data/login/cleanapk/CleanApkAuthenticatorTest.kt @@ -114,15 +114,18 @@ class CleanApkAuthenticatorTest { private val isPwaSelected: Boolean = true, ) : AppPreferencesRepository { override fun preferredApplicationType(): String = "any" + override fun isFdroidSelected(): Boolean = true override fun isOpenSourceSelected(): Boolean = isOpenSourceSelected override fun isPWASelected(): Boolean = isPwaSelected override fun isPlayStoreSelected(): Boolean = true override fun shouldShowUpdateNotification(): Boolean = true override fun isAutomaticInstallEnabled(): Boolean = true override fun disablePlayStore() = Unit + override fun disableFdroid() = Unit override fun disableOpenSource() = Unit override fun disablePwa() = Unit override fun enablePlayStore() = Unit + override fun enableFdroid() = Unit override fun enableOpenSource() = Unit override fun enablePwa() = Unit override fun shouldUpdateAppsFromOtherStores(): Boolean = true diff --git a/app/src/test/java/foundation/e/apps/data/login/playstore/PlayStoreAuthenticatorTest.kt b/app/src/test/java/foundation/e/apps/data/login/playstore/PlayStoreAuthenticatorTest.kt index dc3e34210..7fba16116 100644 --- a/app/src/test/java/foundation/e/apps/data/login/playstore/PlayStoreAuthenticatorTest.kt +++ b/app/src/test/java/foundation/e/apps/data/login/playstore/PlayStoreAuthenticatorTest.kt @@ -398,15 +398,18 @@ class PlayStoreAuthenticatorTest { private val isPlayStoreSelected: Boolean = true, ) : AppPreferencesRepository { override fun preferredApplicationType(): String = "" + override fun isFdroidSelected(): Boolean = false override fun isOpenSourceSelected(): Boolean = false override fun isPWASelected(): Boolean = false override fun isPlayStoreSelected(): Boolean = isPlayStoreSelected override fun shouldShowUpdateNotification(): Boolean = false override fun isAutomaticInstallEnabled(): Boolean = false override fun disablePlayStore() = Unit + override fun disableFdroid() = Unit override fun disableOpenSource() = Unit override fun disablePwa() = Unit override fun enablePlayStore() = Unit + override fun enableFdroid() = Unit override fun enableOpenSource() = Unit override fun enablePwa() = Unit override fun shouldUpdateAppsFromOtherStores(): Boolean = false diff --git a/app/src/test/java/foundation/e/apps/domain/install/GetAppDetailsUseCaseTest.kt b/app/src/test/java/foundation/e/apps/domain/install/GetAppDetailsUseCaseTest.kt index d037ba701..68860cbb2 100644 --- a/app/src/test/java/foundation/e/apps/domain/install/GetAppDetailsUseCaseTest.kt +++ b/app/src/test/java/foundation/e/apps/domain/install/GetAppDetailsUseCaseTest.kt @@ -23,6 +23,7 @@ import foundation.e.apps.data.blockedApps.BlockedAppRepository import foundation.e.apps.data.cleanapk.repositories.CleanApkAppsRepository import foundation.e.apps.data.cleanapk.repositories.CleanApkPwaRepository import foundation.e.apps.data.enums.Source +import foundation.e.apps.data.fdroid.store.FdroidStoreRepository import foundation.e.apps.data.playstore.PlayStoreRepository import foundation.e.apps.domain.model.LoginState import foundation.e.apps.domain.model.User @@ -46,6 +47,7 @@ class GetAppDetailsUseCaseTest { private val blockedAppRepository: BlockedAppRepository = mockk(relaxed = true) private val cleanApkAppsRepository: CleanApkAppsRepository = mockk() private val cleanApkPwaRepository: CleanApkPwaRepository = mockk() + private val fdroidStoreRepository: FdroidStoreRepository = mockk() private val playStoreRepository: PlayStoreRepository = mockk() private val getEnabledSearchSourcesUseCase: GetEnabledSearchSourcesUseCase = mockk() private val sessionRepository: SessionRepository = mockk() @@ -60,6 +62,7 @@ class GetAppDetailsUseCaseTest { blockedAppRepository, cleanApkAppsRepository, cleanApkPwaRepository, + fdroidStoreRepository, playStoreRepository, getEnabledSearchSourcesUseCase, sessionRepository diff --git a/app/src/test/java/foundation/e/apps/fused/SearchRepositoryImplTest.kt b/app/src/test/java/foundation/e/apps/fused/SearchRepositoryImplTest.kt index 39718266e..58fb1a42e 100644 --- a/app/src/test/java/foundation/e/apps/fused/SearchRepositoryImplTest.kt +++ b/app/src/test/java/foundation/e/apps/fused/SearchRepositoryImplTest.kt @@ -34,6 +34,7 @@ import foundation.e.apps.data.cleanapk.data.search.Search import foundation.e.apps.data.cleanapk.repositories.CleanApkAppsRepository import foundation.e.apps.data.cleanapk.repositories.CleanApkPwaRepository import foundation.e.apps.data.enums.Source +import foundation.e.apps.data.fdroid.store.FdroidStoreRepository import foundation.e.apps.domain.model.install.Status import foundation.e.apps.data.playstore.PlayStoreRepository import foundation.e.apps.data.preference.SessionDataStore @@ -88,6 +89,9 @@ class SearchRepositoryImplTest { @Mock private lateinit var playStoreRepository: PlayStoreRepository + @Mock + private lateinit var fdroidStoreRepository: FdroidStoreRepository + private lateinit var stores: Stores @Mock @@ -111,6 +115,7 @@ class SearchRepositoryImplTest { preferenceManagerModule = FakeAppLoungePreference() stores = Stores( playStoreRepository, + fdroidStoreRepository, cleanApkAppsRepository, cleanApkPWARepository, preferenceManagerModule diff --git a/app/src/test/java/foundation/e/apps/ui/compose/state/InstallStatusStreamTest.kt b/app/src/test/java/foundation/e/apps/ui/compose/state/InstallStatusStreamTest.kt index 661fbf2f7..2c49b633b 100644 --- a/app/src/test/java/foundation/e/apps/ui/compose/state/InstallStatusStreamTest.kt +++ b/app/src/test/java/foundation/e/apps/ui/compose/state/InstallStatusStreamTest.kt @@ -27,8 +27,8 @@ import foundation.e.apps.data.install.pkg.PwaManager import foundation.e.apps.util.MainCoroutineRule import io.mockk.every import io.mockk.mockk -import kotlinx.coroutines.async import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.toList @@ -84,16 +84,17 @@ class InstallStatusStreamTest { every { pwaManager.getInstalledPwaUrls() } returns setOf("https://pwa.example") val stream = InstallStatusStream(appManager, appLoungePackageManager, pwaManager) - val collectedSnapshots = backgroundScope.async { + val snapshots = mutableListOf() + val job = launch { stream.stream(packagePollIntervalMs = 50, pwaPollIntervalMs = 50) .take(2) - .toList() + .toList(snapshots) } runCurrent() - advanceTimeBy(50) + advanceTimeBy(60) runCurrent() - val snapshots = collectedSnapshots.await() + job.join() assertEquals(setOf("com.example.one"), snapshots[0].installedPackages) assertEquals(setOf("com.example.two"), snapshots[1].installedPackages) diff --git a/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt b/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt index 6a6f79aa8..ffc7ed4c0 100644 --- a/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt +++ b/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt @@ -29,6 +29,8 @@ import foundation.e.apps.data.cleanapk.repositories.CleanApkAppsRepository import foundation.e.apps.data.cleanapk.repositories.CleanApkPwaRepository import foundation.e.apps.data.enums.Source import foundation.e.apps.domain.model.install.Status +import foundation.e.apps.data.fdroid.search.FdroidSearchPagingRepository +import foundation.e.apps.data.fdroid.store.FdroidStoreRepository import foundation.e.apps.data.playstore.PlayStoreRepository import foundation.e.apps.data.search.CleanApkSearchParams import foundation.e.apps.data.search.FakeSuggestionSource @@ -36,6 +38,7 @@ import foundation.e.apps.data.search.PlayStorePagingRepository import foundation.e.apps.data.search.SearchPagingRepository import foundation.e.apps.domain.preferences.AppPreferencesRepository import foundation.e.apps.domain.search.CleanApkSearchPagingUseCase +import foundation.e.apps.domain.search.FdroidSearchPagingUseCase import foundation.e.apps.domain.search.FetchSearchSuggestionsUseCase import foundation.e.apps.domain.search.PlayStoreAppMapper import foundation.e.apps.domain.search.PlayStoreSearchPagingUseCase @@ -79,6 +82,7 @@ class SearchViewModelV2Test { private lateinit var playStorePagingRepository: PlayStorePagingRepository private lateinit var playStoreAppMapper: PlayStoreAppMapper private lateinit var cleanApkSearchPagingUseCase: CleanApkSearchPagingUseCase + private lateinit var fdroidSearchPagingUseCase: FdroidSearchPagingUseCase private lateinit var playStoreSearchPagingUseCase: PlayStoreSearchPagingUseCase private lateinit var fetchSearchSuggestionsUseCase: FetchSearchSuggestionsUseCase private lateinit var prepareSearchSubmissionUseCase: PrepareSearchSubmissionUseCase @@ -86,6 +90,7 @@ class SearchViewModelV2Test { private lateinit var installStatusStream: InstallStatusStream private lateinit var installStatusReconciler: InstallStatusReconciler private var playStoreSelected = true + private var fdroidSelected = false private var openSourceSelected = true private var pwaSelected = false private lateinit var viewModel: SearchViewModelV2 @@ -98,6 +103,9 @@ class SearchViewModelV2Test { playStorePagingRepository = mockk(relaxed = true) playStoreAppMapper = mockk(relaxed = true) cleanApkSearchPagingUseCase = CleanApkSearchPagingUseCase(searchPagingRepository) + fdroidSearchPagingUseCase = FdroidSearchPagingUseCase( + mockk(relaxed = true) + ) playStoreSearchPagingUseCase = PlayStoreSearchPagingUseCase( playStorePagingRepository, playStoreAppMapper, @@ -106,10 +114,13 @@ class SearchViewModelV2Test { installStatusReconciler = mockk(relaxed = true) every { preference.isPlayStoreSelected() } answers { playStoreSelected } + every { preference.isFdroidSelected() } answers { fdroidSelected } every { preference.isOpenSourceSelected() } answers { openSourceSelected } every { preference.isPWASelected() } answers { pwaSelected } every { preference.enablePlayStore() } answers { playStoreSelected = true } every { preference.disablePlayStore() } answers { playStoreSelected = false } + every { preference.enableFdroid() } answers { fdroidSelected = true } + every { preference.disableFdroid() } answers { fdroidSelected = false } every { preference.enableOpenSource() } answers { openSourceSelected = true } every { preference.disableOpenSource() } answers { openSourceSelected = false } every { preference.enablePwa() } answers { pwaSelected = true } @@ -136,11 +147,13 @@ class SearchViewModelV2Test { private fun buildStores(): Stores { val playStoreRepository = mockk(relaxed = true) + val fdroidStoreRepository = mockk(relaxed = true) val cleanApkAppsRepository = mockk(relaxed = true) val cleanApkPwaRepository = mockk(relaxed = true) return Stores( playStoreRepository, + fdroidStoreRepository, cleanApkAppsRepository, cleanApkPwaRepository, preference @@ -813,6 +826,7 @@ class SearchViewModelV2Test { private fun visibleTabs(): List = buildList { if (playStoreSelected) add(SearchTabType.COMMON_APPS) + if (fdroidSelected) add(SearchTabType.FDROID) if (openSourceSelected) add(SearchTabType.OPEN_SOURCE) if (pwaSelected) add(SearchTabType.PWA) } @@ -826,6 +840,7 @@ class SearchViewModelV2Test { prepareSearchSubmissionUseCase = PrepareSearchSubmissionUseCase(stores) viewModel = SearchViewModelV2( cleanApkSearchPagingUseCase, + fdroidSearchPagingUseCase, playStoreSearchPagingUseCase, preference, fetchSearchSuggestionsUseCase, diff --git a/data/src/main/java/foundation/e/apps/data/installation/model/InstallationSource.kt b/data/src/main/java/foundation/e/apps/data/installation/model/InstallationSource.kt index 4eac090da..803ed1bcb 100644 --- a/data/src/main/java/foundation/e/apps/data/installation/model/InstallationSource.kt +++ b/data/src/main/java/foundation/e/apps/data/installation/model/InstallationSource.kt @@ -22,5 +22,6 @@ enum class InstallationSource { OPEN_SOURCE, PWA, SYSTEM_APP, - PLAY_STORE + PLAY_STORE, + FDROID } diff --git a/data/src/main/java/foundation/e/apps/data/preference/AppLoungePreference.kt b/data/src/main/java/foundation/e/apps/data/preference/AppLoungePreference.kt index c3609a4e0..ce17aeb87 100644 --- a/data/src/main/java/foundation/e/apps/data/preference/AppLoungePreference.kt +++ b/data/src/main/java/foundation/e/apps/data/preference/AppLoungePreference.kt @@ -28,6 +28,7 @@ import javax.inject.Singleton internal const val PREFERENCE_SHOW_FOSS = "showFOSSApplications" internal const val PREFERENCE_SHOW_PWA = "showPWAApplications" internal const val PREFERENCE_SHOW_GPLAY = "showAllApplications" +internal const val PREFERENCE_SHOW_FDROID = "showFDroidApplications" private const val UPDATE_APPS_FROM_OTHER_STORES_KEY = "updateAppsFromOtherStores" private const val UPDATE_NOTIFY_KEY = "updateNotify" @@ -44,12 +45,15 @@ class AppLoungePreference @Inject constructor( override fun preferredApplicationType(): String { val showFOSSApplications = sharedPreferences.getBoolean(PREFERENCE_SHOW_FOSS, DEFAULT_APPLICATION_SOURCE_SELECTED) + val showFDroidApplications = + sharedPreferences.getBoolean(PREFERENCE_SHOW_FDROID, DEFAULT_APPLICATION_SOURCE_SELECTED) val showPWAApplications = sharedPreferences.getBoolean(PREFERENCE_SHOW_PWA, DEFAULT_APPLICATION_SOURCE_SELECTED) + val showOpenSourceApplications = showFOSSApplications || showFDroidApplications return when { - showFOSSApplications && !showPWAApplications -> "open" - showPWAApplications && !showFOSSApplications -> "pwa" + showOpenSourceApplications && !showPWAApplications -> "open" + showPWAApplications && !showOpenSourceApplications -> "pwa" else -> "any" } } @@ -57,6 +61,9 @@ class AppLoungePreference @Inject constructor( override fun isOpenSourceSelected(): Boolean = sharedPreferences.getBoolean(PREFERENCE_SHOW_FOSS, DEFAULT_APPLICATION_SOURCE_SELECTED) + override fun isFdroidSelected(): Boolean = + sharedPreferences.getBoolean(PREFERENCE_SHOW_FDROID, DEFAULT_APPLICATION_SOURCE_SELECTED) + override fun isPWASelected(): Boolean = sharedPreferences.getBoolean(PREFERENCE_SHOW_PWA, DEFAULT_APPLICATION_SOURCE_SELECTED) @@ -72,6 +79,9 @@ class AppLoungePreference @Inject constructor( override fun disablePlayStore() = sharedPreferences.edit { putBoolean(PREFERENCE_SHOW_GPLAY, false) } + override fun disableFdroid() = + sharedPreferences.edit { putBoolean(PREFERENCE_SHOW_FDROID, false) } + override fun disableOpenSource() = sharedPreferences.edit { putBoolean(PREFERENCE_SHOW_FOSS, false) } @@ -81,6 +91,9 @@ class AppLoungePreference @Inject constructor( override fun enablePlayStore() = sharedPreferences.edit { putBoolean(PREFERENCE_SHOW_GPLAY, true) } + override fun enableFdroid() = + sharedPreferences.edit { putBoolean(PREFERENCE_SHOW_FDROID, true) } + override fun enableOpenSource() = sharedPreferences.edit { putBoolean(PREFERENCE_SHOW_FOSS, true) } diff --git a/data/src/test/java/foundation/e/apps/data/preference/AppLoungePreferenceTest.kt b/data/src/test/java/foundation/e/apps/data/preference/AppLoungePreferenceTest.kt index d43331865..f2e63f4a1 100644 --- a/data/src/test/java/foundation/e/apps/data/preference/AppLoungePreferenceTest.kt +++ b/data/src/test/java/foundation/e/apps/data/preference/AppLoungePreferenceTest.kt @@ -65,6 +65,7 @@ class AppLoungePreferenceTest { @Test fun preferredApplicationType_returnsPwaWhenPwaSelected() { sharedPreferences.edit().putBoolean(PREFERENCE_SHOW_FOSS, false).commit() + sharedPreferences.edit().putBoolean(PREFERENCE_SHOW_FDROID, false).commit() sharedPreferences.edit().putBoolean(PREFERENCE_SHOW_PWA, true).commit() assertThat(preference.preferredApplicationType()).isEqualTo("pwa") diff --git a/domain/src/main/kotlin/foundation/e/apps/domain/preferences/AppPreferencesRepository.kt b/domain/src/main/kotlin/foundation/e/apps/domain/preferences/AppPreferencesRepository.kt index e1040a6cc..c9cfa8c98 100644 --- a/domain/src/main/kotlin/foundation/e/apps/domain/preferences/AppPreferencesRepository.kt +++ b/domain/src/main/kotlin/foundation/e/apps/domain/preferences/AppPreferencesRepository.kt @@ -20,15 +20,18 @@ package foundation.e.apps.domain.preferences interface AppPreferencesRepository { fun preferredApplicationType(): String + fun isFdroidSelected(): Boolean fun isOpenSourceSelected(): Boolean fun isPWASelected(): Boolean fun isPlayStoreSelected(): Boolean fun shouldShowUpdateNotification(): Boolean fun isAutomaticInstallEnabled(): Boolean fun disablePlayStore() + fun disableFdroid() fun disableOpenSource() fun disablePwa() fun enablePlayStore() + fun enableFdroid() fun enableOpenSource() fun enablePwa() fun shouldUpdateAppsFromOtherStores(): Boolean diff --git a/install-app-lib/src/main/kotlin/foundation/e/apps/installapp/SeachableSources.kt b/install-app-lib/src/main/kotlin/foundation/e/apps/installapp/SeachableSources.kt index f3c954ff2..afd3ae22a 100644 --- a/install-app-lib/src/main/kotlin/foundation/e/apps/installapp/SeachableSources.kt +++ b/install-app-lib/src/main/kotlin/foundation/e/apps/installapp/SeachableSources.kt @@ -1,7 +1,8 @@ package foundation.e.apps.installapp enum class SearchableSources { + FDROID, OPEN_SOURCE, PWA, PLAY_STORE; -} \ No newline at end of file +} -- GitLab