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 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/auth/src/test/java/com/firebase/ui/auth/ReauthTestRequests.kt b/auth/src/test/java/com/firebase/ui/auth/ReauthTestRequests.kt
index b570b7c129..ffafb36a0c 100644
--- a/auth/src/test/java/com/firebase/ui/auth/ReauthTestRequests.kt
+++ b/auth/src/test/java/com/firebase/ui/auth/ReauthTestRequests.kt
@@ -78,7 +78,7 @@ internal class ReauthScopeProbe {
/** Call from inside a content slot. */
@Composable
- fun capture() {
+ fun Capture() {
scope = LocalAuthFlowScope.current
}
diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenEmailRecoveryTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenEmailRecoveryTest.kt
index a1024c42ae..ab7efb4105 100644
--- a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenEmailRecoveryTest.kt
+++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenEmailRecoveryTest.kt
@@ -555,7 +555,7 @@ class FirebaseAuthScreenEmailRecoveryTest {
onSignInFailure = {},
onSignInCancelled = {},
// One provider, so the sheet opens at the email step and the picker never composes.
- emailContent = { probe.capture() },
+ emailContent = { probe.Capture() },
)
}
composeTestRule.waitForIdle()
diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthContentStateTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthContentStateTest.kt
index 75d2dae0a1..261b7a9087 100644
--- a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthContentStateTest.kt
+++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthContentStateTest.kt
@@ -305,7 +305,7 @@ class FirebaseAuthScreenReauthContentStateTest {
onSignInFailure = {},
onSignInCancelled = { cancelledCount++ },
reauthContent = {
- probe.capture()
+ probe.Capture()
Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot"))
}
)
@@ -363,7 +363,7 @@ class FirebaseAuthScreenReauthContentStateTest {
onSignInCancelled = { cancelledCount++ },
// The default sheet has no app slot; the picker layout is inside the request's flow.
customMethodPickerLayout = { providers, onSelected ->
- probe.capture()
+ probe.Capture()
AuthMethodPicker(providers = providers, onProviderSelected = onSelected)
},
)
@@ -414,7 +414,7 @@ class FirebaseAuthScreenReauthContentStateTest {
onSignInFailure = {},
onSignInCancelled = {},
reauthContent = { state ->
- probe.capture()
+ probe.Capture()
captured = state
Button(
onClick = { state.onProviderSelected(state.providers.first()) },
@@ -618,7 +618,7 @@ class FirebaseAuthScreenReauthContentStateTest {
onSignInFailure = {},
onSignInCancelled = {},
reauthContent = {
- probe.capture()
+ probe.Capture()
Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot"))
},
authenticatedContent = { _, _ -> Text(text = "AUTHENTICATED") },
@@ -673,7 +673,7 @@ class FirebaseAuthScreenReauthContentStateTest {
onSignInCancelled = {},
// The default sheet has no app slot; the picker layout is inside the request's flow.
customMethodPickerLayout = { providers, onSelected ->
- probe.capture()
+ probe.Capture()
AuthMethodPicker(providers = providers, onProviderSelected = onSelected)
},
)
@@ -820,7 +820,7 @@ class FirebaseAuthScreenReauthContentStateTest {
onSignInFailure = {},
onSignInCancelled = {},
reauthContent = {
- probe.capture()
+ probe.Capture()
Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot"))
}
)
@@ -873,7 +873,7 @@ class FirebaseAuthScreenReauthContentStateTest {
onSignInFailure = {},
onSignInCancelled = {},
reauthContent = {
- probe.capture()
+ probe.Capture()
Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot"))
},
authenticatedContent = { _, _ -> Text(text = "AUTHENTICATED") },
@@ -925,7 +925,7 @@ class FirebaseAuthScreenReauthContentStateTest {
onSignInFailure = {},
onSignInCancelled = {},
reauthContent = { state ->
- probe.capture()
+ probe.Capture()
captured = state
Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot"))
}
@@ -1044,7 +1044,7 @@ class FirebaseAuthScreenReauthContentStateTest {
Text(text = "MFA", modifier = Modifier.testTag("mfa_challenge"))
},
reauthContent = {
- probe.capture()
+ probe.Capture()
Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot"))
},
authenticatedContent = { _, _ -> Text(text = "AUTHENTICATED") },
@@ -1102,7 +1102,7 @@ class FirebaseAuthScreenReauthContentStateTest {
onSignInFailure = {},
onSignInCancelled = {},
// Password-only, so the sheet opens at the email step and the picker never composes.
- emailContent = { probe.capture() },
+ emailContent = { probe.Capture() },
mfaChallengeContent = { state ->
challenge = state
Text(text = "MFA", modifier = Modifier.testTag("mfa_challenge"))
@@ -1165,7 +1165,7 @@ class FirebaseAuthScreenReauthContentStateTest {
Text(text = "MFA", modifier = Modifier.testTag("mfa_challenge"))
},
reauthContent = {
- probe.capture()
+ probe.Capture()
Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot"))
},
authenticatedContent = { _, _ -> Text(text = "AUTHENTICATED") },
@@ -1232,7 +1232,7 @@ class FirebaseAuthScreenReauthContentStateTest {
Text(text = "MFA", modifier = Modifier.testTag("mfa_challenge"))
},
reauthContent = { state ->
- probe.capture()
+ probe.Capture()
captured = state
Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot"))
},
@@ -1296,7 +1296,7 @@ class FirebaseAuthScreenReauthContentStateTest {
Text(text = "MFA", modifier = Modifier.testTag("mfa_challenge"))
},
reauthContent = { state ->
- probe.capture()
+ probe.Capture()
captured = state
Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot"))
},
@@ -1404,7 +1404,7 @@ class FirebaseAuthScreenReauthContentStateTest {
onSignInFailure = {},
onSignInCancelled = {},
reauthContent = { state ->
- probe.capture()
+ probe.Capture()
captured = state
Text(text = "SLOT_ERROR=${state.error}", modifier = Modifier.testTag("slot"))
}
@@ -1500,7 +1500,7 @@ class FirebaseAuthScreenReauthContentStateTest {
onSignInFailure = {},
onSignInCancelled = {},
reauthContent = {
- probe.capture()
+ probe.Capture()
Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot"))
}
)
@@ -1552,7 +1552,7 @@ class FirebaseAuthScreenReauthContentStateTest {
onSignInFailure = {},
onSignInCancelled = {},
reauthContent = {
- probe.capture()
+ probe.Capture()
Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot"))
}
)
@@ -1609,7 +1609,7 @@ class FirebaseAuthScreenReauthContentStateTest {
onSignInFailure = {},
onSignInCancelled = {},
reauthContent = {
- probe.capture()
+ probe.Capture()
Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot"))
}
)
@@ -1666,7 +1666,7 @@ class FirebaseAuthScreenReauthContentStateTest {
onSignInFailure = {},
onSignInCancelled = {},
reauthContent = {
- probe.capture()
+ probe.Capture()
Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot"))
}
)
@@ -1715,7 +1715,7 @@ class FirebaseAuthScreenReauthContentStateTest {
onSignInFailure = {},
onSignInCancelled = {},
reauthContent = {
- probe.capture()
+ probe.Capture()
Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot"))
}
)
@@ -1801,7 +1801,7 @@ class FirebaseAuthScreenReauthContentStateTest {
onSignInFailure = {},
onSignInCancelled = {},
reauthContent = {
- probe.capture()
+ probe.Capture()
Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot"))
}
)
@@ -1949,7 +1949,7 @@ class FirebaseAuthScreenReauthContentStateTest {
onSignInFailure = {},
onSignInCancelled = {},
emailContent = {
- probe.capture()
+ probe.Capture()
Text(text = "EMAIL", modifier = Modifier.testTag("reauth_email"))
},
mfaChallengeContent = {
@@ -2001,7 +2001,7 @@ class FirebaseAuthScreenReauthContentStateTest {
Text(text = "MFA", modifier = Modifier.testTag("mfa_challenge"))
},
reauthContent = {
- probe.capture()
+ probe.Capture()
Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot"))
},
authenticatedContent = { _, _ -> Text(text = "AUTHENTICATED") },
@@ -2052,7 +2052,7 @@ class FirebaseAuthScreenReauthContentStateTest {
onSignInFailure = {},
onSignInCancelled = {},
reauthContent = {
- probe.capture()
+ probe.Capture()
Text(text = "REAUTH", modifier = Modifier.testTag("reauth_slot"))
}
)
@@ -2092,7 +2092,7 @@ class FirebaseAuthScreenReauthContentStateTest {
onSignInFailure = {},
onSignInCancelled = {},
emailContent = {
- probe.capture()
+ probe.Capture()
Text(text = "EMAIL", modifier = Modifier.testTag("reauth_email"))
},
authenticatedContent = { _, _ -> Text(text = "AUTHENTICATED") },
diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthIdleResetTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthIdleResetTest.kt
index cb0302679d..fc11c81260 100644
--- a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthIdleResetTest.kt
+++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenReauthIdleResetTest.kt
@@ -125,7 +125,7 @@ class FirebaseAuthScreenReauthIdleResetTest {
onSignInFailure = {},
onSignInCancelled = {},
reauthContent = { state ->
- probe.capture()
+ probe.Capture()
capturedError = state.error
Text(text = "Reauth UI", modifier = Modifier.testTag("reauth_marker"))
}
@@ -193,7 +193,7 @@ class FirebaseAuthScreenReauthIdleResetTest {
onSignInFailure = {},
onSignInCancelled = {},
reauthContent = {
- probe.capture()
+ probe.Capture()
Text(text = "Reauth UI", modifier = Modifier.testTag("reauth_marker"))
}
)
diff --git a/build.gradle.kts b/build.gradle.kts
index 3f3ccd5966..e5e280ced7 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -30,3 +30,23 @@ allprojects {
}
}
}
+
+// Android Lint is configured per module, so there is no repo-wide entry point by default.
+// This task is that entry point, and the module list is the gate's definition:
+// - :app and :e2eTest declare no lint { } block yet, so they are deliberately absent (CPRN-433).
+// - :proguard-tests disables its debug variant on CI, so it is gated on release instead.
+tasks.register("lintAll") {
+ group = "verification"
+ description = "Runs Android Lint for every module that configures a lint { } block."
+
+ dependsOn(
+ ":auth:lintDebug",
+ ":common:lintDebug",
+ ":database:lintDebug",
+ ":firestore:lintDebug",
+ ":library:lintDebug",
+ ":storage:lintDebug",
+ ":internal:lintchecks:lintDebug",
+ ":proguard-tests:lintRelease"
+ )
+}
diff --git a/common/build.gradle.kts b/common/build.gradle.kts
index 66a744073b..7b8e3c497b 100644
--- a/common/build.gradle.kts
+++ b/common/build.gradle.kts
@@ -36,7 +36,6 @@ android {
warningsAsErrors = true
abortOnError = true
- baseline = file("$rootDir/library/quality/lint-baseline.xml")
}
buildTypes {
diff --git a/database/build.gradle.kts b/database/build.gradle.kts
index af64323abf..7e308b9e57 100644
--- a/database/build.gradle.kts
+++ b/database/build.gradle.kts
@@ -38,7 +38,6 @@ android {
warningsAsErrors = true
abortOnError = true
- baseline = file("$rootDir/library/quality/lint-baseline.xml")
}
buildTypes {
diff --git a/database/src/main/java/com/firebase/ui/database/paging/DatabasePagingKey.java b/database/src/main/java/com/firebase/ui/database/paging/DatabasePagingKey.java
index b04e57723e..9b42c6cdcc 100644
--- a/database/src/main/java/com/firebase/ui/database/paging/DatabasePagingKey.java
+++ b/database/src/main/java/com/firebase/ui/database/paging/DatabasePagingKey.java
@@ -2,19 +2,23 @@
import java.util.Objects;
+import androidx.annotation.Nullable;
+
public class DatabasePagingKey {
private final Object mChildValue;
private final String mNodeKey;
- public DatabasePagingKey(Object childValue, String nodeKey) {
+ public DatabasePagingKey(@Nullable Object childValue, @Nullable String nodeKey) {
mChildValue = childValue;
mNodeKey = nodeKey;
}
+ @Nullable
public Object getChildValue() {
return mChildValue;
}
+ @Nullable
public String getNodeKey() {
return mNodeKey;
}
diff --git a/database/src/main/java/com/firebase/ui/database/paging/DatabasePagingSource.java b/database/src/main/java/com/firebase/ui/database/paging/DatabasePagingSource.java
index 0ab53cd6e4..f28f85e62a 100644
--- a/database/src/main/java/com/firebase/ui/database/paging/DatabasePagingSource.java
+++ b/database/src/main/java/com/firebase/ui/database/paging/DatabasePagingSource.java
@@ -32,7 +32,7 @@ public class DatabasePagingSource extends RxPagingSource
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/okf-bundle/ci-workflows/android.md b/okf-bundle/ci-workflows/android.md
index f834b75d58..92631d23ea 100644
--- a/okf-bundle/ci-workflows/android.md
+++ b/okf-bundle/ci-workflows/android.md
@@ -38,6 +38,20 @@ Canonical owner for the CI unit-path step list. Script: [scripts/build.sh](../..
4. `./gradlew --max-workers=2 checkstyle`
5. `./gradlew --max-workers=2 testDebugUnitTest -x :e2eTest:testDebugUnitTest`
+Step 4 is **Java-only** (`include("**/*.java")`), so it inspects zero files in the Kotlin modules — [Kotlin blind spot](../testing/agent-command-policy.md#checkstyle-kotlin-blind-spot). Android Lint covers that gap and runs in its own workflow, **not** in `build.sh` — see below.
+
+
+
+## Android Lint (`lint.yml`)
+
+Separate workflow, `pull_request` only, running `./gradlew --max-workers=2 lintAll`.
+
+`lintAll` is registered in the root `build.gradle.kts` and gates the 8 modules that configure a `lint { }` block; `:app` and `:e2eTest` are not yet among them (CPRN-433).
+
+It is a separate workflow rather than a step in `build.sh` for two reasons. Lint measured **~4-5 minutes** on this repo — the `build` job went from 3-6 min to 8-11 min when it was inline — so running it in parallel roughly halves PR feedback time at about the same total runner cost, since the extra compile the lint job pays is the one `build.sh` stops paying (`lintAnalyze` depends on `compileDebugKotlin`, and there is no remote build cache: Develocity here is configured for build scans only). And under `set -e` an inline lint failure aborted the run **before** `testDebugUnitTest`, so one new finding cost you every test result for that run.
+
+The workflow copies `library/google-services.json` into `app/` and `proguard-tests/` before running, because `lintAll` gates `:proguard-tests:lintRelease` and that module applies the `google-services` plugin.
+
`proguard-tests:build` is currently commented out (re-enable before release). Green Android CI does **not** prove ProGuard/R8 packaging.
## Agent notes
diff --git a/okf-bundle/ci-workflows/index.md b/okf-bundle/ci-workflows/index.md
index 10d0cafb9d..986eb2f145 100644
--- a/okf-bundle/ci-workflows/index.md
+++ b/okf-bundle/ci-workflows/index.md
@@ -5,6 +5,7 @@ GitHub Actions job shape and failure triage for FirebaseUI-Android.
## Jobs
* [Android CI](android.md) — `android.yml`: JDK 21, Gradle cache, `./scripts/build.sh`
+* [Android Lint](android.md#lint-workflow) — `lint.yml`: JDK 21, Gradle cache, `./gradlew lintAll`. Separate workflow so it runs in parallel with `build.sh` and a lint failure cannot mask unit-test results.
* [E2E Tests](e2e.md) — `e2e_test.yml`: firebase-tools, Auth emulator, `./gradlew e2eTest`
## Shared dependencies
diff --git a/okf-bundle/modules/index.md b/okf-bundle/modules/index.md
index ee3af39800..7c5f368643 100644
--- a/okf-bundle/modules/index.md
+++ b/okf-bundle/modules/index.md
@@ -22,9 +22,27 @@ Version and SDK floors: `buildSrc/.../Config.kt` — [repo tooling](../repo-tool
| `:app` | Demo app (Auth Compose sample) |
| `:e2eTest` | Auth emulator e2e (Robolectric + Compose UI test) |
| `:proguard-tests` | R8/ProGuard packaging checks (disabled in CI unit path — [Android CI](../ci-workflows/android.md)) |
-| `:lint`, `:internal:lint`, `:internal:lintchecks` | Custom lint plumbing |
+| `:lint`, `:internal:lint`, `:internal:lintchecks` | Custom lint detectors — [what each is for](#custom-lint-modules) |
| `buildSrc` | Shared `Config` (version, SDK levels, submodule list) |
+
+
+### Custom lint modules
+
+Three separate modules, and the difference is not cosmetic:
+
+| Module | Detector | Reaches |
+|--------|----------|---------|
+| `:lint` | `FirestoreRecyclerAdapterLifecycleDetector` (2 issues) | `:firestore` only, via `lintChecks(project(":lint"))` in `firestore/build.gradle.kts` |
+| `:internal:lint` | `NonGlobalIdDetector` (`NonGlobalIdInLayout`) | `:internal:lintchecks` only |
+| `:internal:lintchecks` | none — an empty AAR (`` is its whole source) | itself |
+
+`:internal:lintchecks` exists only to declare `lintChecks(project(":internal:lint"))`, and `:auth` takes it as a `debugImplementation` dependency. That does **not** propagate the detector: `lintChecks` applies a check to the module that declares it, while `lintPublish` is the configuration that ships one to consumers. So `NonGlobalIdInLayout` runs against an empty module and reaches nothing.
+
+It is dead wiring rather than a missed bug — `:auth` has no `"@id/` references left in its layouts after the Compose rewrite, so the detector would report nothing today even if it were wired correctly. Whether `:internal:lintchecks` should exist at all is unresolved.
+
+Both registries declare a `Vendor`; omitting one makes lint print a `does not specify a vendor` warning on every run.
+
## Cross-cutting note (v10 Auth)
FirebaseUI Auth **10.x** is a Compose rewrite (breaking vs 9.x Views). User migration: [docs/upgrade-to-10.0.md](../../docs/upgrade-to-10.0.md). Firestore/database/storage remain View-based; there is no active Compose migration work-queue for those modules unless one is opened later ([documentation policy § work queues](../documentation-policy.md#work-queue-documents)).
diff --git a/okf-bundle/testing/agent-command-policy.md b/okf-bundle/testing/agent-command-policy.md
index 94fa7b0575..5e052d9246 100644
--- a/okf-bundle/testing/agent-command-policy.md
+++ b/okf-bundle/testing/agent-command-policy.md
@@ -17,7 +17,7 @@ Single source for **which shell commands agents may run** in this repo. E2e is a
1. Run **only** commands in the [registry](#canonical-registry) below (repo root unless noted).
-2. Prefer **`./scripts/build.sh`** for the CI-equivalent unit build/test path (step list: [Android CI](../ci-workflows/android.md#what-buildsh-runs)). Do not invent alternate Gradle task graphs that skip `checkstyle` or re-include `:e2eTest` unit tests in the unit CI path.
+2. Prefer **`./scripts/build.sh`** for the CI-equivalent unit build/test path (step list: [Android CI](../ci-workflows/android.md#what-buildsh-runs)). Do not invent alternate Gradle task graphs that skip `checkstyle` or re-include `:e2eTest` unit tests in the unit CI path. `build.sh` is the unit path only — lint and e2e are separate gates with their own workflows, so a green `build.sh` is **not** a green CI.
3. When a canonical command fails: read the **full** output, fix **product code** (or environment prerequisites), re-run the **same** command. Do **not** switch invocation style.
4. Do **not** infer alternate commands from error strings — see [known traps](#known-traps).
5. Subagents (Task, explore, orchestrator): same rule — paste the [handoff block](#subagent-handoff) into every FirebaseUI-Android task prompt.
@@ -30,7 +30,10 @@ Single source for **which shell commands agents may run** in this repo. E2e is a
| Unit tests (all library modules; exclude e2eTest) | `./gradlew testDebugUnitTest -x :e2eTest:testDebugUnitTest` | Bare `./gradlew test` (pulls wrong tasks / e2e); IDE-only as the agent gate |
| Unit tests (one module with a real `src/test` suite) | `./gradlew ::testDebugUnitTest` (e.g. `:auth:testDebugUnitTest`, `:firestore:…`, `:storage:…`) | `:common:testDebugUnitTest` / `:database:testDebugUnitTest` as “green” evidence (empty suites — [empty unit-suite trap](#empty-unit-suite-trap)); full suite when only one module changed *as a substitute for* the CI path at handoff |
| Assemble one module (when no JVM unit suite) | `./gradlew ::assembleDebug` (e.g. `:database`, `:common`) | Treating empty `testDebugUnitTest` as validation |
-| Checkstyle | `./gradlew checkstyle` | Invented ktlint/detekt entrypoints; editing files without re-running checkstyle when Java/Kotlin style is in scope |
+| Checkstyle (**Java only**) | `./gradlew checkstyle` | Invented ktlint/detekt entrypoints; treating a green checkstyle as style coverage for Kotlin sources ([Kotlin blind spot](#checkstyle-kotlin-blind-spot)) |
+| Android Lint (all gated modules) — **not** in `build.sh`, own workflow | `./gradlew lintAll` | Bare `./gradlew lint` / `lintDebug` (pulls `:app` and `:e2eTest`, which declare no `lint { }` block yet); assuming a green `build.sh` covered lint |
+| Android Lint (one module) | `./gradlew ::lintDebug` (`:proguard-tests` uses `lintRelease`) | Editing Kotlin in a gated module without re-running lint |
+| Accept new lint debt (**needs a human decision**) | `./gradlew :auth:updateLintBaseline` | Running this to make a red build green — see [lint baseline trap](#lint-baseline-trap) |
| Assemble debug | `./gradlew assembleDebug` | Module-scoped assemble as the only CI substitute at handoff |
| Install demo app | `./gradlew :app:installDebug` | Manual APK sideload scripts |
| Start Auth emulator (e2e prerequisite) | `./scripts/start-firebase-emulator.sh` | Bare `firebase emulators:start` with invented flags/ports; starting Firestore/Database emulators “just in case” |
@@ -54,7 +57,7 @@ Single source for **which shell commands agents may run** in this repo. E2e is a
1. Re-run from repo root with the **same** canonical command (full log — do not truncate). On CI-shaped failures, also run `./scripts/print_build_logs.sh`.
2. Fix **product code** or missing prerequisites (JDK, SDK, emulator online).
3. Re-run the **same** command.
-4. Do **not** “verify tooling” with invented Gradle flags, alternate tasks, or skipping checkstyle.
+4. Do **not** “verify tooling” with invented Gradle flags, alternate tasks, or skipping checkstyle/lint.
## Forbidden (always)
@@ -64,7 +67,8 @@ Single source for **which shell commands agents may run** in this repo. E2e is a
| Including `:e2eTest:testDebugUnitTest` in the unit CI path without an emulator | E2e suite expects Auth emulator; unit CI explicitly excludes it (`scripts/build.sh`) |
| Bare `firebase emulators:start` with custom ports | Ports and project ID come from `e2eTest/firebase.json` / `.firebaserc` via the start script |
| `connectedAndroidTest` / device Espresso as a substitute for `e2eTest` | Canonical e2e is Robolectric + emulator via `./gradlew e2eTest` |
-| Invented formatters (`ktlintFormat`, random `spotlessApply`) as the style gate | Canonical style gate is `./gradlew checkstyle` |
+| Invented formatters (`ktlintFormat`, random `spotlessApply`) as the style gate | Canonical gates are `./gradlew checkstyle` (Java) and `./gradlew lintAll` (everything else) |
+| `updateLintBaseline` to clear a lint failure you introduced | The baseline records **pre-existing** debt only — see [lint baseline trap](#lint-baseline-trap) |
| Publishing to Maven Central / Sonatype unless the user explicitly requested a release | Release process is human-gated — [repo tooling](../repo-tooling/index.md) |
## Known traps
@@ -86,6 +90,31 @@ Single source for **which shell commands agents may run** in this repo. E2e is a
- Modules with real JVM unit suites today: `:auth`, `:firestore`, `:storage` (and `:e2eTest` via the custom `e2eTest` task, not the unit CI path).
- Instrumented `connectedAndroidTest` is forbidden as an Auth e2e substitute and is **not** an allowlisted database/firestore gate (not run in `android.yml`). Module matrix: [validation checklist](validation-checklist.md#module-validation-matrix).
+
+
+### Checkstyle is Java-only
+
+- Root `build.gradle.kts` scopes the `checkstyle` task with `include("**/*.java")`.
+- `:auth`, `:app` and `:e2eTest` are Kotlin. `./gradlew checkstyle` inspects **zero files** there and exits 0.
+- A green `checkstyle` on a Kotlin-only diff is **not** evidence of anything. `./gradlew lintAll` is the gate that reads Kotlin (via UAST).
+
+
+
+### `lintAll --rerun-tasks` can fail for reasons unrelated to your diff
+
+- Observed once: `Unexpected failure during lint analysis of Bean.java (this is a bug in lint…)`, a `FileNotFoundException` on a `:database` annotation-processor output under `build/generated/ap_generated_sources/`. It did not reproduce on an immediate re-run.
+- It is a race between annotation-processor regeneration and lint analysis that `--rerun-tasks` makes possible. Re-run the same command before investigating; treat a single red `--rerun-tasks` run naming a module you did not touch as suspect, not as a finding.
+- Plain `./gradlew lintAll` (no `--rerun-tasks`) has not shown it.
+
+
+
+### Lint baseline is pre-existing debt, not an escape hatch
+
+- `auth/lint-baseline.xml` suppresses 180 pre-existing findings (localization: CPRN-432; Compose: CPRN-436). It is the **only** baseline in the repo — every other gated module is clean and declares no `baseline`.
+- `warningsAsErrors = true` and `abortOnError = true` in every gated module, so a **new** finding fails the build hard. That is the point.
+- Running `updateLintBaseline` to absorb a finding your change introduced defeats the gate. Fix the code instead; regenerating the baseline is a human decision tied to a tracked issue.
+- A baseline's `file=` paths are recorded relative to the module that produced them, and `updateLintBaseline` rewrites the whole file per module — so a shared baseline silently misbehaves in both directions. The old `library/quality/lint-baseline.xml` had one entry (`src/main/AndroidManifest.xml`) that matched *any* module's manifest and one (`auth/src/main/res/...`) recorded root-relative that matched *none*, which is why every module reported `LintBaselineFixed`. Keep baselines per module.
+
### PR template `./gradlew check`
@@ -110,7 +139,8 @@ Paste into Task / explore / work-queue prompts:
FirebaseUI-Android agent command policy: okf-bundle/testing/agent-command-policy.md ONLY.
Unit CI path: ./scripts/build.sh (or the exact gradle tasks it runs) — never invent alternate graphs.
Unit tests: ./gradlew testDebugUnitTest -x :e2eTest:testDebugUnitTest OR ./gradlew ::testDebugUnitTest only for modules with src/test (auth/firestore/storage). Never treat :common/:database testDebugUnitTest as evidence (empty suites).
-Style: ./gradlew checkstyle ONLY — do not invent ktlint/detekt entrypoints.
+Style: ./gradlew checkstyle (Java only — it inspects zero files in Kotlin modules) AND ./gradlew lintAll (Android Lint, covers Kotlin; separate workflow, NOT run by build.sh). Do not invent ktlint/detekt entrypoints.
+Never run updateLintBaseline to silence a failure you caused; the baseline is pre-existing debt only.
E2e: ./scripts/start-firebase-emulator.sh then ./gradlew e2eTest — okf-bundle/testing/running-e2e.md.
Never: bare firebase emulators:start with invented ports; connectedCheck as e2e substitute; Maven Central publish unless user asked.
On failure: fix product code / prerequisites, re-run the same canonical command; use ./scripts/print_build_logs.sh for CI-shaped failures.
diff --git a/okf-bundle/testing/validation-checklist.md b/okf-bundle/testing/validation-checklist.md
index 71b59eb6cc..f8764672e1 100644
--- a/okf-bundle/testing/validation-checklist.md
+++ b/okf-bundle/testing/validation-checklist.md
@@ -26,7 +26,7 @@ Work types and tiers: [change authoring workflow](change-authoring-workflow.md).
## Build and unit tests
-Repo root. Full CI unit path (what `build.sh` runs): [Android CI](../ci-workflows/android.md).
+Repo root. Full CI unit path (what `build.sh` runs — `assembleDebug`, `checkstyle`, unit tests): [Android CI](../ci-workflows/android.md). Lint and e2e are **separate** workflows; `build.sh` does not run them.
```bash
./scripts/build.sh
@@ -55,13 +55,20 @@ Instrumented `androidTest` (database/firestore) is **not** in CI or the agent al
## Lint and formatting
-**Blocking before `implementation` handoff and on the frozen tree for `independent-review`** when Java/Kotlin style-relevant sources changed.
+**Blocking before `implementation` handoff and on the frozen tree for `independent-review`** when style-relevant sources changed. Which gate applies depends on the language:
```bash
-./gradlew checkstyle
+./gradlew checkstyle # Java sources only
+./gradlew lintAll # Android Lint — reads Kotlin and resources
```
-Config: `library/quality/checkstyle.xml` (wired from root `build.gradle.kts`). There is **no** separate agent entrypoint for ktlint/detekt — do not invent one.
+`checkstyle` is scoped `include("**/*.java")` from the root `build.gradle.kts`, so on a Kotlin-only diff it inspects **zero files and exits 0**. A green checkstyle is not evidence for a change in `:auth`, `:app` or `:e2eTest` — [Kotlin blind spot](agent-command-policy.md#checkstyle-kotlin-blind-spot).
+
+`lintAll` runs Android Lint for the 8 modules that configure a `lint { }` block, each at `checkAllWarnings = true`, `warningsAsErrors = true` and `abortOnError = true` — so any new finding fails the build. It runs in its own workflow ([lint.yml](../ci-workflows/android.md#lint-workflow)), **not** in `build.sh`, so you must run it separately — a green `build.sh` says nothing about lint. `:app` and `:e2eTest` are not yet gated (CPRN-433). Config: each module's `lint { }` block; `library/quality/checkstyle.xml` for checkstyle.
+
+`auth/lint-baseline.xml` suppresses 180 pre-existing findings. **Never** run `updateLintBaseline` to clear a failure your change caused — [baseline trap](agent-command-policy.md#lint-baseline-trap).
+
+There is **no** separate agent entrypoint for ktlint/detekt — do not invent one.
Follow [Kotlin coding conventions](https://kotlinlang.org/docs/coding-conventions.html), [Android Kotlin style](https://developer.android.com/kotlin/style-guide), and [Compose API guidelines](https://github.com/androidx/androidx/blob/androidx-main/compose/docs/compose-api-guidelines.md) as described in [CONTRIBUTING.md](../../CONTRIBUTING.md).
@@ -98,7 +105,8 @@ Before closing **`implementation_gate`**, **`review_gate`**, **`commit_gate`**,
| unit CI | ./scripts/build.sh | 0 | — |
| module unit | ./gradlew ::testDebugUnitTest | 0 | N/N tests — only if module has `src/test` |
| assemble | ./gradlew ::assembleDebug | 0 | when module has no JVM unit suite |
-| checkstyle | ./gradlew checkstyle | 0 | when style-relevant sources |
+| checkstyle | ./gradlew checkstyle | 0 | Java sources only — no signal on Kotlin |
+| lint | ./gradlew lintAll | 0 | when Kotlin/resources changed in a gated module |
| e2e | ./gradlew e2eTest | 0 | when Auth UI — /tmp/...log |
```
@@ -108,7 +116,8 @@ Before closing **`implementation_gate`**, **`review_gate`**, **`commit_gate`**,
- [ ] `./scripts/build.sh` (or equivalent assemble + checkstyle + unit exclusion path) exit 0
- [ ] Module evidence per [module validation matrix](#module-validation-matrix)
-- [ ] `./gradlew checkstyle` when style-relevant sources changed
+- [ ] `./gradlew checkstyle` when **Java** sources changed
+- [ ] `./gradlew lintAll` when Kotlin or resources changed in a gated module
- [ ] E2e green when Auth UI / `e2eTest` changed ([running e2e](running-e2e.md))
- [ ] [Validation evidence package](#validation-evidence-package) recorded
- [ ] OKF bundle reviewed/updated per § above
diff --git a/proguard-tests/build.gradle.kts b/proguard-tests/build.gradle.kts
index 854702eca6..0a419b7402 100644
--- a/proguard-tests/build.gradle.kts
+++ b/proguard-tests/build.gradle.kts
@@ -60,7 +60,6 @@ android {
warningsAsErrors = true
abortOnError = true
- baseline = file("$rootDir/library/quality/lint-baseline.xml")
}
androidComponents {
diff --git a/scripts/build.sh b/scripts/build.sh
index 6d1a1e8f77..b216f04b76 100755
--- a/scripts/build.sh
+++ b/scripts/build.sh
@@ -11,4 +11,7 @@ cp library/google-services.json proguard-tests/google-services.json
# TODO(thatfiredev): re-enable before release
# ./gradlew $GRADLE_ARGS proguard-tests:build
./gradlew $GRADLE_ARGS checkstyle
+# Android Lint is the Kotlin-capable gate, but it runs in its own workflow
+# (.github/workflows/lint.yml) so it runs in parallel with this path rather than
+# adding ~5 minutes to it, and so a lint failure does not mask unit-test results.
./gradlew $GRADLE_ARGS testDebugUnitTest -x :e2eTest:testDebugUnitTest
diff --git a/storage/build.gradle.kts b/storage/build.gradle.kts
index c45b44a632..0e6f8af175 100644
--- a/storage/build.gradle.kts
+++ b/storage/build.gradle.kts
@@ -36,7 +36,6 @@ android {
warningsAsErrors = true
abortOnError = true
- baseline = file("$rootDir/library/quality/lint-baseline.xml")
}
buildTypes {