Skip to content

Commit a46b32f

Browse files
Settings: Added Passkey Management Page (#3445)
Co-authored-by: Marvin W <git@larma.de>
1 parent 97c9927 commit a46b32f

12 files changed

Lines changed: 335 additions & 0 deletions

File tree

play-services-core/src/main/kotlin/org/microg/gms/ui/AccountsFragment.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ class AccountsFragment : PreferenceFragmentCompat() {
142142

143143
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
144144
menu.add(0, MENU_GAMES_MANAGED, 0, org.microg.gms.base.core.R.string.menu_game_managed)
145+
menu.add(0, MENU_PASSKEY_MANAGER, 1, R.string.pref_passkey_manager_title)
145146
super.onCreateOptionsMenu(menu, inflater)
146147
}
147148

@@ -152,11 +153,17 @@ class AccountsFragment : PreferenceFragmentCompat() {
152153
true
153154
}
154155

156+
MENU_PASSKEY_MANAGER -> {
157+
findNavController().navigate(requireContext(), R.id.openPasskeyManagerSettings)
158+
true
159+
}
160+
155161
else -> super.onOptionsItemSelected(item)
156162
}
157163
}
158164

159165
companion object {
160166
private const val MENU_GAMES_MANAGED = Menu.FIRST
167+
private const val MENU_PASSKEY_MANAGER = Menu.FIRST + 1
161168
}
162169
}
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2026 microG Project Team
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package org.microg.gms.ui
7+
8+
import android.content.Context
9+
import android.os.Bundle
10+
import android.text.format.DateUtils
11+
import android.util.Base64
12+
import android.util.Log
13+
import android.widget.Toast
14+
import androidx.appcompat.app.AlertDialog
15+
import androidx.lifecycle.lifecycleScope
16+
import androidx.preference.Preference
17+
import androidx.preference.PreferenceCategory
18+
import androidx.preference.PreferenceFragmentCompat
19+
import com.google.android.gms.R
20+
import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialUserEntity
21+
import kotlinx.coroutines.Dispatchers
22+
import kotlinx.coroutines.withContext
23+
import org.microg.gms.fido.core.Database
24+
import org.microg.gms.fido.core.KnownRegistration
25+
import org.microg.gms.fido.core.transport.Transport
26+
import org.microg.gms.fido.core.transport.screenlock.ScreenLockCredentialStore
27+
import org.microg.gms.profile.Build
28+
29+
class PasskeyManagerFragment : PreferenceFragmentCompat() {
30+
31+
private lateinit var category: PreferenceCategory
32+
private lateinit var emptyPlaceholder: Preference
33+
34+
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
35+
addPreferencesFromResource(R.xml.preferences_passkey_manager)
36+
category = preferenceScreen.findPreference(PREFCAT_PASSKEYS) ?: return
37+
emptyPlaceholder = preferenceScreen.findPreference(PREF_PASSKEYS_NONE) ?: return
38+
}
39+
40+
override fun onResume() {
41+
super.onResume()
42+
updateContent()
43+
}
44+
45+
private fun updateContent() {
46+
val ctx = requireContext().applicationContext
47+
lifecycleScope.launchWhenResumed {
48+
val list = withContext(Dispatchers.IO) {
49+
runCatching { Database(ctx).getAllKnownRegistrations() }
50+
.onFailure { Log.w(TAG, "Failed to load passkeys", it) }
51+
.getOrDefault(emptyList())
52+
}
53+
category.removeAll()
54+
if (list.isEmpty()) {
55+
category.addPreference(emptyPlaceholder)
56+
} else {
57+
list.forEachIndexed { index, item ->
58+
category.addPreference(buildPasskeyPreference(ctx, item, index))
59+
}
60+
}
61+
}
62+
}
63+
64+
private fun buildPasskeyPreference(ctx: Context, item: KnownRegistration, order: Int): Preference =
65+
Preference(ctx).apply {
66+
key = "pref_passkey_${item.rpId}_${item.credentialId}"
67+
this.order = order
68+
isIconSpaceReserved = false
69+
widgetLayoutResource = R.layout.widget_passkey_delete
70+
title = item.rpId
71+
summary = buildSummary(ctx, item)
72+
setOnPreferenceClickListener {
73+
confirmDelete(item)
74+
true
75+
}
76+
}
77+
78+
private fun confirmDelete(item: KnownRegistration) {
79+
val displayUser = formatPasskeyUser(requireContext(), item.userJson)
80+
AlertDialog.Builder(requireContext())
81+
.setTitle(R.string.pref_passkey_manager_delete_dialog_title)
82+
.setMessage(getString(R.string.pref_passkey_manager_delete_dialog_message, item.rpId, displayUser))
83+
.setNegativeButton(android.R.string.cancel, null)
84+
.setPositiveButton(R.string.pref_passkey_manager_delete_dialog_confirm) { _, _ ->
85+
performDelete(item)
86+
}
87+
.show()
88+
}
89+
90+
private fun performDelete(item: KnownRegistration) {
91+
val ctx = requireContext().applicationContext
92+
lifecycleScope.launchWhenResumed {
93+
val ok = withContext(Dispatchers.IO) {
94+
runCatching {
95+
if (Build.VERSION.SDK_INT >= 23) {
96+
val keyId = Base64.decode(item.credentialId, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
97+
ScreenLockCredentialStore(ctx).deleteKey(item.rpId, keyId)
98+
}
99+
Database(ctx).deleteKnownRegistration(item.rpId, item.credentialId)
100+
}.onFailure { Log.w(TAG, "Failed to delete passkey", it) }.isSuccess
101+
}
102+
Log.d(TAG, "performDelete ok? = $ok")
103+
updateContent()
104+
}
105+
}
106+
107+
companion object {
108+
private const val TAG = "PasskeyManager"
109+
private const val PREFCAT_PASSKEYS = "prefcat_passkeys"
110+
private const val PREF_PASSKEYS_NONE = "pref_passkeys_none"
111+
}
112+
}
113+
114+
internal fun formatPasskeyUser(context: Context, userJson: String?): String {
115+
if (userJson.isNullOrBlank()) return context.getString(R.string.pref_passkey_manager_unknown_user)
116+
return try {
117+
val entity = PublicKeyCredentialUserEntity.parseJson(userJson)
118+
val displayName = entity.displayName?.takeIf { it.isNotBlank() }
119+
val name = entity.name?.takeIf { it.isNotBlank() }
120+
when {
121+
displayName != null && name != null && displayName != name -> "$displayName ($name)"
122+
displayName != null -> displayName
123+
name != null -> name
124+
else -> context.getString(R.string.pref_passkey_manager_unknown_user)
125+
}
126+
} catch (e: Exception) {
127+
context.getString(R.string.pref_passkey_manager_unknown_user)
128+
}
129+
}
130+
131+
private fun buildSummary(context: Context, item: KnownRegistration): String {
132+
val user = formatPasskeyUser(context, item.userJson)
133+
val time = DateUtils.getRelativeTimeSpanString(
134+
item.timestamp,
135+
System.currentTimeMillis(),
136+
DateUtils.MINUTE_IN_MILLIS
137+
).toString()
138+
val transportLabel = context.getString(transportLabelRes(item.transport))
139+
val credId = context.getString(
140+
R.string.pref_passkey_manager_credential_id_format_internal,
141+
truncateCredentialId(item.credentialId)
142+
)
143+
return "$user\n$time · $transportLabel\n$credId"
144+
}
145+
146+
private fun transportLabelRes(transport: Transport): Int = when (transport) {
147+
Transport.SCREEN_LOCK -> R.string.pref_passkey_manager_transport_screen_lock
148+
Transport.USB -> R.string.pref_passkey_manager_transport_usb
149+
Transport.NFC -> R.string.pref_passkey_manager_transport_nfc
150+
Transport.BLUETOOTH -> R.string.pref_passkey_manager_transport_bluetooth
151+
Transport.HYBRID -> R.string.pref_passkey_manager_transport_hybrid
152+
}
153+
154+
private fun truncateCredentialId(id: String): String {
155+
if (id.length <= 16) return id
156+
return id.substring(0, 6) + "" + id.substring(id.length - 6)
157+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
2+
android:width="24dp"
3+
android:height="24dp"
4+
android:viewportWidth="24"
5+
android:viewportHeight="24"
6+
android:tint="?attr/colorControlNormal">
7+
<path
8+
android:fillColor="@android:color/white"
9+
android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM8.46,11.88l1.41,-1.41L12,12.59l2.12,-2.12 1.41,1.41L13.41,14l2.12,2.12 -1.41,1.41L12,15.41l-2.12,2.12 -1.41,-1.41L10.59,14l-2.13,-2.12zM15.5,4l-1,-1h-5l-1,1H5v2h14V4z"/>
10+
</vector>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<ImageView xmlns:android="http://schemas.android.com/apk/res/android"
3+
android:layout_width="24dp"
4+
android:layout_height="24dp"
5+
android:src="@drawable/ic_delete"
6+
android:contentDescription="@null"
7+
android:importantForAccessibility="no" />

play-services-core/src/main/res/navigation/nav_settings.xml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,21 @@
5656
<action
5757
android:id="@+id/openGameManagerSettings"
5858
app:destination="@id/gameManagerFragment" />
59+
<action
60+
android:id="@+id/openPasskeyManagerSettings"
61+
app:destination="@id/passkeyManagerFragment" />
5962
</fragment>
6063

6164
<fragment
6265
android:id="@+id/gameManagerFragment"
6366
android:name="org.microg.gms.ui.GameProfileFragment"
6467
android:label="@string/pref_game_accounts_title"/>
6568

69+
<fragment
70+
android:id="@+id/passkeyManagerFragment"
71+
android:name="org.microg.gms.ui.PasskeyManagerFragment"
72+
android:label="@string/pref_passkey_manager_title" />
73+
6674
<!-- Device registration -->
6775

6876
<fragment

play-services-core/src/main/res/values-zh-rCN/strings.xml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,4 +403,19 @@ microG GmsCore 内置一套自由的 SafetyNet 实现,但是官方服务器要
403403

404404
<string name="credentials_service_sign_in_with_google_label">用 Google 登录</string>
405405
<string name="credentials_service_remote_custom_subtitle">安全密钥、智能手机或平板</string>
406+
407+
<!-- Passkey management -->
408+
<string name="pref_passkey_manager_title">管理通行密钥</string>
409+
<string name="pref_passkey_manager_empty_title">暂无已保存的通行密钥</string>
410+
<string name="pref_passkey_manager_description">第三方应用注册通行密钥时,microG 会在本地保存对应记录。若应用内删除密钥时未同步通知 microG,本地会留下残留记录,可在此手动清理。</string>
411+
<string name="pref_passkey_manager_delete_dialog_title">删除通行密钥</string>
412+
<string name="pref_passkey_manager_delete_dialog_message">即将删除\"%1$s\"上账号\"%2$s\"的通行密钥。删除后该密钥无法恢复,下次登录需重新注册。</string>
413+
<string name="pref_passkey_manager_delete_dialog_confirm">删除</string>
414+
<string name="pref_passkey_manager_unknown_user">未知用户</string>
415+
<string name="pref_passkey_manager_transport_screen_lock">屏幕锁</string>
416+
<string name="pref_passkey_manager_transport_usb">USB</string>
417+
<string name="pref_passkey_manager_transport_nfc">NFC</string>
418+
<string name="pref_passkey_manager_transport_bluetooth">蓝牙</string>
419+
<string name="pref_passkey_manager_transport_hybrid">混合</string>
420+
<string name="pref_passkey_manager_credential_id_format_internal">ID: %1$s</string>
406421
</resources>

play-services-core/src/main/res/values-zh-rTW/strings.xml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,4 +406,18 @@
406406
<string name="games_delete_profile_dialog_title">確定要刪除此帳戶嗎?</string>
407407
<string name="pref_app_install_permission_instruction">為確保您已安裝的應用程式正常運作,請授權 microG Companion 安裝來自其他來源的應用程式。</string>
408408
<string name="location_sharing_confirm_dialog_text">與您分享位置的人始終可看到:\n·您的姓名和相片\n·您裝置的近期位置,即使您不在使用 Google 服務\n·您裝置的電量以及是否正在充電\n·您的抵達和離開時間(若他們新增位置分享通知)</string>
409+
<!-- Passkey management -->
410+
<string name="pref_passkey_manager_title">管理通行密鑰</string>
411+
<string name="pref_passkey_manager_empty_title">尚無已儲存的通行密鑰</string>
412+
<string name="pref_passkey_manager_description">第三方應用註冊通行密鑰時,microG 會在本地保存對應記錄。若應用內刪除密鑰時未同步通知 microG,本地會留下殘留記錄,可在此手動清理。</string>
413+
<string name="pref_passkey_manager_delete_dialog_title">刪除通行密鑰</string>
414+
<string name="pref_passkey_manager_delete_dialog_message">即將刪除\"%1$s\"上帳號\"%2$s\"的通行密鑰。刪除後該密鑰無法復原,下次登入需重新註冊。</string>
415+
<string name="pref_passkey_manager_delete_dialog_confirm">刪除</string>
416+
<string name="pref_passkey_manager_unknown_user">未知使用者</string>
417+
<string name="pref_passkey_manager_transport_screen_lock">螢幕鎖</string>
418+
<string name="pref_passkey_manager_transport_usb">USB</string>
419+
<string name="pref_passkey_manager_transport_nfc">NFC</string>
420+
<string name="pref_passkey_manager_transport_bluetooth">藍牙</string>
421+
<string name="pref_passkey_manager_transport_hybrid">混合</string>
422+
<string name="pref_passkey_manager_credential_id_format_internal">ID: %1$s</string>
409423
</resources>

play-services-core/src/main/res/values/strings.xml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,4 +460,19 @@ Please set up a password, PIN, or pattern lock screen."</string>
460460
<string name="location_sharing_turn_off_confirm">Turn off</string>
461461
<string name="location_sharing_confirm_dialog_title">Enable Location Sharing</string>
462462
<string name="location_sharing_confirm_dialog_text">People you share your location with can always see:\n·Your name and photo\n·Your device\'s recent location,even when you\'re not using a Google service\n·Your device\'s battery power,and if it\'s charging\n·Your arrival and departure time,if they add a Location Sharing notification</string>
463+
464+
<!-- Passkey management -->
465+
<string name="pref_passkey_manager_title">Manage passkeys</string>
466+
<string name="pref_passkey_manager_empty_title">No saved passkeys</string>
467+
<string name="pref_passkey_manager_description">When third-party apps register passkeys, microG keeps a local record. If an app deletes a passkey without notifying microG, the record can remain stranded. You can remove such residual entries here.</string>
468+
<string name="pref_passkey_manager_delete_dialog_title">Delete passkey</string>
469+
<string name="pref_passkey_manager_delete_dialog_message">Delete passkey for \"%2$s\" on \"%1$s\". This cannot be undone; you will need to register a new passkey to sign in again.</string>
470+
<string name="pref_passkey_manager_delete_dialog_confirm">Delete</string>
471+
<string name="pref_passkey_manager_unknown_user">Unknown user</string>
472+
<string name="pref_passkey_manager_transport_screen_lock">Screen lock</string>
473+
<string name="pref_passkey_manager_transport_usb">USB</string>
474+
<string name="pref_passkey_manager_transport_nfc">NFC</string>
475+
<string name="pref_passkey_manager_transport_bluetooth">Bluetooth</string>
476+
<string name="pref_passkey_manager_transport_hybrid">Hybrid</string>
477+
<string name="pref_passkey_manager_credential_id_format_internal">ID: %1$s</string>
463478
</resources>
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
3+
xmlns:app="http://schemas.android.com/apk/res-auto"
4+
android:title="@string/pref_passkey_manager_title">
5+
6+
<Preference
7+
android:key="pref_passkey_manager_description"
8+
android:selectable="false"
9+
android:summary="@string/pref_passkey_manager_description"
10+
app:iconSpaceReserved="false" />
11+
12+
<PreferenceCategory
13+
android:key="prefcat_passkeys"
14+
android:layout="@layout/preference_category_no_label"
15+
app:iconSpaceReserved="false">
16+
<Preference
17+
android:enabled="false"
18+
android:key="pref_passkeys_none"
19+
android:title="@string/pref_passkey_manager_empty_title"
20+
app:iconSpaceReserved="false" />
21+
</PreferenceCategory>
22+
23+
</PreferenceScreen>

play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/Database.kt

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@ import androidx.core.database.getStringOrNull
1616
import org.microg.gms.fido.core.transport.Transport
1717
import org.microg.gms.fido.core.ui.TAG
1818

19+
data class KnownRegistration(
20+
val rpId: String,
21+
val credentialId: String,
22+
val userJson: String?,
23+
val transport: Transport,
24+
val timestamp: Long
25+
)
26+
1927
class Database(context: Context) : SQLiteOpenHelper(context, "fido.db", null, VERSION) {
2028

2129
fun isPrivileged(packageName: String, signatureDigest: String): Boolean = readableDatabase.use {
@@ -57,6 +65,41 @@ class Database(context: Context) : SQLiteOpenHelper(context, "fido.db", null, VE
5765
result
5866
}
5967

68+
fun getAllKnownRegistrations(): List<KnownRegistration> = readableDatabase.use { db ->
69+
val cursor = db.query(
70+
TABLE_KNOWN_REGISTRATIONS,
71+
arrayOf(COLUMN_RP_ID, COLUMN_CREDENTIAL_ID, COLUMN_REGISTER_USER, COLUMN_TRANSPORT, COLUMN_TIMESTAMP),
72+
null, null, null, null,
73+
"$COLUMN_TIMESTAMP DESC"
74+
)
75+
val result = mutableListOf<KnownRegistration>()
76+
cursor.use { c ->
77+
while (c.moveToNext()) {
78+
val rpId = c.getStringOrNull(0) ?: continue
79+
val credentialId = c.getStringOrNull(1) ?: continue
80+
val userJson = c.getStringOrNull(2)
81+
val transportName = c.getStringOrNull(3) ?: continue
82+
val timestamp = c.getLongOrNull(4) ?: 0L
83+
val transport = try {
84+
Transport.valueOf(transportName)
85+
} catch (e: IllegalArgumentException) {
86+
Log.w(TAG, "Skipping registration with unknown transport: $transportName")
87+
continue
88+
}
89+
result.add(KnownRegistration(rpId, credentialId, userJson, transport, timestamp))
90+
}
91+
}
92+
result
93+
}
94+
95+
fun deleteKnownRegistration(rpId: String, credentialId: String): Int = writableDatabase.use { db ->
96+
db.delete(
97+
TABLE_KNOWN_REGISTRATIONS,
98+
"$COLUMN_RP_ID = ? AND $COLUMN_CREDENTIAL_ID = ?",
99+
arrayOf(rpId, credentialId)
100+
)
101+
}
102+
60103
fun insertPrivileged(packageName: String, signatureDigest: String) = writableDatabase.use {
61104
it.insertWithOnConflict(TABLE_PRIVILEGED_APPS, null, ContentValues().apply {
62105
put(COLUMN_PACKAGE_NAME, packageName)

0 commit comments

Comments
 (0)