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

Commit 48e78a48 authored by Abhishek Aggarwal's avatar Abhishek Aggarwal
Browse files

Merge branch '0000-main-login_refactor' into 'main'

refactor login and auth

See merge request !780
parents a126b93b 5d1b0010
Loading
Loading
Loading
Loading
Loading
+65 −47
Original line number Diff line number Diff line

plugins {
    id 'com.android.application'
    id 'kotlin-android'
    id 'com.google.devtools.ksp'
    alias libs.plugins.ktlint
    alias libs.plugins.kotlin.serialization
    id 'androidx.navigation.safeargs.kotlin'
    id 'com.google.dagger.hilt.android'
    id 'kotlin-allopen'
    id 'kotlin-parcelize'
    alias libs.plugins.kotlin.plugin.parcelize
    id 'jacoco'
    alias libs.plugins.compose.compiler
}
@@ -55,15 +54,30 @@ def jacocoFileFilter = [
        '**/Manifest*.*',
        '**/*Test*.*',
        '**/ui/**',
        '**/feature/auth/dialog/**',
        '**/feature/auth/login/component/**',
        '**/feature/auth/login/*Fragment*.*',
        '**/feature/auth/login/*Screen*.*',
        '**/feature/auth/toc/component/**',
        '**/feature/auth/toc/*Fragment*.*',
        '**/feature/auth/toc/*Screen*.*',
        '**/*_ViewBinding*.*',
        '**/*Binding.class',
        '**/*BindingImpl.class',
        '**/*_Impl*.*',
        '**/*$DefaultImpls.class',
        '**/*$WhenMappings.class',
        '**/ComposableSingletons*.*',
        '**/Dagger*.*',
        '**/*MembersInjector*.*',
        '**/*_Factory*.*',
        '**/*Module_*Factory*.*',
        '**/*_Provide*Factory*.*',
        '**/*_AssistedFactory*.*',
        '**/*_GeneratedInjector*.*',
        '**/*Hilt*.*'
        '**/*Hilt*.*',
        '**/hilt_aggregated_deps/**',
        '**/dagger/hilt/internal/**'
]

def jacocoCoverageProjects = [
@@ -73,12 +87,19 @@ def jacocoCoverageProjects = [
]

def collectJacocoClassDirectories = { Project module, String variantName ->
    def variantCap = variantName.capitalize()
    return [
            module.fileTree("${module.buildDir}/intermediates/javac/${variantName}/classes") {
                exclude jacocoFileFilter
            },
            module.fileTree("${module.buildDir}/intermediates/javac/${variantName}/compile${variantCap}JavaWithJavac/classes") {
                exclude jacocoFileFilter
            },
            module.fileTree("${module.buildDir}/tmp/kotlin-classes/${variantName}") {
                exclude jacocoFileFilter
            },
            module.fileTree("${module.buildDir}/intermediates/built_in_kotlinc/${variantName}/compile${variantCap}Kotlin/classes") {
                exclude jacocoFileFilter
            }
    ]
}
@@ -105,13 +126,18 @@ tasks.withType(Test).configureEach {
    }
}

kotlin {
    compilerOptions {
        optIn.add("kotlin.RequiresOptIn")
    }
}

