diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index b3cf8229af..38d61bbf92 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -122,7 +122,7 @@ import org.thoughtcrime.securesms.util.BatterySnapshotTracker; import org.thoughtcrime.securesms.util.DeviceProperties; import org.thoughtcrime.securesms.util.DynamicTheme; import org.thoughtcrime.securesms.util.Environment; -import org.thoughtcrime.securesms.util.PlayServicesUtil; +import org.signal.core.util.PlayServicesUtil; import org.thoughtcrime.securesms.util.RemoteConfig; import org.thoughtcrime.securesms.util.SignalLocalMetrics; import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler; diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/data/DataAndStorageSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/data/DataAndStorageSettingsViewModel.kt index 26af1639e4..51bec86711 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/data/DataAndStorageSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/data/DataAndStorageSettingsViewModel.kt @@ -6,12 +6,12 @@ import androidx.lifecycle.ViewModelProvider import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update +import org.signal.core.util.PlayServicesUtil import org.signal.mediasend.SentMediaQuality import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.keyvalue.SettingsValues.ForceWebsocketMode import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.messages.IncomingMessageObserver -import org.thoughtcrime.securesms.util.PlayServicesUtil import org.thoughtcrime.securesms.util.TextSecurePreferences import org.thoughtcrime.securesms.webrtc.CallDataMode diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/FcmRefreshJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/FcmRefreshJob.java index c809e22a22..039db2d786 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/FcmRefreshJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/FcmRefreshJob.java @@ -32,7 +32,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.messages.IncomingMessageObserver; import org.thoughtcrime.securesms.net.SignalNetwork; import org.thoughtcrime.securesms.transport.RetryLaterException; -import org.thoughtcrime.securesms.util.PlayServicesUtil; +import org.signal.core.util.PlayServicesUtil; import org.whispersystems.signalservice.api.NetworkResultUtil; import org.signal.network.exceptions.NonSuccessfulResponseCodeException; diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/phonenumber/EnterPhoneNumberFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/phonenumber/EnterPhoneNumberFragment.kt index e84352839e..8134f70afc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/phonenumber/EnterPhoneNumberFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/phonenumber/EnterPhoneNumberFragment.kt @@ -38,6 +38,7 @@ import com.google.i18n.phonenumbers.NumberParseException import com.google.i18n.phonenumbers.PhoneNumberUtil import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber import org.signal.core.ui.logging.LoggingFragment +import org.signal.core.util.PlayServicesUtil import org.signal.core.util.ThreadUtil import org.signal.core.util.getParcelableCompat import org.signal.core.util.isNotNullOrBlank @@ -64,7 +65,6 @@ import org.thoughtcrime.securesms.registration.ui.toE164 import org.thoughtcrime.securesms.registration.util.CountryPrefix import org.thoughtcrime.securesms.util.CommunicationActions import org.thoughtcrime.securesms.util.Dialogs -import org.thoughtcrime.securesms.util.PlayServicesUtil import org.thoughtcrime.securesms.util.SignalE164Util import org.thoughtcrime.securesms.util.SpanUtil import org.thoughtcrime.securesms.util.SupportEmailUtil diff --git a/core/util/build.gradle.kts b/core/util/build.gradle.kts index 1dadb35e3b..177f511f2c 100644 --- a/core/util/build.gradle.kts +++ b/core/util/build.gradle.kts @@ -20,6 +20,7 @@ dependencies { implementation(libs.jackson.core) implementation(libs.jackson.module.kotlin) implementation(libs.google.libphonenumber) + implementation(libs.google.play.services.base) testImplementation(libs.androidx.sqlite.framework) testImplementation(testLibs.junit.junit) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/PlayServicesUtil.java b/core/util/src/main/java/org/signal/core/util/PlayServicesUtil.java similarity index 97% rename from app/src/main/java/org/thoughtcrime/securesms/util/PlayServicesUtil.java rename to core/util/src/main/java/org/signal/core/util/PlayServicesUtil.java index 60143358b6..e341014058 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/PlayServicesUtil.java +++ b/core/util/src/main/java/org/signal/core/util/PlayServicesUtil.java @@ -1,4 +1,4 @@ -package org.thoughtcrime.securesms.util; +package org.signal.core.util; import android.content.Context; diff --git a/feature/registration/build.gradle.kts b/feature/registration/build.gradle.kts index d15a572024..c7e07cb85d 100644 --- a/feature/registration/build.gradle.kts +++ b/feature/registration/build.gradle.kts @@ -87,6 +87,10 @@ dependencies { // Phone number hint implementation(libs.google.play.services.auth) + // Credential Manager (password manager retrieval) + implementation(libs.androidx.credentials) + implementation(libs.androidx.credentials.compat) + // Testing testImplementation(testFixtures(project(":core:ui"))) testImplementation(testLibs.junit.junit) diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt index d21930f9f2..a1792f3d17 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt @@ -107,6 +107,7 @@ import org.signal.registration.screens.verificationcode.VerificationCodeViewMode import org.signal.registration.screens.welcome.WelcomeScreen import org.signal.registration.screens.welcome.WelcomeScreenEvents import org.signal.registration.util.AccountEntropyPoolParceler +import org.signal.registration.util.RegistrationCredentialManager /** * Navigation routes for the registration flow. @@ -756,11 +757,13 @@ private fun EntryProviderScope.navigationEntries( // -- Enter AEP entry { + val context = LocalContext.current val viewModel: EnterAepForLocalBackupViewModel = viewModel( factory = EnterAepForLocalBackupViewModel.Factory( parentEventEmitter = registrationViewModel::onEvent, resultBus = registrationViewModel.resultBus, - resultKey = BACKUP_CREDENTIAL_RESULT + resultKey = BACKUP_CREDENTIAL_RESULT, + isPasswordManagerAvailable = RegistrationCredentialManager.isSupported(context) ) ) val state by viewModel.state.collectAsStateWithLifecycle() @@ -772,11 +775,13 @@ private fun EntryProviderScope.navigationEntries( } entry { key -> + val context = LocalContext.current val viewModel: EnterAepForRemoteBackupPreRegistrationViewModel = viewModel( factory = EnterAepForRemoteBackupPreRegistrationViewModel.Factory( e164 = key.e164, repository = registrationRepository, - parentEventEmitter = registrationViewModel::onEvent + parentEventEmitter = registrationViewModel::onEvent, + isPasswordManagerAvailable = RegistrationCredentialManager.isSupported(context) ) ) val state by viewModel.state.collectAsStateWithLifecycle() @@ -788,9 +793,11 @@ private fun EntryProviderScope.navigationEntries( } entry { + val context = LocalContext.current val viewModel: EnterAepForRemoteBackupPostRegistrationViewModel = viewModel( factory = EnterAepForRemoteBackupPostRegistrationViewModel.Factory( - parentEventEmitter = registrationViewModel::onEvent + parentEventEmitter = registrationViewModel::onEvent, + isPasswordManagerAvailable = RegistrationCredentialManager.isSupported(context) ) ) val state by viewModel.state.collectAsStateWithLifecycle() diff --git a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForLocalBackupViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForLocalBackupViewModel.kt index 351f1ed807..58dec0f63c 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForLocalBackupViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForLocalBackupViewModel.kt @@ -19,14 +19,15 @@ import org.signal.registration.screens.util.navigateBack class EnterAepForLocalBackupViewModel( private val parentEventEmitter: (RegistrationFlowEvent) -> Unit, private val resultBus: ResultEventBus, - private val resultKey: String + private val resultKey: String, + isPasswordManagerAvailable: Boolean = false ) : ViewModel() { companion object { private val TAG = Log.tag(EnterAepForLocalBackupViewModel::class) } - private val _state = MutableStateFlow(EnterAepState()) + private val _state = MutableStateFlow(EnterAepState(isPasswordManagerAvailable = isPasswordManagerAvailable)) val state: StateFlow = _state.asStateFlow() fun onEvent(event: EnterAepEvents) { @@ -53,10 +54,11 @@ class EnterAepForLocalBackupViewModel( class Factory( private val parentEventEmitter: (RegistrationFlowEvent) -> Unit, private val resultBus: ResultEventBus, - private val resultKey: String + private val resultKey: String, + private val isPasswordManagerAvailable: Boolean = false ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { - return EnterAepForLocalBackupViewModel(parentEventEmitter, resultBus, resultKey) as T + return EnterAepForLocalBackupViewModel(parentEventEmitter, resultBus, resultKey, isPasswordManagerAvailable) as T } } } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPostRegistrationViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPostRegistrationViewModel.kt index aaa5865050..13606d71c3 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPostRegistrationViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPostRegistrationViewModel.kt @@ -18,14 +18,15 @@ import org.signal.registration.RegistrationRoute import org.signal.registration.screens.util.navigateTo class EnterAepForRemoteBackupPostRegistrationViewModel( - private val parentEventEmitter: (RegistrationFlowEvent) -> Unit + private val parentEventEmitter: (RegistrationFlowEvent) -> Unit, + isPasswordManagerAvailable: Boolean = false ) : ViewModel() { companion object { private val TAG = Log.tag(EnterAepForRemoteBackupPostRegistrationViewModel::class) } - private val _state = MutableStateFlow(EnterAepState()) + private val _state = MutableStateFlow(EnterAepState(isPasswordManagerAvailable = isPasswordManagerAvailable)) val state: StateFlow = _state.asStateFlow() fun onEvent(event: EnterAepEvents) { @@ -51,10 +52,11 @@ class EnterAepForRemoteBackupPostRegistrationViewModel( } class Factory( - private val parentEventEmitter: (RegistrationFlowEvent) -> Unit + private val parentEventEmitter: (RegistrationFlowEvent) -> Unit, + private val isPasswordManagerAvailable: Boolean = false ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { - return EnterAepForRemoteBackupPostRegistrationViewModel(parentEventEmitter) as T + return EnterAepForRemoteBackupPostRegistrationViewModel(parentEventEmitter, isPasswordManagerAvailable) as T } } } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPreRegistrationViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPreRegistrationViewModel.kt index 10c453db16..ca070a27d6 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPreRegistrationViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPreRegistrationViewModel.kt @@ -24,14 +24,15 @@ import org.signal.registration.screens.util.navigateTo class EnterAepForRemoteBackupPreRegistrationViewModel( private val e164: String, private val repository: RegistrationRepository, - private val parentEventEmitter: (RegistrationFlowEvent) -> Unit + private val parentEventEmitter: (RegistrationFlowEvent) -> Unit, + isPasswordManagerAvailable: Boolean = false ) : EventDrivenViewModel(TAG) { companion object { private val TAG = Log.tag(EnterAepForRemoteBackupPreRegistrationViewModel::class) } - private val _state = MutableStateFlow(EnterAepState()) + private val _state = MutableStateFlow(EnterAepState(isPasswordManagerAvailable = isPasswordManagerAvailable)) val state: StateFlow = _state.asStateFlow() @@ -80,7 +81,7 @@ class EnterAepForRemoteBackupPreRegistrationViewModel( is RequestResult.NonSuccess -> { when (val error = result.error) { is NetworkController.RegisterAccountError.RegistrationRecoveryPasswordIncorrect -> { - Log.w(TAG, "[Submit] RRP incorrect.") + Log.w(TAG, "[Submit] RRP incorrect. Message: ${error.message}") stateEmitter( inputState.copy( isRegistering = false, @@ -134,10 +135,11 @@ class EnterAepForRemoteBackupPreRegistrationViewModel( class Factory( private val e164: String, private val repository: RegistrationRepository, - private val parentEventEmitter: (RegistrationFlowEvent) -> Unit + private val parentEventEmitter: (RegistrationFlowEvent) -> Unit, + private val isPasswordManagerAvailable: Boolean = false ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { - return EnterAepForRemoteBackupPreRegistrationViewModel(e164, repository, parentEventEmitter) as T + return EnterAepForRemoteBackupPreRegistrationViewModel(e164, repository, parentEventEmitter, isPasswordManagerAvailable) as T } } } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepScreen.kt index 94ab7b5586..876d6e94b6 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepScreen.kt @@ -29,12 +29,14 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString @@ -46,6 +48,7 @@ import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import kotlinx.coroutines.launch import org.signal.core.ui.compose.AllDevicePreviews import org.signal.core.ui.compose.Buttons import org.signal.core.ui.compose.Previews @@ -57,6 +60,7 @@ import org.signal.registration.screens.TwoPaneRegistrationScaffold import org.signal.registration.screens.attachDebugLogHelper import org.signal.registration.screens.localbackuprestore.attachBackupKeyAutoFillHelper import org.signal.registration.screens.localbackuprestore.backupKeyAutoFillHelper +import org.signal.registration.util.RegistrationCredentialManager @Composable fun EnterAepScreen( @@ -93,6 +97,9 @@ private fun OnePaneLayout( Description() Spacer(modifier = Modifier.size(24.dp)) RecoveryKeyTextField(state, onEvent) + if (state.isPasswordManagerAvailable) { + FillFromPasswordManagerButton(onEvent) + } } }, footer = { @@ -156,6 +163,9 @@ private fun TwoPaneLayout( .padding(paddingValues) ) { RecoveryKeyTextField(state, onEvent) + if (state.isPasswordManagerAvailable) { + FillFromPasswordManagerButton(onEvent) + } } }, footer = { @@ -259,6 +269,26 @@ private fun RecoveryKeyTextField(state: EnterAepState, onEvent: (EnterAepEvents) ) } +@Composable +private fun FillFromPasswordManagerButton(onEvent: (EnterAepEvents) -> Unit, modifier: Modifier = Modifier) { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + + Buttons.MediumTonal( + modifier = modifier, + onClick = { + coroutineScope.launch { + val password = RegistrationCredentialManager.getPasswordCredential(context) + if (password != null) { + onEvent(EnterAepEvents.BackupKeyChanged(password)) + } + } + } + ) { + Text(text = stringResource(R.string.EnterAepScreen__fill_from_password_manager)) + } +} + @Composable private fun NoRecoverKeyButton(onEvent: (EnterAepEvents) -> Unit, modifier: Modifier = Modifier) { TextButton( @@ -324,7 +354,7 @@ internal class AepVisualTransformation(private val chunkSize: Int) : VisualTrans private fun EnterAepScreenPreview() { Previews.Preview { EnterAepScreen( - state = EnterAepState(), + state = EnterAepState(isPasswordManagerAvailable = true), onEvent = {} ) } @@ -338,7 +368,8 @@ private fun EnterAepScreenFilledPreview() { state = EnterAepState( enteredText = "uy38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t", backupKey = "uy38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t", - isBackupKeyValid = true + isBackupKeyValid = true, + isPasswordManagerAvailable = true ), onEvent = {} ) @@ -354,7 +385,8 @@ private fun EnterAepScreenErrorPreview() { enteredText = "uy38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t", backupKey = "uy38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t", isBackupKeyValid = false, - aepValidationError = AepValidationError.Invalid + aepValidationError = AepValidationError.Invalid, + isPasswordManagerAvailable = true ), onEvent = {} ) diff --git a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepState.kt b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepState.kt index d322090ce3..1526c27774 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepState.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepState.kt @@ -16,9 +16,11 @@ data class EnterAepState( val aepValidationError: AepValidationError? = null, val chunkLength: Int = 4, val isRegistering: Boolean = false, - val registrationError: RegistrationError? = null + val registrationError: RegistrationError? = null, + /** Whether a password manager / credential provider is available to fill the recovery key. */ + val isPasswordManagerAvailable: Boolean = false ) { - override fun toString(): String = "EnterAepState(enteredText=${enteredText.censor()}, backupKey=${backupKey.censor()}, isBackupKeyValid=$isBackupKeyValid, aepValidationError=$aepValidationError, chunkLength=$chunkLength, isRegistering=$isRegistering, registrationError=$registrationError)" + override fun toString(): String = "EnterAepState(enteredText=${enteredText.censor()}, backupKey=${backupKey.censor()}, isBackupKeyValid=$isBackupKeyValid, aepValidationError=$aepValidationError, chunkLength=$chunkLength, isRegistering=$isRegistering, registrationError=$registrationError, isPasswordManagerAvailable=$isPasswordManagerAvailable)" } sealed interface AepValidationError { diff --git a/feature/registration/src/main/java/org/signal/registration/util/RegistrationCredentialManager.kt b/feature/registration/src/main/java/org/signal/registration/util/RegistrationCredentialManager.kt new file mode 100644 index 0000000000..94924f44bb --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/util/RegistrationCredentialManager.kt @@ -0,0 +1,56 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.util + +import android.content.Context +import android.os.Build +import android.view.autofill.AutofillManager +import androidx.annotation.UiContext +import androidx.core.content.getSystemService +import androidx.credentials.CredentialManager +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetPasswordOption +import androidx.credentials.PasswordCredential +import androidx.credentials.exceptions.GetCredentialException +import org.signal.core.util.PlayServicesUtil +import org.signal.core.util.logging.Log + +/** + * Retrieves a previously saved password credential (e.g. a backup/recovery key) using Android's + * Credential Manager. + */ +object RegistrationCredentialManager { + + private val TAG = Log.tag(RegistrationCredentialManager::class) + + /** + * Whether a password manager / credential provider is available to fill credentials. On API 26+ + * this tracks whether the user has an autofill service enabled; older devices fall back to the + * Credential Manager Play Services backend, so they are supported only when Play Services is. + */ + fun isSupported(context: Context): Boolean { + return if (Build.VERSION.SDK_INT >= 26) { + context.getSystemService()?.isEnabled == true + } else { + PlayServicesUtil.getPlayServicesStatus(context) == PlayServicesUtil.PlayServicesStatus.SUCCESS + } + } + + /** + * Prompts the device password manager to let the user pick a saved password credential and returns + * its value, or null if none was chosen or retrieval failed. Must be called with an Activity + * context so the Credential Manager UI can be shown. + */ + suspend fun getPasswordCredential(@UiContext activityContext: Context): String? { + return try { + val result = CredentialManager.create(activityContext).getCredential(activityContext, GetCredentialRequest(listOf(GetPasswordOption()))) + (result.credential as? PasswordCredential)?.password + } catch (e: GetCredentialException) { + Log.w(TAG, "Failed to retrieve credential from password manager.", e) + null + } + } +} diff --git a/feature/registration/src/main/res/values/strings.xml b/feature/registration/src/main/res/values/strings.xml index e765beae83..e0a5383497 100644 --- a/feature/registration/src/main/res/values/strings.xml +++ b/feature/registration/src/main/res/values/strings.xml @@ -204,6 +204,8 @@ Invalid recovery key Incorrect recovery key + + Fill from password manager Preparing restore… diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 43fd73f80b..3cee6b3664 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -4365,6 +4365,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + +