Adapt key connection instructions to available transports

This commit is contained in:
mimi89999
2026-07-19 12:40:44 +02:00
parent c8ab18bf06
commit 6cf0f80389
10 changed files with 253 additions and 8 deletions
@@ -50,18 +50,22 @@ class CredentialBottomSheet : BottomSheetDialogFragment() {
private lateinit var iconStatus: ImageView
private lateinit var iconBackground: View
private lateinit var accountList: RecyclerView
private lateinit var nfcHintContainer: View
private lateinit var btnNfcSettings: MaterialButton
private var pulseAnimator: ObjectAnimator? = null
private var pendingStatus: String? = null
private var pendingInstruction: String? = null
private var pendingShowPinInput: Boolean = false
private var pendingShowNfcHint: Boolean = false
private var pendingState: State = State.WAITING
var onCancelClick: (() -> Unit)? = null
var onPinEntered: ((String) -> Unit)? = null
var onAccountSelected: ((Int) -> Unit)? = null
var onBiometricSelected: (() -> Unit)? = null
var onNfcSettingsClick: (() -> Unit)? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -93,6 +97,8 @@ class CredentialBottomSheet : BottomSheetDialogFragment() {
iconStatus = view.findViewById(R.id.iconStatus)
iconBackground = view.findViewById(R.id.iconBackground)
accountList = view.findViewById(R.id.accountList)
nfcHintContainer = view.findViewById(R.id.nfcHintContainer)
btnNfcSettings = view.findViewById(R.id.btnNfcSettings)
accountList.layoutManager = LinearLayoutManager(context)
@@ -103,6 +109,8 @@ class CredentialBottomSheet : BottomSheetDialogFragment() {
pendingStatus?.let { statusText.text = it }
pendingInstruction?.let { instructionText.text = it }
nfcHintContainer.visibility = if (pendingShowNfcHint) View.VISIBLE else View.GONE
if (pendingShowPinInput) {
pinInputField.visibility = View.VISIBLE
btnContinue.visibility = View.VISIBLE
@@ -123,6 +131,10 @@ class CredentialBottomSheet : BottomSheetDialogFragment() {
onBiometricSelected?.invoke()
}
btnNfcSettings.setOnClickListener {
onNfcSettingsClick?.invoke()
}
pinInputField.setOnDoneAction {
submitPin()
}
@@ -230,6 +242,15 @@ class CredentialBottomSheet : BottomSheetDialogFragment() {
}
}
/** Shows the "NFC is off" row with a shortcut to system NFC settings. */
fun showNfcHint(show: Boolean) {
if (::nfcHintContainer.isInitialized) {
nfcHintContainer.visibility = if (show) View.VISIBLE else View.GONE
} else {
pendingShowNfcHint = show
}
}
fun showProgress(show: Boolean) {
if (::progressBar.isInitialized) {
progressBar.visibility = if (show) View.VISIBLE else View.GONE
@@ -13,6 +13,7 @@ import android.nfc.Tag
import android.nfc.tech.IsoDep
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.util.Base64
import android.util.Log
import androidx.annotation.RequiresApi
@@ -49,6 +50,7 @@ class CredentialProviderActivity : AppCompatActivity() {
private data class ClientData(val json: String?, val hash: ByteArray)
private var nfcAdapter: NfcAdapter? = null
private var connectPromptVisible = false
private lateinit var usbManager: UsbManager
private var bottomSheet: CredentialBottomSheet? = null
@@ -116,7 +118,7 @@ class CredentialProviderActivity : AppCompatActivity() {
if (granted && device != null) {
connectToUsbDevice(device)
} else {
setInstruction(getString(R.string.instruction_usb_permission_denied))
setInstruction(usbPermissionDeniedInstruction())
}
}
}
@@ -145,6 +147,17 @@ class CredentialProviderActivity : AppCompatActivity() {
}
}
// Fires when NFC is toggled anywhere, including the quick settings shade.
private val nfcStateReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != NfcAdapter.ACTION_ADAPTER_STATE_CHANGED) return
when (intent.getIntExtra(NfcAdapter.EXTRA_ADAPTER_STATE, NfcAdapter.STATE_OFF)) {
NfcAdapter.STATE_ON, NfcAdapter.STATE_OFF ->
if (connectPromptVisible) showConnectPrompt()
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -171,7 +184,7 @@ class CredentialProviderActivity : AppCompatActivity() {
val publicKeyRequest = createRequest!!.callingRequest as? CreatePublicKeyCredentialRequest
requestJson = publicKeyRequest?.requestJson
providedClientDataHash = publicKeyRequest?.clientDataHash
showBottomSheet(getString(R.string.create_passkey), getString(R.string.instruction_connect_key))
showBottomSheet(getString(R.string.create_passkey))
}
getRequest != null -> {
isCreateRequest = false
@@ -180,7 +193,7 @@ class CredentialProviderActivity : AppCompatActivity() {
val publicKeyOption = options.firstOrNull { it is GetPublicKeyCredentialOption } as? GetPublicKeyCredentialOption
requestJson = publicKeyOption?.requestJson
providedClientDataHash = publicKeyOption?.clientDataHash
showBottomSheet(getString(R.string.sign_in), getString(R.string.instruction_connect_key))
showBottomSheet(getString(R.string.sign_in))
}
else -> {
Log.e(TAG, "No valid request found in intent")
@@ -199,14 +212,35 @@ class CredentialProviderActivity : AppCompatActivity() {
checkPinRequirement()
}
private fun showBottomSheet(status: String, instruction: String) {
bottomSheet = CredentialBottomSheet.newInstance(status, instruction).apply {
private fun showBottomSheet(status: String) {
bottomSheet = CredentialBottomSheet.newInstance(status, connectKeyInstruction()).apply {
onCancelClick = { cancelOperation() }
onPinEntered = { pin -> handlePinEntered(pin) }
onBiometricSelected = { handleBiometricSelected() }
onNfcSettingsClick = { openNfcSettings() }
}
bottomSheet?.show(supportFragmentManager, CredentialBottomSheet.TAG)
bottomSheet?.setState(CredentialBottomSheet.State.WAITING)
connectPromptVisible = true
bottomSheet?.showNfcHint(shouldOfferNfcSettings())
}
/**
* Asks the user to present a key, naming only the transports this device can
* currently use. Re-callable: the instruction depends on live NFC state.
*/
private fun showConnectPrompt() {
connectPromptVisible = true
bottomSheet?.setInstruction(connectKeyInstruction())
bottomSheet?.showNfcHint(shouldOfferNfcSettings())
}
private fun openNfcSettings() {
try {
startActivity(Intent(Settings.ACTION_NFC_SETTINGS))
} catch (e: Exception) {
Log.w(TAG, "No NFC settings activity", e)
}
}
private fun handlePinEntered(pin: String) {
@@ -219,7 +253,7 @@ class CredentialProviderActivity : AppCompatActivity() {
authenticateAndExecute(pin, json)
} else {
pendingPin = pin
setInstruction(getString(R.string.instruction_connect_key))
showConnectPrompt()
setState(CredentialBottomSheet.State.WAITING)
bottomSheet?.showPinInput(false)
}
@@ -238,7 +272,7 @@ class CredentialProviderActivity : AppCompatActivity() {
}
} else {
// Key not connected yet — show waiting state
setInstruction(getString(R.string.instruction_connect_key))
showConnectPrompt()
setState(CredentialBottomSheet.State.WAITING)
}
}
@@ -248,7 +282,9 @@ class CredentialProviderActivity : AppCompatActivity() {
}
private fun setInstruction(text: String) {
connectPromptVisible = false
bottomSheet?.setInstruction(text)
bottomSheet?.showNfcHint(false)
}
private fun showProgress(show: Boolean) {
@@ -310,6 +346,16 @@ class CredentialProviderActivity : AppCompatActivity() {
override fun onResume() {
super.onResume()
// NFC may have been toggled while we were backgrounded.
if (connectPromptVisible) showConnectPrompt()
// Also catch toggles that happen while we're in the foreground (e.g. the quick
// settings shade, which does not pause us).
registerReceiver(
nfcStateReceiver,
IntentFilter(NfcAdapter.ACTION_ADAPTER_STATE_CHANGED)
)
nfcAdapter?.let { adapter ->
val intent = Intent(this, javaClass).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
@@ -355,6 +401,11 @@ class CredentialProviderActivity : AppCompatActivity() {
} catch (e: Exception) {
// Ignore
}
try {
unregisterReceiver(nfcStateReceiver)
} catch (e: Exception) {
// Ignore
}
}
override fun onDestroy() {
@@ -369,6 +420,11 @@ class CredentialProviderActivity : AppCompatActivity() {
} catch (e: Exception) {
// Ignore
}
try {
unregisterReceiver(nfcStateReceiver)
} catch (e: Exception) {
// Ignore
}
scope.cancel()
ctapSession?.close()
}
@@ -51,6 +51,8 @@ class MainActivity : AppCompatActivity() {
private lateinit var providerStatusContainer: LinearLayout
private lateinit var providerStatusText: TextView
private lateinit var btnEnableProvider: Button
private lateinit var nfcHintContainer: LinearLayout
private lateinit var btnNfcSettings: MaterialButton
private var currentTransport: FidoTransport? = null
private var pinProtocol: PinProtocol? = null
@@ -119,6 +121,16 @@ class MainActivity : AppCompatActivity() {
}
}
// Fires when NFC is toggled anywhere, including the quick settings shade.
private val nfcStateReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != NfcAdapter.ACTION_ADAPTER_STATE_CHANGED) return
when (intent.getIntExtra(NfcAdapter.EXTRA_ADAPTER_STATE, NfcAdapter.STATE_OFF)) {
NfcAdapter.STATE_ON, NfcAdapter.STATE_OFF -> updateConnectionStatus()
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
@@ -147,6 +159,8 @@ class MainActivity : AppCompatActivity() {
providerStatusContainer = findViewById(R.id.providerStatusContainer)
providerStatusText = findViewById(R.id.providerStatusText)
btnEnableProvider = findViewById(R.id.btnEnableProvider)
nfcHintContainer = findViewById(R.id.nfcHintContainer)
btnNfcSettings = findViewById(R.id.btnNfcSettings)
nfcAdapter = NfcAdapter.getDefaultAdapter(this)
usbManager = getSystemService(Context.USB_SERVICE) as UsbManager
@@ -164,6 +178,7 @@ class MainActivity : AppCompatActivity() {
btnListCredentials.setOnClickListener { listCredentials() }
btnChangePin.setOnClickListener { showChangePinDialog() }
btnEnableProvider.setOnClickListener { openProviderSettings() }
btnNfcSettings.setOnClickListener { openNfcSettings() }
updateConnectionStatus()
}
@@ -180,6 +195,11 @@ class MainActivity : AppCompatActivity() {
} catch (e: Exception) {
// Ignore
}
try {
unregisterReceiver(nfcStateReceiver)
} catch (e: Exception) {
// Ignore
}
scope.cancel()
}
@@ -189,6 +209,16 @@ class MainActivity : AppCompatActivity() {
// Check credential provider status
checkProviderStatus()
// Refresh the waiting prompt: NFC may have been toggled while backgrounded
updateConnectionStatus()
// Catch NFC toggles that happen while we're in the foreground (e.g. from the
// quick settings shade, which does not pause us).
registerReceiver(
nfcStateReceiver,
IntentFilter(NfcAdapter.ACTION_ADAPTER_STATE_CHANGED)
)
// Enable NFC foreground dispatch
nfcAdapter?.let { adapter ->
val intent = Intent(this, javaClass).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
@@ -234,6 +264,11 @@ class MainActivity : AppCompatActivity() {
} catch (e: Exception) {
// Ignore
}
try {
unregisterReceiver(nfcStateReceiver)
} catch (e: Exception) {
// Ignore
}
}
override fun onNewIntent(intent: Intent) {
@@ -422,7 +457,19 @@ class MainActivity : AppCompatActivity() {
// Update status text if not connected and not waiting for reconnect
if (!connected && !awaitingNfcReconnect) {
statusText.text = getString(R.string.waiting_for_key)
statusText.text = connectKeyInstruction()
}
// Offer to turn NFC on, but only while there's nothing connected anyway
nfcHintContainer.visibility =
if (!connected && shouldOfferNfcSettings()) View.VISIBLE else View.GONE
}
private fun openNfcSettings() {
try {
startActivity(Intent(Settings.ACTION_NFC_SETTINGS))
} catch (e: Exception) {
// No NFC settings activity available
}
}
@@ -0,0 +1,41 @@
package pl.lebihan.authnkey
import android.content.Context
import android.content.pm.PackageManager
import android.nfc.NfcAdapter
/** Whether the device has NFC hardware at all. */
fun Context.hasNfc(): Boolean = NfcAdapter.getDefaultAdapter(this) != null
/** Whether NFC is present and switched on. Read at point of use; the user can toggle it anytime. */
fun Context.isNfcEnabled(): Boolean = NfcAdapter.getDefaultAdapter(this)?.isEnabled == true
/** Whether the device can act as a USB host (required to talk to a plugged-in key). */
fun Context.hasUsbHost(): Boolean =
packageManager.hasSystemFeature(PackageManager.FEATURE_USB_HOST)
/** Whether to offer the "NFC is off" hint: hardware exists but is disabled. */
fun Context.shouldOfferNfcSettings(): Boolean = hasNfc() && !isNfcEnabled()
/**
* The instruction to show while waiting for a key, covering only the transports
* this device can actually use right now.
*/
fun Context.connectKeyInstruction(): String {
val nfc = isNfcEnabled()
val usb = hasUsbHost()
return getString(
when {
nfc && usb -> R.string.instruction_connect_key
nfc -> R.string.instruction_connect_key_nfc_only
usb -> R.string.instruction_connect_key_usb_only
else -> R.string.instruction_no_transport
}
)
}
/** USB permission denial message, only suggesting NFC when NFC is usable. */
fun Context.usbPermissionDeniedInstruction(): String = getString(
if (isNfcEnabled()) R.string.instruction_usb_permission_denied
else R.string.instruction_usb_permission_denied_no_nfc
)
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="12dp" />
<solid android:color="@color/hint_background" />
</shape>
+29
View File
@@ -37,6 +37,35 @@
style="@style/Widget.Material3.Button.OutlinedButton" />
</LinearLayout>
<!-- NFC off hint -->
<LinearLayout
android:id="@+id/nfcHintContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:padding="12dp"
android:layout_marginBottom="8dp"
android:background="@drawable/bg_hint_row"
android:visibility="gone">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/hint_nfc_off"
android:textSize="14sp"
android:textColor="@color/hint_text" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnNfcSettings"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/action_turn_on_nfc"
app:strokeColor="@color/provider_button_outline"
style="@style/Widget.Material3.Button.OutlinedButton" />
</LinearLayout>
<TextView
android:id="@+id/statusText"
android:layout_width="match_parent"
@@ -62,6 +62,35 @@
android:textColor="?android:attr/textColorSecondary"
android:layout_marginBottom="24dp" />
<LinearLayout
android:id="@+id/nfcHintContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:padding="12dp"
android:layout_marginBottom="16dp"
android:background="@drawable/bg_hint_row"
android:visibility="gone">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/hint_nfc_off"
android:textSize="14sp"
android:textColor="@color/hint_text" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnNfcSettings"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/action_turn_on_nfc"
app:strokeColor="@color/provider_button_outline"
style="@style/Widget.Material3.Button.OutlinedButton" />
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/accountList"
android:layout_width="match_parent"
+4
View File
@@ -13,6 +13,10 @@
<color name="provider_not_supported_background">#B71C1C</color>
<color name="provider_not_supported_text">#FFCDD2</color>
<!-- Inline hint row (dark theme) -->
<color name="hint_background">#3A2E22</color>
<color name="hint_text">#FFCC80</color>
<!-- Tag lost warning state -->
<color name="warning_container">#7C2D12</color>
+4
View File
@@ -13,6 +13,10 @@
<color name="provider_not_supported_background">#FFEBEE</color>
<color name="provider_not_supported_text">#C62828</color>
<!-- Inline hint row (light theme) -->
<color name="hint_background">#FFF3E0</color>
<color name="hint_text">#E65100</color>
<!-- Tag lost warning state -->
<color name="warning_container">#FED7AA</color>
+8
View File
@@ -19,11 +19,15 @@
<!-- Instructions -->
<string name="instruction_connect_key">Hold your security key against the back of your phone, or plug it in via USB</string>
<string name="instruction_connect_key_nfc_only">Hold your security key against the back of your phone</string>
<string name="instruction_connect_key_usb_only">Plug your security key in via USB</string>
<string name="instruction_no_transport">No usable connection method</string>
<string name="instruction_touch_key">Touch your security key to confirm…</string>
<string name="instruction_signing_in">Signing in…</string>
<string name="instruction_creating">Creating passkey…</string>
<string name="instruction_enter_pin">Enter your security key PIN</string>
<string name="instruction_usb_permission_denied">USB permission denied. Try again or use NFC.</string>
<string name="instruction_usb_permission_denied_no_nfc">USB permission denied. Try again.</string>
<string name="instruction_key_connected">Security key connected</string>
<string name="instruction_connecting_usb">Connecting to USB device…</string>
<string name="instruction_authenticating">Authenticating…</string>
@@ -33,6 +37,10 @@
<string name="instruction_tag_lost">Lost contact with the security key\n\nReposition and hold until completion</string>
<string name="instruction_waiting_biometric">Touch the fingerprint sensor on your security key…</string>
<!-- NFC hint -->
<string name="hint_nfc_off">NFC is off</string>
<string name="action_turn_on_nfc">Turn on</string>
<!-- PIN Dialog -->
<string name="pin_dialog_title">Security Key PIN</string>
<string name="pin_dialog_message">Enter your security key PIN</string>