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" }