package com.peachpasswords.ui.vault /** * Shared card formatting/detection helpers for D2 (card type fields). * * Pure functions — no Compose, no Android deps — so they are unit-testable and * reusable by both EntryEditScreen (live formatting) and EntryDetailScreen * (masked display). Network ids match the VaultEntry.cardNetwork doc comment: * "visa" | "mastercard" | "amex" | "discover" | "unknown". */ object CardFieldUtils { /** Detect the network id from the leading digit(s) of a raw (digits-or-spaces) number. */ fun detectNetwork(rawNumber: String): String { val digits = rawNumber.filter { it.isDigit() } if (digits.isEmpty()) return "unknown" return when { digits.startsWith("4") -> "visa" digits.length >= 2 && digits.take(2).toIntOrNull() in 51..55 -> "mastercard" digits.length >= 2 && digits.take(2).toIntOrNull() in 22..27 -> "mastercard" digits.startsWith("34") || digits.startsWith("37") -> "amex" digits.startsWith("6011") || digits.startsWith("65") -> "discover" else -> "unknown" } } /** Human label for a network id, e.g. "visa" -> "Visa". Blank for unknown. */ fun networkLabel(network: String?): String = when (network) { "visa" -> "Visa" "mastercard" -> "Mastercard" "amex" -> "Amex" "discover" -> "Discover" else -> "" } /** Max PAN digits by network: Amex = 15, everyone else = 16. */ fun panMaxDigits(network: String?): Int = if (network == "amex") 15 else 16 /** CVC length by network: Amex = 4, everyone else = 3. */ fun cvcMaxDigits(network: String?): Int = if (network == "amex") 4 else 3 /** * Group a raw PAN into display chunks. Amex uses 4-6-5; all others 4-4-4-4. * Input may contain spaces; only digits are kept. Caps at the network max. */ fun formatPan(rawNumber: String, network: String? = null): String { val net = network ?: detectNetwork(rawNumber) val digits = rawNumber.filter { it.isDigit() }.take(panMaxDigits(net)) val groups = if (net == "amex") intArrayOf(4, 6, 5) else intArrayOf(4, 4, 4, 4) val out = StringBuilder() var i = 0 for (g in groups) { if (i >= digits.length) break if (out.isNotEmpty()) out.append(' ') out.append(digits.substring(i, minOf(i + g, digits.length))) i += g } return out.toString() } /** Raw digits only (for storage / network detection). */ fun digitsOnly(value: String): String = value.filter { it.isDigit() } /** Last 4 digits of a stored/raw PAN, or "" if fewer than 4. */ fun last4(rawNumber: String?): String { val digits = (rawNumber ?: "").filter { it.isDigit() } return if (digits.length >= 4) digits.takeLast(4) else digits } /** * Masked display: groups of bullets matching the network layout with the * real last-4 shown, e.g. "•••• •••• •••• 4242" (Amex: "•••• •••••• •2345"). * Falls back to a plain masked group when the stored number is short. */ fun maskedPan(rawNumber: String?, network: String? = null, last4: String? = null): String { val digits = (rawNumber ?: "").filter { it.isDigit() } // Prefer the stored last4 (MF-3) over deriving it from the raw PAN at render. val storedTail = last4?.filter { it.isDigit() }?.takeIf { it.isNotEmpty() } if (digits.isEmpty() && storedTail == null) return "" val net = network ?: detectNetwork(digits) val tail = storedTail ?: if (digits.length >= 4) digits.takeLast(4) else digits val groups = if (net == "amex") intArrayOf(4, 6, 5) else intArrayOf(4, 4, 4, 4) // Build a bullet template of total length, then overlay the last-4. val total = groups.sum() val visibleFrom = (total - tail.length).coerceAtLeast(0) val chars = CharArray(total) { idx -> if (idx >= visibleFrom) tail[idx - visibleFrom] else '•' } val out = StringBuilder() var i = 0 for (g in groups) { if (out.isNotEmpty()) out.append(' ') out.append(chars, i, g) i += g } return out.toString() } /** Auto-format expiry as "MM/YY" or "MM/YYYY": inserts "/" after 2 digits. */ fun formatExpiry(raw: String): String { val digits = raw.filter { it.isDigit() }.take(6) return when { digits.length <= 2 -> digits else -> digits.substring(0, 2) + "/" + digits.substring(2) } } /** Combine stored expMonth/expYear into "MM/YY" or browser-style "MM/YYYY". */ fun displayExpiry(expMonth: String?, expYear: String?): String { val mm = (expMonth ?: "").filter { it.isDigit() }.takeLast(2).padStart(2, '0').takeIf { expMonth?.isNotBlank() == true } ?: "" val year = (expYear ?: "").filter { it.isDigit() }.take(4) return if (mm.isNotBlank() && year.isNotBlank()) "$mm/$year" else (mm.takeIf { it.isNotBlank() } ?: year) } fun expiryMonth(formatted: String): String = formatted.filter { it.isDigit() }.take(2) fun expiryYear(formatted: String): String = formatted.filter { it.isDigit() }.drop(2).take(4) fun combineExpiry(month: String, year: String): String { val mm = month.filter { it.isDigit() }.take(2) val yy = year.filter { it.isDigit() }.take(4) return if (mm.isNotBlank() && yy.isNotBlank()) "$mm/$yy" else mm.ifBlank { yy } } /** * Validate a "MM/YY" or "MM/YYYY" expiry: month 01-12 and year >= current * 2-digit year. `currentYear2`/`currentMonth` are injected for testability. */ fun isExpiryValid(formatted: String, currentYear2: Int, currentMonth: Int): Boolean { val digits = formatted.filter { it.isDigit() } if (digits.length != 4 && digits.length != 6) return false val mm = digits.substring(0, 2).toIntOrNull() ?: return false val yy = digits.takeLast(2).toIntOrNull() ?: return false if (mm !in 1..12) return false if (yy < currentYear2) return false if (yy == currentYear2 && mm < currentMonth) return false return true } }