diff --git a/play-services-core/src/main/kotlin/org/microg/gms/ui/AccountsFragment.kt b/play-services-core/src/main/kotlin/org/microg/gms/ui/AccountsFragment.kt
index 8acb796e44..aff7807ad8 100644
--- a/play-services-core/src/main/kotlin/org/microg/gms/ui/AccountsFragment.kt
+++ b/play-services-core/src/main/kotlin/org/microg/gms/ui/AccountsFragment.kt
@@ -142,6 +142,7 @@ class AccountsFragment : PreferenceFragmentCompat() {
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
menu.add(0, MENU_GAMES_MANAGED, 0, org.microg.gms.base.core.R.string.menu_game_managed)
+ menu.add(0, MENU_PASSKEY_MANAGER, 1, R.string.pref_passkey_manager_title)
super.onCreateOptionsMenu(menu, inflater)
}
@@ -152,11 +153,17 @@ class AccountsFragment : PreferenceFragmentCompat() {
true
}
+ MENU_PASSKEY_MANAGER -> {
+ findNavController().navigate(requireContext(), R.id.openPasskeyManagerSettings)
+ true
+ }
+
else -> super.onOptionsItemSelected(item)
}
}
companion object {
private const val MENU_GAMES_MANAGED = Menu.FIRST
+ private const val MENU_PASSKEY_MANAGER = Menu.FIRST + 1
}
}
diff --git a/play-services-core/src/main/kotlin/org/microg/gms/ui/PasskeyManagerFragment.kt b/play-services-core/src/main/kotlin/org/microg/gms/ui/PasskeyManagerFragment.kt
new file mode 100644
index 0000000000..5eb93607ad
--- /dev/null
+++ b/play-services-core/src/main/kotlin/org/microg/gms/ui/PasskeyManagerFragment.kt
@@ -0,0 +1,160 @@
+/*
+ * SPDX-FileCopyrightText: 2026 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.ui
+
+import android.content.Context
+import android.os.Bundle
+import android.text.format.DateUtils
+import android.util.Base64
+import android.util.Log
+import android.widget.Toast
+import androidx.appcompat.app.AlertDialog
+import androidx.lifecycle.lifecycleScope
+import androidx.preference.Preference
+import androidx.preference.PreferenceCategory
+import androidx.preference.PreferenceFragmentCompat
+import com.google.android.gms.R
+import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialUserEntity
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.microg.gms.fido.core.Database
+import org.microg.gms.fido.core.KnownRegistration
+import org.microg.gms.fido.core.transport.Transport
+import org.microg.gms.fido.core.transport.screenlock.ScreenLockCredentialStore
+import org.microg.gms.profile.Build
+
+class PasskeyManagerFragment : PreferenceFragmentCompat() {
+
+ private lateinit var category: PreferenceCategory
+ private lateinit var emptyPlaceholder: Preference
+
+ override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
+ addPreferencesFromResource(R.xml.preferences_passkey_manager)
+ category = preferenceScreen.findPreference(PREFCAT_PASSKEYS) ?: return
+ emptyPlaceholder = preferenceScreen.findPreference(PREF_PASSKEYS_NONE) ?: return
+ }
+
+ override fun onResume() {
+ super.onResume()
+ updateContent()
+ }
+
+ private fun updateContent() {
+ val ctx = requireContext().applicationContext
+ lifecycleScope.launchWhenResumed {
+ val list = withContext(Dispatchers.IO) {
+ runCatching { Database(ctx).getAllKnownRegistrations() }
+ .onFailure { Log.w(TAG, "Failed to load passkeys", it) }
+ .getOrDefault(emptyList())
+ }
+ category.removeAll()
+ if (list.isEmpty()) {
+ category.addPreference(emptyPlaceholder)
+ } else {
+ list.forEachIndexed { index, item ->
+ category.addPreference(buildPasskeyPreference(ctx, item, index))
+ }
+ }
+ }
+ }
+
+ private fun buildPasskeyPreference(ctx: Context, item: KnownRegistration, order: Int): Preference =
+ Preference(ctx).apply {
+ key = "pref_passkey_${item.rpId}_${item.credentialId}"
+ this.order = order
+ isIconSpaceReserved = false
+ widgetLayoutResource = R.layout.widget_passkey_delete
+ title = item.rpId
+ summary = buildSummary(ctx, item)
+ setOnPreferenceClickListener {
+ confirmDelete(item)
+ true
+ }
+ }
+
+ private fun confirmDelete(item: KnownRegistration) {
+ val displayUser = formatPasskeyUser(requireContext(), item.userJson)
+ AlertDialog.Builder(requireContext())
+ .setTitle(R.string.pref_passkey_manager_delete_dialog_title)
+ .setMessage(getString(R.string.pref_passkey_manager_delete_dialog_message, item.rpId, displayUser))
+ .setNegativeButton(android.R.string.cancel, null)
+ .setPositiveButton(R.string.pref_passkey_manager_delete_dialog_confirm) { _, _ ->
+ performDelete(item)
+ }
+ .show()
+ }
+
+ private fun performDelete(item: KnownRegistration) {
+ val ctx = requireContext().applicationContext
+ lifecycleScope.launchWhenResumed {
+ val ok = withContext(Dispatchers.IO) {
+ runCatching {
+ if (Build.VERSION.SDK_INT >= 23) {
+ val keyId = Base64.decode(item.credentialId, Base64.NO_PADDING or Base64.NO_WRAP)
+ ScreenLockCredentialStore(ctx).deleteKey(item.rpId, keyId)
+ }
+ Database(ctx).deleteKnownRegistration(item.rpId, item.credentialId)
+ }.onFailure { Log.w(TAG, "Failed to delete passkey", it) }.isSuccess
+ }
+ if (ok) {
+ updateContent()
+ } else {
+ Toast.makeText(requireContext(), R.string.pref_passkey_manager_delete_failed_toast, Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+
+ companion object {
+ private const val TAG = "PasskeyManager"
+ private const val PREFCAT_PASSKEYS = "prefcat_passkeys"
+ private const val PREF_PASSKEYS_NONE = "pref_passkeys_none"
+ }
+}
+
+internal fun formatPasskeyUser(context: Context, userJson: String?): String {
+ if (userJson.isNullOrBlank()) return context.getString(R.string.pref_passkey_manager_unknown_user)
+ return try {
+ val entity = PublicKeyCredentialUserEntity.parseJson(userJson)
+ val displayName = entity.displayName?.takeIf { it.isNotBlank() }
+ val name = entity.name?.takeIf { it.isNotBlank() }
+ when {
+ displayName != null && name != null && displayName != name -> "$displayName ($name)"
+ displayName != null -> displayName
+ name != null -> name
+ else -> context.getString(R.string.pref_passkey_manager_unknown_user)
+ }
+ } catch (e: Exception) {
+ context.getString(R.string.pref_passkey_manager_unknown_user)
+ }
+}
+
+private fun buildSummary(context: Context, item: KnownRegistration): String {
+ val user = formatPasskeyUser(context, item.userJson)
+ val time = DateUtils.getRelativeTimeSpanString(
+ item.timestamp,
+ System.currentTimeMillis(),
+ DateUtils.MINUTE_IN_MILLIS
+ ).toString()
+ val transportLabel = context.getString(transportLabelRes(item.transport))
+ val credId = context.getString(
+ R.string.pref_passkey_manager_credential_id_format_internal,
+ truncateCredentialId(item.credentialId)
+ )
+ return "$user\n$time · $transportLabel\n$credId"
+}
+
+private fun transportLabelRes(transport: Transport): Int = when (transport) {
+ Transport.SCREEN_LOCK -> R.string.pref_passkey_manager_transport_screen_lock
+ Transport.USB -> R.string.pref_passkey_manager_transport_usb
+ Transport.NFC -> R.string.pref_passkey_manager_transport_nfc
+ Transport.BLUETOOTH -> R.string.pref_passkey_manager_transport_bluetooth
+ Transport.HYBRID -> R.string.pref_passkey_manager_transport_hybrid
+}
+
+private fun truncateCredentialId(id: String): String {
+ if (id.length <= 16) return id
+ return id.substring(0, 6) + "…" + id.substring(id.length - 6)
+}
diff --git a/play-services-core/src/main/res/drawable/ic_delete.xml b/play-services-core/src/main/res/drawable/ic_delete.xml
new file mode 100644
index 0000000000..4db9470c46
--- /dev/null
+++ b/play-services-core/src/main/res/drawable/ic_delete.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/play-services-core/src/main/res/layout/widget_passkey_delete.xml b/play-services-core/src/main/res/layout/widget_passkey_delete.xml
new file mode 100644
index 0000000000..693c630bc4
--- /dev/null
+++ b/play-services-core/src/main/res/layout/widget_passkey_delete.xml
@@ -0,0 +1,7 @@
+
+
diff --git a/play-services-core/src/main/res/navigation/nav_settings.xml b/play-services-core/src/main/res/navigation/nav_settings.xml
index 7aacdbf48f..b1b11dd439 100644
--- a/play-services-core/src/main/res/navigation/nav_settings.xml
+++ b/play-services-core/src/main/res/navigation/nav_settings.xml
@@ -56,6 +56,9 @@
+
+
+
授权渠道
用 Google 登录
安全密钥、智能手机或平板
+
+
+ 管理通行密钥
+ 暂无已保存的通行密钥
+ 第三方应用注册通行密钥时,microG 会在本地保存对应记录。若应用内删除密钥时未同步通知 microG,本地会留下残留记录,可在此手动清理。
+ 删除通行密钥
+ 即将删除\"%1$s\"上账号\"%2$s\"的通行密钥。删除后该密钥无法恢复,下次登录需重新注册。
+ 删除
+ 删除失败
+ 未知用户
+ 屏幕锁
+ USB
+ NFC
+ 蓝牙
+ 混合
+ ID: %1$s
diff --git a/play-services-core/src/main/res/values-zh-rTW/strings.xml b/play-services-core/src/main/res/values-zh-rTW/strings.xml
index 1ece62ea27..e41497a675 100644
--- a/play-services-core/src/main/res/values-zh-rTW/strings.xml
+++ b/play-services-core/src/main/res/values-zh-rTW/strings.xml
@@ -406,4 +406,19 @@
確定要刪除此帳戶嗎?
為確保您已安裝的應用程式正常運作,請授權 microG Companion 安裝來自其他來源的應用程式。
與您分享位置的人始終可看到:\n·您的姓名和相片\n·您裝置的近期位置,即使您不在使用 Google 服務\n·您裝置的電量以及是否正在充電\n·您的抵達和離開時間(若他們新增位置分享通知)
+
+ 管理通行密鑰
+ 尚無已儲存的通行密鑰
+ 第三方應用註冊通行密鑰時,microG 會在本地保存對應記錄。若應用內刪除密鑰時未同步通知 microG,本地會留下殘留記錄,可在此手動清理。
+ 刪除通行密鑰
+ 即將刪除\"%1$s\"上帳號\"%2$s\"的通行密鑰。刪除後該密鑰無法復原,下次登入需重新註冊。
+ 刪除
+ 刪除失敗
+ 未知使用者
+ 螢幕鎖
+ USB
+ NFC
+ 藍牙
+ 混合
+ ID: %1$s
diff --git a/play-services-core/src/main/res/values/strings.xml b/play-services-core/src/main/res/values/strings.xml
index ed08fecd18..d1810504fb 100644
--- a/play-services-core/src/main/res/values/strings.xml
+++ b/play-services-core/src/main/res/values/strings.xml
@@ -460,4 +460,20 @@ Please set up a password, PIN, or pattern lock screen."
Turn off
Enable Location Sharing
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
+
+
+ Manage passkeys
+ No saved passkeys
+ 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.
+ Delete passkey
+ Delete passkey for \"%2$s\" on \"%1$s\". This cannot be undone; you will need to register a new passkey to sign in again.
+ Delete
+ Delete failed
+ Unknown user
+ Screen lock
+ USB
+ NFC
+ Bluetooth
+ Hybrid
+ ID: %1$s
diff --git a/play-services-core/src/main/res/xml/preferences_passkey_manager.xml b/play-services-core/src/main/res/xml/preferences_passkey_manager.xml
new file mode 100644
index 0000000000..0327b29b5f
--- /dev/null
+++ b/play-services-core/src/main/res/xml/preferences_passkey_manager.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/Database.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/Database.kt
index 7a1133fd86..aaf0449cb7 100644
--- a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/Database.kt
+++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/Database.kt
@@ -16,6 +16,14 @@ import androidx.core.database.getStringOrNull
import org.microg.gms.fido.core.transport.Transport
import org.microg.gms.fido.core.ui.TAG
+data class KnownRegistration(
+ val rpId: String,
+ val credentialId: String,
+ val userJson: String?,
+ val transport: Transport,
+ val timestamp: Long
+)
+
class Database(context: Context) : SQLiteOpenHelper(context, "fido.db", null, VERSION) {
fun isPrivileged(packageName: String, signatureDigest: String): Boolean = readableDatabase.use {
@@ -57,6 +65,41 @@ class Database(context: Context) : SQLiteOpenHelper(context, "fido.db", null, VE
result
}
+ fun getAllKnownRegistrations(): List = readableDatabase.use { db ->
+ val cursor = db.query(
+ TABLE_KNOWN_REGISTRATIONS,
+ arrayOf(COLUMN_RP_ID, COLUMN_CREDENTIAL_ID, COLUMN_REGISTER_USER, COLUMN_TRANSPORT, COLUMN_TIMESTAMP),
+ null, null, null, null,
+ "$COLUMN_TIMESTAMP DESC"
+ )
+ val result = mutableListOf()
+ cursor.use { c ->
+ while (c.moveToNext()) {
+ val rpId = c.getStringOrNull(0) ?: continue
+ val credentialId = c.getStringOrNull(1) ?: continue
+ val userJson = c.getStringOrNull(2)
+ val transportName = c.getStringOrNull(3) ?: continue
+ val timestamp = c.getLongOrNull(4) ?: 0L
+ val transport = try {
+ Transport.valueOf(transportName)
+ } catch (e: IllegalArgumentException) {
+ Log.w(TAG, "Skipping registration with unknown transport: $transportName")
+ continue
+ }
+ result.add(KnownRegistration(rpId, credentialId, userJson, transport, timestamp))
+ }
+ }
+ result
+ }
+
+ fun deleteKnownRegistration(rpId: String, credentialId: String): Int = writableDatabase.use { db ->
+ db.delete(
+ TABLE_KNOWN_REGISTRATIONS,
+ "$COLUMN_RP_ID = ? AND $COLUMN_CREDENTIAL_ID = ?",
+ arrayOf(rpId, credentialId)
+ )
+ }
+
fun insertPrivileged(packageName: String, signatureDigest: String) = writableDatabase.use {
it.insertWithOnConflict(TABLE_PRIVILEGED_APPS, null, ContentValues().apply {
put(COLUMN_PACKAGE_NAME, packageName)
diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/screenlock/ScreenLockCredentialStore.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/screenlock/ScreenLockCredentialStore.kt
index b42f5fb8a8..dbc0ce40a4 100644
--- a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/screenlock/ScreenLockCredentialStore.kt
+++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/screenlock/ScreenLockCredentialStore.kt
@@ -105,6 +105,21 @@ class ScreenLockCredentialStore(val context: Context) {
fun containsKey(rpId: String, keyId: ByteArray): Boolean = keyStore.containsAlias(getAlias(rpId, keyId))
+ fun deleteKey(rpId: String, keyId: ByteArray): Boolean {
+ val alias = getAlias(rpId, keyId)
+ return try {
+ if (keyStore.containsAlias(alias)) {
+ keyStore.deleteEntry(alias)
+ true
+ } else {
+ false
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "deleteKey failed for alias $alias", e)
+ false
+ }
+ }
+
companion object {
const val TAG = "FidoLockStore"
}