From 42b945471c19f964293f1bff0349f57ad5795723 Mon Sep 17 00:00:00 2001 From: Neelansh Sahai Date: Wed, 30 Jul 2025 19:24:18 +0530 Subject: [PATCH 1/2] Add Signal API implementation (Provider) Change-Id: I5284a17da9791cd64e8d7ea8befe96dd5c770e76 --- .../MyVault/app/build.gradle.kts | 5 + .../MyVault/app/src/main/AndroidManifest.xml | 12 ++ .../authentication/myvault/Constants.kt | 9 ++ .../myvault/NotificationUtils.kt | 56 +++++++++ .../myvault/data/CredentialProviderService.kt | 106 ++++++++++++++++++ .../myvault/data/CredentialsDataSource.kt | 12 ++ .../myvault/data/CredentialsRepository.kt | 52 ++++----- .../myvault/data/PasskeyItem.kt | 2 + .../myvault/data/room/MyVaultDatabase.kt | 5 +- .../authentication/myvault/ui/MainActivity.kt | 6 + .../myvault/ui/home/ShowCredentialsScreen.kt | 2 +- .../app/src/main/res/values/strings.xml | 6 + .../MyVault/app/src/main/res/xml/provider.xml | 2 + CredentialProvider/MyVault/build.gradle.kts | 1 + .../MyVault/gradle/libs.versions.toml | 12 +- .../MyVault/settings.gradle.kts | 1 - 16 files changed, 257 insertions(+), 32 deletions(-) create mode 100644 CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/Constants.kt create mode 100644 CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/NotificationUtils.kt create mode 100644 CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/data/CredentialProviderService.kt diff --git a/CredentialProvider/MyVault/app/build.gradle.kts b/CredentialProvider/MyVault/app/build.gradle.kts index 8f8456d4..e8ca419b 100644 --- a/CredentialProvider/MyVault/app/build.gradle.kts +++ b/CredentialProvider/MyVault/app/build.gradle.kts @@ -17,6 +17,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.jetbrains.kotlin.android) alias(libs.plugins.devtools.ksp) + alias(libs.plugins.compose.compiler) } android { @@ -75,7 +76,11 @@ dependencies { implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) + implementation(libs.androidx.credential.manager) + implementation(libs.provider.events) + implementation(libs.provider.events.ps) + implementation(libs.androidx.room.ktx) implementation(libs.androidx.room.runtime) ksp(libs.androidx.room.compiler) diff --git a/CredentialProvider/MyVault/app/src/main/AndroidManifest.xml b/CredentialProvider/MyVault/app/src/main/AndroidManifest.xml index 907bc0f4..de73600a 100644 --- a/CredentialProvider/MyVault/app/src/main/AndroidManifest.xml +++ b/CredentialProvider/MyVault/app/src/main/AndroidManifest.xml @@ -18,6 +18,7 @@ xmlns:tools="http://schemas.android.com/tools"> + + + + + + + + diff --git a/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/Constants.kt b/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/Constants.kt new file mode 100644 index 00000000..2394a96b --- /dev/null +++ b/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/Constants.kt @@ -0,0 +1,9 @@ +package com.example.android.authentication.myvault + +const val NOTIFICATION_CHANNEL_ID = "channel_id" +const val NOTIFICATION_ID = 135 +const val CREDENTIAL_ID = "credentialId" +const val USER_ID = "userId" +const val ACCEPTED_CREDENTIAL_IDS = "allAcceptedCredentialIds" +const val NAME = "name" +const val DISPLAY_NAME = "displayName" diff --git a/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/NotificationUtils.kt b/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/NotificationUtils.kt new file mode 100644 index 00000000..f49ccb03 --- /dev/null +++ b/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/NotificationUtils.kt @@ -0,0 +1,56 @@ +package com.example.android.authentication.myvault + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.example.android.authentication.myvault.ui.MainActivity + +fun Context.createNotificationChannel( + channelName: String, + channelDescription: String, +) { + val channel = NotificationChannel( + NOTIFICATION_CHANNEL_ID, + channelName, + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = channelDescription + } + // Register the channel with the system. + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) +} + +fun Context.showNotification( + title: String, + content: String, +) { + val intent = Intent(this, MainActivity::class.java) + val pendingIntent: PendingIntent = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE) + + val builder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) + .setSmallIcon(R.drawable.android_secure) + .setContentTitle(title) + .setContentText(content) + .setPriority(NotificationCompat.PRIORITY_MAX) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + + with(NotificationManagerCompat.from(this)) { + if (ActivityCompat.checkSelfPermission( + this@showNotification, + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED) { + return@with + } + notify(NOTIFICATION_ID, builder.build()) + } +} diff --git a/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/data/CredentialProviderService.kt b/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/data/CredentialProviderService.kt new file mode 100644 index 00000000..6bad435c --- /dev/null +++ b/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/data/CredentialProviderService.kt @@ -0,0 +1,106 @@ +package com.example.android.authentication.myvault.data + +import android.annotation.SuppressLint +import android.util.Log +import androidx.credentials.SignalAllAcceptedCredentialIdsRequest +import androidx.credentials.SignalCurrentUserDetailsRequest +import androidx.credentials.SignalUnknownCredentialRequest +import androidx.credentials.providerevents.service.CredentialProviderEventsService +import androidx.credentials.providerevents.signal.ProviderSignalCredentialStateCallback +import androidx.credentials.providerevents.signal.ProviderSignalCredentialStateRequest +import com.example.android.authentication.myvault.ACCEPTED_CREDENTIAL_IDS +import com.example.android.authentication.myvault.AppDependencies +import com.example.android.authentication.myvault.CREDENTIAL_ID +import com.example.android.authentication.myvault.DISPLAY_NAME +import com.example.android.authentication.myvault.NAME +import com.example.android.authentication.myvault.R +import com.example.android.authentication.myvault.USER_ID +import com.example.android.authentication.myvault.showNotification +import kotlinx.coroutines.runBlocking +import org.json.JSONArray +import org.json.JSONObject + +class CredentialProviderService: CredentialProviderEventsService() { + private val dataSource = AppDependencies.credentialsDataSource + + @SuppressLint("RestrictedApi") + override fun onSignalCredentialStateRequest( + request: ProviderSignalCredentialStateRequest, + callback: ProviderSignalCredentialStateCallback, + ) { + when (request.callingRequest) { + is SignalUnknownCredentialRequest -> { + handleUnknownCredentialRequest(request.callingRequest.requestJson) + showNotification( + getString(R.string.credential_deletion), + getString(R.string.unknown_signal_message) + ) + } + + is SignalAllAcceptedCredentialIdsRequest -> { + handleAcceptedCredentialsRequest(request.callingRequest.requestJson) + showNotification( + getString(R.string.credentials_list_updation), + getString(R.string.all_accepted_signal_message) + ) + } + is SignalCurrentUserDetailsRequest -> { + handleCurrentUserDetailRequest(request.callingRequest.requestJson) + showNotification( + getString(R.string.user_details_updation), + getString(R.string.current_user_signal_message) + ) + } + else -> { } + } + + callback.onSignalConsumed() + } + + private fun handleUnknownCredentialRequest(requestJson: String) = runBlocking { + val credentialId = JSONObject(requestJson).getString(CREDENTIAL_ID) + dataSource.getPasskey(credentialId)?.let { + dataSource.hidePasskey(it) + } + } + + private fun handleAcceptedCredentialsRequest(requestJson: String) = runBlocking { + val request = JSONObject(requestJson) + val userId = request.getString(USER_ID) + val listCurrentPasskeysForUser = dataSource.getPasskeyForUser(userId) ?: emptyList() + val listAllAcceptedCredIds = mutableListOf() + when (val value = request.get(ACCEPTED_CREDENTIAL_IDS)) { + is String -> listAllAcceptedCredIds.add(value) + is JSONArray -> { + for (i in 0 until value.length()) { + val item = value.get(i) + if (item is String) { + listAllAcceptedCredIds.add(item) + } + } + } + else -> { /*do nothing*/ } + } + + for (key in listCurrentPasskeysForUser) { + if (listAllAcceptedCredIds.contains(key.credId)) { + dataSource.unhidePasskey(key) + } else { + dataSource.hidePasskey(key) + } + } + } + + private fun handleCurrentUserDetailRequest(requestJson: String) = runBlocking { + val request = JSONObject(requestJson) + val userId = request.getString(USER_ID) + val updatedName = request.getString(NAME) + val updatedDisplayName = request.getString(DISPLAY_NAME) + val listPasskeys = dataSource.getPasskeyForUser(userId) ?: emptyList() + // Update user details for each passkey + for (key in listPasskeys) { + val newPasskeyItem = key.copy(username = updatedName, displayName = updatedDisplayName) + dataSource.updatePasskey(newPasskeyItem) + } + } +} diff --git a/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/data/CredentialsDataSource.kt b/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/data/CredentialsDataSource.kt index 324132c9..e1ad208b 100644 --- a/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/data/CredentialsDataSource.kt +++ b/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/data/CredentialsDataSource.kt @@ -126,6 +126,18 @@ class CredentialsDataSource( fun getPasskey(credId: String): PasskeyItem? { return myVaultDao.getPasskey(credId) } + + fun getPasskeyForUser(userId: String): List? { + return myVaultDao.getPasskeysForUser(userId) + } + + suspend fun hidePasskey(passkey: PasskeyItem) { + myVaultDao.updatePasskey(passkey.copy(hidden = true)) + } + + suspend fun unhidePasskey(passkey: PasskeyItem) { + myVaultDao.updatePasskey(passkey.copy(hidden = false)) + } } data class PasswordMetaData( diff --git a/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/data/CredentialsRepository.kt b/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/data/CredentialsRepository.kt index a2720f73..52e41dec 100644 --- a/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/data/CredentialsRepository.kt +++ b/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/data/CredentialsRepository.kt @@ -229,34 +229,36 @@ class CredentialsRepository( val passkeys = credentials.passkeys for (passkey in passkeys) { - val data = Bundle() - data.putString("requestJson", option.requestJson) - data.putString("credId", passkey.credId) - - // Create a PendingIntent to launch the activity that will handle the passkey retrieval - val pendingIntent = createNewPendingIntent( - "", - GET_PASSKEY_INTENT, - data, - ) + if (!passkey.hidden) { + val data = Bundle() + data.putString("requestJson", option.requestJson) + data.putString("credId", passkey.credId) + + // Create a PendingIntent to launch the activity that will handle the passkey retrieval + val pendingIntent = createNewPendingIntent( + "", + GET_PASSKEY_INTENT, + data, + ) - // Create a PublicKeyCredentialEntry object to represent the passkey - val entryBuilder = - configurePublicKeyCredentialEntryBuilder(passkey, pendingIntent, option) + // Create a PublicKeyCredentialEntry object to represent the passkey + val entryBuilder = + configurePublicKeyCredentialEntryBuilder(passkey, pendingIntent, option) + + // Configure biometric prompt data + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + entryBuilder.setBiometricPromptData( + BiometricPromptData( + cryptoObject = null, + allowedAuthenticators = allowedAuthenticator, + ), + ) + } - // Configure biometric prompt data - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { - entryBuilder.setBiometricPromptData( - BiometricPromptData( - cryptoObject = null, - allowedAuthenticators = allowedAuthenticator, - ), - ) + val entry = entryBuilder.build() + // Add the entry to the response builder. + responseBuilder.addCredentialEntry(entry) } - - val entry = entryBuilder.build() - // Add the entry to the response builder. - responseBuilder.addCredentialEntry(entry) } } catch (e: IOException) { return false diff --git a/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/data/PasskeyItem.kt b/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/data/PasskeyItem.kt index fe355daf..8ce7bbfd 100644 --- a/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/data/PasskeyItem.kt +++ b/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/data/PasskeyItem.kt @@ -31,6 +31,7 @@ import androidx.room.PrimaryKey * @property credPrivateKey The private key * @property siteId The ID of the site * @property lastUsedTimeMs The last time the passkey item was used + * @property hidden Whether a passkey is hidden from the end user or not */ @Entity( tableName = "passkeys", @@ -47,4 +48,5 @@ data class PasskeyItem( @ColumnInfo(name = "credPrivateKey") val credPrivateKey: String, @ColumnInfo(name = "siteId") val siteId: Long, @ColumnInfo(name = "lastUsedTimeMs") val lastUsedTimeMs: Long, + @ColumnInfo(name = "hidden") val hidden: Boolean = false, ) diff --git a/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/data/room/MyVaultDatabase.kt b/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/data/room/MyVaultDatabase.kt index bccf7e02..0e0ea5a9 100644 --- a/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/data/room/MyVaultDatabase.kt +++ b/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/data/room/MyVaultDatabase.kt @@ -34,7 +34,7 @@ import kotlinx.coroutines.flow.Flow PasswordItem::class, PasskeyItem::class, ], - version = 7, + version = 8, ) abstract class MyVaultDatabase : RoomDatabase() { abstract fun myVaultDao(): MyVaultDao @@ -88,4 +88,7 @@ interface MyVaultDao { @Query("SELECT * from passkeys WHERE credId = :credId") fun getPasskey(credId: String): PasskeyItem? + + @Query("SELECT * from passkeys WHERE uid = :userId and hidden = false") + fun getPasskeysForUser(userId: String): List? } diff --git a/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/ui/MainActivity.kt b/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/ui/MainActivity.kt index 59e8dc8c..b6e537a3 100644 --- a/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/ui/MainActivity.kt +++ b/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/ui/MainActivity.kt @@ -20,12 +20,18 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.core.view.WindowCompat +import com.example.android.authentication.myvault.createNotificationChannel import com.example.android.authentication.myvault.ui.theme.MyVaultTheme class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) + createNotificationChannel( + "Signal API notification channel", + "Notification channel used for testing Signal APIs. Apps pushes a notification if a Signal from RP is received" + ) + WindowCompat.setDecorFitsSystemWindows(window, false) setContent { MyVaultTheme { diff --git a/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/ui/home/ShowCredentialsScreen.kt b/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/ui/home/ShowCredentialsScreen.kt index 9be1596f..eb633694 100644 --- a/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/ui/home/ShowCredentialsScreen.kt +++ b/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/ui/home/ShowCredentialsScreen.kt @@ -191,7 +191,7 @@ private fun CredentialsEntry( horizontalAlignment = Alignment.CenterHorizontally, ) { - items(site.passkeys) { + items(site.passkeys.filter { !it.hidden }) { PasskeyEntry( passkey = it, onPasskeyDelete = onPasskeyDelete, diff --git a/CredentialProvider/MyVault/app/src/main/res/values/strings.xml b/CredentialProvider/MyVault/app/src/main/res/values/strings.xml index 31e718dc..3c0c67ff 100644 --- a/CredentialProvider/MyVault/app/src/main/res/values/strings.xml +++ b/CredentialProvider/MyVault/app/src/main/res/values/strings.xml @@ -69,4 +69,10 @@ Unexpected create request found in intent Unknown Failure Could not retrieve GPM allowlist + Credential deleted successfully + The provider received the SignalUnknown request and thus deleted the credential + Credentials List updated successfully + The provider received the AcceptedCredentialIds request and thus updated the list of user credentials + User details updated successfully + The provider received the CurrentUserDetails request and thus updated the user details in the credential diff --git a/CredentialProvider/MyVault/app/src/main/res/xml/provider.xml b/CredentialProvider/MyVault/app/src/main/res/xml/provider.xml index b68190f5..8925b681 100644 --- a/CredentialProvider/MyVault/app/src/main/res/xml/provider.xml +++ b/CredentialProvider/MyVault/app/src/main/res/xml/provider.xml @@ -19,5 +19,7 @@ + + diff --git a/CredentialProvider/MyVault/build.gradle.kts b/CredentialProvider/MyVault/build.gradle.kts index 0646386f..0c18ab07 100644 --- a/CredentialProvider/MyVault/build.gradle.kts +++ b/CredentialProvider/MyVault/build.gradle.kts @@ -3,4 +3,5 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.jetbrains.kotlin.android) apply false alias(libs.plugins.devtools.ksp) apply false + alias(libs.plugins.compose.compiler) apply false } diff --git a/CredentialProvider/MyVault/gradle/libs.versions.toml b/CredentialProvider/MyVault/gradle/libs.versions.toml index 06da1d2a..ef931eee 100644 --- a/CredentialProvider/MyVault/gradle/libs.versions.toml +++ b/CredentialProvider/MyVault/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] agp = "8.8.1" -ksp = "1.9.22-1.0.17" -kotlin = "1.9.22" +ksp = "2.1.20-2.0.1" +kotlin = "2.1.20" coreKtx = "1.15.0" junit = "4.13.2" junitVersion = "1.2.1" @@ -9,11 +9,12 @@ espressoCore = "3.6.1" lifecycleRuntime = "2.8.7" activityCompose = "1.10.0" composeBom = "2025.02.00" -credentials = "1.5.0-rc01" -room = "2.6.1" +credentials = "1.6.0-alpha04" +room = "2.7.2" biometrics = "1.2.0-alpha05" accompanist = "0.28.0" navigation = "2.8.7" +providerevents = "1.0.0-alpha02" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -38,6 +39,8 @@ androidx-room-compiler = { group = "androidx.room", name = "room-compiler", vers androidx-biometrics = { group = "androidx.biometric", name = "biometric", version.ref = "biometrics" } androidx-navigation = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" } google-accompanist = { group = "com.google.accompanist", name = "accompanist-systemuicontroller", version.ref = "accompanist" } +provider-events = { group = "androidx.credentials.providerevents", name = "providerevents", version.ref = "providerevents" } +provider-events-ps = { group = "androidx.credentials.providerevents", name = "providerevents-play-services", version.ref = "providerevents" } @@ -45,6 +48,7 @@ google-accompanist = { group = "com.google.accompanist", name = "accompanist-sys android-application = { id = "com.android.application", version.ref = "agp" } jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } devtools-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } diff --git a/CredentialProvider/MyVault/settings.gradle.kts b/CredentialProvider/MyVault/settings.gradle.kts index 2db4150d..1cab8b7c 100644 --- a/CredentialProvider/MyVault/settings.gradle.kts +++ b/CredentialProvider/MyVault/settings.gradle.kts @@ -21,4 +21,3 @@ dependencyResolutionManagement { rootProject.name = "MyVault" include(":app") - \ No newline at end of file From cfaaaedd1480caf397b12d6e064ba86afcaa98bc Mon Sep 17 00:00:00 2001 From: Neelansh Sahai <96shubh@gmail.com> Date: Thu, 11 Sep 2025 21:54:17 +0530 Subject: [PATCH 2/2] Fix code review changes Change-Id: I0571c151e62851634783b2aa8943b47fb84eea36 --- .../authentication/myvault/AppDependencies.kt | 7 + .../myvault/NotificationUtils.kt | 20 +- .../myvault/data/CredentialProviderService.kt | 211 ++++++++++++++---- .../myvault/data/CredentialsDataSource.kt | 4 +- .../myvault/data/CredentialsRepository.kt | 6 +- .../myvault/data/room/MyVaultDatabase.kt | 4 +- .../authentication/myvault/ui/MainActivity.kt | 5 +- .../app/src/main/res/values/strings.xml | 5 + .../MyVault/gradle/libs.versions.toml | 2 +- 9 files changed, 207 insertions(+), 57 deletions(-) diff --git a/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/AppDependencies.kt b/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/AppDependencies.kt index 0e745863..1fb29c5f 100644 --- a/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/AppDependencies.kt +++ b/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/AppDependencies.kt @@ -23,6 +23,9 @@ import com.example.android.authentication.myvault.data.CredentialsDataSource import com.example.android.authentication.myvault.data.CredentialsRepository import com.example.android.authentication.myvault.data.RPIconDataSource import com.example.android.authentication.myvault.data.room.MyVaultDatabase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob /** * This class is an application-level singleton object which is providing dependencies required for the app to function. @@ -42,6 +45,8 @@ object AppDependencies { lateinit var rpIconDataSource: RPIconDataSource + lateinit var coroutineScope: CoroutineScope + /** * Initializes the core components required for the application's data storage and icon handling. * This includes: @@ -72,5 +77,7 @@ object AppDependencies { credentialsDataSource, context, ) + + coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) } } diff --git a/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/NotificationUtils.kt b/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/NotificationUtils.kt index f49ccb03..de1536eb 100644 --- a/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/NotificationUtils.kt +++ b/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/NotificationUtils.kt @@ -13,6 +13,18 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import com.example.android.authentication.myvault.ui.MainActivity +/** + * Creates and registers a notification channel with the system. + * + * This is a utility extension function that creates a Notification Channel with + * [NotificationManager.IMPORTANCE_HIGH] to show pop-up notification on receiving + * signals from the RP apps + * + * @param channelName The user-visible name of the channel. + * This is displayed in the system's notification settings. + * @param channelDescription The user-visible description of the channel. + * This is displayed in the system's notification settings. + */ fun Context.createNotificationChannel( channelName: String, channelDescription: String, @@ -24,11 +36,17 @@ fun Context.createNotificationChannel( ).apply { description = channelDescription } - // Register the channel with the system. + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager notificationManager.createNotificationChannel(channel) } +/** + * Utility extension function that displays a system notification with the given title and content. + * + * @param title The title of the notification. + * @param content The main content text of the notification. + */ fun Context.showNotification( title: String, content: String, diff --git a/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/data/CredentialProviderService.kt b/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/data/CredentialProviderService.kt index 6bad435c..42e7bee9 100644 --- a/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/data/CredentialProviderService.kt +++ b/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/data/CredentialProviderService.kt @@ -16,13 +16,38 @@ import com.example.android.authentication.myvault.NAME import com.example.android.authentication.myvault.R import com.example.android.authentication.myvault.USER_ID import com.example.android.authentication.myvault.showNotification -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.json.JSONArray import org.json.JSONObject +/** + * A service that listens to credential provider events triggered by the relying parties + * + * This service is responsible for handling signals related to credential state changes in the RPs, + * such as when a credential is no longer valid, when a list of accepted credentials is accepted, + * or when current user details change for credentials + */ class CredentialProviderService: CredentialProviderEventsService() { private val dataSource = AppDependencies.credentialsDataSource + private val coroutineScope = AppDependencies.coroutineScope + /** + * Called when the system or another credential provider signals a change in credential state. + * + * This method inspects the type of [ProviderSignalCredentialStateRequest] and delegates + * to the appropriate handler function to update the local data store and show a notification. + * After processing the signal, {@link ProviderSignalCredentialStateCallback#onSignalConsumed()} + * is called to acknowledge receipt of the signal. + * + * The {@link SuppressLint("RestrictedApi")} annotation is used because this method + * interacts with APIs from the {@code androidx.credentials} library that might be + * marked as restricted for extension by library developers. + * + * @param request The request containing details about the credential state signal. + * @param callback The callback to be invoked after the signal has been processed. + */ @SuppressLint("RestrictedApi") override fun onSignalCredentialStateRequest( request: ProviderSignalCredentialStateRequest, @@ -30,77 +55,171 @@ class CredentialProviderService: CredentialProviderEventsService() { ) { when (request.callingRequest) { is SignalUnknownCredentialRequest -> { - handleUnknownCredentialRequest(request.callingRequest.requestJson) - showNotification( - getString(R.string.credential_deletion), - getString(R.string.unknown_signal_message) + updateDataOnSignalAndShowNotification( + handleRequest = ::handleUnknownCredentialRequest, + requestJson = request.callingRequest.requestJson, + notificationTitle = getString(R.string.credential_deletion), + notificationContent = getString(R.string.unknown_signal_message) ) } is SignalAllAcceptedCredentialIdsRequest -> { - handleAcceptedCredentialsRequest(request.callingRequest.requestJson) - showNotification( - getString(R.string.credentials_list_updation), - getString(R.string.all_accepted_signal_message) + updateDataOnSignalAndShowNotification( + handleRequest = ::handleAcceptedCredentialsRequest, + requestJson = request.callingRequest.requestJson, + notificationTitle = getString(R.string.credentials_list_updation), + notificationContent = getString(R.string.all_accepted_signal_message) ) } + is SignalCurrentUserDetailsRequest -> { - handleCurrentUserDetailRequest(request.callingRequest.requestJson) - showNotification( - getString(R.string.user_details_updation), - getString(R.string.current_user_signal_message) + updateDataOnSignalAndShowNotification( + handleRequest = ::handleCurrentUserDetailRequest, + requestJson = request.callingRequest.requestJson, + notificationTitle = getString(R.string.user_details_updation), + notificationContent = getString(R.string.current_user_signal_message) ) } + else -> { } } callback.onSignalConsumed() } - private fun handleUnknownCredentialRequest(requestJson: String) = runBlocking { - val credentialId = JSONObject(requestJson).getString(CREDENTIAL_ID) - dataSource.getPasskey(credentialId)?.let { - dataSource.hidePasskey(it) + /** + * A helper function to asynchronously handle a credential state update request, + * update the data source, and then show a system notification on the main thread. + * + * @param handleRequest A suspend function that takes the request JSON string and processes it. + * This function is responsible for interacting with the data source. + * @param requestJson The JSON string payload from the original credential signal request. + * @param notificationTitle The title to be used for the system notification. + * @param notificationContent The content text for the system notification. + */ + private fun updateDataOnSignalAndShowNotification( + handleRequest: suspend (String) -> Boolean, + requestJson: String, + notificationTitle: String, + notificationContent: String, + ) { + coroutineScope.launch { + val success = handleRequest(requestJson) + withContext(Dispatchers.Main) { + if (success) { + showNotification( + title = notificationTitle, + content = notificationContent, + ) + } + } } } - private fun handleAcceptedCredentialsRequest(requestJson: String) = runBlocking { - val request = JSONObject(requestJson) - val userId = request.getString(USER_ID) - val listCurrentPasskeysForUser = dataSource.getPasskeyForUser(userId) ?: emptyList() - val listAllAcceptedCredIds = mutableListOf() - when (val value = request.get(ACCEPTED_CREDENTIAL_IDS)) { - is String -> listAllAcceptedCredIds.add(value) - is JSONArray -> { - for (i in 0 until value.length()) { - val item = value.get(i) - if (item is String) { - listAllAcceptedCredIds.add(item) + /** + * Handles a [SignalUnknownCredentialRequest] by parsing the credential ID + * from the request JSON and attempting to hide the corresponding passkey in the data source. + * + * "Hiding" a passkey typically means marking it as inactive or not to be suggested + * for autofill, often because the system has indicated it's no longer valid + * (e.g., deleted from the authenticator). + * + * @param requestJson The JSON string payload from the [SignalUnknownCredentialRequest]. + * Expected to contain a {@code CREDENTIAL_ID}. + */ + private suspend fun handleUnknownCredentialRequest(requestJson: String): Boolean { + try { + val credentialId = JSONObject(requestJson).getString(CREDENTIAL_ID) + dataSource.getPasskey(credentialId)?.let { + // Currently hiding the passkey on UnknownSignal for testing purpose + // If the business logc requires deletion, please add deletion code instead + dataSource.hidePasskey(it) + } + return true + } catch (e: Exception) { + Log.e(getString(R.string.failed_to_handle_unknowncredentialrequest), e.toString()) + return false + } + } + + /** + * Handles a {@link SignalAllAcceptedCredentialIdsRequest} by synchronizing the visibility + * state of passkeys for a specific user. + * + * It retrieves all current passkeys for the user from the data source. Then, it compares + * this list against the list of accepted credential IDs provided in the signal. + * Passkeys whose IDs are in the accepted list are unhidden (made active). + * Passkeys whose IDs are not in the accepted list are hidden (made inactive). + * + * This is useful for scenarios where the system provides an authoritative list of + * credentials that are currently valid or preferred for a user. + * + * @param requestJson The JSON string payload from the {@link SignalAllAcceptedCredentialIdsRequest}. + * Expected to contain a {@code USER_ID} and {@code ACCEPTED_CREDENTIAL_IDS} + * (which can be a string or a JSON array of strings). + */ + private suspend fun handleAcceptedCredentialsRequest(requestJson: String): Boolean { + try { + val request = JSONObject(requestJson) + val userId = request.getString(USER_ID) + val listCurrentPasskeysForUser = dataSource.getAllPasskeysForUser(userId) ?: emptyList() + val listAllAcceptedCredIds = mutableListOf() + when (val value = request.get(ACCEPTED_CREDENTIAL_IDS)) { + is String -> listAllAcceptedCredIds.add(value) + is JSONArray -> { + for (i in 0 until value.length()) { + val item = value.get(i) + if (item is String) { + listAllAcceptedCredIds.add(item) + } } } + + else -> { /*do nothing*/ } } - else -> { /*do nothing*/ } - } - for (key in listCurrentPasskeysForUser) { - if (listAllAcceptedCredIds.contains(key.credId)) { - dataSource.unhidePasskey(key) - } else { - dataSource.hidePasskey(key) + for (key in listCurrentPasskeysForUser) { + if (listAllAcceptedCredIds.contains(key.credId)) { + dataSource.unhidePasskey(key) + } else { + dataSource.hidePasskey(key) + } } + return true + } catch (e: Exception) { + Log.e(getString(R.string.failed_to_handle_acceptedcredentialsrequest), e.toString()) + return false } } - private fun handleCurrentUserDetailRequest(requestJson: String) = runBlocking { - val request = JSONObject(requestJson) - val userId = request.getString(USER_ID) - val updatedName = request.getString(NAME) - val updatedDisplayName = request.getString(DISPLAY_NAME) - val listPasskeys = dataSource.getPasskeyForUser(userId) ?: emptyList() - // Update user details for each passkey - for (key in listPasskeys) { - val newPasskeyItem = key.copy(username = updatedName, displayName = updatedDisplayName) - dataSource.updatePasskey(newPasskeyItem) + /** + * Handles a {@link SignalCurrentUserDetailsRequest} by updating the username and display name + * for all passkeys associated with a given user ID. + * + * This is useful when the user's profile information (like name or display name) + * changes elsewhere, and the credential provider needs to reflect these changes + * in its stored passkey data. + * + * @param requestJson The JSON string payload from the {@link SignalCurrentUserDetailsRequest}. + * Expected to contain {@code USER_ID}, {@code NAME}, and {@code DISPLAY_NAME}. + */ + private suspend fun handleCurrentUserDetailRequest(requestJson: String): Boolean { + try { + val request = JSONObject(requestJson) + val userId = request.getString(USER_ID) + val updatedName = request.getString(NAME) + val updatedDisplayName = request.getString(DISPLAY_NAME) + val listPasskeys = dataSource.getAllPasskeysForUser(userId) ?: emptyList() + // Update user details for each passkey + for (key in listPasskeys) { + val newPasskeyItem = + key.copy(username = updatedName, displayName = updatedDisplayName) + dataSource.updatePasskey(newPasskeyItem) + } + return true + } catch (e: Exception) { + Log.e(getString(R.string.failed_to_handle_currentuserdetailrequest), e.toString()) + return false } } } diff --git a/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/data/CredentialsDataSource.kt b/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/data/CredentialsDataSource.kt index e1ad208b..5e37d591 100644 --- a/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/data/CredentialsDataSource.kt +++ b/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/data/CredentialsDataSource.kt @@ -127,8 +127,8 @@ class CredentialsDataSource( return myVaultDao.getPasskey(credId) } - fun getPasskeyForUser(userId: String): List? { - return myVaultDao.getPasskeysForUser(userId) + suspend fun getAllPasskeysForUser(userId: String): List? { + return myVaultDao.getAllPasskeysForUser(userId) } suspend fun hidePasskey(passkey: PasskeyItem) { diff --git a/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/data/CredentialsRepository.kt b/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/data/CredentialsRepository.kt index 52e41dec..94cfc2b2 100644 --- a/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/data/CredentialsRepository.kt +++ b/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/data/CredentialsRepository.kt @@ -228,8 +228,9 @@ class CredentialsRepository( val credentials = credentialsDataSource.credentialsForSite(request.rpId) ?: return false val passkeys = credentials.passkeys - for (passkey in passkeys) { - if (!passkey.hidden) { + passkeys + .filter { !it.hidden } + .forEach { passkey -> val data = Bundle() data.putString("requestJson", option.requestJson) data.putString("credId", passkey.credId) @@ -259,7 +260,6 @@ class CredentialsRepository( // Add the entry to the response builder. responseBuilder.addCredentialEntry(entry) } - } } catch (e: IOException) { return false } diff --git a/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/data/room/MyVaultDatabase.kt b/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/data/room/MyVaultDatabase.kt index 0e0ea5a9..c32818af 100644 --- a/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/data/room/MyVaultDatabase.kt +++ b/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/data/room/MyVaultDatabase.kt @@ -89,6 +89,6 @@ interface MyVaultDao { @Query("SELECT * from passkeys WHERE credId = :credId") fun getPasskey(credId: String): PasskeyItem? - @Query("SELECT * from passkeys WHERE uid = :userId and hidden = false") - fun getPasskeysForUser(userId: String): List? + @Query("SELECT * from passkeys WHERE uid = :userId") + suspend fun getAllPasskeysForUser(userId: String): List? } diff --git a/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/ui/MainActivity.kt b/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/ui/MainActivity.kt index b6e537a3..f95f7799 100644 --- a/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/ui/MainActivity.kt +++ b/CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/ui/MainActivity.kt @@ -20,6 +20,7 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.core.view.WindowCompat +import com.example.android.authentication.myvault.R import com.example.android.authentication.myvault.createNotificationChannel import com.example.android.authentication.myvault.ui.theme.MyVaultTheme @@ -28,8 +29,8 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() super.onCreate(savedInstanceState) createNotificationChannel( - "Signal API notification channel", - "Notification channel used for testing Signal APIs. Apps pushes a notification if a Signal from RP is received" + getString(R.string.notification_channel_name), + getString(R.string.notification_channel_description), ) WindowCompat.setDecorFitsSystemWindows(window, false) diff --git a/CredentialProvider/MyVault/app/src/main/res/values/strings.xml b/CredentialProvider/MyVault/app/src/main/res/values/strings.xml index 3c0c67ff..8fb460d7 100644 --- a/CredentialProvider/MyVault/app/src/main/res/values/strings.xml +++ b/CredentialProvider/MyVault/app/src/main/res/values/strings.xml @@ -75,4 +75,9 @@ The provider received the AcceptedCredentialIds request and thus updated the list of user credentials User details updated successfully The provider received the CurrentUserDetails request and thus updated the user details in the credential + Notification channel used for testing Signal APIs. Apps pushes a notification if a Signal from RP is received + Signal API notification channel + Failed to handle UnknownCredentialRequest + Failed to handle AcceptedCredentialsRequest + Failed to handle CurrentUserDetailRequest diff --git a/CredentialProvider/MyVault/gradle/libs.versions.toml b/CredentialProvider/MyVault/gradle/libs.versions.toml index ef931eee..2628a0f4 100644 --- a/CredentialProvider/MyVault/gradle/libs.versions.toml +++ b/CredentialProvider/MyVault/gradle/libs.versions.toml @@ -9,7 +9,7 @@ espressoCore = "3.6.1" lifecycleRuntime = "2.8.7" activityCompose = "1.10.0" composeBom = "2025.02.00" -credentials = "1.6.0-alpha04" +credentials = "1.6.0-alpha05" room = "2.7.2" biometrics = "1.2.0-alpha05" accompanist = "0.28.0"