diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index 5feaed5a4475c315da2ba0873df24d9ffa344bae..3c941c851105b6a522a6f12748266eb03c1da7ea 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -36,7 +36,7 @@ LongParameterList:ApplicationViewModel.kt$ApplicationViewModel$( id: String, packageName: String, origin: Origin, isFdroidLink: Boolean, authObjectList: List<AuthObject>, retryBlock: (failedObjects: List<AuthObject>) -> Boolean, ) LongParameterList:CleanApkRetrofit.kt$CleanApkRetrofit$( @Query("keyword") keyword: String, @Query("source") source: String = APP_SOURCE_FOSS, @Query("type") type: String = APP_TYPE_ANY, @Query("nres") nres: Int = 20, @Query("page") page: Int = 1, @Query("by") by: String? = null, ) LongParameterList:EglExtensionProvider.kt$EglExtensionProvider$( egl10: EGL10, eglDisplay: EGLDisplay, eglConfig: EGLConfig?, ai: IntArray, ai1: IntArray?, set: MutableSet<String> ) - LongParameterList:MainActivityViewModel.kt$MainActivityViewModel$( private val appLoungeDataStore: AppLoungeDataStore, private val applicationRepository: ApplicationRepository, private val appManagerWrapper: AppManagerWrapper, private val appLoungePackageManager: AppLoungePackageManager, private val pwaManager: PWAManager, private val ecloudRepository: EcloudRepository, private val blockedAppRepository: BlockedAppRepository, private val appInstallProcessor: AppInstallProcessor, ) + LongParameterList:MainActivityViewModel.kt$MainActivityViewModel$( private val appLoungeDataStore: AppLoungeDataStore, private val applicationRepository: ApplicationRepository, private val appManagerWrapper: AppManagerWrapper, private val appLoungePackageManager: AppLoungePackageManager, private val pwaManager: PWAManager, private val ecloudRepository: EcloudRepository, private val blockedAppRepository: BlockedAppRepository, private val contentRatingsRepository: ContentRatingsRepository, private val appInstallProcessor: AppInstallProcessor, ) LongParameterList:UpdatesManagerImpl.kt$UpdatesManagerImpl$( @ApplicationContext private val context: Context, private val appLoungePackageManager: AppLoungePackageManager, private val applicationRepository: ApplicationRepository, private val faultyAppRepository: FaultyAppRepository, private val appLoungePreference: AppLoungePreference, private val fdroidRepository: FdroidRepository, private val blockedAppRepository: BlockedAppRepository, ) LongParameterList:UpdatesWorker.kt$UpdatesWorker$( @Assisted private val context: Context, @Assisted private val params: WorkerParameters, private val updatesManagerRepository: UpdatesManagerRepository, private val dataStoreManager: DataStoreManager, private val authenticatorRepository: AuthenticatorRepository, private val appInstallProcessor: AppInstallProcessor, private val blockedAppRepository: BlockedAppRepository, ) MagicNumber:AnonymousLoginManager.kt$AnonymousLoginManager$200 diff --git a/app/src/main/java/foundation/e/apps/MainActivity.kt b/app/src/main/java/foundation/e/apps/MainActivity.kt index 704d9f030542f2ff6c2307a5fada9551ae70dc4c..61c632596c3e36ff50420a95978d98aedf96a00a 100644 --- a/app/src/main/java/foundation/e/apps/MainActivity.kt +++ b/app/src/main/java/foundation/e/apps/MainActivity.kt @@ -120,6 +120,7 @@ class MainActivity : AppCompatActivity() { } viewModel.updateAppWarningList() + viewModel.updateContentRatings() observeEvents() } @@ -194,7 +195,11 @@ class MainActivity : AppCompatActivity() { } launch { - observerErrorEvent() + observeErrorEvent() + } + + launch { + observeErrorDialogEvent() } launch { @@ -204,10 +209,26 @@ class MainActivity : AppCompatActivity() { launch { observeNoInternetEvent() } + + launch { + observeAgeLimitRestrictionEvent() + } } } } + private suspend fun observeAgeLimitRestrictionEvent() { + EventBus.events.filter { + it is AppEvent.AgeLimitRestrictionEvent + }.collectLatest { + ApplicationDialogFragment( + getString(R.string.restricted_app, it.data as String), + getString(R.string.age_rate_limit_message, it.data as String), + positiveButtonText = getString(R.string.ok), + ).show(supportFragmentManager, TAG) + } + } + private fun observePurchaseDeclined() { viewModel.purchaseDeclined.observe(this) { if (it.isNotEmpty()) { @@ -355,7 +376,7 @@ class MainActivity : AppCompatActivity() { findNavController(R.id.fragment).navigate(action) } - private suspend fun observerErrorEvent() { + private suspend fun observeErrorEvent() { EventBus.events.filter { appEvent -> appEvent is AppEvent.ErrorMessageEvent }.collectLatest { @@ -363,6 +384,17 @@ class MainActivity : AppCompatActivity() { } } + private suspend fun observeErrorDialogEvent() { + EventBus.events.filter { appEvent -> + appEvent is AppEvent.ErrorMessageDialogEvent + }.collectLatest { + ApplicationDialogFragment( + title = getString(R.string.unknown_error), + message = getString(it.data as Int) + ).show(supportFragmentManager, TAG) + } + } + private suspend fun observeSignatureMissMatchError() { EventBus.events.filter { appEvent -> appEvent is AppEvent.SignatureMissMatchError 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 dd32f1a5d7cebbef84d56f431118a9be4fefd2a4..98d8d56c9b2780c6718271ed10d6222b3260ec75 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,7 @@ data class Application( var isGplayReplaced: Boolean = false, @SerializedName(value = "on_fdroid") val isFDroidApp: Boolean = false, - val contentRating: ContentRating = ContentRating() + var contentRating: ContentRating = ContentRating() ) { fun updateType() { this.type = if (this.is_pwa) PWA else NATIVE diff --git a/app/src/main/java/foundation/e/apps/data/blockedApps/ContentRatingGroup.kt b/app/src/main/java/foundation/e/apps/data/blockedApps/ContentRatingGroup.kt new file mode 100644 index 0000000000000000000000000000000000000000..cfb734abcef8f7e38af4c8bb26a82917e81f762a --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/blockedApps/ContentRatingGroup.kt @@ -0,0 +1,29 @@ +/* + * 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.blockedApps + +import com.google.gson.annotations.SerializedName + +data class ContentRatingGroup( + val id: String, + @SerializedName("age_group") + val ageGroup: String, + var ratings: List +) diff --git a/app/src/main/java/foundation/e/apps/data/blockedApps/ContentRatingParser.kt b/app/src/main/java/foundation/e/apps/data/blockedApps/ContentRatingParser.kt new file mode 100644 index 0000000000000000000000000000000000000000..cde87e63857918a0678281d366f0061905bc1954 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/blockedApps/ContentRatingParser.kt @@ -0,0 +1,89 @@ +/* + * 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.blockedApps + +import com.google.gson.Gson +import com.google.gson.JsonSyntaxException +import com.google.gson.reflect.TypeToken +import foundation.e.apps.data.install.FileManager +import timber.log.Timber +import java.io.File +import java.io.IOException +import javax.inject.Inject +import javax.inject.Named + +class ContentRatingParser @Inject constructor( + private val gson: Gson, + @Named("cacheDir") private val cacheDir: String +) { + + companion object { + private const val CONTENT_RATINGS_FILE_NAME = "content_ratings.json" + } + + fun parseContentRatingData(): List { + return try { + val outputPath = moveFile() + val contentRatingJson = readJsonFromFile(outputPath) + Timber.d("ContentRatings file contents: $contentRatingJson") + parseJsonOfContentRatingGroup(contentRatingJson) + } catch (exception: IOException) { + handleException(exception) + } catch (exception: JsonSyntaxException) { + handleException(exception) + } + } + + private fun readJsonFromFile(outputPath: String): String { + val downloadedFile = + File(outputPath + CONTENT_RATINGS_FILE_NAME) + val contentRatingJson = String(downloadedFile.inputStream().readBytes()) + + return contentRatingJson + } + + private fun moveFile(): String { + val outputPath = "$cacheDir/content_ratings/" + FileManager.moveFile( + "$cacheDir/", + CONTENT_RATINGS_FILE_NAME, outputPath + ) + + return outputPath + } + + private fun parseJsonOfContentRatingGroup(contentRatingJson: String): List { + val contentRatingsListTypeGroup = object : TypeToken>() {}.type + val contentRatingGroups: List = + gson.fromJson(contentRatingJson, contentRatingsListTypeGroup) + + return contentRatingGroups.map { + it.ratings = it.ratings.map { rating -> + rating.lowercase() + } + it + } + } + + private fun handleException(exception: Exception): List { + Timber.e(exception.localizedMessage ?: "", exception) + return listOf() + } +} 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 new file mode 100644 index 0000000000000000000000000000000000000000..44f17df51e5ed2035e548ab30f991f084701d00a --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/blockedApps/ContentRatingsRepository.kt @@ -0,0 +1,60 @@ +/* + * 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.blockedApps + +import com.google.gson.Gson +import com.google.gson.JsonSyntaxException +import com.google.gson.reflect.TypeToken +import foundation.e.apps.data.DownloadManager +import foundation.e.apps.data.install.FileManager +import timber.log.Timber +import java.io.File +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class ContentRatingsRepository @Inject constructor( + private val downloadManager: DownloadManager, + private val contentRatingParser: ContentRatingParser +) { + + private var _contentRatingGroups = listOf() + val contentRatingGroups: List + get() = _contentRatingGroups + + companion object { + private const val CONTENT_RATINGS_FILE_URL = + "https://gitlab.e.foundation/e/os/app-lounge-content-ratings/-/raw/main/" + + "content_ratings.json?ref_type=heads&inline=false" + private const val CONTENT_RATINGS_FILE_NAME = "content_ratings.json" + } + + fun fetchContentRatingData() { + downloadManager.downloadFileInCache( + CONTENT_RATINGS_FILE_URL, + fileName = CONTENT_RATINGS_FILE_NAME + ) { success, _ -> + if (success) { + contentRatingParser.parseContentRatingData() + } + } + } +} diff --git a/app/src/main/java/foundation/e/apps/data/blockedApps/ParentalControlRepository.kt b/app/src/main/java/foundation/e/apps/data/blockedApps/ParentalControlRepository.kt new file mode 100644 index 0000000000000000000000000000000000000000..6cab641e34873363211bef205395976b37c97fff --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/blockedApps/ParentalControlRepository.kt @@ -0,0 +1,60 @@ +/* + * 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.blockedApps + +import android.content.Context +import android.net.Uri +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ParentalControlRepository @Inject constructor( + @ApplicationContext private val context: Context +) { + + companion object { + private const val URI_PARENTAL_CONTROL_PROVIDER = + "content://foundation.e.parentalcontrol.provider/age" + } + + fun getSelectedAgeGroup(): Age { + val uri = Uri.parse(URI_PARENTAL_CONTROL_PROVIDER) + val cursor = context.contentResolver.query(uri, null, null, null, null) + + cursor?.use { + if (it.moveToFirst()) { + val ageOrdinal = it.getInt(it.getColumnIndexOrThrow("age")) + return Age.values()[ageOrdinal] + } + } + + return Age.PARENTAL_CONTROL_DISABLED + } +} + +enum class Age { + THREE, + SIX, + ELEVEN, + FIFTEEN, + SEVENTEEN, + PARENTAL_CONTROL_DISABLED +} diff --git a/app/src/main/java/foundation/e/apps/data/install/models/AppInstall.kt b/app/src/main/java/foundation/e/apps/data/install/models/AppInstall.kt index 2d654261881d1cdae4eb62f9ab16ed61acbab408..28f92b4d64e5be5481ff3b7a5a6a92cfc5442a44 100644 --- a/app/src/main/java/foundation/e/apps/data/install/models/AppInstall.kt +++ b/app/src/main/java/foundation/e/apps/data/install/models/AppInstall.kt @@ -3,6 +3,7 @@ package foundation.e.apps.data.install.models import androidx.room.Entity import androidx.room.Ignore import androidx.room.PrimaryKey +import com.aurora.gplayapi.data.models.ContentRating import com.aurora.gplayapi.data.models.File import foundation.e.apps.data.cleanapk.CleanApkRetrofit import foundation.e.apps.data.enums.Origin @@ -36,6 +37,9 @@ data class AppInstall( Status.INSTALLING ) + @Ignore + var contentRating: ContentRating = ContentRating() + fun isAppInstalling() = installingStatusList.contains(status) fun isAwaiting() = status == Status.AWAITING diff --git a/app/src/main/java/foundation/e/apps/data/playstore/PlayStoreRepository.kt b/app/src/main/java/foundation/e/apps/data/playstore/PlayStoreRepository.kt index 798eb4daa09362d8dfed036f1b60cab5a96ee0f2..0239ce1f3366e1819c763876af65e8ffd3269af0 100644 --- a/app/src/main/java/foundation/e/apps/data/playstore/PlayStoreRepository.kt +++ b/app/src/main/java/foundation/e/apps/data/playstore/PlayStoreRepository.kt @@ -45,7 +45,7 @@ interface PlayStoreRepository : StoreRepository { offerType: Int ): List - suspend fun updateContentRatingWithId( + suspend fun getContentRatingWithId( appPackage: String, contentRating: ContentRating ): ContentRating diff --git a/app/src/main/java/foundation/e/apps/data/playstore/PlayStoreRepositoryImpl.kt b/app/src/main/java/foundation/e/apps/data/playstore/PlayStoreRepositoryImpl.kt index d971814641eab2d081f901391e634fea012d5cff..da884772134504403fa3cc1bdf52ffec02d5fe0c 100644 --- a/app/src/main/java/foundation/e/apps/data/playstore/PlayStoreRepositoryImpl.kt +++ b/app/src/main/java/foundation/e/apps/data/playstore/PlayStoreRepositoryImpl.kt @@ -214,7 +214,7 @@ class PlayStoreRepositoryImpl @Inject constructor( return downloadData } - override suspend fun updateContentRatingWithId( + override suspend fun getContentRatingWithId( appPackage: String, contentRating: ContentRating ): ContentRating { diff --git a/app/src/main/java/foundation/e/apps/domain/ValidateAppAgeLimitUseCase.kt b/app/src/main/java/foundation/e/apps/domain/ValidateAppAgeLimitUseCase.kt new file mode 100644 index 0000000000000000000000000000000000000000..c859fe5e1d5d40511f2d78bbae8cb9a4322f729d --- /dev/null +++ b/app/src/main/java/foundation/e/apps/domain/ValidateAppAgeLimitUseCase.kt @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2024 MURENA SAS + * + * 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 + +import com.aurora.gplayapi.data.models.AuthData +import foundation.e.apps.data.ResultSupreme +import foundation.e.apps.data.application.ApplicationRepository +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.ResultStatus +import foundation.e.apps.data.install.models.AppInstall +import foundation.e.apps.data.playstore.PlayStoreRepository +import foundation.e.apps.data.preference.DataStoreManager +import timber.log.Timber +import javax.inject.Inject + +class ValidateAppAgeLimitUseCase @Inject constructor( + private val applicationRepository: ApplicationRepository, + private val dataStoreManager: DataStoreManager, + private val contentRatingRepository: ContentRatingsRepository, + private val parentalControlRepository: ParentalControlRepository, + private val playStoreRepository: PlayStoreRepository +) { + + suspend operator fun invoke(app: AppInstall): ResultSupreme { + val authData = dataStoreManager.getAuthData() + val ageGroup = parentalControlRepository.getSelectedAgeGroup() + + return when { + isParentalControlDisabled(ageGroup) -> ResultSupreme.Success(data = true) + hasNoContentRating(app, authData) -> ResultSupreme.Error(data = false) + else -> validateAgeLimit(ageGroup, app) + } + } + + private fun validateAgeLimit( + ageGroup: Age, + app: AppInstall + ): ResultSupreme.Success { + val allowedContentRating = + contentRatingRepository.contentRatingGroups.find { it.id == ageGroup.toString() } + + Timber.d( + "Selected age group: $ageGroup \n" + + "Content rating: ${app.contentRating.id} \n" + + "Allowed content rating: $allowedContentRating" + ) + + return ResultSupreme.Success(isValidAppAgeRating(app, allowedContentRating)) + } + + private suspend fun hasNoContentRating(app: AppInstall, authData: AuthData) = + !verifyContentRatingExists(app, authData) + + private fun isValidAppAgeRating( + app: AppInstall, + allowedContentRating: ContentRatingGroup? + ) = (app.contentRating.id.isNotEmpty() + && allowedContentRating?.ratings?.contains(app.contentRating.id) == true) + + private fun isParentalControlDisabled(ageGroup: Age) = ageGroup == Age.PARENTAL_CONTROL_DISABLED + + private suspend fun verifyContentRatingExists( + app: AppInstall, + authData: AuthData + ): Boolean { + if (app.contentRating.title.isEmpty()) { + applicationRepository + .getApplicationDetails( + app.id, app.packageName, authData, app.origin + ).let { (appDetails, resultStatus) -> + if (resultStatus == ResultStatus.OK) { + app.contentRating = appDetails.contentRating + } else { + return false + } + } + } + + if (app.contentRating.id.isEmpty()) { + app.contentRating = + playStoreRepository.getContentRatingWithId( + app.packageName, + app.contentRating + ) + } + + return app.contentRating.title.isNotEmpty() && + app.contentRating.id.isNotEmpty() + } +} diff --git a/app/src/main/java/foundation/e/apps/install/AppInstallComponents.kt b/app/src/main/java/foundation/e/apps/install/AppInstallComponents.kt new file mode 100644 index 0000000000000000000000000000000000000000..dfe3f93ac9ca6266b65c3ad51c82b0f2c78fe85f --- /dev/null +++ b/app/src/main/java/foundation/e/apps/install/AppInstallComponents.kt @@ -0,0 +1,31 @@ +/* + * 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.install + +import foundation.e.apps.data.install.AppInstallRepository +import foundation.e.apps.data.install.AppManagerWrapper +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AppInstallComponents @Inject constructor( + val appInstallRepository: AppInstallRepository, + val appManagerWrapper: AppManagerWrapper +) 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 b71e6e6bc029e2371c7a465b1e0913bd67d521b8..4a269e55585730549c684f67ab5b6e732a691daa 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 @@ -29,11 +29,11 @@ import foundation.e.apps.data.enums.Type import foundation.e.apps.data.application.ApplicationRepository import foundation.e.apps.data.application.UpdatesDao import foundation.e.apps.data.application.data.Application -import foundation.e.apps.data.install.AppInstallRepository -import foundation.e.apps.data.install.AppManagerWrapper import foundation.e.apps.data.install.models.AppInstall import foundation.e.apps.data.playstore.utils.GplayHttpRequestException import foundation.e.apps.data.preference.DataStoreManager +import foundation.e.apps.domain.ValidateAppAgeLimitUseCase +import foundation.e.apps.install.AppInstallComponents import foundation.e.apps.install.download.DownloadManagerUtils import foundation.e.apps.install.notification.StorageNotificationManager import foundation.e.apps.install.updates.UpdatesNotifier @@ -51,9 +51,9 @@ import javax.inject.Inject class AppInstallProcessor @Inject constructor( @ApplicationContext private val context: Context, - private val appInstallRepository: AppInstallRepository, - private val appManagerWrapper: AppManagerWrapper, + private val appInstallComponents: AppInstallComponents, private val applicationRepository: ApplicationRepository, + private val validateAppAgeLimitUseCase: ValidateAppAgeLimitUseCase, private val dataStoreManager: DataStoreManager, private val storageNotificationManager: StorageNotificationManager, ) { @@ -93,7 +93,9 @@ class AppInstallProcessor @Inject constructor( application.offer_type, application.isFree, application.originalSize - ) + ).also { + it.contentRating = application.contentRating + } if (appInstall.type == Type.PWA) { appInstall.downloadURLList = mutableListOf(application.url) @@ -115,6 +117,7 @@ class AppInstallProcessor @Inject constructor( ) { try { val authData = dataStoreManager.getAuthData() + if (!appInstall.isFree && authData.isAnonymous) { EventBus.invokeEvent(AppEvent.ErrorMessageEvent(R.string.paid_app_anonymous_message)) return @@ -122,14 +125,27 @@ class AppInstallProcessor @Inject constructor( if (appInstall.type != Type.PWA && !updateDownloadUrls(appInstall)) return - val downloadAdded = appManagerWrapper.addDownload(appInstall) + val downloadAdded = appInstallComponents.appManagerWrapper.addDownload(appInstall) if (!downloadAdded) { Timber.i("Update adding ABORTED! status: $downloadAdded") return } + val ageLimitValidationResult = validateAppAgeLimitUseCase.invoke(appInstall) + if (ageLimitValidationResult.data == false) { + if (ageLimitValidationResult.isSuccess()) { + Timber.i("Content rating is not allowed for: ${appInstall.name}") + EventBus.invokeEvent(AppEvent.AgeLimitRestrictionEvent(appInstall.name)) + } else { + EventBus.invokeEvent(AppEvent.ErrorMessageDialogEvent(R.string.data_load_error_desc)) + } + + appInstallComponents.appManagerWrapper.cancelDownload(appInstall) + return + } + if (!context.isNetworkAvailable()) { - appManagerWrapper.installationIssue(appInstall) + appInstallComponents.appManagerWrapper.installationIssue(appInstall) EventBus.invokeEvent(AppEvent.NoInternetEvent(false)) return } @@ -137,19 +153,19 @@ class AppInstallProcessor @Inject constructor( if (StorageComputer.spaceMissing(appInstall) > 0) { Timber.d("Storage is not available for: ${appInstall.name} size: ${appInstall.appSize}") storageNotificationManager.showNotEnoughSpaceNotification(appInstall) - appManagerWrapper.installationIssue(appInstall) + appInstallComponents.appManagerWrapper.installationIssue(appInstall) EventBus.invokeEvent(AppEvent.ErrorMessageEvent(R.string.not_enough_storage)) return } - appManagerWrapper.updateAwaiting(appInstall) + appInstallComponents.appManagerWrapper.updateAwaiting(appInstall) InstallWorkManager.enqueueWork(appInstall, isAnUpdate) } catch (e: Exception) { Timber.e( "Enqueuing App install work is failed for ${appInstall.packageName} exception: ${e.localizedMessage}", e ) - appManagerWrapper.installationIssue(appInstall) + appInstallComponents.appManagerWrapper.installationIssue(appInstall) } } @@ -158,7 +174,7 @@ class AppInstallProcessor @Inject constructor( try { updateFusedDownloadWithAppDownloadLink(appInstall) } catch (e: ApiException.AppNotPurchased) { - appManagerWrapper.addFusedDownloadPurchaseNeeded(appInstall) + appInstallComponents.appManagerWrapper.addFusedDownloadPurchaseNeeded(appInstall) EventBus.invokeEvent(AppEvent.AppPurchaseEvent(appInstall)) return false } catch (e: GplayHttpRequestException) { @@ -212,7 +228,7 @@ class AppInstallProcessor @Inject constructor( try { Timber.d("Fused download name $fusedDownloadId") - appInstall = appInstallRepository.getDownloadById(fusedDownloadId) + appInstall = appInstallComponents.appInstallRepository.getDownloadById(fusedDownloadId) Timber.i(">>> dowork started for Fused download name " + appInstall?.name + " " + fusedDownloadId) appInstall?.let { @@ -220,22 +236,27 @@ class AppInstallProcessor @Inject constructor( checkDownloadingState(appInstall) this.isItUpdateWork = - isItUpdateWork && appManagerWrapper.isFusedDownloadInstalled(appInstall) + isItUpdateWork && appInstallComponents.appManagerWrapper.isFusedDownloadInstalled( + appInstall + ) if (!appInstall.isAppInstalling()) { Timber.d("!!! returned") return@let } - if (!appManagerWrapper.validateFusedDownload(appInstall)) { - appManagerWrapper.installationIssue(it) + if (!appInstallComponents.appManagerWrapper.validateFusedDownload(appInstall)) { + appInstallComponents.appManagerWrapper.installationIssue(it) Timber.d("!!! installationIssue") return@let } if (areFilesDownloadedButNotInstalled(appInstall)) { Timber.i("===> Downloaded But not installed ${appInstall.name}") - appManagerWrapper.updateDownloadStatus(appInstall, Status.INSTALLING) + appInstallComponents.appManagerWrapper.updateDownloadStatus( + appInstall, + Status.INSTALLING + ) } runInForeground?.invoke(it.name) @@ -248,7 +269,7 @@ class AppInstallProcessor @Inject constructor( e ) appInstall?.let { - appManagerWrapper.cancelDownload(appInstall) + appInstallComponents.appManagerWrapper.cancelDownload(appInstall) } } @@ -266,7 +287,7 @@ class AppInstallProcessor @Inject constructor( } private fun areFilesDownloadedButNotInstalled(appInstall: AppInstall) = - appInstall.areFilesDownloaded() && (!appManagerWrapper.isFusedDownloadInstalled( + appInstall.areFilesDownloaded() && (!appInstallComponents.appManagerWrapper.isFusedDownloadInstalled( appInstall ) || appInstall.status == Status.INSTALLING) @@ -276,7 +297,7 @@ class AppInstallProcessor @Inject constructor( if (isItUpdateWork) { appInstall?.let { val packageStatus = - appManagerWrapper.getFusedDownloadPackageStatus(appInstall) + appInstallComponents.appManagerWrapper.getFusedDownloadPackageStatus(appInstall) if (packageStatus == Status.INSTALLED) { UpdatesDao.addSuccessfullyUpdatedApp(it) @@ -291,11 +312,12 @@ class AppInstallProcessor @Inject constructor( } private suspend fun isUpdateCompleted(): Boolean { - val downloadListWithoutAnyIssue = appInstallRepository.getDownloadList().filter { - !listOf( - Status.INSTALLATION_ISSUE, Status.PURCHASE_NEEDED - ).contains(it.status) - } + val downloadListWithoutAnyIssue = + appInstallComponents.appInstallRepository.getDownloadList().filter { + !listOf( + Status.INSTALLATION_ISSUE, Status.PURCHASE_NEEDED + ).contains(it.status) + } return UpdatesDao.successfulUpdatedApps.isNotEmpty() && downloadListWithoutAnyIssue.isEmpty() } @@ -317,14 +339,15 @@ class AppInstallProcessor @Inject constructor( private suspend fun startAppInstallationProcess(appInstall: AppInstall) { if (appInstall.isAwaiting()) { - appManagerWrapper.downloadApp(appInstall) + appInstallComponents.appManagerWrapper.downloadApp(appInstall) Timber.i("===> doWork: Download started ${appInstall.name} ${appInstall.status}") } - appInstallRepository.getDownloadFlowById(appInstall.id).transformWhile { - emit(it) - isInstallRunning(it) - }.collect { latestFusedDownload -> + appInstallComponents.appInstallRepository.getDownloadFlowById(appInstall.id) + .transformWhile { + emit(it) + isInstallRunning(it) + }.collect { latestFusedDownload -> handleFusedDownload(latestFusedDownload, appInstall) } } @@ -361,7 +384,7 @@ class AppInstallProcessor @Inject constructor( val message = "Handling install status is failed for ${download.packageName} exception: ${e.localizedMessage}" Timber.e(message, e) - appManagerWrapper.installationIssue(download) + appInstallComponents.appManagerWrapper.installationIssue(download) finishInstallation(download) } } @@ -372,7 +395,10 @@ class AppInstallProcessor @Inject constructor( } Status.DOWNLOADED -> { - appManagerWrapper.updateDownloadStatus(appInstall, Status.INSTALLING) + appInstallComponents.appManagerWrapper.updateDownloadStatus( + appInstall, + Status.INSTALLING + ) } Status.INSTALLING -> { 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 d7604a66af9ff571185d944a03d64443df3d1e12..d3976c568f6960c5117eeb665456e63c04e696b6 100644 --- a/app/src/main/java/foundation/e/apps/ui/MainActivityViewModel.kt +++ b/app/src/main/java/foundation/e/apps/ui/MainActivityViewModel.kt @@ -35,6 +35,7 @@ import foundation.e.apps.R import foundation.e.apps.data.application.ApplicationRepository import foundation.e.apps.data.application.data.Application import foundation.e.apps.data.blockedApps.BlockedAppRepository +import foundation.e.apps.data.blockedApps.ContentRatingsRepository import foundation.e.apps.data.ecloud.EcloudRepository import foundation.e.apps.data.enums.User import foundation.e.apps.data.enums.isInitialized @@ -59,6 +60,7 @@ class MainActivityViewModel @Inject constructor( private val pwaManager: PWAManager, private val ecloudRepository: EcloudRepository, private val blockedAppRepository: BlockedAppRepository, + private val contentRatingsRepository: ContentRatingsRepository, private val appInstallProcessor: AppInstallProcessor, ) : ViewModel() { @@ -229,6 +231,12 @@ class MainActivityViewModel @Inject constructor( } } + fun updateContentRatings() { + viewModelScope.launch { + contentRatingsRepository.fetchContentRatingData() + } + } + fun getAppNameByPackageName(packageName: String): String { return appLoungePackageManager.getAppNameFromPackageName(packageName) } diff --git a/app/src/main/java/foundation/e/apps/ui/application/ApplicationFragment.kt b/app/src/main/java/foundation/e/apps/ui/application/ApplicationFragment.kt index e2011f01b23d36412450353097e7941a4d6e53a0..bd95259361d53ad4eb9f6b0a479b7ccc0d424c91 100644 --- a/app/src/main/java/foundation/e/apps/ui/application/ApplicationFragment.kt +++ b/app/src/main/java/foundation/e/apps/ui/application/ApplicationFragment.kt @@ -170,7 +170,8 @@ class ApplicationFragment : TimeoutFragment(R.layout.fragment_application) { binding.applicationLayout.visibility = View.INVISIBLE - applicationViewModel.application.observe(viewLifecycleOwner) { resultPair -> + applicationViewModel.applicationLiveData.observe(viewLifecycleOwner) { resultPair -> + Timber.d("ApplicationLiveData: ${resultPair.first.contentRating}") updateUi(resultPair) } @@ -473,7 +474,7 @@ class ApplicationFragment : TimeoutFragment(R.layout.fragment_application) { } private fun openShareSheet() { - val application = applicationViewModel.application.value?.first ?: return + val application = applicationViewModel.applicationLiveData.value?.first ?: return val shareIntent = AppShareIntent.create(application.name, application.shareUri) startActivity(Intent.createChooser(shareIntent, null)) } @@ -935,7 +936,7 @@ class ApplicationFragment : TimeoutFragment(R.layout.fragment_application) { return EXODUS_URL } - val reportId = applicationViewModel.application.value!!.first.reportId + val reportId = applicationViewModel.applicationLiveData.value!!.first.reportId return "$EXODUS_REPORT_URL${Locale.getDefault().language}/reports/$reportId" } diff --git a/app/src/main/java/foundation/e/apps/ui/application/ApplicationViewModel.kt b/app/src/main/java/foundation/e/apps/ui/application/ApplicationViewModel.kt index 50869d8357fa9a20472af5c4d9ce4d2b4b168acd..22101b2c6a10eff0c42a9576276867344a6eeb94 100644 --- a/app/src/main/java/foundation/e/apps/ui/application/ApplicationViewModel.kt +++ b/app/src/main/java/foundation/e/apps/ui/application/ApplicationViewModel.kt @@ -57,7 +57,7 @@ class ApplicationViewModel @Inject constructor( private val appManagerWrapper: AppManagerWrapper, ) : LoadingViewModel() { - val application: MutableLiveData> = MutableLiveData() + val applicationLiveData: MutableLiveData> = MutableLiveData() val appStatus: MutableLiveData = MutableLiveData() val downloadProgress = downloadProgressLD private val _errorMessageLiveData: MutableLiveData = MutableLiveData() @@ -118,7 +118,7 @@ class ApplicationViewModel @Inject constructor( authData, origin ) - application.postValue(appData) + applicationLiveData.postValue(appData) updateShareVisibilityState(appData.first.shareUri.toString()) updateAppContentRatingState(packageName, appData.first.contentRating) @@ -155,10 +155,18 @@ class ApplicationViewModel @Inject constructor( // Initially update the state without ID to show the UI immediately _appContentRatingState.update { contentRating } - val ratingWithId = playStoreRepository.updateContentRatingWithId(packageName, contentRating) + val ratingWithId = playStoreRepository.getContentRatingWithId(packageName, contentRating) + // Later, update with a new rating; no visual change in the UI - _appContentRatingState.update { contentRating.copy(id = ratingWithId.id) } + val updatedContentRating = contentRating.copy(id = ratingWithId.id) + _appContentRatingState.update { updatedContentRating } + + applicationLiveData.value?.copy()?.let { + val application = it.first + application.contentRating = updatedContentRating + applicationLiveData.postValue(it) + } } private fun updateShareVisibilityState(shareUri: String) { @@ -177,7 +185,7 @@ class ApplicationViewModel @Inject constructor( if (this.first.package_name.isBlank()) { _errorMessageLiveData.postValue(R.string.app_not_found) } else { - application.postValue(this) + applicationLiveData.postValue(this) updateShareVisibilityState(first.shareUri.toString()) } } @@ -189,7 +197,7 @@ class ApplicationViewModel @Inject constructor( fun transformPermsToString(): String { var permissionString = "" - application.value?.first?.let { + applicationLiveData.value?.first?.let { // Filter list to only keep platform permissions val filteredList = it.perms.filter { it.startsWith("android.permission.") @@ -205,7 +213,7 @@ class ApplicationViewModel @Inject constructor( } fun getFusedApp(): Application? { - return application.value?.first + return applicationLiveData.value?.first } fun handleRatingFormat(rating: Double): String { @@ -214,13 +222,13 @@ class ApplicationViewModel @Inject constructor( suspend fun calculateProgress(progress: DownloadProgress): Pair { return appManagerWrapper.getCalculateProgressWithTotalSize( - application.value?.first, + applicationLiveData.value?.first, progress ) } fun updateApplicationStatus(downloadList: List) { - application.value?.first?.let { app -> + applicationLiveData.value?.first?.let { app -> appStatus.value = appManagerWrapper.getDownloadingItemStatus(app, downloadList) ?: applicationRepository.getFusedAppInstallationStatus(app) } diff --git a/app/src/main/java/foundation/e/apps/utils/eventBus/AppEvent.kt b/app/src/main/java/foundation/e/apps/utils/eventBus/AppEvent.kt index 30494abb82edf3c55f52cc2bc792313eee0ad1fd..e3db13f9fc914158d5b1cc669e95d6659440e477 100644 --- a/app/src/main/java/foundation/e/apps/utils/eventBus/AppEvent.kt +++ b/app/src/main/java/foundation/e/apps/utils/eventBus/AppEvent.kt @@ -30,7 +30,9 @@ sealed class AppEvent(val data: Any) { class InvalidAuthEvent(authName: String) : AppEvent(authName) class ErrorMessageEvent(stringResourceId: Int) : AppEvent(stringResourceId) + class ErrorMessageDialogEvent(stringResourceId: Int) : AppEvent(stringResourceId) class AppPurchaseEvent(appInstall: AppInstall) : AppEvent(appInstall) class NoInternetEvent(isInternetAvailable: Boolean) : AppEvent(isInternetAvailable) class TooManyRequests : AppEvent(Unit) + class AgeLimitRestrictionEvent(type: String) : AppEvent(type) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 344bb732c16df61f2ca0bfea3d3c7b5e7e40dcf6..d01d12f84f06766118ec092fdefe02f87b22c4c9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -124,6 +124,9 @@ Having troubles? https://doc.e.foundation/support-topics/app_lounge_troubleshooting Share + [%1$s] Restricted App + You are too young to be able to install %1$s. Please check with your parent your age group is correct or disable Parental Control to be able to install it. + Update All diff --git a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt index 6c1a4197488e7367b49a462bfc78071447797b23..480eeadda84da678d53a30e92c3f8adc0215ad4e 100644 --- a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt +++ b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt @@ -21,13 +21,18 @@ package foundation.e.apps.installProcessor import android.content.Context import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.aurora.gplayapi.data.models.AuthData +import com.aurora.gplayapi.data.models.ContentRating +import foundation.e.apps.data.ResultSupreme import foundation.e.apps.data.enums.Status import foundation.e.apps.data.fdroid.FdroidRepository import foundation.e.apps.data.application.ApplicationRepository +import foundation.e.apps.data.enums.ResultStatus import foundation.e.apps.data.install.AppInstallRepository import foundation.e.apps.data.install.AppManager import foundation.e.apps.data.install.models.AppInstall import foundation.e.apps.data.preference.DataStoreManager +import foundation.e.apps.domain.ValidateAppAgeLimitUseCase +import foundation.e.apps.install.AppInstallComponents import foundation.e.apps.install.notification.StorageNotificationManager import foundation.e.apps.install.workmanager.AppInstallProcessor import foundation.e.apps.util.MainCoroutineRule @@ -41,6 +46,7 @@ import org.junit.Test import org.mockito.Mock import org.mockito.Mockito import org.mockito.MockitoAnnotations +import kotlin.reflect.jvm.internal.ReflectProperties.Val @OptIn(ExperimentalCoroutinesApi::class) class AppInstallProcessorTest { @@ -75,6 +81,9 @@ class AppInstallProcessorTest { private lateinit var appInstallProcessor: AppInstallProcessor + @Mock + private lateinit var validateAppAgeRatingUseCase: ValidateAppAgeLimitUseCase + @Mock private lateinit var storageNotificationManager: StorageNotificationManager @@ -85,12 +94,14 @@ class AppInstallProcessorTest { appInstallRepository = AppInstallRepository(fakeFusedDownloadDAO) fakeFusedManagerRepository = FakeAppManagerWrapper(fakeFusedDownloadDAO, fakeFusedManager, fakeFdroidRepository) + val appInstallComponents = + AppInstallComponents(appInstallRepository, fakeFusedManagerRepository) appInstallProcessor = AppInstallProcessor( context, - appInstallRepository, - fakeFusedManagerRepository, + appInstallComponents, applicationRepository, + validateAppAgeRatingUseCase, dataStoreManager, storageNotificationManager ) @@ -155,7 +166,10 @@ class AppInstallProcessorTest { fakeFusedManagerRepository.forceCrash = true val finalFusedDownload = runProcessInstall(fusedDownload) - assertTrue("processInstall", finalFusedDownload == null || fusedDownload.status == Status.INSTALLATION_ISSUE) + assertTrue( + "processInstall", + finalFusedDownload == null || fusedDownload.status == Status.INSTALLATION_ISSUE + ) } @Test @@ -176,6 +190,26 @@ class AppInstallProcessorTest { assertEquals("processInstall", Status.INSTALLATION_ISSUE, finalFusedDownload?.status) } + @Test + fun `processInstallTest when age limit is satisfied`() = runTest { + val fusedDownload = initTest() + Mockito.`when`(validateAppAgeRatingUseCase.invoke(fusedDownload)) + .thenReturn(ResultSupreme.create(ResultStatus.OK, true)) + + val finalFusedDownload = runProcessInstall(fusedDownload) + assertEquals("processInstall", finalFusedDownload, null) + } + + @Test + fun `processInstallTest when age limit is not satisfied`() = runTest { + val fusedDownload = initTest() + Mockito.`when`(validateAppAgeRatingUseCase.invoke(fusedDownload)) + .thenReturn(ResultSupreme.create(ResultStatus.OK, false)) + + val finalFusedDownload = runProcessInstall(fusedDownload) + assertEquals("processInstall", finalFusedDownload, null) + } + private suspend fun runProcessInstall(appInstall: AppInstall): AppInstall? { appInstallProcessor.processInstall(appInstall.id, false) return fakeFusedDownloadDAO.getDownloadById(appInstall.id)