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))
+ }
+}