Loading app/src/main/java/foundation/e/apps/domain/ValidateAppAgeLimitUseCase.kt +33 −5 Original line number Diff line number Diff line Loading @@ -33,6 +33,7 @@ import foundation.e.apps.data.parentalcontrol.ParentalControlRepository.Companio import foundation.e.apps.data.parentalcontrol.fdroid.FDroidAntiFeatureRepository import foundation.e.apps.data.parentalcontrol.googleplay.GPlayContentRatingGroup import foundation.e.apps.data.parentalcontrol.googleplay.GPlayContentRatingRepository import foundation.e.apps.domain.contentrating.ContentRatingPolicy import foundation.e.apps.domain.model.ContentRatingValidity import org.e.parentalcontrol.data.model.TypeAppManagement import timber.log.Timber Loading @@ -45,6 +46,7 @@ class ValidateAppAgeLimitUseCase @Inject constructor( private val blockedAppRepository: BlockedAppRepository, private val appsApi: AppsApi, private val contentRatingDao: ContentRatingDao, private val contentRatingPolicy: ContentRatingPolicy, ) { companion object { const val KEY_ANTI_FEATURES_NSFW = "NSFW" Loading Loading @@ -156,6 +158,8 @@ class ValidateAppAgeLimitUseCase @Inject constructor( } private suspend fun verifyContentRatingExists(app: AppInstall): Boolean { app.normalizeContentRatingForValidation() if (app.contentRating.id.isEmpty()) { val fetchedContentRating = try { gPlayContentRatingRepository.getEnglishContentRating(app.packageName) Loading @@ -175,6 +179,7 @@ class ValidateAppAgeLimitUseCase @Inject constructor( } else { fetchedContentRating } app.normalizeContentRatingForValidation() } return app.contentRating.title.isNotEmpty() && Loading @@ -182,11 +187,34 @@ class ValidateAppAgeLimitUseCase @Inject constructor( } private suspend fun isParentalGuidance(app: AppInstall): Boolean { if (typeAppManagement == TypeAppManagement.CONTENT_RATING) { app.normalizeContentRatingForValidation() return if (typeAppManagement == TypeAppManagement.CONTENT_RATING) { when { app.contentRating.id.isNotEmpty() -> app.contentRating.id == KEY_PARENTAL_GUIDANCE else -> { val fetchedContentRating = gPlayContentRatingRepository.getEnglishContentRating(app.packageName) return fetchedContentRating?.id == KEY_PARENTAL_GUIDANCE fetchedContentRating ?.normalizedIdForValidation() ?.id == KEY_PARENTAL_GUIDANCE } return false } } else { false } } private fun AppInstall.normalizeContentRatingForValidation() { contentRating = contentRating.normalizedIdForValidation() } private fun ContentRating.normalizedIdForValidation(): ContentRating { return copy( id = contentRatingPolicy.normalizeIdForValidation( id = id, title = title, ) ) } } app/src/main/java/foundation/e/apps/ui/application/ApplicationViewModel.kt +29 −7 Original line number Diff line number Diff line Loading @@ -108,7 +108,7 @@ class ApplicationViewModel @Inject constructor( applicationLiveData.postValue(result) updateShareVisibilityState(app.shareUri.toString()) updateAppContentRatingState(packageName, app.contentRating, source) updateAppContentRatingState(app, result.second) } catch (e: InternalException.AppNotFound) { _errorMessageLiveData.postValue(R.string.app_not_found) scheduleAutoRedirect() Loading @@ -119,28 +119,50 @@ class ApplicationViewModel @Inject constructor( } private suspend fun updateAppContentRatingState( packageName: String, contentRating: ContentRating, source: Source, application: Application, resultStatus: ResultStatus, ) { val contentRating = application.contentRating // Initially update the state without ID to show the UI immediately _appContentRatingState.update { contentRating } if (source != Source.PLAY_STORE) { if (application.source != Source.PLAY_STORE) { return } runCatching { playStoreRepository.getContentRatingWithId(packageName, contentRating) playStoreRepository.getContentRatingWithId(application.package_name, contentRating) }.onSuccess { ratingWithId -> // Later, update with a new rating; no visual change in the UI val updatedContentRating = contentRating.copy(id = ratingWithId.id) republishApplicationWithContentRating( application = application, resultStatus = resultStatus, contentRating = updatedContentRating, ) _appContentRatingState.update { updatedContentRating } }.onFailure { throwable -> Timber.w(throwable, "Failed to enrich content rating for %s", packageName) Timber.w(throwable, "Failed to enrich content rating for %s", application.package_name) } } private fun republishApplicationWithContentRating( application: Application, resultStatus: ResultStatus, contentRating: ContentRating, ) { val currentResult = applicationLiveData.value if (currentResult != null && currentResult.first.package_name != application.package_name) { return } val applicationToUpdate = currentResult?.first ?: application val statusToUpdate = currentResult?.second ?: resultStatus applicationLiveData.postValue( applicationToUpdate.copy(contentRating = contentRating) to statusToUpdate ) } private fun updateShareVisibilityState(shareUri: String) { val isValidUri = shareUri.isNotBlank() _shareButtonVisibilityState.value = if (isValidUri) Visible else Hidden Loading app/src/test/java/foundation/e/apps/domain/ValidateAppAgeLimitUseCaseTest.kt +43 −3 Original line number Diff line number Diff line Loading @@ -18,15 +18,18 @@ import foundation.e.apps.data.parentalcontrol.ParentalControlRepository import foundation.e.apps.data.parentalcontrol.fdroid.FDroidAntiFeatureRepository import foundation.e.apps.data.parentalcontrol.googleplay.GPlayContentRatingGroup import foundation.e.apps.data.parentalcontrol.googleplay.GPlayContentRatingRepository import foundation.e.apps.domain.model.install.Status import foundation.e.apps.domain.ValidateAppAgeLimitUseCase.Companion.KEY_ANTI_FEATURES_NSFW import foundation.e.apps.domain.contentrating.ContentRatingPolicy import foundation.e.apps.domain.model.install.Status import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.test.runTest import org.e.parentalcontrol.data.model.TypeAppManagement import org.junit.Before import org.junit.Test import java.util.Locale class ValidateAppAgeLimitUseCaseTest { Loading @@ -47,7 +50,8 @@ class ValidateAppAgeLimitUseCaseTest { parentalControlRepository, blockedAppRepository, appsApi, contentRatingDao contentRatingDao, ContentRatingPolicy(), ) every { blockedAppRepository.isThirdPartyStoreApp(any()) } returns false Loading Loading @@ -118,11 +122,47 @@ class ValidateAppAgeLimitUseCaseTest { coEvery { gPlayContentRatingRepository.getEnglishContentRating(any()) } returns ContentRating(id = ParentalControlRepository.KEY_PARENTAL_GUIDANCE, title = "PG") val result = useCase(appInstall()) val result = useCase(appInstall(contentRating = ContentRating())) val data = requireNotNull((result as ResultSupreme.Success).data) assertThat(data.isValid).isFalse() assertThat(data.requestPin).isTrue() } @Test fun parentalGuidanceRequestsPinFromExistingEnglishTitle() = runTest { val defaultLocale = Locale.getDefault() Locale.setDefault(Locale.US) try { val result = useCase( appInstall(contentRating = ContentRating(title = "Parental Guidance")) ) val data = requireNotNull((result as ResultSupreme.Success).data) assertThat(data.isValid).isFalse() assertThat(data.requestPin).isTrue() coVerify(exactly = 0) { gPlayContentRatingRepository.getEnglishContentRating(any()) } } finally { Locale.setDefault(defaultLocale) } } @Test fun parentalGuidanceRequestsPinFromFetchedEnglishTitleOnNonEnglishDevice() = runTest { val defaultLocale = Locale.getDefault() Locale.setDefault(Locale.FRENCH) try { coEvery { gPlayContentRatingRepository.getEnglishContentRating(any()) } returns ContentRating(title = "Parental Guidance") val result = useCase(appInstall(contentRating = ContentRating())) val data = requireNotNull((result as ResultSupreme.Success).data) assertThat(data.isValid).isFalse() assertThat(data.requestPin).isTrue() } finally { Locale.setDefault(defaultLocale) } } @Test Loading domain/src/main/kotlin/foundation/e/apps/domain/contentrating/ContentRatingPolicy.kt 0 → 100644 +38 −0 Original line number Diff line number Diff line /* * 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 <https://www.gnu.org/licenses/>. * */ package foundation.e.apps.domain.contentrating import javax.inject.Inject class ContentRatingPolicy @Inject constructor() { private val knownEnglishTitleIds = mapOf( "Parental Guidance" to "parental guidance", ) fun normalizeIdForValidation( id: String, title: String, ): String { if (id.isNotBlank() || title.isBlank()) { return id } return knownEnglishTitleIds[title.trim()].orEmpty() } } domain/src/test/java/foundation/e/apps/domain/contentrating/ContentRatingPolicyTest.kt 0 → 100644 +66 −0 Original line number Diff line number Diff line /* * 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 <https://www.gnu.org/licenses/>. * */ package foundation.e.apps.domain.contentrating import com.google.common.truth.Truth.assertThat import org.junit.Test class ContentRatingPolicyTest { private val policy = ContentRatingPolicy() @Test fun normalizeIdForValidation_usesKnownEnglishTitleAsIdForEnglishLocale() { val result = policy.normalizeIdForValidation( id = "", title = "Parental Guidance", ) assertThat(result).isEqualTo("parental guidance") } @Test fun normalizeIdForValidation_keepsUnknownEnglishTitleEmpty() { val result = policy.normalizeIdForValidation( id = "", title = "Teen", ) assertThat(result).isEmpty() } @Test fun normalizeIdForValidation_keepsExistingId() { val result = policy.normalizeIdForValidation( id = "existing", title = "Teen", ) assertThat(result).isEqualTo("existing") } @Test fun normalizeIdForValidation_keepsBlankTitleEmpty() { val result = policy.normalizeIdForValidation( id = "", title = "", ) assertThat(result).isEmpty() } } Loading
app/src/main/java/foundation/e/apps/domain/ValidateAppAgeLimitUseCase.kt +33 −5 Original line number Diff line number Diff line Loading @@ -33,6 +33,7 @@ import foundation.e.apps.data.parentalcontrol.ParentalControlRepository.Companio import foundation.e.apps.data.parentalcontrol.fdroid.FDroidAntiFeatureRepository import foundation.e.apps.data.parentalcontrol.googleplay.GPlayContentRatingGroup import foundation.e.apps.data.parentalcontrol.googleplay.GPlayContentRatingRepository import foundation.e.apps.domain.contentrating.ContentRatingPolicy import foundation.e.apps.domain.model.ContentRatingValidity import org.e.parentalcontrol.data.model.TypeAppManagement import timber.log.Timber Loading @@ -45,6 +46,7 @@ class ValidateAppAgeLimitUseCase @Inject constructor( private val blockedAppRepository: BlockedAppRepository, private val appsApi: AppsApi, private val contentRatingDao: ContentRatingDao, private val contentRatingPolicy: ContentRatingPolicy, ) { companion object { const val KEY_ANTI_FEATURES_NSFW = "NSFW" Loading Loading @@ -156,6 +158,8 @@ class ValidateAppAgeLimitUseCase @Inject constructor( } private suspend fun verifyContentRatingExists(app: AppInstall): Boolean { app.normalizeContentRatingForValidation() if (app.contentRating.id.isEmpty()) { val fetchedContentRating = try { gPlayContentRatingRepository.getEnglishContentRating(app.packageName) Loading @@ -175,6 +179,7 @@ class ValidateAppAgeLimitUseCase @Inject constructor( } else { fetchedContentRating } app.normalizeContentRatingForValidation() } return app.contentRating.title.isNotEmpty() && Loading @@ -182,11 +187,34 @@ class ValidateAppAgeLimitUseCase @Inject constructor( } private suspend fun isParentalGuidance(app: AppInstall): Boolean { if (typeAppManagement == TypeAppManagement.CONTENT_RATING) { app.normalizeContentRatingForValidation() return if (typeAppManagement == TypeAppManagement.CONTENT_RATING) { when { app.contentRating.id.isNotEmpty() -> app.contentRating.id == KEY_PARENTAL_GUIDANCE else -> { val fetchedContentRating = gPlayContentRatingRepository.getEnglishContentRating(app.packageName) return fetchedContentRating?.id == KEY_PARENTAL_GUIDANCE fetchedContentRating ?.normalizedIdForValidation() ?.id == KEY_PARENTAL_GUIDANCE } return false } } else { false } } private fun AppInstall.normalizeContentRatingForValidation() { contentRating = contentRating.normalizedIdForValidation() } private fun ContentRating.normalizedIdForValidation(): ContentRating { return copy( id = contentRatingPolicy.normalizeIdForValidation( id = id, title = title, ) ) } }
app/src/main/java/foundation/e/apps/ui/application/ApplicationViewModel.kt +29 −7 Original line number Diff line number Diff line Loading @@ -108,7 +108,7 @@ class ApplicationViewModel @Inject constructor( applicationLiveData.postValue(result) updateShareVisibilityState(app.shareUri.toString()) updateAppContentRatingState(packageName, app.contentRating, source) updateAppContentRatingState(app, result.second) } catch (e: InternalException.AppNotFound) { _errorMessageLiveData.postValue(R.string.app_not_found) scheduleAutoRedirect() Loading @@ -119,28 +119,50 @@ class ApplicationViewModel @Inject constructor( } private suspend fun updateAppContentRatingState( packageName: String, contentRating: ContentRating, source: Source, application: Application, resultStatus: ResultStatus, ) { val contentRating = application.contentRating // Initially update the state without ID to show the UI immediately _appContentRatingState.update { contentRating } if (source != Source.PLAY_STORE) { if (application.source != Source.PLAY_STORE) { return } runCatching { playStoreRepository.getContentRatingWithId(packageName, contentRating) playStoreRepository.getContentRatingWithId(application.package_name, contentRating) }.onSuccess { ratingWithId -> // Later, update with a new rating; no visual change in the UI val updatedContentRating = contentRating.copy(id = ratingWithId.id) republishApplicationWithContentRating( application = application, resultStatus = resultStatus, contentRating = updatedContentRating, ) _appContentRatingState.update { updatedContentRating } }.onFailure { throwable -> Timber.w(throwable, "Failed to enrich content rating for %s", packageName) Timber.w(throwable, "Failed to enrich content rating for %s", application.package_name) } } private fun republishApplicationWithContentRating( application: Application, resultStatus: ResultStatus, contentRating: ContentRating, ) { val currentResult = applicationLiveData.value if (currentResult != null && currentResult.first.package_name != application.package_name) { return } val applicationToUpdate = currentResult?.first ?: application val statusToUpdate = currentResult?.second ?: resultStatus applicationLiveData.postValue( applicationToUpdate.copy(contentRating = contentRating) to statusToUpdate ) } private fun updateShareVisibilityState(shareUri: String) { val isValidUri = shareUri.isNotBlank() _shareButtonVisibilityState.value = if (isValidUri) Visible else Hidden Loading
app/src/test/java/foundation/e/apps/domain/ValidateAppAgeLimitUseCaseTest.kt +43 −3 Original line number Diff line number Diff line Loading @@ -18,15 +18,18 @@ import foundation.e.apps.data.parentalcontrol.ParentalControlRepository import foundation.e.apps.data.parentalcontrol.fdroid.FDroidAntiFeatureRepository import foundation.e.apps.data.parentalcontrol.googleplay.GPlayContentRatingGroup import foundation.e.apps.data.parentalcontrol.googleplay.GPlayContentRatingRepository import foundation.e.apps.domain.model.install.Status import foundation.e.apps.domain.ValidateAppAgeLimitUseCase.Companion.KEY_ANTI_FEATURES_NSFW import foundation.e.apps.domain.contentrating.ContentRatingPolicy import foundation.e.apps.domain.model.install.Status import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.test.runTest import org.e.parentalcontrol.data.model.TypeAppManagement import org.junit.Before import org.junit.Test import java.util.Locale class ValidateAppAgeLimitUseCaseTest { Loading @@ -47,7 +50,8 @@ class ValidateAppAgeLimitUseCaseTest { parentalControlRepository, blockedAppRepository, appsApi, contentRatingDao contentRatingDao, ContentRatingPolicy(), ) every { blockedAppRepository.isThirdPartyStoreApp(any()) } returns false Loading Loading @@ -118,11 +122,47 @@ class ValidateAppAgeLimitUseCaseTest { coEvery { gPlayContentRatingRepository.getEnglishContentRating(any()) } returns ContentRating(id = ParentalControlRepository.KEY_PARENTAL_GUIDANCE, title = "PG") val result = useCase(appInstall()) val result = useCase(appInstall(contentRating = ContentRating())) val data = requireNotNull((result as ResultSupreme.Success).data) assertThat(data.isValid).isFalse() assertThat(data.requestPin).isTrue() } @Test fun parentalGuidanceRequestsPinFromExistingEnglishTitle() = runTest { val defaultLocale = Locale.getDefault() Locale.setDefault(Locale.US) try { val result = useCase( appInstall(contentRating = ContentRating(title = "Parental Guidance")) ) val data = requireNotNull((result as ResultSupreme.Success).data) assertThat(data.isValid).isFalse() assertThat(data.requestPin).isTrue() coVerify(exactly = 0) { gPlayContentRatingRepository.getEnglishContentRating(any()) } } finally { Locale.setDefault(defaultLocale) } } @Test fun parentalGuidanceRequestsPinFromFetchedEnglishTitleOnNonEnglishDevice() = runTest { val defaultLocale = Locale.getDefault() Locale.setDefault(Locale.FRENCH) try { coEvery { gPlayContentRatingRepository.getEnglishContentRating(any()) } returns ContentRating(title = "Parental Guidance") val result = useCase(appInstall(contentRating = ContentRating())) val data = requireNotNull((result as ResultSupreme.Success).data) assertThat(data.isValid).isFalse() assertThat(data.requestPin).isTrue() } finally { Locale.setDefault(defaultLocale) } } @Test Loading
domain/src/main/kotlin/foundation/e/apps/domain/contentrating/ContentRatingPolicy.kt 0 → 100644 +38 −0 Original line number Diff line number Diff line /* * 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 <https://www.gnu.org/licenses/>. * */ package foundation.e.apps.domain.contentrating import javax.inject.Inject class ContentRatingPolicy @Inject constructor() { private val knownEnglishTitleIds = mapOf( "Parental Guidance" to "parental guidance", ) fun normalizeIdForValidation( id: String, title: String, ): String { if (id.isNotBlank() || title.isBlank()) { return id } return knownEnglishTitleIds[title.trim()].orEmpty() } }
domain/src/test/java/foundation/e/apps/domain/contentrating/ContentRatingPolicyTest.kt 0 → 100644 +66 −0 Original line number Diff line number Diff line /* * 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 <https://www.gnu.org/licenses/>. * */ package foundation.e.apps.domain.contentrating import com.google.common.truth.Truth.assertThat import org.junit.Test class ContentRatingPolicyTest { private val policy = ContentRatingPolicy() @Test fun normalizeIdForValidation_usesKnownEnglishTitleAsIdForEnglishLocale() { val result = policy.normalizeIdForValidation( id = "", title = "Parental Guidance", ) assertThat(result).isEqualTo("parental guidance") } @Test fun normalizeIdForValidation_keepsUnknownEnglishTitleEmpty() { val result = policy.normalizeIdForValidation( id = "", title = "Teen", ) assertThat(result).isEmpty() } @Test fun normalizeIdForValidation_keepsExistingId() { val result = policy.normalizeIdForValidation( id = "existing", title = "Teen", ) assertThat(result).isEqualTo("existing") } @Test fun normalizeIdForValidation_keepsBlankTitleEmpty() { val result = policy.normalizeIdForValidation( id = "", title = "", ) assertThat(result).isEmpty() } }