Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
10 changes: 10 additions & 0 deletions play-services-core/src/main/res/drawable/ic_delete.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
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"/>
</vector>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<ImageView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@drawable/ic_delete"
android:contentDescription="@null"
android:importantForAccessibility="no" />
8 changes: 8 additions & 0 deletions play-services-core/src/main/res/navigation/nav_settings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,21 @@
<action
android:id="@+id/openGameManagerSettings"
app:destination="@id/gameManagerFragment" />
<action
android:id="@+id/openPasskeyManagerSettings"
app:destination="@id/passkeyManagerFragment" />
</fragment>

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

<fragment
android:id="@+id/passkeyManagerFragment"
android:name="org.microg.gms.ui.PasskeyManagerFragment"
android:label="@string/pref_passkey_manager_title" />

<!-- Device registration -->

<fragment
Expand Down
16 changes: 16 additions & 0 deletions play-services-core/src/main/res/values-zh-rCN/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -402,4 +402,20 @@ microG GmsCore 内置一套自由的 SafetyNet 实现,但是官方服务器要
<string name="prefcat_app_install_list_title">授权渠道</string>
<string name="credentials_service_sign_in_with_google_label">用 Google 登录</string>
<string name="credentials_service_remote_custom_subtitle">安全密钥、智能手机或平板</string>

<!-- Passkey management -->
<string name="pref_passkey_manager_title">管理通行密钥</string>
<string name="pref_passkey_manager_empty_title">暂无已保存的通行密钥</string>
<string name="pref_passkey_manager_description">第三方应用注册通行密钥时,microG 会在本地保存对应记录。若应用内删除密钥时未同步通知 microG,本地会留下残留记录,可在此手动清理。</string>
<string name="pref_passkey_manager_delete_dialog_title">删除通行密钥</string>
<string name="pref_passkey_manager_delete_dialog_message">即将删除\"%1$s\"上账号\"%2$s\"的通行密钥。删除后该密钥无法恢复,下次登录需重新注册。</string>
<string name="pref_passkey_manager_delete_dialog_confirm">删除</string>
<string name="pref_passkey_manager_delete_failed_toast">删除失败</string>
<string name="pref_passkey_manager_unknown_user">未知用户</string>
<string name="pref_passkey_manager_transport_screen_lock">屏幕锁</string>
<string name="pref_passkey_manager_transport_usb">USB</string>
<string name="pref_passkey_manager_transport_nfc">NFC</string>
<string name="pref_passkey_manager_transport_bluetooth">蓝牙</string>
<string name="pref_passkey_manager_transport_hybrid">混合</string>
<string name="pref_passkey_manager_credential_id_format_internal">ID: %1$s</string>
</resources>
15 changes: 15 additions & 0 deletions play-services-core/src/main/res/values-zh-rTW/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -406,4 +406,19 @@
<string name="games_delete_profile_dialog_title">確定要刪除此帳戶嗎?</string>
<string name="pref_app_install_permission_instruction">為確保您已安裝的應用程式正常運作,請授權 microG Companion 安裝來自其他來源的應用程式。</string>
<string name="location_sharing_confirm_dialog_text">與您分享位置的人始終可看到:\n·您的姓名和相片\n·您裝置的近期位置,即使您不在使用 Google 服務\n·您裝置的電量以及是否正在充電\n·您的抵達和離開時間(若他們新增位置分享通知)</string>
<!-- Passkey management -->
<string name="pref_passkey_manager_title">管理通行密鑰</string>
<string name="pref_passkey_manager_empty_title">尚無已儲存的通行密鑰</string>
<string name="pref_passkey_manager_description">第三方應用註冊通行密鑰時,microG 會在本地保存對應記錄。若應用內刪除密鑰時未同步通知 microG,本地會留下殘留記錄,可在此手動清理。</string>
<string name="pref_passkey_manager_delete_dialog_title">刪除通行密鑰</string>
<string name="pref_passkey_manager_delete_dialog_message">即將刪除\"%1$s\"上帳號\"%2$s\"的通行密鑰。刪除後該密鑰無法復原,下次登入需重新註冊。</string>
<string name="pref_passkey_manager_delete_dialog_confirm">刪除</string>
<string name="pref_passkey_manager_delete_failed_toast">刪除失敗</string>
<string name="pref_passkey_manager_unknown_user">未知使用者</string>
<string name="pref_passkey_manager_transport_screen_lock">螢幕鎖</string>
<string name="pref_passkey_manager_transport_usb">USB</string>
<string name="pref_passkey_manager_transport_nfc">NFC</string>
<string name="pref_passkey_manager_transport_bluetooth">藍牙</string>
<string name="pref_passkey_manager_transport_hybrid">混合</string>
<string name="pref_passkey_manager_credential_id_format_internal">ID: %1$s</string>
</resources>
16 changes: 16 additions & 0 deletions play-services-core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -460,4 +460,20 @@ Please set up a password, PIN, or pattern lock screen."</string>
<string name="location_sharing_turn_off_confirm">Turn off</string>
<string name="location_sharing_confirm_dialog_title">Enable Location Sharing</string>
<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>

<!-- Passkey management -->
<string name="pref_passkey_manager_title">Manage passkeys</string>
<string name="pref_passkey_manager_empty_title">No saved passkeys</string>
<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>
<string name="pref_passkey_manager_delete_dialog_title">Delete passkey</string>
<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>
<string name="pref_passkey_manager_delete_dialog_confirm">Delete</string>
<string name="pref_passkey_manager_delete_failed_toast">Delete failed</string>
<string name="pref_passkey_manager_unknown_user">Unknown user</string>
<string name="pref_passkey_manager_transport_screen_lock">Screen lock</string>
<string name="pref_passkey_manager_transport_usb">USB</string>
<string name="pref_passkey_manager_transport_nfc">NFC</string>
<string name="pref_passkey_manager_transport_bluetooth">Bluetooth</string>
<string name="pref_passkey_manager_transport_hybrid">Hybrid</string>
<string name="pref_passkey_manager_credential_id_format_internal">ID: %1$s</string>
</resources>
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:title="@string/pref_passkey_manager_title">

<Preference
android:key="pref_passkey_manager_description"
android:selectable="false"
android:summary="@string/pref_passkey_manager_description"
app:iconSpaceReserved="false" />

<PreferenceCategory
android:key="prefcat_passkeys"
android:layout="@layout/preference_category_no_label"
app:iconSpaceReserved="false">
<Preference
android:enabled="false"
android:key="pref_passkeys_none"
android:title="@string/pref_passkey_manager_empty_title"
app:iconSpaceReserved="false" />
</PreferenceCategory>

</PreferenceScreen>
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -57,6 +65,41 @@ class Database(context: Context) : SQLiteOpenHelper(context, "fido.db", null, VE
result
}

fun getAllKnownRegistrations(): List<KnownRegistration> = 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<KnownRegistration>()
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down