diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000000..a6d5269d33 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,46 @@ +name: Android Lint + +# pull_request only, matching e2e_test.yml. android.yml's [pull_request, push] +# is why every commit there produces two identical `build` runs. +on: + - pull_request + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Cache Gradle packages + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + + - name: Set up JDK 21 + uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + with: + java-version: '21' + distribution: 'temurin' + + # lintAll gates :proguard-tests, which applies the google-services plugin and + # will not configure without this file. Mirrors what scripts/build.sh copies. + - name: Copy google-services.json + run: | + cp library/google-services.json app/google-services.json + cp library/google-services.json proguard-tests/google-services.json + + - name: Android Lint + run: ./gradlew --max-workers=2 lintAll + + - name: Print Logs + if: failure() + run: ./scripts/print_build_logs.sh diff --git a/auth/build.gradle.kts b/auth/build.gradle.kts index b4a700553d..76d2d5ab92 100644 --- a/auth/build.gradle.kts +++ b/auth/build.gradle.kts @@ -53,14 +53,24 @@ android { "DuplicateStrings", "LocaleFolder", "IconLocation", - "VectorPath" + "VectorPath", + "RtlEnabled", // A library cannot decide this; the consuming app declares it + // Satisfied by any enclosing if(), so it flags 5 of this module's 23 Log.d calls + // and misses the rest. Guarding those 5 with Log.isLoggable does not protect them, + // it silences them: the default per-tag level is INFO. Two of the five are wanted + // in field reports (PhoneAuthScreen.kt "Logged, not silent") and carry no user + // data; the other three log an email, a display name and a verificationId, which + // needs redaction rather than a guard — CPRN-440, which also owns re-enabling this. + "LogConditional" ) checkAllWarnings = true warningsAsErrors = true abortOnError = true - baseline = file("$rootDir/library/quality/lint-baseline.xml") + // Pre-existing debt only: 168 localization findings (CPRN-432). Every entry is + // suppressed; new ones still fail. + baseline = file("lint-baseline.xml") } testOptions { diff --git a/auth/lint-baseline.xml b/auth/lint-baseline.xml new file mode 100644 index 0000000000..b9c0976fa1 --- /dev/null +++ b/auth/lint-baseline.xml @@ -0,0 +1,2132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/auth/src/main/AndroidManifest.xml b/auth/src/main/AndroidManifest.xml index 29f9d060e5..45a4e5a625 100644 --- a/auth/src/main/AndroidManifest.xml +++ b/auth/src/main/AndroidManifest.xml @@ -34,10 +34,15 @@ android:name="com.facebook.sdk.ClientToken" android:value="@string/facebook_client_token"/> + + android:exported="false" + tools:ignore="RedundantLabel" /> provider.validate(context) is AuthProvider.Facebook -> provider.validate(context) is AuthProvider.GenericOAuth -> provider.validate() - else -> null + else -> {} } } diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/components/AuthTextField.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/AuthTextField.kt index f41f6cae1d..d5c12223b5 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/components/AuthTextField.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/AuthTextField.kt @@ -42,6 +42,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.input.PasswordVisualTransformation @@ -113,7 +114,10 @@ fun AuthTextField( visibilityToggleModifier: Modifier = Modifier, ) { var passwordVisible by remember { mutableStateOf(false) } - val localContext = LocalContext.current + // semantics {} is not a composable scope, so the description is resolved out here — and + // only when it is wanted, since this recomposes on every keystroke. + val readOnlyStateDescription = + if (readOnly) stringResource(R.string.fui_text_field_read_only) else "" // Automatically set the correct keyboard type based on validator or field type val resolvedKeyboardOptions = remember(validator, isSecureTextField, keyboardOptions) { @@ -138,7 +142,7 @@ fun AuthTextField( .then( if (readOnly) { Modifier.semantics { - stateDescription = localContext.getString(R.string.fui_text_field_read_only) + stateDescription = readOnlyStateDescription } } else { Modifier diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/components/QrCodeImage.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/QrCodeImage.kt index 754aa5cc78..22b358a3b2 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/components/QrCodeImage.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/QrCodeImage.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.core.graphics.createBitmap import com.google.zxing.BarcodeFormat import com.google.zxing.EncodeHintType import com.google.zxing.WriterException @@ -97,7 +98,7 @@ private fun generateQrCodeBitmap( hints ) - val bitmap = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888) + val bitmap = createBitmap(sizePx, sizePx) val foregroundArgb = android.graphics.Color.argb( (foregroundColor.alpha * 255).toInt(), @@ -113,15 +114,15 @@ private fun generateQrCodeBitmap( (backgroundColor.blue * 255).toInt() ) - for (x in 0 until sizePx) { - for (y in 0 until sizePx) { - bitmap.setPixel( - x, - y, - if (bitMatrix[x, y]) foregroundArgb else backgroundArgb - ) + // One bulk copy rather than sizePx^2 setPixel calls: at the default 250.dp rendered + // at 2x that is 250,000 JNI crossings on the composition thread. + val pixels = IntArray(sizePx * sizePx) + for (y in 0 until sizePx) { + for (x in 0 until sizePx) { + pixels[y * sizePx + x] = if (bitMatrix[x, y]) foregroundArgb else backgroundArgb } } + bitmap.setPixels(pixels, 0, sizePx, 0, 0, sizePx, sizePx) bitmap } catch (e: WriterException) { diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPicker.kt b/auth/src/main/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPicker.kt index cf0328c2a0..e5a17170b8 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPicker.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPicker.kt @@ -84,7 +84,6 @@ class MethodPickerTermsConfiguration( * @param providers The list of providers to display. * @param logo An optional logo to display. * @param onProviderSelected A callback when a provider is selected. - * @param customLayout An optional custom layout composable for the provider buttons. * @param termsOfServiceUrl The URL for the Terms of Service. * @param privacyPolicyUrl The URL for the Privacy Policy. * @param lastSignInPreference The last sign-in preference to show a "Continue as..." button. @@ -93,6 +92,7 @@ class MethodPickerTermsConfiguration( * @param onContinueAsSelected A callback when the "Continue as..." button is selected, with the * provider and saved identifier (email address). Falls back to [onProviderSelected] * if not provided. + * @param customLayout An optional custom layout composable for the provider buttons. * * @since 10.0.0 */ @@ -105,9 +105,9 @@ fun AuthMethodPicker( termsOfServiceUrl: String? = null, privacyPolicyUrl: String? = null, lastSignInPreference: SignInPreferenceManager.SignInPreference? = null, - customLayout: (@Composable (List, (AuthProvider) -> Unit) -> Unit)? = null, termsConfiguration: MethodPickerTermsConfiguration? = null, onContinueAsSelected: ((AuthProvider, String?) -> Unit)? = null, + customLayout: (@Composable (List, (AuthProvider) -> Unit) -> Unit)? = null, ) { val continueAsHandler: (AuthProvider, String?) -> Unit = onContinueAsSelected ?: { provider, _ -> onProviderSelected(provider) } diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt index 6a1968ce62..c986fcd4c5 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt @@ -55,6 +55,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.navigation3.runtime.entryProvider @@ -182,6 +183,14 @@ fun FirebaseAuthScreen( val coroutineScope = rememberCoroutineScope() val stringProvider = remember(context) { DefaultAuthUIStringProvider(context) } + // The reauth effects below run outside composition, so they cannot call stringResource + // themselves. + val reauthInterruptedMessage = stringResource(R.string.fui_error_reauth_interrupted) + val reauthNoLinkedProvidersMessage = + stringResource(R.string.fui_error_reauth_no_linked_providers) + val reauthIncompleteMessage = stringResource(R.string.fui_error_reauth_incomplete) + val reauthRetryingMessage = stringResource(R.string.fui_loading_reauth_retrying) + val observedAuthState by remember(authUI) { authUI.authStateFlow() } .collectAsState(initial = null as AuthState?) val rawAuthState = observedAuthState ?: AuthState.Idle @@ -764,7 +773,7 @@ fun FirebaseAuthScreen( authUI.updateAuthState( AuthState.Error( AuthException.UnknownException( - context.getString(R.string.fui_error_reauth_interrupted) + reauthInterruptedMessage ) ) ) @@ -781,7 +790,7 @@ fun FirebaseAuthScreen( required, AuthState.Error( AuthException.UnknownException( - context.getString(R.string.fui_error_reauth_no_linked_providers) + reauthNoLinkedProvidersMessage ) ), ) @@ -793,7 +802,7 @@ fun FirebaseAuthScreen( required, AuthState.Error( AuthException.UnknownException( - context.getString(R.string.fui_error_reauth_interrupted) + reauthInterruptedMessage ) ), ) @@ -839,7 +848,7 @@ fun FirebaseAuthScreen( AuthState.Reauthentication.AttemptFailed( request, AuthException.UnknownException( - context.getString(R.string.fui_error_reauth_incomplete) + reauthIncompleteMessage ), ) } @@ -848,7 +857,7 @@ fun FirebaseAuthScreen( // A Success here would claim the pending operation had already succeeded. val terminal = if (request.hasPendingOperation) { AuthState.Loading( - context.getString(R.string.fui_loading_reauth_retrying) + reauthRetryingMessage ) } else { AuthState.Success(result = null, user = request.user) diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthDestinations.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthDestinations.kt index d7c447461e..ebe5560258 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthDestinations.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthDestinations.kt @@ -124,7 +124,6 @@ internal fun EmailAuthStep( context: Context, configuration: AuthUIConfiguration, authUI: FirebaseAuthUI, - content: (@Composable (EmailAuthContentState) -> Unit)?, navigateToStep: (AuthRoute.Email.Step) -> Unit, isStepBelow: (NavKey?) -> Boolean, onCancel: () -> Unit, @@ -136,6 +135,7 @@ internal fun EmailAuthStep( onError: (AuthException) -> Unit = {}, /** Passed through to [EmailAuthScreen]: where a consumed notification leaves the flow. */ onNotificationConsumed: (() -> Unit)? = null, + content: (@Composable (EmailAuthContentState) -> Unit)? = null, ) { if (!configuration.isEmailStepOffered(step)) { LaunchedEffect(entryKey) { diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInUI.kt index c4413fded7..2f51eaf351 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInUI.kt @@ -49,6 +49,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign @@ -242,7 +243,7 @@ fun SignInUI( modifier = Modifier .align(Alignment.Start) .testTag(FirebaseAuthTestTags.SignIn.REAUTH_PASSWORD_NOTICE), - text = context.getString(R.string.fui_reauth_password_required_notice), + text = stringResource(R.string.fui_reauth_password_required_notice), style = MaterialTheme.typography.bodySmall, ) Spacer(modifier = Modifier.height(8.dp)) diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentDefaults.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentDefaults.kt index c162fafce7..a892fbcd12 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentDefaults.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentDefaults.kt @@ -46,6 +46,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLocale import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign @@ -361,6 +362,17 @@ private fun EnrolledFactorItem( enabled: Boolean, stringProvider: AuthUIStringProvider ) { + // LocalLocale is the Activity's configured locale. Locale.getDefault() and + // intl.Locale.current both read the process global, which disagrees with it when the host + // installs a per-context locale override — rendering the date in a different language from + // every stringResource around it. + val locale = LocalLocale.current + val enrollmentDateFormat = remember(locale) { + // getDateInstance, not a fixed pattern: "MMM dd, yyyy" puts the month first in every + // language, which is wrong in most of them. + java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM, locale.platformLocale) + } + Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors( @@ -394,10 +406,9 @@ private fun EnrolledFactorItem( ) Text( text = stringProvider.enrolledOnDateLabel( - java.text.SimpleDateFormat( - "MMM dd, yyyy", - java.util.Locale.getDefault() - ).format(java.util.Date(factorInfo.enrollmentTimestamp * 1000)) + enrollmentDateFormat.format( + java.util.Date(factorInfo.enrollmentTimestamp * 1000) + ) ), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentScreen.kt index 8d720a9725..f3322f9cea 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentScreen.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/mfa/MfaEnrollmentScreen.kt @@ -33,6 +33,7 @@ import com.firebase.ui.auth.mfa.TotpEnrollmentHandler import com.firebase.ui.auth.util.CountryUtils import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.MultiFactorInfo import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -156,7 +157,11 @@ internal fun MfaEnrollmentScreenInternal( val isLoading = remember { mutableStateOf(false) } val error = remember { mutableStateOf(null) } val lastException = remember { mutableStateOf(null) } - val enrolledFactors = remember { mutableStateOf(user.multiFactor.enrolledFactors) } + // Snapshot the SDK's list: it hands back a mutable one, and holding that instance would + // both trip MutableCollectionMutableState and skip recomposition if it mutated in place. + val enrolledFactors = remember { + mutableStateOf>(user.multiFactor.enrolledFactors.toList()) + } // The SMS steps read only the terms and privacy URLs off this, so a host that supplied no // configuration gets a stand-in. Its provider is arbitrary — a configuration must declare at @@ -269,7 +274,7 @@ internal fun MfaEnrollmentScreenInternal( try { user.multiFactor.unenroll(factorInfo).addOnCompleteListener { task -> if (task.isSuccessful) { - enrolledFactors.value = user.multiFactor.enrolledFactors + enrolledFactors.value = user.multiFactor.enrolledFactors.toList() error.value = null } else { error.value = task.exception?.message @@ -361,7 +366,7 @@ internal fun MfaEnrollmentScreenInternal( null -> throw IllegalStateException("No factor selected") } - enrolledFactors.value = user.multiFactor.enrolledFactors + enrolledFactors.value = user.multiFactor.enrolledFactors.toList() onComplete() error.value = null diff --git a/auth/src/main/res/drawable/fui_ic_facebook_white_22dp.xml b/auth/src/main/res/drawable/fui_ic_facebook_white_22dp.xml index 85afe860da..8b1102de03 100644 --- a/auth/src/main/res/drawable/fui_ic_facebook_white_22dp.xml +++ b/auth/src/main/res/drawable/fui_ic_facebook_white_22dp.xml @@ -1,6 +1,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/auth/src/main/res/layout/fui_phone_layout.xml b/auth/src/main/res/layout/fui_phone_layout.xml deleted file mode 100644 index 3ed6d1be61..0000000000 --- a/auth/src/main/res/layout/fui_phone_layout.xml +++ /dev/null @@ -1,97 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - -