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

Verified Commit 1ca209e1 authored by Fahim M. Choudhury's avatar Fahim M. Choudhury
Browse files

refactor: extract AppInstallProcessor age validation flow into separate class

Move the age-limit dialog and parental-auth checks behind AppInstallAgeLimitGate so enqueue validation keeps the current order while the restriction flow gains direct unit coverage.
parent f8ed48d3
Loading
Loading
Loading
Loading
+70 −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.data.install.workmanager

import foundation.e.apps.R
import foundation.e.apps.data.event.AppEvent
import foundation.e.apps.data.install.AppManagerWrapper
import foundation.e.apps.data.install.models.AppInstall
import foundation.e.apps.data.install.wrapper.AppEventDispatcher
import foundation.e.apps.data.install.wrapper.ParentalControlAuthGateway
import foundation.e.apps.domain.ValidateAppAgeLimitUseCase
import foundation.e.apps.domain.model.ContentRatingValidity
import kotlinx.coroutines.CompletableDeferred
import javax.inject.Inject

@Suppress("ReturnCount") // FIXME: Remove suppression and fix detekt
class AppInstallAgeLimitGate @Inject constructor(
    private val validateAppAgeLimitUseCase: ValidateAppAgeLimitUseCase,
    private val appManagerWrapper: AppManagerWrapper,
    private val appEventDispatcher: AppEventDispatcher,
    private val parentalControlAuthGateway: ParentalControlAuthGateway,
) {
    suspend fun allow(appInstall: AppInstall): Boolean {
        val ageLimitValidationResult = validateAppAgeLimitUseCase(appInstall)
        if (ageLimitValidationResult.data?.isValid == true) {
            return true
        }

        if (ageLimitValidationResult.isSuccess()) {
            awaitInvokeAgeLimitEvent(appInstall.name)
            if (ageLimitValidationResult.data?.requestPin == true) {
                val isAuthenticated = parentalControlAuthGateway.awaitAuthentication()
                if (isAuthenticated) {
                    ageLimitValidationResult.setData(ContentRatingValidity(true))
                }
            }
        } else {
            appEventDispatcher.dispatch(AppEvent.ErrorMessageDialogEvent(R.string.data_load_error_desc))
        }

        if (ageLimitValidationResult.data?.isValid == true) {
            return true
        }

        appManagerWrapper.cancelDownload(appInstall)
        return false
    }

    private suspend fun awaitInvokeAgeLimitEvent(type: String) {
        val deferred = CompletableDeferred<Unit>()
        appEventDispatcher.dispatch(AppEvent.AgeLimitRestrictionEvent(type, deferred))
        deferred.await()
    }
}
+2 −38
Original line number Diff line number Diff line
@@ -38,15 +38,11 @@ import foundation.e.apps.data.install.models.AppInstall
import foundation.e.apps.data.install.notification.StorageNotificationManager
import foundation.e.apps.data.install.wrapper.AppEventDispatcher
import foundation.e.apps.data.install.wrapper.NetworkStatusChecker
import foundation.e.apps.data.install.wrapper.ParentalControlAuthGateway
import foundation.e.apps.data.install.wrapper.StorageSpaceChecker
import foundation.e.apps.data.playstore.utils.GplayHttpRequestException
import foundation.e.apps.data.preference.PlayStoreAuthStore
import foundation.e.apps.domain.ValidateAppAgeLimitUseCase
import foundation.e.apps.domain.model.ContentRatingValidity
import foundation.e.apps.domain.model.User
import foundation.e.apps.domain.preferences.SessionRepository
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.flow.transformWhile
import timber.log.Timber
@@ -57,14 +53,13 @@ class AppInstallProcessor @Inject constructor(
    @ApplicationContext private val context: Context,
    private val appInstallComponents: AppInstallComponents,
    private val applicationRepository: ApplicationRepository,
    private val validateAppAgeLimitUseCase: ValidateAppAgeLimitUseCase,
    private val sessionRepository: SessionRepository,
    private val playStoreAuthStore: PlayStoreAuthStore,
    private val storageNotificationManager: StorageNotificationManager,
    private val appEventDispatcher: AppEventDispatcher,
    private val storageSpaceChecker: StorageSpaceChecker,
    private val parentalControlAuthGateway: ParentalControlAuthGateway,
    private val networkStatusChecker: NetworkStatusChecker,
    private val appInstallAgeLimitGate: AppInstallAgeLimitGate,
    private val appUpdateCompletionHandler: AppUpdateCompletionHandler,
) {
    @Inject
@@ -166,7 +161,7 @@ class AppInstallProcessor @Inject constructor(
            return false
        }

        if (!validateAgeLimit(appInstall)) {
        if (!appInstallAgeLimitGate.allow(appInstall)) {
            return false
        }

@@ -187,37 +182,6 @@ class AppInstallProcessor @Inject constructor(
        return true
    }

    private suspend fun validateAgeLimit(appInstall: AppInstall): Boolean {
        val ageLimitValidationResult = validateAppAgeLimitUseCase(appInstall)
        if (ageLimitValidationResult.data?.isValid == true) {
            return true
        }
        if (ageLimitValidationResult.isSuccess()) {
            awaitInvokeAgeLimitEvent(appInstall.name)
            if (ageLimitValidationResult.data?.requestPin == true) {
                val isAuthenticated = parentalControlAuthGateway.awaitAuthentication()
                if (isAuthenticated) {
                    ageLimitValidationResult.setData(ContentRatingValidity(true))
                }
            }
        } else {
            appEventDispatcher.dispatch(AppEvent.ErrorMessageDialogEvent(R.string.data_load_error_desc))
        }

        var ageIsValid = true
        if (ageLimitValidationResult.data?.isValid != true) {
            appInstallComponents.appManagerWrapper.cancelDownload(appInstall)
            ageIsValid = false
        }
        return ageIsValid
    }

    suspend fun awaitInvokeAgeLimitEvent(type: String) {
        val deferred = CompletableDeferred<Unit>()
        appEventDispatcher.dispatch(AppEvent.AgeLimitRestrictionEvent(type, deferred))
        deferred.await() // await closing dialog box
    }

    // returns TRUE if updating urls is successful, otherwise false.
    private suspend fun updateDownloadUrls(appInstall: AppInstall): Boolean {
        try {
+140 −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.installProcessor

import foundation.e.apps.R
import foundation.e.apps.data.ResultSupreme
import foundation.e.apps.data.enums.ResultStatus
import foundation.e.apps.data.event.AppEvent
import foundation.e.apps.data.install.AppManagerWrapper
import foundation.e.apps.data.install.models.AppInstall
import foundation.e.apps.data.install.workmanager.AppInstallAgeLimitGate
import foundation.e.apps.data.install.wrapper.ParentalControlAuthGateway
import foundation.e.apps.domain.ValidateAppAgeLimitUseCase
import foundation.e.apps.domain.model.ContentRatingValidity
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.mockito.Mockito

@OptIn(ExperimentalCoroutinesApi::class)
class AppInstallAgeLimitGateTest {
    private lateinit var validateAppAgeLimitUseCase: ValidateAppAgeLimitUseCase
    private lateinit var appManagerWrapper: AppManagerWrapper
    private lateinit var parentalControlAuthGateway: ParentalControlAuthGateway
    private lateinit var appEventDispatcher: FakeAppEventDispatcher
    private lateinit var gate: AppInstallAgeLimitGate

    @Before
    fun setup() {
        validateAppAgeLimitUseCase = Mockito.mock(ValidateAppAgeLimitUseCase::class.java)
        appManagerWrapper = mockk(relaxed = true)
        parentalControlAuthGateway = mockk(relaxed = true)
        appEventDispatcher = FakeAppEventDispatcher(autoCompleteDeferred = true)
        gate = AppInstallAgeLimitGate(
            validateAppAgeLimitUseCase,
            appManagerWrapper,
            appEventDispatcher,
            parentalControlAuthGateway
        )
    }

    @Test
    fun allow_returnsTrueWhenAgeRatingIsValid() = runTest {
        val appInstall = AppInstall(id = "123", packageName = "com.example.app")
        Mockito.`when`(validateAppAgeLimitUseCase(appInstall))
            .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true)))

        val result = gate.allow(appInstall)

        assertTrue(result)
        coVerify(exactly = 0) { appManagerWrapper.cancelDownload(any()) }
    }

    @Test
    fun allow_returnsFalseAndCancelsWhenAgeRatingInvalidWithoutPin() = runTest {
        val appInstall = AppInstall(id = "123", name = "App", packageName = "com.example.app")
        Mockito.`when`(validateAppAgeLimitUseCase(appInstall))
            .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(false)))

        val result = gate.allow(appInstall)

        assertFalse(result)
        assertTrue(appEventDispatcher.events.any { it is AppEvent.AgeLimitRestrictionEvent })
        coVerify { appManagerWrapper.cancelDownload(appInstall) }
    }

    @Test
    fun allow_returnsTrueWhenPinIsRequiredAndAuthenticationSucceeds() = runTest {
        val appInstall = AppInstall(id = "123", name = "App", packageName = "com.example.app")
        Mockito.`when`(validateAppAgeLimitUseCase(appInstall))
            .thenReturn(
                ResultSupreme.create(
                    ResultStatus.OK,
                    ContentRatingValidity(false, requestPin = true)
                )
            )
        coEvery { parentalControlAuthGateway.awaitAuthentication() } returns true

        val result = gate.allow(appInstall)

        assertTrue(result)
        assertTrue(appEventDispatcher.events.any { it is AppEvent.AgeLimitRestrictionEvent })
        coVerify(exactly = 0) { appManagerWrapper.cancelDownload(any()) }
    }

    @Test
    fun allow_returnsFalseWhenPinIsRequiredAndAuthenticationFails() = runTest {
        val appInstall = AppInstall(id = "123", name = "App", packageName = "com.example.app")
        Mockito.`when`(validateAppAgeLimitUseCase(appInstall))
            .thenReturn(
                ResultSupreme.create(
                    ResultStatus.OK,
                    ContentRatingValidity(false, requestPin = true)
                )
            )
        coEvery { parentalControlAuthGateway.awaitAuthentication() } returns false

        val result = gate.allow(appInstall)

        assertFalse(result)
        coVerify { appManagerWrapper.cancelDownload(appInstall) }
    }

    @Test
    fun allow_dispatchesErrorDialogWhenValidationFails() = runTest {
        val appInstall = AppInstall(id = "123", packageName = "com.example.app")
        Mockito.`when`(validateAppAgeLimitUseCase(appInstall))
            .thenReturn(ResultSupreme.create(ResultStatus.UNKNOWN, ContentRatingValidity(false)))

        val result = gate.allow(appInstall)

        assertFalse(result)
        val errorDialogEvent = appEventDispatcher.events.last() as AppEvent.ErrorMessageDialogEvent
        assertEquals(R.string.data_load_error_desc, errorDialogEvent.data)
        coVerify { appManagerWrapper.cancelDownload(appInstall) }
    }
}
+19 −16
Original line number Diff line number Diff line
@@ -35,7 +35,8 @@ import foundation.e.apps.data.install.AppManager
import foundation.e.apps.data.install.AppManagerWrapper
import foundation.e.apps.data.install.models.AppInstall
import foundation.e.apps.data.install.notification.StorageNotificationManager
import foundation.e.apps.data.install.wrapper.AppEventDispatcher
import foundation.e.apps.data.install.workmanager.AppInstallAgeLimitGate
import foundation.e.apps.data.install.workmanager.AppInstallProcessor
import foundation.e.apps.data.install.workmanager.AppUpdateCompletionHandler
import foundation.e.apps.data.install.workmanager.InstallWorkManager
import foundation.e.apps.data.install.wrapper.NetworkStatusChecker
@@ -43,7 +44,6 @@ import foundation.e.apps.data.install.wrapper.ParentalControlAuthGateway
import foundation.e.apps.data.install.wrapper.StorageSpaceChecker
import foundation.e.apps.data.install.wrapper.UpdatesNotificationSender
import foundation.e.apps.data.install.wrapper.UpdatesTracker
import foundation.e.apps.data.install.workmanager.AppInstallProcessor
import foundation.e.apps.data.playstore.utils.GplayHttpRequestException
import foundation.e.apps.data.preference.PlayStoreAuthStore
import foundation.e.apps.domain.ValidateAppAgeLimitUseCase
@@ -109,12 +109,13 @@ class AppInstallProcessorTest {
    @Mock
    private lateinit var storageNotificationManager: StorageNotificationManager

    private lateinit var appEventDispatcher: RecordingAppEventDispatcher
    private lateinit var appEventDispatcher: FakeAppEventDispatcher
    private lateinit var storageSpaceChecker: StorageSpaceChecker
    private lateinit var parentalControlAuthGateway: ParentalControlAuthGateway
    private lateinit var updatesTracker: UpdatesTracker
    private lateinit var updatesNotificationSender: UpdatesNotificationSender
    private lateinit var networkStatusChecker: NetworkStatusChecker
    private lateinit var appInstallAgeLimitGate: AppInstallAgeLimitGate
    private lateinit var appUpdateCompletionHandler: AppUpdateCompletionHandler

    @Before
@@ -126,7 +127,7 @@ class AppInstallProcessorTest {
        applicationRepository = mockk(relaxed = true)
        coEvery { sessionRepository.awaitUser() } returns User.NO_GOOGLE
        coEvery { playStoreAuthStore.awaitAuthData() } returns null
        appEventDispatcher = RecordingAppEventDispatcher()
        appEventDispatcher = FakeAppEventDispatcher()
        storageSpaceChecker = mockk(relaxed = true)
        parentalControlAuthGateway = mockk(relaxed = true)
        updatesTracker = mockk(relaxed = true)
@@ -138,6 +139,12 @@ class AppInstallProcessorTest {
            FakeAppManagerWrapper(fakeFusedDownloadDAO, context, fakeFusedManager, fakeFDroidRepository)
        val appInstallComponents =
            AppInstallComponents(appInstallRepository, fakeFusedManagerRepository)
        appInstallAgeLimitGate = AppInstallAgeLimitGate(
            validateAppAgeRatingUseCase,
            fakeFusedManagerRepository,
            appEventDispatcher,
            parentalControlAuthGateway
        )
        appUpdateCompletionHandler = AppUpdateCompletionHandler(
            context,
            appInstallRepository,
@@ -151,14 +158,13 @@ class AppInstallProcessorTest {
            context,
            appInstallComponents,
            applicationRepository,
            validateAppAgeRatingUseCase,
            sessionRepository,
            playStoreAuthStore,
            storageNotificationManager,
            appEventDispatcher,
            storageSpaceChecker,
            parentalControlAuthGateway,
            networkStatusChecker,
            appInstallAgeLimitGate,
            appUpdateCompletionHandler
        )
    }
@@ -627,27 +633,24 @@ class AppInstallProcessorTest {
    ): AppInstallProcessor {
        val appInstallRepository = AppInstallRepository(FakeAppInstallDAO())
        val appInstallComponents = AppInstallComponents(appInstallRepository, appManagerWrapper)
        val ageLimitGate = AppInstallAgeLimitGate(
            validateAppAgeRatingUseCase,
            appManagerWrapper,
            appEventDispatcher,
            parentalControlAuthGateway
        )
        return AppInstallProcessor(
            context,
            appInstallComponents,
            applicationRepository,
            validateAppAgeRatingUseCase,
            sessionRepository,
            playStoreAuthStore,
            storageNotificationManager,
            appEventDispatcher,
            storageSpaceChecker,
            parentalControlAuthGateway,
            networkStatusChecker,
            ageLimitGate,
            appUpdateCompletionHandler
        )
    }
}

private class RecordingAppEventDispatcher : AppEventDispatcher {
    val events = mutableListOf<AppEvent>()

    override suspend fun dispatch(event: AppEvent) {
        events.add(event)
    }
}
+35 −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.installProcessor

import foundation.e.apps.data.event.AppEvent
import foundation.e.apps.data.install.wrapper.AppEventDispatcher

internal class FakeAppEventDispatcher(
    private val autoCompleteDeferred: Boolean = false
) : AppEventDispatcher {
    val events = mutableListOf<AppEvent>()

    override suspend fun dispatch(event: AppEvent) {
        events.add(event)
        if (autoCompleteDeferred && event is AppEvent.AgeLimitRestrictionEvent) {
            event.onClose?.complete(Unit)
        }
    }
}