Skip to content

Commit ff82228

Browse files
neelanshsahaiNeelansh Sahai
andauthored
Add Signal API implementation (Provider) (#160)
* Add Signal API implementation (Provider) Change-Id: I5284a17da9791cd64e8d7ea8befe96dd5c770e76 * Fix code review changes Change-Id: I0571c151e62851634783b2aa8943b47fb84eea36 --------- Co-authored-by: Neelansh Sahai <[email protected]>
1 parent f119255 commit ff82228

File tree

17 files changed

+409
-34
lines changed

17 files changed

+409
-34
lines changed

CredentialProvider/MyVault/app/build.gradle.kts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ plugins {
1717
alias(libs.plugins.android.application)
1818
alias(libs.plugins.jetbrains.kotlin.android)
1919
alias(libs.plugins.devtools.ksp)
20+
alias(libs.plugins.compose.compiler)
2021
}
2122

2223
android {
@@ -75,7 +76,11 @@ dependencies {
7576
implementation(libs.androidx.ui.graphics)
7677
implementation(libs.androidx.ui.tooling.preview)
7778
implementation(libs.androidx.material3)
79+
7880
implementation(libs.androidx.credential.manager)
81+
implementation(libs.provider.events)
82+
implementation(libs.provider.events.ps)
83+
7984
implementation(libs.androidx.room.ktx)
8085
implementation(libs.androidx.room.runtime)
8186
ksp(libs.androidx.room.compiler)

CredentialProvider/MyVault/app/src/main/AndroidManifest.xml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
xmlns:tools="http://schemas.android.com/tools">
1919

2020
<uses-permission android:name="android.permission.INTERNET" />
21+
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
2122
<application
2223
android:name="com.example.android.authentication.myvault.MyVaultApplication"
2324
android:icon="@drawable/android_secure"
@@ -105,5 +106,16 @@
105106
android:name="android.credentials.provider"
106107
android:resource="@xml/provider" />
107108
</service>
109+
110+
<service android:name=".data.CredentialProviderService"
111+
android:enabled="true"
112+
android:exported="true"
113+
android:label="My Credential Provider"
114+
android:icon="@drawable/android_secure">
115+
<intent-filter>
116+
<action android:name="android.credentials.EVENTS_SERVICE_ACTION"/>
117+
</intent-filter>
118+
</service>
119+
108120
</application>
109121
</manifest>

CredentialProvider/MyVault/app/src/main/java/com/example/android/authentication/myvault/AppDependencies.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ import com.example.android.authentication.myvault.data.CredentialsDataSource
2323
import com.example.android.authentication.myvault.data.CredentialsRepository
2424
import com.example.android.authentication.myvault.data.RPIconDataSource
2525
import com.example.android.authentication.myvault.data.room.MyVaultDatabase
26+
import kotlinx.coroutines.CoroutineScope
27+
import kotlinx.coroutines.Dispatchers
28+
import kotlinx.coroutines.SupervisorJob
2629

2730
/**
2831
* This class is an application-level singleton object which is providing dependencies required for the app to function.
@@ -42,6 +45,8 @@ object AppDependencies {
4245

4346
lateinit var rpIconDataSource: RPIconDataSource
4447

48+
lateinit var coroutineScope: CoroutineScope
49+
4550
/**
4651
* Initializes the core components required for the application's data storage and icon handling.
4752
* This includes:
@@ -72,5 +77,7 @@ object AppDependencies {
7277
credentialsDataSource,
7378
context,
7479
)
80+
81+
coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
7582
}
7683
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.example.android.authentication.myvault
2+
3+
const val NOTIFICATION_CHANNEL_ID = "channel_id"
4+
const val NOTIFICATION_ID = 135
5+
const val CREDENTIAL_ID = "credentialId"
6+
const val USER_ID = "userId"
7+
const val ACCEPTED_CREDENTIAL_IDS = "allAcceptedCredentialIds"
8+
const val NAME = "name"
9+
const val DISPLAY_NAME = "displayName"
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package com.example.android.authentication.myvault
2+
3+
import android.Manifest
4+
import android.app.NotificationChannel
5+
import android.app.NotificationManager
6+
import android.app.PendingIntent
7+
import android.content.ComponentName
8+
import android.content.Context
9+
import android.content.Intent
10+
import android.content.pm.PackageManager
11+
import androidx.core.app.ActivityCompat
12+
import androidx.core.app.NotificationCompat
13+
import androidx.core.app.NotificationManagerCompat
14+
import com.example.android.authentication.myvault.ui.MainActivity
15+
16+
/**
17+
* Creates and registers a notification channel with the system.
18+
*
19+
* This is a utility extension function that creates a Notification Channel with
20+
* [NotificationManager.IMPORTANCE_HIGH] to show pop-up notification on receiving
21+
* signals from the RP apps
22+
*
23+
* @param channelName The user-visible name of the channel.
24+
* This is displayed in the system's notification settings.
25+
* @param channelDescription The user-visible description of the channel.
26+
* This is displayed in the system's notification settings.
27+
*/
28+
fun Context.createNotificationChannel(
29+
channelName: String,
30+
channelDescription: String,
31+
) {
32+
val channel = NotificationChannel(
33+
NOTIFICATION_CHANNEL_ID,
34+
channelName,
35+
NotificationManager.IMPORTANCE_HIGH
36+
).apply {
37+
description = channelDescription
38+
}
39+
40+
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
41+
notificationManager.createNotificationChannel(channel)
42+
}
43+
44+
/**
45+
* Utility extension function that displays a system notification with the given title and content.
46+
*
47+
* @param title The title of the notification.
48+
* @param content The main content text of the notification.
49+
*/
50+
fun Context.showNotification(
51+
title: String,
52+
content: String,
53+
) {
54+
val intent = Intent(this, MainActivity::class.java)
55+
val pendingIntent: PendingIntent = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE)
56+
57+
val builder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
58+
.setSmallIcon(R.drawable.android_secure)
59+
.setContentTitle(title)
60+
.setContentText(content)
61+
.setPriority(NotificationCompat.PRIORITY_MAX)
62+
.setContentIntent(pendingIntent)
63+
.setAutoCancel(true)
64+
65+
with(NotificationManagerCompat.from(this)) {
66+
if (ActivityCompat.checkSelfPermission(
67+
this@showNotification,
68+
Manifest.permission.POST_NOTIFICATIONS
69+
) != PackageManager.PERMISSION_GRANTED) {
70+
return@with
71+
}
72+
notify(NOTIFICATION_ID, builder.build())
73+
}
74+
}
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
package com.example.android.authentication.myvault.data
2+
3+
import android.annotation.SuppressLint
4+
import android.util.Log
5+
import androidx.credentials.SignalAllAcceptedCredentialIdsRequest
6+
import androidx.credentials.SignalCurrentUserDetailsRequest
7+
import androidx.credentials.SignalUnknownCredentialRequest
8+
import androidx.credentials.providerevents.service.CredentialProviderEventsService
9+
import androidx.credentials.providerevents.signal.ProviderSignalCredentialStateCallback
10+
import androidx.credentials.providerevents.signal.ProviderSignalCredentialStateRequest
11+
import com.example.android.authentication.myvault.ACCEPTED_CREDENTIAL_IDS
12+
import com.example.android.authentication.myvault.AppDependencies
13+
import com.example.android.authentication.myvault.CREDENTIAL_ID
14+
import com.example.android.authentication.myvault.DISPLAY_NAME
15+
import com.example.android.authentication.myvault.NAME
16+
import com.example.android.authentication.myvault.R
17+
import com.example.android.authentication.myvault.USER_ID
18+
import com.example.android.authentication.myvault.showNotification
19+
import kotlinx.coroutines.Dispatchers
20+
import kotlinx.coroutines.launch
21+
import kotlinx.coroutines.withContext
22+
import org.json.JSONArray
23+
import org.json.JSONObject
24+
25+
/**
26+
* A service that listens to credential provider events triggered by the relying parties
27+
*
28+
* This service is responsible for handling signals related to credential state changes in the RPs,
29+
* such as when a credential is no longer valid, when a list of accepted credentials is accepted,
30+
* or when current user details change for credentials
31+
*/
32+
class CredentialProviderService: CredentialProviderEventsService() {
33+
private val dataSource = AppDependencies.credentialsDataSource
34+
private val coroutineScope = AppDependencies.coroutineScope
35+
36+
/**
37+
* Called when the system or another credential provider signals a change in credential state.
38+
*
39+
* This method inspects the type of [ProviderSignalCredentialStateRequest] and delegates
40+
* to the appropriate handler function to update the local data store and show a notification.
41+
* After processing the signal, {@link ProviderSignalCredentialStateCallback#onSignalConsumed()}
42+
* is called to acknowledge receipt of the signal.
43+
*
44+
* The {@link SuppressLint("RestrictedApi")} annotation is used because this method
45+
* interacts with APIs from the {@code androidx.credentials} library that might be
46+
* marked as restricted for extension by library developers.
47+
*
48+
* @param request The request containing details about the credential state signal.
49+
* @param callback The callback to be invoked after the signal has been processed.
50+
*/
51+
@SuppressLint("RestrictedApi")
52+
override fun onSignalCredentialStateRequest(
53+
request: ProviderSignalCredentialStateRequest,
54+
callback: ProviderSignalCredentialStateCallback,
55+
) {
56+
when (request.callingRequest) {
57+
is SignalUnknownCredentialRequest -> {
58+
updateDataOnSignalAndShowNotification(
59+
handleRequest = ::handleUnknownCredentialRequest,
60+
requestJson = request.callingRequest.requestJson,
61+
notificationTitle = getString(R.string.credential_deletion),
62+
notificationContent = getString(R.string.unknown_signal_message)
63+
)
64+
}
65+
66+
is SignalAllAcceptedCredentialIdsRequest -> {
67+
updateDataOnSignalAndShowNotification(
68+
handleRequest = ::handleAcceptedCredentialsRequest,
69+
requestJson = request.callingRequest.requestJson,
70+
notificationTitle = getString(R.string.credentials_list_updation),
71+
notificationContent = getString(R.string.all_accepted_signal_message)
72+
)
73+
}
74+
75+
is SignalCurrentUserDetailsRequest -> {
76+
updateDataOnSignalAndShowNotification(
77+
handleRequest = ::handleCurrentUserDetailRequest,
78+
requestJson = request.callingRequest.requestJson,
79+
notificationTitle = getString(R.string.user_details_updation),
80+
notificationContent = getString(R.string.current_user_signal_message)
81+
)
82+
}
83+
84+
else -> { }
85+
}
86+
87+
callback.onSignalConsumed()
88+
}
89+
90+
/**
91+
* A helper function to asynchronously handle a credential state update request,
92+
* update the data source, and then show a system notification on the main thread.
93+
*
94+
* @param handleRequest A suspend function that takes the request JSON string and processes it.
95+
* This function is responsible for interacting with the data source.
96+
* @param requestJson The JSON string payload from the original credential signal request.
97+
* @param notificationTitle The title to be used for the system notification.
98+
* @param notificationContent The content text for the system notification.
99+
*/
100+
private fun updateDataOnSignalAndShowNotification(
101+
handleRequest: suspend (String) -> Boolean,
102+
requestJson: String,
103+
notificationTitle: String,
104+
notificationContent: String,
105+
) {
106+
coroutineScope.launch {
107+
val success = handleRequest(requestJson)
108+
withContext(Dispatchers.Main) {
109+
if (success) {
110+
showNotification(
111+
title = notificationTitle,
112+
content = notificationContent,
113+
)
114+
}
115+
}
116+
}
117+
}
118+
119+
/**
120+
* Handles a [SignalUnknownCredentialRequest] by parsing the credential ID
121+
* from the request JSON and attempting to hide the corresponding passkey in the data source.
122+
*
123+
* "Hiding" a passkey typically means marking it as inactive or not to be suggested
124+
* for autofill, often because the system has indicated it's no longer valid
125+
* (e.g., deleted from the authenticator).
126+
*
127+
* @param requestJson The JSON string payload from the [SignalUnknownCredentialRequest].
128+
* Expected to contain a {@code CREDENTIAL_ID}.
129+
*/
130+
private suspend fun handleUnknownCredentialRequest(requestJson: String): Boolean {
131+
try {
132+
val credentialId = JSONObject(requestJson).getString(CREDENTIAL_ID)
133+
dataSource.getPasskey(credentialId)?.let {
134+
// Currently hiding the passkey on UnknownSignal for testing purpose
135+
// If the business logc requires deletion, please add deletion code instead
136+
dataSource.hidePasskey(it)
137+
}
138+
return true
139+
} catch (e: Exception) {
140+
Log.e(getString(R.string.failed_to_handle_unknowncredentialrequest), e.toString())
141+
return false
142+
}
143+
}
144+
145+
/**
146+
* Handles a {@link SignalAllAcceptedCredentialIdsRequest} by synchronizing the visibility
147+
* state of passkeys for a specific user.
148+
*
149+
* It retrieves all current passkeys for the user from the data source. Then, it compares
150+
* this list against the list of accepted credential IDs provided in the signal.
151+
* Passkeys whose IDs are in the accepted list are unhidden (made active).
152+
* Passkeys whose IDs are not in the accepted list are hidden (made inactive).
153+
*
154+
* This is useful for scenarios where the system provides an authoritative list of
155+
* credentials that are currently valid or preferred for a user.
156+
*
157+
* @param requestJson The JSON string payload from the {@link SignalAllAcceptedCredentialIdsRequest}.
158+
* Expected to contain a {@code USER_ID} and {@code ACCEPTED_CREDENTIAL_IDS}
159+
* (which can be a string or a JSON array of strings).
160+
*/
161+
private suspend fun handleAcceptedCredentialsRequest(requestJson: String): Boolean {
162+
try {
163+
val request = JSONObject(requestJson)
164+
val userId = request.getString(USER_ID)
165+
val listCurrentPasskeysForUser = dataSource.getAllPasskeysForUser(userId) ?: emptyList()
166+
val listAllAcceptedCredIds = mutableListOf<String>()
167+
when (val value = request.get(ACCEPTED_CREDENTIAL_IDS)) {
168+
is String -> listAllAcceptedCredIds.add(value)
169+
is JSONArray -> {
170+
for (i in 0 until value.length()) {
171+
val item = value.get(i)
172+
if (item is String) {
173+
listAllAcceptedCredIds.add(item)
174+
}
175+
}
176+
}
177+
178+
else -> { /*do nothing*/ }
179+
}
180+
181+
for (key in listCurrentPasskeysForUser) {
182+
if (listAllAcceptedCredIds.contains(key.credId)) {
183+
dataSource.unhidePasskey(key)
184+
} else {
185+
dataSource.hidePasskey(key)
186+
}
187+
}
188+
return true
189+
} catch (e: Exception) {
190+
Log.e(getString(R.string.failed_to_handle_acceptedcredentialsrequest), e.toString())
191+
return false
192+
}
193+
}
194+
195+
/**
196+
* Handles a {@link SignalCurrentUserDetailsRequest} by updating the username and display name
197+
* for all passkeys associated with a given user ID.
198+
*
199+
* This is useful when the user's profile information (like name or display name)
200+
* changes elsewhere, and the credential provider needs to reflect these changes
201+
* in its stored passkey data.
202+
*
203+
* @param requestJson The JSON string payload from the {@link SignalCurrentUserDetailsRequest}.
204+
* Expected to contain {@code USER_ID}, {@code NAME}, and {@code DISPLAY_NAME}.
205+
*/
206+
private suspend fun handleCurrentUserDetailRequest(requestJson: String): Boolean {
207+
try {
208+
val request = JSONObject(requestJson)
209+
val userId = request.getString(USER_ID)
210+
val updatedName = request.getString(NAME)
211+
val updatedDisplayName = request.getString(DISPLAY_NAME)
212+
val listPasskeys = dataSource.getAllPasskeysForUser(userId) ?: emptyList()
213+
// Update user details for each passkey
214+
for (key in listPasskeys) {
215+
val newPasskeyItem =
216+
key.copy(username = updatedName, displayName = updatedDisplayName)
217+
dataSource.updatePasskey(newPasskeyItem)
218+
}
219+
return true
220+
} catch (e: Exception) {
221+
Log.e(getString(R.string.failed_to_handle_currentuserdetailrequest), e.toString())
222+
return false
223+
}
224+
}
225+
}

0 commit comments

Comments
 (0)