Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit cd76c857 authored by Abhishek Aggarwal's avatar Abhishek Aggarwal Committed by Abhishek Aggarwal
Browse files

fix(parental-control): normalize parental guidance ratings for validation

parent 327d294b
Loading
Loading
Loading
Loading
Loading
+33 −5
Original line number Diff line number Diff line
@@ -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
@@ -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"
@@ -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)
@@ -175,6 +179,7 @@ class ValidateAppAgeLimitUseCase @Inject constructor(
            } else {
                fetchedContentRating
            }
            app.normalizeContentRatingForValidation()
        }

        return app.contentRating.title.isNotEmpty() &&
@@ -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,
            )
        )
    }
}
+29 −7
Original line number Diff line number Diff line
@@ -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()
@@ -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
+43 −3
Original line number Diff line number Diff line
@@ -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 {

@@ -47,7 +50,8 @@ class ValidateAppAgeLimitUseCaseTest {
            parentalControlRepository,
            blockedAppRepository,
            appsApi,
            contentRatingDao
            contentRatingDao,
            ContentRatingPolicy(),
        )

        every { blockedAppRepository.isThirdPartyStoreApp(any()) } returns false
@@ -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
+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()
    }
}
+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()
    }
}