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