diff --git a/app/src/main/java/pl/lebihan/authnkey/CredentialBottomSheet.kt b/app/src/main/java/pl/lebihan/authnkey/CredentialBottomSheet.kt
index 1913c80..7a0b0c9 100644
--- a/app/src/main/java/pl/lebihan/authnkey/CredentialBottomSheet.kt
+++ b/app/src/main/java/pl/lebihan/authnkey/CredentialBottomSheet.kt
@@ -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
diff --git a/app/src/main/java/pl/lebihan/authnkey/CredentialProviderActivity.kt b/app/src/main/java/pl/lebihan/authnkey/CredentialProviderActivity.kt
index 89f1d7c..865625e 100644
--- a/app/src/main/java/pl/lebihan/authnkey/CredentialProviderActivity.kt
+++ b/app/src/main/java/pl/lebihan/authnkey/CredentialProviderActivity.kt
@@ -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()
}
diff --git a/app/src/main/java/pl/lebihan/authnkey/MainActivity.kt b/app/src/main/java/pl/lebihan/authnkey/MainActivity.kt
index 9a7f661..152317f 100644
--- a/app/src/main/java/pl/lebihan/authnkey/MainActivity.kt
+++ b/app/src/main/java/pl/lebihan/authnkey/MainActivity.kt
@@ -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
}
}
diff --git a/app/src/main/java/pl/lebihan/authnkey/TransportHints.kt b/app/src/main/java/pl/lebihan/authnkey/TransportHints.kt
new file mode 100644
index 0000000..18e9d4e
--- /dev/null
+++ b/app/src/main/java/pl/lebihan/authnkey/TransportHints.kt
@@ -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
+)
diff --git a/app/src/main/res/drawable/bg_hint_row.xml b/app/src/main/res/drawable/bg_hint_row.xml
new file mode 100644
index 0000000..aaf9c8b
--- /dev/null
+++ b/app/src/main/res/drawable/bg_hint_row.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index a919c9c..c950b15 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -37,6 +37,35 @@
style="@style/Widget.Material3.Button.OutlinedButton" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
#B71C1C
#FFCDD2
+
+ #3A2E22
+ #FFCC80
+
#7C2D12
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index edbacc8..62a5620 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -13,6 +13,10 @@
#FFEBEE
#C62828
+
+ #FFF3E0
+ #E65100
+
#FED7AA
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 8c70d87..ddecd23 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -19,11 +19,15 @@
Hold your security key against the back of your phone, or plug it in via USB
+ Hold your security key against the back of your phone
+ Plug your security key in via USB
+ No usable connection method
Touch your security key to confirm…
Signing in…
Creating passkey…
Enter your security key PIN
USB permission denied. Try again or use NFC.
+ USB permission denied. Try again.
Security key connected
Connecting to USB device…
Authenticating…
@@ -33,6 +37,10 @@
Lost contact with the security key\n\nReposition and hold until completion
Touch the fingerprint sensor on your security key…
+
+ NFC is off
+ Turn on
+
Security Key PIN
Enter your security key PIN