android {
    compileSdk = libs.versions.compileSdk.get().toInteger()

    defaultConfig {
        applicationId = "foundation.e.apps"
        minSdk = libs.versions.minSdk.get().toInteger()
        //noinspection OldTargetApi
        targetSdk = libs.versions.targetSdk.get().toInteger()
        versionCode = versionMajor * 1000000 + versionMinor * 1000 + versionPatch
        versionName = "${versionMajor}.${versionMinor}.${versionPatch}"
@@ -161,11 +187,6 @@ android {
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }

        applicationVariants.all { variant ->
            variant.outputs.all { output ->
                outputFileName = "AppLounge_${variant.buildType.name}.apk"
            }
        }
    }
    buildFeatures {
        buildConfig = true
@@ -177,25 +198,21 @@ android {
        sourceCompatibility = JavaVersion.toVersion(libs.versions.jvmTarget.get())
        targetCompatibility = JavaVersion.toVersion(libs.versions.jvmTarget.get())
    }
    kotlinOptions {
        jvmTarget = libs.versions.jvmTarget.get()
    }
    lint {
        disable.add('OldTargetApi')
        lintConfig = file('lint.xml')
    }
    namespace = 'foundation.e.apps'
    kotlin.sourceSets.configureEach {
        languageSettings.optIn("kotlin.RequiresOptIn")
}

androidComponents {
    onVariants(selector().all()) { variant ->
        variant.outputs.each { output ->
            output.outputFileName.set("AppLounge_${variant.buildType}.apk")
        }

android.applicationVariants.configureEach { variant ->
        def variantCap = variant.name.capitalize()
        def unitTestTaskName = "test${variantCap}UnitTest"
    def unitTestTask = tasks.findByName(unitTestTaskName)
    if (unitTestTask == null) {
        return
    }

        tasks.register("jacoco${variantCap}Report", JacocoReport) {
            dependsOn(jacocoCoverageProjects.collect { module ->
@@ -228,6 +245,7 @@ android.applicationVariants.configureEach { variant ->
            )
        }
    }
}

allOpen {
    // allows mocking for classes w/o directly opening them for release builds
+0 −4
Original line number Diff line number Diff line
@@ -8,7 +8,6 @@
    <ID>ChainWrapping:ValidateAppAgeLimitUseCase.kt$ValidateAppAgeLimitUseCase$&amp;&amp;</ID>
    <ID>InstanceOfCheckForException:GPlayHttpClient.kt$GPlayHttpClient$e is SocketTimeoutException</ID>
    <ID>InvalidPackageDeclaration:Trackers.kt$package foundation.e.apps.data.exodus</ID>
    <ID>LargeClass:ApplicationFragment.kt$ApplicationFragment : TimeoutFragment</ID>
    <ID>LongParameterList:ApplicationDialogFragment.kt$ApplicationDialogFragment$( title: String, message: String, @DrawableRes drawableResId: Int = -1, drawable: Drawable? = null, positiveButtonText: String = "", positiveButtonAction: (() -&gt; Unit)? = null, cancelButtonText: String = "", cancelButtonAction: (() -&gt; Unit)? = null, cancelable: Boolean = true, onDismissListener: (() -&gt; Unit)? = null, )</ID>
    <ID>LongParameterList:ApplicationListRVAdapter.kt$ApplicationListRVAdapter$( private val applicationInstaller: ApplicationInstaller, private val privacyInfoViewModel: PrivacyInfoViewModel, private val appInfoFetchViewModel: AppInfoFetchViewModel, private val mainActivityViewModel: MainActivityViewModel, private val currentDestinationId: Int, private var lifecycleOwner: LifecycleOwner?, private var paidAppHandler: ((Application) -&gt; Unit)? = null )</ID>
    <ID>LongParameterList:EglExtensionProvider.kt$EglExtensionProvider$( egl10: EGL10, eglDisplay: EGLDisplay, eglConfig: EGLConfig?, ai: IntArray, ai1: IntArray?, set: MutableSet&lt;String&gt; )</ID>
@@ -70,11 +69,8 @@
    <ID>TooGenericExceptionThrown:AnonymousLoginManager.kt$AnonymousLoginManager$throw Exception( "Error fetching Anonymous credentials\n" + "Network code: ${response.code}\n" + "Success: ${response.isSuccessful}" + response.errorString.run { if (isNotBlank()) "\nError message: $this" else "" } )</ID>
    <ID>TooManyFunctions:AppLoungePackageManager.kt$AppLoungePackageManager</ID>
    <ID>TooManyFunctions:AppManagerImpl.kt$AppManagerImpl : AppManager</ID>
    <ID>TooManyFunctions:ApplicationListFragment.kt$ApplicationListFragment : TimeoutFragmentApplicationInstaller</ID>
    <ID>TooManyFunctions:ApplicationRepository.kt$ApplicationRepository</ID>
    <ID>TooManyFunctions:MainActivityViewModel.kt$MainActivityViewModel : ViewModel</ID>
    <ID>TooManyFunctions:TimeoutFragment.kt$TimeoutFragment : Fragment</ID>
    <ID>TooManyFunctions:UpdatesFragment.kt$UpdatesFragment : TimeoutFragmentApplicationInstaller</ID>
    <ID>UnusedParameter:PrivacyInfoViewModel.kt$PrivacyInfoViewModel$forced: Boolean = false</ID>
    <ID>UseCheckOrError:AppsApiImpl.kt$AppsApiImpl$throw IllegalStateException("Could not get store")</ID>
    <ID>UseCheckOrError:CleanApkAppsRepository.kt$CleanApkAppsRepository$throw IllegalStateException("No home data found")</ID>
+92 −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.feature.auth.dialog

import androidx.activity.ComponentActivity
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import foundation.e.apps.R
import foundation.e.apps.ui.compose.theme.AppTheme
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class RefreshSessionDialogTest {
    @get:Rule
    val composeRule = createAndroidComposeRule<ComponentActivity>()

    @Test
    fun dialog_showsContentAndInvokesRefresh() {
        val recorder = RefreshSessionDialogRecorder()

        composeRule.setContent {
            RefreshSessionDialogTestContent(recorder = recorder)
        }

        composeRule.onNodeWithText(composeRule.activity.getString(R.string.account_unavailable))
            .assertIsDisplayed()
        composeRule.onNodeWithText(composeRule.activity.getString(R.string.too_many_requests_desc))
            .assertIsDisplayed()
        composeRule.onNodeWithText(composeRule.activity.getString(R.string.refresh_session))
            .performClick()

        composeRule.runOnIdle {
            assertEquals(1, recorder.refreshClicks)
        }
    }

    @Test
    fun dialog_invokesIgnore() {
        val recorder = RefreshSessionDialogRecorder()

        composeRule.setContent {
            RefreshSessionDialogTestContent(recorder = recorder)
        }

        composeRule.onNodeWithText(composeRule.activity.getString(R.string.ignore))
            .performClick()

        composeRule.runOnIdle {
            assertEquals(1, recorder.ignoreClicks)
        }
    }
}

private class RefreshSessionDialogRecorder {
    var refreshClicks = 0
    var ignoreClicks = 0
}

@Composable
private fun RefreshSessionDialogTestContent(
    recorder: RefreshSessionDialogRecorder = RefreshSessionDialogRecorder(),
) {
    AppTheme {
        RefreshSessionDialog(
            onRefresh = { recorder.refreshClicks += 1 },
            onIgnore = { recorder.ignoreClicks += 1 },
        )
    }
}
+149 −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.feature.auth.login

import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import foundation.e.apps.R
import foundation.e.apps.ui.compose.theme.AppTheme
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

private const val GoogleOAuthLoadingOverlayTag = "google_oauth_loading_overlay"

@RunWith(AndroidJUnit4::class)
class GoogleOAuthScreenTest {
    @get:Rule
    val composeRule = createAndroidComposeRule<ComponentActivity>()

    @Test
    fun backButton_invokesNavigateUp() {
        val recorder = GoogleOAuthRecorder()

        composeRule.setContent {
            GoogleOAuthScreenTestContent(recorder = recorder)
        }

        composeRule.onNodeWithContentDescription(
            composeRule.activity.getString(R.string.search_back_button),
        ).assertIsDisplayed()
            .performClick()

        composeRule.runOnIdle {
            assertEquals(1, recorder.navigateUpClicks)
        }
    }

    @Test
    fun customWebContent_isDisplayed() {
        composeRule.setContent {
            GoogleOAuthScreenTestContent()
        }

        composeRule.onNodeWithText("Fake Google sign-in web content")
            .assertIsDisplayed()
    }

    @Test
    fun loadingState_showsOverlay() {
        composeRule.setContent {
            GoogleOAuthScreenTestContent(isSubmitting = true)
        }

        composeRule.onNodeWithTag(GoogleOAuthLoadingOverlayTag)
            .assertIsDisplayed()
    }

    @Test
    fun idleState_hidesOverlay() {
        composeRule.setContent {
            GoogleOAuthScreenTestContent(isSubmitting = false)
        }

        composeRule.onNodeWithTag(GoogleOAuthLoadingOverlayTag)
            .assertDoesNotExist()
    }

    @Test
    fun errorState_showsDialogAndDismisses() {
        val recorder = GoogleOAuthRecorder()
        val message = "Unable to sign in right now."

        composeRule.setContent {
            GoogleOAuthScreenTestContent(
                errorMessage = message,
                recorder = recorder,
            )
        }

        composeRule.onNodeWithText(composeRule.activity.getString(R.string.sign_in_failed_title))
            .assertIsDisplayed()
        composeRule.onNodeWithText(message)
            .assertIsDisplayed()
        composeRule.onNodeWithText(composeRule.activity.getString(android.R.string.ok))
            .performClick()

        composeRule.runOnIdle {
            assertEquals(1, recorder.dismissErrorClicks)
        }
    }
}

private class GoogleOAuthRecorder {
    var dismissErrorClicks = 0
    var navigateUpClicks = 0
    val submissions = mutableListOf<Pair<String, String>>()
}

@Composable
private fun GoogleOAuthScreenTestContent(
    isSubmitting: Boolean = false,
    errorMessage: String? = null,
    recorder: GoogleOAuthRecorder = GoogleOAuthRecorder(),
) {
    AppTheme {
        GoogleOAuthScreen(
            isSubmitting = isSubmitting,
            errorMessage = errorMessage,
            retryVersion = 0,
            onDismissError = { recorder.dismissErrorClicks += 1 },
            onNavigateUp = { recorder.navigateUpClicks += 1 },
            onGoogleLoginSubmit = { email, token ->
                recorder.submissions += email to token
            },
            webContent = { modifier ->
                Box(modifier = modifier.fillMaxSize()) {
                    Text(text = "Fake Google sign-in web content")
                }
            },
        )
    }
}
+234 −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.feature.auth.login

import androidx.activity.ComponentActivity
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import foundation.e.apps.R
import foundation.e.apps.ui.compose.theme.AppTheme
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class LoginSelectionScreenTest {
    @get:Rule
    val composeRule = createAndroidComposeRule<ComponentActivity>()

    @Test
    fun defaultState_showsWelcomeActions() {
        composeRule.setContent {
            LoginSelectionScreenTestContent()
        }

        composeRule.onNodeWithText(composeRule.activity.getString(R.string.sign_in_title))
            .assertIsDisplayed()
        composeRule.onNodeWithText(composeRule.activity.getString(R.string.sign_in_google))
            .assertIsDisplayed()
        composeRule.onNodeWithText(composeRule.activity.getString(R.string.sign_in_anonymous))
            .assertIsDisplayed()
        composeRule.onNodeWithText(composeRule.activity.getString(R.string.pwa_and_open_source_apps))
            .assertIsDisplayed()
    }

    @Test
    fun primaryActions_invokeCallbacks() {
        val recorder = LoginSelectionRecorder()

        composeRule.setContent {
            LoginSelectionScreenTestContent(recorder = recorder)
        }

        composeRule.onNodeWithText(composeRule.activity.getString(R.string.sign_in_google))
            .performClick()
        composeRule.onNodeWithText(composeRule.activity.getString(R.string.sign_in_anonymous))
            .performClick()
        composeRule.onNodeWithText(composeRule.activity.getString(R.string.pwa_and_open_source_apps))
            .performClick()

        composeRule.runOnIdle {
            assertEquals(1, recorder.googleClicks)
            assertEquals(1, recorder.anonymousClicks)
            assertEquals(1, recorder.noGoogleClicks)
        }
    }

    @Test
    fun googleWarningDialog_displaysAndInvokesCallbacks() {
        val recorder = LoginSelectionRecorder()

        composeRule.setContent {
            LoginSelectionScreenTestContent(
                dialogState = LoginSelectionDialogState.GoogleWarning,
                recorder = recorder,
            )
        }

        composeRule.onNodeWithText(
            "dedicated Google account",
            substring = true,
        ).assertIsDisplayed()
        composeRule.onNodeWithText(composeRule.activity.getString(R.string.proceed_to_google_login))
            .performClick()
        composeRule.onNodeWithText(composeRule.activity.getString(R.string.cancel))
            .performClick()

        composeRule.runOnIdle {
            assertEquals(1, recorder.proceedClicks)
            assertEquals(1, recorder.dismissClicks)
        }
    }

    @Test
    fun googleChoiceDialog_displaysAndReturnsSelection() {
        val recorder = LoginSelectionRecorder()

        composeRule.setContent {
            LoginSelectionScreenTestContent(
                dialogState = LoginSelectionDialogState.GoogleChoice,
                recorder = recorder,
            )
        }

        composeRule.onNodeWithText(
            composeRule.activity.getString(R.string.sign_in_google_choice_title),
        ).assertIsDisplayed()
        composeRule.onNodeWithText(
            composeRule.activity.getString(R.string.sign_in_google_applounge_only),
        ).performClick()
        composeRule.onNodeWithText(
            composeRule.activity.getString(R.string.sign_in_google_system_wide),
        ).performClick()

        composeRule.runOnIdle {
            assertEquals(
                listOf(
                    GoogleLoginSelection.APP_LOUNGE_ONLY,
                    GoogleLoginSelection.SYSTEM_WIDE,
                ),
                recorder.googleSelections,
            )
        }
    }

    @Test
    fun loadingState_showsProgressIndicator() {
        composeRule.setContent {
            LoginSelectionScreenTestContent(
                uiState = LoginUiState(isSubmitting = true),
            )
        }

        composeRule.onNodeWithTag(LoginSelectionTestTags.LOADING_INDICATOR).assertExists()
    }

    @Test
    fun loadingState_disablesPrimaryActions() {
        composeRule.setContent {
            LoginSelectionScreenTestContent(
                uiState = LoginUiState(isSubmitting = true),
            )
        }

        composeRule.onNodeWithText(composeRule.activity.getString(R.string.sign_in_google))
            .assertIsNotEnabled()
        composeRule.onNodeWithText(composeRule.activity.getString(R.string.sign_in_anonymous))
            .assertIsNotEnabled()
        composeRule.onNodeWithText(composeRule.activity.getString(R.string.pwa_and_open_source_apps))
            .assertIsNotEnabled()
    }

    @Test
    fun errorMessage_showsDismissibleDialog() {
        val recorder = LoginSelectionRecorder()

        composeRule.setContent {
            LoginSelectionScreenTestContent(
                errorMessage = "Sign in failed",
                recorder = recorder,
            )
        }

        composeRule.onNodeWithText("Sign in failed").assertIsDisplayed()
        composeRule.onNodeWithText(composeRule.activity.getString(android.R.string.ok)).performClick()

        composeRule.runOnIdle {
            assertEquals(1, recorder.dismissErrorClicks)
        }
    }

    @Test
    fun errorMessage_takesPrecedenceOverSelectionDialog() {
        composeRule.setContent {
            LoginSelectionScreenTestContent(
                dialogState = LoginSelectionDialogState.GoogleWarning,
                errorMessage = "Sign in failed",
            )
        }

        composeRule.onNodeWithText("Sign in failed").assertIsDisplayed()
        composeRule.onNodeWithText(
            "dedicated Google account",
            substring = true,
        ).assertDoesNotExist()
    }
}

private class LoginSelectionRecorder {
    var googleClicks = 0
    var anonymousClicks = 0
    var noGoogleClicks = 0
    var dismissClicks = 0
    var dismissErrorClicks = 0
    var proceedClicks = 0
    val googleSelections = mutableListOf<GoogleLoginSelection>()
}

@Composable
private fun LoginSelectionScreenTestContent(
    uiState: LoginUiState = LoginUiState(),
    dialogState: LoginSelectionDialogState? = null,
    errorMessage: String? = null,
    recorder: LoginSelectionRecorder = LoginSelectionRecorder(),
) {
    AppTheme {
        LoginSelectionScreen(
            uiState = uiState,
            dialogState = dialogState,
            errorMessage = errorMessage,
            onGoogleClick = { recorder.googleClicks += 1 },
            onAnonymousClick = { recorder.anonymousClicks += 1 },
            onNoGoogleClick = { recorder.noGoogleClicks += 1 },
            onDismissDialog = { recorder.dismissClicks += 1 },
            onDismissError = { recorder.dismissErrorClicks += 1 },
            onProceedToGoogleLogin = { recorder.proceedClicks += 1 },
            onSelectGoogleLoginMode = { selection ->
                recorder.googleSelections += selection
            },
        )
    }
}
Loading