package com.logship.mobile; import android.app.Activity; import android.app.AlertDialog; import android.content.Context; import android.content.pm.PackageManager; import android.nfc.NfcAdapter; import android.os.Build; import android.os.Handler; import android.os.Looper; import android.util.Log; import androidx.core.content.ContextCompat; import androidx.credentials.CreateCredentialResponse; import androidx.credentials.CreatePublicKeyCredentialRequest; import androidx.credentials.CreatePublicKeyCredentialResponse; import androidx.credentials.Credential; import androidx.credentials.CredentialManager; import androidx.credentials.CredentialManagerCallback; import androidx.credentials.GetCredentialRequest; import androidx.credentials.GetCredentialResponse; import androidx.credentials.GetPublicKeyCredentialOption; import androidx.credentials.PublicKeyCredential; import androidx.credentials.exceptions.CreateCredentialCancellationException; import androidx.credentials.exceptions.CreateCredentialException; import androidx.credentials.exceptions.CreateCredentialProviderConfigurationException; import androidx.credentials.exceptions.GetCredentialCancellationException; import androidx.credentials.exceptions.GetCredentialException; import androidx.credentials.exceptions.GetCredentialProviderConfigurationException; import androidx.credentials.exceptions.NoCredentialException; import androidx.credentials.exceptions.publickeycredential.CreatePublicKeyCredentialDomException; import androidx.credentials.exceptions.publickeycredential.GetPublicKeyCredentialDomException; import com.getcapacitor.JSObject; import com.getcapacitor.Plugin; import com.getcapacitor.PluginCall; import com.getcapacitor.PluginMethod; import com.getcapacitor.annotation.CapacitorPlugin; import com.yubico.yubikit.android.YubiKitManager; import com.yubico.yubikit.android.transport.nfc.NfcConfiguration; import com.yubico.yubikit.android.transport.nfc.NfcNotAvailable; import com.yubico.yubikit.android.transport.usb.UsbConfiguration; import com.yubico.yubikit.core.Transport; import com.yubico.yubikit.core.YubiKeyConnection; import com.yubico.yubikit.core.YubiKeyDevice; import com.yubico.yubikit.core.application.ApplicationNotAvailableException; import com.yubico.yubikit.core.application.CommandException; import com.yubico.yubikit.core.application.CommandState; import com.yubico.yubikit.core.fido.CtapException; import com.yubico.yubikit.core.fido.FidoConnection; import com.yubico.yubikit.core.smartcard.SmartCardConnection; import com.yubico.yubikit.fido.ctap.Ctap2Session; import com.yubico.yubikit.fido.webauthn.AttestationObject; import com.yubico.yubikit.fido.webauthn.AuthenticatorAttestationResponse; import com.yubico.yubikit.fido.webauthn.PublicKeyCredentialDescriptor; import com.yubico.yubikit.fido.webauthn.PublicKeyCredentialUserEntity; import com.yubico.yubikit.fido.webauthn.SerializationType; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import java.io.IOException; import java.net.URI; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.ArrayList; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicBoolean; /** * WebAuthn bridge for the bundled app (origin https://localhost → the WebView itself cannot run * navigator.credentials for app.logship.de). * * Default mode "key": the ceremony runs INSIDE the app with Yubico's YubiKit — the plugin opens * NFC reader mode + USB discovery, shows its own "hold the key to the phone" dialog and performs * CTAP2 getAssertion / makeCredential against the key directly on the raw Ctap2Session (presence * only, never a pinUvAuthParam → a PIN-protected key is NOT asked for its PIN; YubiKit's own * WebAuthnClient in 3.1.0 would, and 3.2.x needs compileSdk 37). No Android Credential Manager * sheet, no Google Play services, no Digital Asset Links: the clientDataJSON carries the web * origin (https://app.logship.de) so the server verifies it exactly like a browser login. Reader * mode also keeps Android from opening the YubiKey's NDEF URL while the dialog is up. * * Mode "system" keeps the previous Credential Manager path (Play services FIDO, origin * android:apk-key-hash, assetlinks.json) as a fallback for non-YubiKey authenticators. * * JS contract (app/composables/useWebAuthn.ts): create/get take the @simplewebauthn options JSON * and return the matching response JSON; rejections carry a DOMException-like `code` * (NotAllowedError, InvalidStateError, NotSupportedError) so the web and app flows share one UI. */ @CapacitorPlugin(name = "WebAuthn") public class WebAuthnPlugin extends Plugin { private static final String TAG = "WebAuthnPlugin"; private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor(); private static final Handler MAIN = new Handler(Looper.getMainLooper()); private YubiKitManager yubiKit; private volatile KeyCeremony active; private volatile String loadError; // Created lazily: a failure here must never take the whole plugin down (Capacitor drops a plugin // whose load() throws, and the sign-in page then loses the key button without any message). private synchronized YubiKitManager yubiKit() { if (yubiKit == null) yubiKit = new YubiKitManager(getContext().getApplicationContext()); return yubiKit; } private CredentialManager manager() { return CredentialManager.create(getContext()); } @PluginMethod public void isAvailable(PluginCall call) { JSObject ret = new JSObject(); ret.put("available", true); try { Context ctx = getContext(); NfcAdapter nfc = NfcAdapter.getDefaultAdapter(ctx); boolean usbHost = ctx.getPackageManager().hasSystemFeature(PackageManager.FEATURE_USB_HOST); ret.put("nfc", nfc != null); ret.put("nfcEnabled", nfc != null && nfc.isEnabled()); ret.put("usb", usbHost); ret.put("system", Build.VERSION.SDK_INT >= Build.VERSION_CODES.P); try { yubiKit(); ret.put("yubikit", true); } catch (Throwable t) { Log.e(TAG, "YubiKit init failed", t); loadError = t.getClass().getSimpleName() + ": " + t.getMessage(); ret.put("yubikit", false); ret.put("error", loadError); } } catch (Throwable t) { Log.e(TAG, "isAvailable failed", t); ret.put("error", t.getClass().getSimpleName() + ": " + t.getMessage()); } call.resolve(ret); } @PluginMethod public void get(PluginCall call) { String requestJson = call.getString("requestJson"); if (requestJson == null || requestJson.isEmpty()) { call.reject("requestJson is required", "TypeError"); return; } if ("system".equals(call.getString("mode"))) { systemGet(call, requestJson); return; } startKeyCeremony(call, requestJson, false); } @PluginMethod public void create(PluginCall call) { String requestJson = call.getString("requestJson"); if (requestJson == null || requestJson.isEmpty()) { call.reject("requestJson is required", "TypeError"); return; } if ("system".equals(call.getString("mode"))) { systemCreate(call, requestJson); return; } startKeyCeremony(call, requestJson, true); } /** Abort the running in-app ceremony (the JS side navigates away / unmounts). */ @PluginMethod public void cancel(PluginCall call) { KeyCeremony c = active; if (c != null) c.fail("NotAllowedError", "cancelled"); call.resolve(); } // ───────────────────────── direct YubiKit ceremony ───────────────────────── private void startKeyCeremony(PluginCall call, String requestJson, boolean create) { KeyCeremony previous = active; if (previous != null) previous.fail("NotAllowedError", "cancelled"); try { KeyCeremony c = new KeyCeremony(call, new JSONObject(requestJson), create); active = c; c.start(); } catch (JSONException e) { call.reject("requestJson is not valid JSON", "TypeError"); } catch (Exception e) { Log.e(TAG, "startKeyCeremony threw", e); call.reject(messageOf(e), "UnknownError"); } } private final class KeyCeremony { private final PluginCall call; private final JSONObject request; private final boolean create; private final String origin; private final String effectiveDomain; private final String title; private final String message; private final String detectedMessage; private final String cancelLabel; private final CommandState state = new CommandState(); private final AtomicBoolean finished = new AtomicBoolean(false); private final AtomicBoolean busy = new AtomicBoolean(false); private AlertDialog dialog; private boolean nfcStarted; private boolean usbStarted; KeyCeremony(PluginCall call, JSONObject request, boolean create) throws JSONException { this.call = call; this.request = request; this.create = create; String o = call.getString("origin"); if (o == null || o.isEmpty()) throw new JSONException("origin is required for the in-app ceremony"); this.origin = o; String rp = create ? request.optJSONObject("rp") != null ? request.getJSONObject("rp").optString("id", "") : "" : request.optString("rpId", ""); if (rp == null || rp.isEmpty()) { try { rp = URI.create(o).getHost(); } catch (Exception ignored) {} } if (rp == null || rp.isEmpty()) throw new JSONException("rpId missing"); this.effectiveDomain = rp; this.title = call.getString("title", create ? "Enrol security key" : "Security key"); this.message = call.getString("message", "Hold your security key to the back of the phone or plug it in via USB."); this.detectedMessage = call.getString("detectedMessage", "Key detected – keep holding it…"); this.cancelLabel = call.getString("cancelLabel", "Cancel"); } void start() { MAIN.post(() -> { Activity activity = getActivity(); if (activity == null || activity.isFinishing()) { fail("InvalidStateError", "No foreground activity"); return; } NfcAdapter nfc = NfcAdapter.getDefaultAdapter(getContext()); boolean usbHost = getContext().getPackageManager().hasSystemFeature(PackageManager.FEATURE_USB_HOST); if (nfc == null && !usbHost) { fail("NotSupportedError", "This device has neither NFC nor USB host support"); return; } String text = message; if (nfc != null && !nfc.isEnabled()) { text += "\n\n" + call.getString("nfcOffMessage", "NFC is switched off on this phone – enable it in the settings or plug the key in."); } dialog = new AlertDialog.Builder(activity) .setTitle(title) .setMessage(text) .setCancelable(false) .setNegativeButton(cancelLabel, (d, w) -> fail("NotAllowedError", "cancelled")) .create(); try { dialog.show(); } catch (Throwable t) { Log.e(TAG, "dialog failed", t); fail("UnknownError", "Dialog failed: " + t.getMessage()); return; } YubiKitManager kit; try { kit = yubiKit(); } catch (Throwable t) { Log.e(TAG, "YubiKit init failed", t); fail("NotSupportedError", "YubiKit init failed: " + t.getClass().getSimpleName() + ": " + t.getMessage()); return; } // USB first (also covers USB-C keys plugged into the phone); YubiKit asks for the // USB permission itself and only then hands us the device. try { kit.startUsbDiscovery(new UsbConfiguration().handlePermissions(true), this::onDevice); usbStarted = true; } catch (Exception e) { Log.w(TAG, "USB discovery unavailable: " + messageOf(e)); } // NFC reader mode: skipNdefCheck keeps the key's NDEF URL from being dispatched, // handleUnavailableNfc(false) → we tell the user instead of jumping to settings. try { kit.startNfcDiscovery( new NfcConfiguration().skipNdefCheck(true).disableNfcDiscoverySound(true).handleUnavailableNfc(false).timeout(10_000), activity, this::onDevice); nfcStarted = true; } catch (NfcNotAvailable e) { Log.w(TAG, "NFC unavailable: " + e.getMessage()); if (!usbStarted) fail("NotSupportedError", "NFC is not available on this device"); } catch (Exception e) { Log.w(TAG, "NFC discovery failed: " + messageOf(e)); if (!usbStarted) fail("NotSupportedError", messageOf(e)); } }); } private void onDevice(YubiKeyDevice device) { if (finished.get()) return; if (!busy.compareAndSet(false, true)) return; // NFC: presence is the tap itself. USB: the key blinks and waits for a touch. setMessage(device.getTransport() == Transport.USB ? call.getString("touchMessage", "Key connected – touch the gold contact on the key.") : detectedMessage); EXECUTOR.execute(() -> run(device)); } private void run(YubiKeyDevice device) { Ctap2Session ctap = null; try { ctap = openCtap2(device); byte[] clientDataJson = buildClientData(create ? "webauthn.create" : "webauthn.get", request.getString("challenge"), origin); byte[] clientDataHash = MessageDigest.getInstance("SHA-256").digest(clientDataJson); String transport = device.getTransport() == Transport.NFC ? "nfc" : "usb"; com.yubico.yubikit.fido.webauthn.PublicKeyCredential credential = create ? rawMakeCredential(ctap, clientDataJson, clientDataHash, transport) : rawGetAssertion(ctap, clientDataJson, clientDataHash); JSONObject out = new JSONObject(credential.toMap(SerializationType.JSON)); out.put("authenticatorAttachment", "cross-platform"); if (!out.has("clientExtensionResults")) out.put("clientExtensionResults", new JSONObject()); succeed(out.toString(), transport); } catch (NoMatchingCredential e) { fail("NotAllowedError", create ? "This key is already enrolled" : "This key holds no enrolled LogShip credential"); } catch (CtapException e) { Log.w(TAG, "CTAP error 0x" + Integer.toHexString(e.getCtapError() & 0xff) + ": " + e.getMessage()); switch (e.getCtapError()) { case CtapException.ERR_NO_CREDENTIALS: fail("NotAllowedError", "This key holds no enrolled LogShip credential"); break; case CtapException.ERR_CREDENTIAL_EXCLUDED: fail("InvalidStateError", "This key is already enrolled"); break; case CtapException.ERR_PUAT_REQUIRED: case CtapException.ERR_PIN_AUTH_INVALID: case CtapException.ERR_OPERATION_DENIED: fail("NotAllowedError", "This key insists on a PIN (alwaysUv / old firmware) – it cannot be used PIN-free"); break; case CtapException.ERR_USER_ACTION_TIMEOUT: case CtapException.ERR_ACTION_TIMEOUT: case CtapException.ERR_TIMEOUT: case CtapException.ERR_KEEPALIVE_CANCEL: fail("NotAllowedError", "The key did not answer in time – hold it still and try again"); break; default: fail("NotAllowedError", "The key rejected the request (" + messageOf(e) + ")"); } } catch (ApplicationNotAvailableException e) { fail("NotSupportedError", "No FIDO2 application on this key (FIDO2 disabled or too old)"); } catch (IOException e) { // NFC field lost / key pulled too early: stay in the dialog and wait for the next tap. Log.w(TAG, "connection lost during ceremony: " + e.getMessage()); if (!finished.get()) { busy.set(false); setMessage(call.getString("retryMessage", "Connection lost – hold the key to the phone again and keep it still.")); } } catch (CommandException e) { fail("NotAllowedError", "The key rejected the request: " + messageOf(e)); } catch (Exception e) { Log.e(TAG, "ceremony failed", e); fail("UnknownError", messageOf(e)); } finally { if (ctap != null) { try { ctap.close(); } catch (Exception ignored) {} } } } /** Open the FIDO application: USB → CTAP-HID, NFC → ISO 7816 (what the device offers). */ private Ctap2Session openCtap2(YubiKeyDevice device) throws IOException, CommandException { if (device.supportsConnection(FidoConnection.class)) { FidoConnection c = device.openConnection(FidoConnection.class); try { return new Ctap2Session(c); } catch (IOException | CommandException | RuntimeException e) { try { c.close(); } catch (IOException ignored) {} throw e; } } SmartCardConnection c = device.openConnection(SmartCardConnection.class); try { return new Ctap2Session(c); } catch (IOException | CommandException | RuntimeException e) { try { c.close(); } catch (IOException ignored) {} throw e; } } /** * getAssertion with the server's allow-list, presence only. The YubiKey accepts at most * maxCredentialCountInList (8) ids per call, so a longer global allow-list is first probed * in silent chunks (up=false, no touch) to find the credential this key holds. */ private com.yubico.yubikit.fido.webauthn.PublicKeyCredential rawGetAssertion( Ctap2Session ctap, byte[] clientDataJson, byte[] clientDataHash) throws IOException, CommandException, JSONException, NoMatchingCredential { List allow = new ArrayList<>(); JSONArray ac = request.optJSONArray("allowCredentials"); if (ac != null) { for (int i = 0; i < ac.length(); i++) { allow.add(PublicKeyCredentialDescriptor.fromMap(jsonToMap(ac.getJSONObject(i)), SerializationType.JSON)); } } if (allow.isEmpty()) { // Discoverable (usernameless) assertions need UV on a PIN-protected key — not our flow. throw new NoMatchingCredential(); } Integer maxRaw = ctap.getCachedInfo().getMaxCredentialCountInList(); int max = maxRaw != null && maxRaw > 0 ? maxRaw : 8; List selected = allow; if (allow.size() > max) { selected = null; Map silent = new HashMap<>(); silent.put("up", false); for (int from = 0; from < allow.size() && selected == null; from += max) { List chunk = allow.subList(from, Math.min(from + max, allow.size())); if (!probe(ctap, clientDataHash, chunk, silent)) continue; if (chunk.size() == 1) { selected = chunk; break; } for (PublicKeyCredentialDescriptor d : chunk) { List one = new ArrayList<>(); one.add(d); if (probe(ctap, clientDataHash, one, silent)) { selected = one; break; } } } if (selected == null) throw new NoMatchingCredential(); } List assertions = ctap.getAssertions( effectiveDomain, clientDataHash, toCbor(selected), null, null, null, null, state); if (assertions.isEmpty()) throw new NoMatchingCredential(); return com.yubico.yubikit.fido.webauthn.PublicKeyCredential.fromAssertion( assertions.get(0), clientDataJson, selected); } private boolean probe(Ctap2Session ctap, byte[] clientDataHash, List chunk, Map silent) throws IOException, CommandException { try { ctap.getAssertions(effectiveDomain, clientDataHash, toCbor(chunk), null, silent, null, null, null); return true; } catch (CtapException e) { if (e.getCtapError() == CtapException.ERR_NO_CREDENTIALS) return false; throw e; } } /** makeCredential: non-discoverable (rk=false), presence only, attestation as delivered. */ private com.yubico.yubikit.fido.webauthn.PublicKeyCredential rawMakeCredential( Ctap2Session ctap, byte[] clientDataJson, byte[] clientDataHash, String transport) throws IOException, CommandException, JSONException { JSONObject rpJson = request.getJSONObject("rp"); Map rp = new HashMap<>(); rp.put("id", rpJson.optString("id", effectiveDomain)); rp.put("name", rpJson.optString("name", rpJson.optString("id", effectiveDomain))); Map user = PublicKeyCredentialUserEntity .fromMap(jsonToMap(request.getJSONObject("user")), SerializationType.JSON) .toMap(SerializationType.CBOR); List> params = new ArrayList<>(); JSONArray pk = request.getJSONArray("pubKeyCredParams"); for (int i = 0; i < pk.length(); i++) params.add(jsonToMap(pk.getJSONObject(i))); List exclude = new ArrayList<>(); JSONArray ex = request.optJSONArray("excludeCredentials"); if (ex != null) { for (int i = 0; i < ex.length(); i++) { exclude.add(PublicKeyCredentialDescriptor.fromMap(jsonToMap(ex.getJSONObject(i)), SerializationType.JSON)); } } Map options = new HashMap<>(); options.put("rk", false); Ctap2Session.CredentialData cred = ctap.makeCredential( clientDataHash, rp, user, params, exclude.isEmpty() ? null : toCbor(exclude), null, options, null, null, null, state); AttestationObject att = AttestationObject.fromCredential(cred); byte[] credentialId = att.getAuthenticatorData().getAttestedCredentialData().getCredentialId(); List transports = new ArrayList<>(); transports.add(transport); return new com.yubico.yubikit.fido.webauthn.PublicKeyCredential( credentialId, new AuthenticatorAttestationResponse(clientDataJson, transports, att)); } private List> toCbor(List list) { List> out = new ArrayList<>(); for (PublicKeyCredentialDescriptor d : list) out.add(d.toMap(SerializationType.CBOR)); return out; } private void setMessage(String text) { MAIN.post(() -> { if (dialog != null && dialog.isShowing()) dialog.setMessage(text); }); } private void succeed(String responseJson, String transport) { if (!finished.compareAndSet(false, true)) return; cleanup(); JSObject ret = new JSObject(); ret.put("responseJson", responseJson); ret.put("transport", transport); call.resolve(ret); } void fail(String code, String message) { if (!finished.compareAndSet(false, true)) return; state.cancel(); cleanup(); call.reject(message, code); } private void cleanup() { if (active == this) active = null; MAIN.post(() -> { try { if (usbStarted && yubiKit != null) yubiKit.stopUsbDiscovery(); } catch (Exception ignored) {} try { Activity a = getActivity(); if (nfcStarted && a != null && yubiKit != null) yubiKit.stopNfcDiscovery(a); } catch (Exception ignored) {} try { if (dialog != null && dialog.isShowing()) dialog.dismiss(); } catch (Exception ignored) {} dialog = null; }); } } /** The tapped key holds none of the allow-listed credentials (or is already enrolled). */ private static final class NoMatchingCredential extends Exception { NoMatchingCredential() { super("no matching credential"); } } private static byte[] buildClientData(String type, String challenge, String origin) throws JSONException { JSONObject cd = new JSONObject(); cd.put("type", type); cd.put("challenge", challenge); cd.put("origin", origin); cd.put("crossOrigin", false); return cd.toString().getBytes(StandardCharsets.UTF_8); } private static String stripCode(String msg) { if (msg == null) return "Security key request failed"; int i = msg.indexOf(" - "); return i > 0 ? msg.substring(i + 3) : msg; } private static Map jsonToMap(JSONObject obj) throws JSONException { Map map = new HashMap<>(); Iterator keys = obj.keys(); while (keys.hasNext()) { String k = keys.next(); map.put(k, fromJsonValue(obj.get(k))); } return map; } private static List jsonToList(JSONArray arr) throws JSONException { List list = new ArrayList<>(); for (int i = 0; i < arr.length(); i++) list.add(fromJsonValue(arr.get(i))); return list; } private static Object fromJsonValue(Object v) throws JSONException { if (v == JSONObject.NULL) return null; if (v instanceof JSONObject) return jsonToMap((JSONObject) v); if (v instanceof JSONArray) return jsonToList((JSONArray) v); // Numbers stay as org.json parsed them: YubiKit casts `alg` to Integer and reads `timeout` // as Number, so an Integer→Long conversion would break pubKeyCredParams. return v; } // ───────────────────────── Credential Manager fallback ───────────────────────── private void systemGet(PluginCall call, String requestJson) { try { GetPublicKeyCredentialOption option = new GetPublicKeyCredentialOption(requestJson); GetCredentialRequest request = new GetCredentialRequest.Builder() .addCredentialOption(option) .build(); manager().getCredentialAsync( getActivity(), request, null, ContextCompat.getMainExecutor(getContext()), new CredentialManagerCallback() { @Override public void onResult(GetCredentialResponse result) { Credential credential = result.getCredential(); if (credential instanceof PublicKeyCredential) { JSObject ret = new JSObject(); ret.put("responseJson", ((PublicKeyCredential) credential).getAuthenticationResponseJson()); ret.put("transport", "system"); call.resolve(ret); } else { call.reject("Unexpected credential type: " + credential.getType(), "NotSupportedError"); } } @Override public void onError(GetCredentialException e) { Log.w(TAG, "getCredential failed: " + e.getType() + " " + e.getMessage()); call.reject(messageOf(e), codeForGet(e)); } }); } catch (Exception e) { Log.e(TAG, "systemGet() threw", e); call.reject(messageOf(e), "UnknownError"); } } private void systemCreate(PluginCall call, String requestJson) { try { CreatePublicKeyCredentialRequest request = new CreatePublicKeyCredentialRequest(requestJson); manager().createCredentialAsync( getActivity(), request, null, ContextCompat.getMainExecutor(getContext()), new CredentialManagerCallback() { @Override public void onResult(CreateCredentialResponse result) { if (result instanceof CreatePublicKeyCredentialResponse) { JSObject ret = new JSObject(); ret.put("responseJson", ((CreatePublicKeyCredentialResponse) result).getRegistrationResponseJson()); ret.put("transport", "system"); call.resolve(ret); } else { call.reject("Unexpected credential type: " + result.getType(), "NotSupportedError"); } } @Override public void onError(CreateCredentialException e) { Log.w(TAG, "createCredential failed: " + e.getType() + " " + e.getMessage()); call.reject(messageOf(e), codeForCreate(e)); } }); } catch (Exception e) { Log.e(TAG, "systemCreate() threw", e); call.reject(messageOf(e), "UnknownError"); } } private static String messageOf(Exception e) { return e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName(); } // Map Credential Manager exceptions onto the DOMException names the web flow already handles. private static String codeForGet(GetCredentialException e) { if (e instanceof GetCredentialCancellationException) return "NotAllowedError"; if (e instanceof NoCredentialException) return "NotAllowedError"; if (e instanceof GetCredentialProviderConfigurationException) return "NotSupportedError"; if (e instanceof GetPublicKeyCredentialDomException) { String dom = ((GetPublicKeyCredentialDomException) e).getDomError().getType(); return dom != null && !dom.isEmpty() ? simpleDomName(dom) : "UnknownError"; } return "UnknownError"; } private static String codeForCreate(CreateCredentialException e) { if (e instanceof CreateCredentialCancellationException) return "NotAllowedError"; if (e instanceof CreateCredentialProviderConfigurationException) return "NotSupportedError"; if (e instanceof CreatePublicKeyCredentialDomException) { String dom = ((CreatePublicKeyCredentialDomException) e).getDomError().getType(); return dom != null && !dom.isEmpty() ? simpleDomName(dom) : "UnknownError"; } return "UnknownError"; } // DomError types look like "androidx.credentials.TYPE_NOT_ALLOWED_ERROR" → "NotAllowedError". private static String simpleDomName(String type) { String t = type.substring(type.lastIndexOf('.') + 1); if (t.startsWith("TYPE_")) t = t.substring(5); StringBuilder out = new StringBuilder(); for (String part : t.toLowerCase().split("_")) { if (part.isEmpty()) continue; out.append(Character.toUpperCase(part.charAt(0))).append(part.substring(1)); } return out.length() > 0 ? out.toString() : "UnknownError"; } }