diff --git a/app/src/main/java/foundation/e/apps/MainActivity.kt b/app/src/main/java/foundation/e/apps/MainActivity.kt index 88496dfac5bede91c4e74d703e19753981edfcf9..cedbf9c84787f3f0c0431d89429520e441ab37f5 100644 --- a/app/src/main/java/foundation/e/apps/MainActivity.kt +++ b/app/src/main/java/foundation/e/apps/MainActivity.kt @@ -372,9 +372,11 @@ class MainActivity : AppCompatActivity() { } private fun broadcastGPlayLogin() { + val user = viewModel.getUser().name + Timber.d("Sending broadcast with login type - $user") val intent = Intent(Constants.ACTION_PARENTAL_CONTROL_APP_LOUNGE_LOGIN).apply { setPackage(BuildConfig.PACKAGE_NAME_PARENTAL_CONTROL) - putExtra(COLUMN_LOGIN_TYPE, viewModel.getUser().name) + putExtra(COLUMN_LOGIN_TYPE, user) } sendBroadcast(intent) } diff --git a/app/src/main/java/foundation/e/apps/data/ageRating/FDroidMonitorApi.kt b/app/src/main/java/foundation/e/apps/data/ageRating/FDroidMonitorApi.kt new file mode 100644 index 0000000000000000000000000000000000000000..ed9de21855f501612e93f2c589075bfb14aae431 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/ageRating/FDroidMonitorApi.kt @@ -0,0 +1,34 @@ +/* + * Copyright MURENA SAS 2024 + * Apps Quickly and easily install Android apps onto your device! + * + * 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.ageRating + +import retrofit2.Response +import retrofit2.http.GET + +interface FDroidMonitorApi { + + companion object { + const val BASE_URL = "https://f-droid.org/repo/" + } + + @GET("status/update.json") + suspend fun getMonitorData(): Response + +} diff --git a/app/src/main/java/foundation/e/apps/data/ageRating/FDroidMonitorData.kt b/app/src/main/java/foundation/e/apps/data/ageRating/FDroidMonitorData.kt new file mode 100644 index 0000000000000000000000000000000000000000..762e6021335de4b761d2aa42a79f6dfe61d7227b --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/ageRating/FDroidMonitorData.kt @@ -0,0 +1,36 @@ +/* + * Copyright MURENA SAS 2024 + * Apps Quickly and easily install Android apps onto your device! + * + * 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.ageRating + +import com.squareup.moshi.Json + +data class FDroidMonitorData( + val antiFeatures: AntiFeatures +) { + fun getNSFWApps() = antiFeatures.nsfw.apps +} + +data class AntiFeatures( + @Json(name = "NSFW") val nsfw: NSFW +) + +data class NSFW( + val apps: List +) 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 98d8d56c9b2780c6718271ed10d6222b3260ec75..2ff78b9473eacf42ad59184d1062e3bd9c4622a4 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 @@ -102,7 +102,9 @@ data class Application( var isGplayReplaced: Boolean = false, @SerializedName(value = "on_fdroid") val isFDroidApp: Boolean = false, - var contentRating: ContentRating = ContentRating() + var contentRating: ContentRating = ContentRating(), + @SerializedName(value = "antifeatures") + val antiFeatures: List> = emptyList(), ) { fun updateType() { this.type = if (this.is_pwa) PWA else NATIVE diff --git a/app/src/main/java/foundation/e/apps/data/blockedApps/ContentRatingsRepository.kt b/app/src/main/java/foundation/e/apps/data/blockedApps/ContentRatingsRepository.kt index 595904bbe3a5bfaac5bf84efa45b4ecc5a934263..a3d77f95207c594f03cab37f4a2a3445586dfbbe 100644 --- a/app/src/main/java/foundation/e/apps/data/blockedApps/ContentRatingsRepository.kt +++ b/app/src/main/java/foundation/e/apps/data/blockedApps/ContentRatingsRepository.kt @@ -22,6 +22,7 @@ package foundation.e.apps.data.blockedApps import com.aurora.gplayapi.data.models.ContentRating import com.aurora.gplayapi.helpers.ContentRatingHelper import foundation.e.apps.data.ageRating.AgeGroupApi +import foundation.e.apps.data.ageRating.FDroidMonitorApi import foundation.e.apps.data.handleNetworkResult import foundation.e.apps.data.login.AuthenticatorRepository import javax.inject.Inject @@ -30,6 +31,7 @@ import javax.inject.Singleton @Singleton class ContentRatingsRepository @Inject constructor( private val ageGroupApi: AgeGroupApi, + private val fDroidMonitorApi: FDroidMonitorApi, private val authenticatorRepository: AuthenticatorRepository, ) { @@ -37,6 +39,9 @@ class ContentRatingsRepository @Inject constructor( val contentRatingGroups: List get() = _contentRatingGroups + var fDroidNSFWApps = listOf() + private set + suspend fun fetchContentRatingData() { val response = ageGroupApi.getDefinedAgeGroups() if (response.isSuccessful) { @@ -44,6 +49,10 @@ class ContentRatingsRepository @Inject constructor( } } + suspend fun fetchNSFWApps() { + fDroidNSFWApps = fDroidMonitorApi.getMonitorData().body()?.getNSFWApps() ?: emptyList() + } + suspend fun getEnglishContentRating(packageName: String): ContentRating? { val authData = authenticatorRepository.gplayAuth ?: return null val contentRatingHelper = ContentRatingHelper(authData) diff --git a/app/src/main/java/foundation/e/apps/di/AgeRatingModule.kt b/app/src/main/java/foundation/e/apps/di/AgeRatingModule.kt index 1cb26267812ce529f8a023ae8df15bf0b899ee64..b47981ec992c270896142449b2f69863c9b7f1cc 100644 --- a/app/src/main/java/foundation/e/apps/di/AgeRatingModule.kt +++ b/app/src/main/java/foundation/e/apps/di/AgeRatingModule.kt @@ -25,6 +25,7 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import foundation.e.apps.data.ageRating.AgeGroupApi +import foundation.e.apps.data.ageRating.FDroidMonitorApi import javax.inject.Singleton import okhttp3.OkHttpClient import retrofit2.Retrofit @@ -44,4 +45,15 @@ object AgeRatingModule { .build() .create(AgeGroupApi::class.java) } + + @Singleton + @Provides + fun provideFDroidMonitorApi(okHttpClient: OkHttpClient, moshi: Moshi): FDroidMonitorApi { + return Retrofit.Builder() + .baseUrl(FDroidMonitorApi.BASE_URL) + .client(okHttpClient) + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .build() + .create(FDroidMonitorApi::class.java) + } } 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 c8797ecda0f17493c191b623311d3ea60d5a3dc2..f94648b500c907d8039edec57616afd606606a07 100644 --- a/app/src/main/java/foundation/e/apps/domain/ValidateAppAgeLimitUseCase.kt +++ b/app/src/main/java/foundation/e/apps/domain/ValidateAppAgeLimitUseCase.kt @@ -24,6 +24,8 @@ import foundation.e.apps.data.blockedApps.Age import foundation.e.apps.data.blockedApps.ContentRatingGroup import foundation.e.apps.data.blockedApps.ContentRatingsRepository import foundation.e.apps.data.blockedApps.ParentalControlRepository +import foundation.e.apps.data.enums.Origin +import foundation.e.apps.data.enums.Type import foundation.e.apps.data.install.models.AppInstall import timber.log.Timber import javax.inject.Inject @@ -31,18 +33,49 @@ import javax.inject.Inject class ValidateAppAgeLimitUseCase @Inject constructor( private val contentRatingRepository: ContentRatingsRepository, private val parentalControlRepository: ParentalControlRepository, + private val appsApi: AppsApi, ) { + companion object { + const val KEY_ANTI_FEATURES_NSFW = "NSFW" + } + suspend operator fun invoke(app: AppInstall): ResultSupreme { val ageGroup = parentalControlRepository.getSelectedAgeGroup() return when { isParentalControlDisabled(ageGroup) -> ResultSupreme.Success(data = true) - hasNoContentRating(app) -> ResultSupreme.Error(data = false) + isKnownNsfwApp(app) -> ResultSupreme.Success(data = false) + isCleanApkApp(app) -> ResultSupreme.Success(!isNsfwAppByCleanApkApi(app)) + isWhiteListedCleanApkApp(app) -> ResultSupreme.Success(data = true) + // Check for GPlay apps now + hasNoContentRatingOnGPlay(app) -> ResultSupreme.Error() else -> validateAgeLimit(ageGroup, app) } } + private fun isCleanApkApp(app: AppInstall): Boolean { + return app.id.isNotBlank() + && app.origin == Origin.CLEANAPK + && app.type == Type.NATIVE + } + + private fun isWhiteListedCleanApkApp(app: AppInstall): Boolean { + return app.origin == Origin.CLEANAPK + } + + private suspend fun isNsfwAppByCleanApkApi(app: AppInstall): Boolean { + return appsApi.getCleanapkAppDetails(app.packageName).first.let { + it.antiFeatures.any { antiFeature -> + antiFeature.containsKey(KEY_ANTI_FEATURES_NSFW) + } + } + } + + private fun isKnownNsfwApp(app: AppInstall): Boolean { + return app.packageName in contentRatingRepository.fDroidNSFWApps + } + private fun validateAgeLimit( ageGroup: Age, app: AppInstall @@ -59,7 +92,7 @@ class ValidateAppAgeLimitUseCase @Inject constructor( return ResultSupreme.Success(isValidAppAgeRating(app, allowedContentRating)) } - private suspend fun hasNoContentRating(app: AppInstall) = + private suspend fun hasNoContentRatingOnGPlay(app: AppInstall) = !verifyContentRatingExists(app) private fun isValidAppAgeRating( diff --git a/app/src/main/java/foundation/e/apps/install/workmanager/AppInstallProcessor.kt b/app/src/main/java/foundation/e/apps/install/workmanager/AppInstallProcessor.kt index 4a269e55585730549c684f67ab5b6e732a691daa..77fbf8b44d88a5a5ff9c0d5e1a5b21086c1049b4 100644 --- a/app/src/main/java/foundation/e/apps/install/workmanager/AppInstallProcessor.kt +++ b/app/src/main/java/foundation/e/apps/install/workmanager/AppInstallProcessor.kt @@ -132,7 +132,7 @@ class AppInstallProcessor @Inject constructor( } val ageLimitValidationResult = validateAppAgeLimitUseCase.invoke(appInstall) - if (ageLimitValidationResult.data == false) { + if (ageLimitValidationResult.data != true) { if (ageLimitValidationResult.isSuccess()) { Timber.i("Content rating is not allowed for: ${appInstall.name}") EventBus.invokeEvent(AppEvent.AgeLimitRestrictionEvent(appInstall.name)) diff --git a/app/src/main/java/foundation/e/apps/provider/AgeRatingProvider.kt b/app/src/main/java/foundation/e/apps/provider/AgeRatingProvider.kt index 57164db099842b096ecee2aad1e04ff39dc75912..73fe65ccfa04122354d9ea3ba1ae2797b6c08423 100644 --- a/app/src/main/java/foundation/e/apps/provider/AgeRatingProvider.kt +++ b/app/src/main/java/foundation/e/apps/provider/AgeRatingProvider.kt @@ -107,6 +107,7 @@ class AgeRatingProvider : ContentProvider() { val cursor = MatrixCursor(arrayOf(COLUMN_PACKAGE_NAME)) val packageNames = appLoungePackageManager.getAllUserApps().map { it.packageName } runBlocking { + Timber.d("Start preparing blocklist from ${packageNames.size} apps.") withContext(IO) { try { if (packageNames.isEmpty()) return@withContext cursor @@ -124,8 +125,18 @@ class AgeRatingProvider : ContentProvider() { } private suspend fun ensureAgeGroupDataExists() { - if (contentRatingsRepository.contentRatingGroups.isEmpty()) { - contentRatingsRepository.fetchContentRatingData() + withContext(IO) { + val deferredFetchRatings = async { + if (contentRatingsRepository.contentRatingGroups.isEmpty()) { + contentRatingsRepository.fetchContentRatingData() + } + } + val deferredFetchNSFW = async { + if (contentRatingsRepository.fDroidNSFWApps.isEmpty()) { + contentRatingsRepository.fetchNSFWApps() + } + } + listOf(deferredFetchRatings, deferredFetchNSFW).awaitAll() } } @@ -142,15 +153,32 @@ class AgeRatingProvider : ContentProvider() { return false } - private suspend fun getAppAgeValidity(packageName: String): Boolean { + private suspend fun isAppValidRegardingAge(packageName: String): Boolean? { val fakeAppInstall = AppInstall( packageName = packageName, origin = Origin.GPLAY ) - val validateResult = validateAppAgeLimitUseCase(fakeAppInstall) + val validateResult = validateAppAgeLimitUseCase.invoke(fakeAppInstall) + return validateResult.data + } + + private suspend fun isAppValidRegardingNSWF(packageName: String): Boolean { + val fakeAppInstall = AppInstall( + packageName = packageName, + origin = Origin.CLEANAPK, + ) + val validateResult = validateAppAgeLimitUseCase.invoke(fakeAppInstall) return validateResult.data ?: false } + private suspend fun shouldAllow(packageName: String): Boolean { + return when { + !isAppValidRegardingNSWF(packageName) -> false + isAppValidRegardingAge(packageName) == false -> false + else -> true + } + } + private suspend fun compileAppBlockList( cursor: MatrixCursor, packageNames: List, @@ -158,7 +186,7 @@ class AgeRatingProvider : ContentProvider() { withContext(IO) { val validityList = packageNames.map { packageName -> async { - getAppAgeValidity(packageName) + shouldAllow(packageName) } }.awaitAll() validityList.forEachIndexed { index: Int, isValid: Boolean? -> @@ -167,6 +195,7 @@ class AgeRatingProvider : ContentProvider() { cursor.addRow(arrayOf(packageNames[index])) } } + Timber.d("Finished compiling blocklist - ${cursor.count} apps blocked.") } } diff --git a/app/src/main/java/foundation/e/apps/ui/MainActivityViewModel.kt b/app/src/main/java/foundation/e/apps/ui/MainActivityViewModel.kt index 4787b8e912aa33c661e98315c2cf3e5603a648b0..18a1671a98762545f768ca208acac1c88f4af16c 100644 --- a/app/src/main/java/foundation/e/apps/ui/MainActivityViewModel.kt +++ b/app/src/main/java/foundation/e/apps/ui/MainActivityViewModel.kt @@ -240,6 +240,7 @@ class MainActivityViewModel @Inject constructor( fun updateContentRatings() { viewModelScope.launch { + contentRatingsRepository.fetchNSFWApps() contentRatingsRepository.fetchContentRatingData() } }