CardFieldUtils.kt

6.2 KB · raw

1package com.peachpasswords.ui.vault23/**4 * Shared card formatting/detection helpers for D2 (card type fields).5 *6 * Pure functions — no Compose, no Android deps — so they are unit-testable and7 * reusable by both EntryEditScreen (live formatting) and EntryDetailScreen8 * (masked display). Network ids match the VaultEntry.cardNetwork doc comment:9 * "visa" | "mastercard" | "amex" | "discover" | "unknown".10 */11object CardFieldUtils {1213    /** Detect the network id from the leading digit(s) of a raw (digits-or-spaces) number. */14    fun detectNetwork(rawNumber: String): String {15        val digits = rawNumber.filter { it.isDigit() }16        if (digits.isEmpty()) return "unknown"17        return when {18            digits.startsWith("4") -> "visa"19            digits.length >= 2 && digits.take(2).toIntOrNull() in 51..55 -> "mastercard"20            digits.length >= 2 && digits.take(2).toIntOrNull() in 22..27 -> "mastercard"21            digits.startsWith("34") || digits.startsWith("37") -> "amex"22            digits.startsWith("6011") || digits.startsWith("65") -> "discover"23            else -> "unknown"24        }25    }2627    /** Human label for a network id, e.g. "visa" -> "Visa". Blank for unknown. */28    fun networkLabel(network: String?): String = when (network) {29        "visa" -> "Visa"30        "mastercard" -> "Mastercard"31        "amex" -> "Amex"32        "discover" -> "Discover"33        else -> ""34    }3536    /** Max PAN digits by network: Amex = 15, everyone else = 16. */37    fun panMaxDigits(network: String?): Int = if (network == "amex") 15 else 163839    /** CVC length by network: Amex = 4, everyone else = 3. */40    fun cvcMaxDigits(network: String?): Int = if (network == "amex") 4 else 34142    /**43     * Group a raw PAN into display chunks. Amex uses 4-6-5; all others 4-4-4-4.44     * Input may contain spaces; only digits are kept. Caps at the network max.45     */46    fun formatPan(rawNumber: String, network: String? = null): String {47        val net = network ?: detectNetwork(rawNumber)48        val digits = rawNumber.filter { it.isDigit() }.take(panMaxDigits(net))49        val groups = if (net == "amex") intArrayOf(4, 6, 5) else intArrayOf(4, 4, 4, 4)50        val out = StringBuilder()51        var i = 052        for (g in groups) {53            if (i >= digits.length) break54            if (out.isNotEmpty()) out.append(' ')55            out.append(digits.substring(i, minOf(i + g, digits.length)))56            i += g57        }58        return out.toString()59    }6061    /** Raw digits only (for storage / network detection). */62    fun digitsOnly(value: String): String = value.filter { it.isDigit() }6364    /** Last 4 digits of a stored/raw PAN, or "" if fewer than 4. */65    fun last4(rawNumber: String?): String {66        val digits = (rawNumber ?: "").filter { it.isDigit() }67        return if (digits.length >= 4) digits.takeLast(4) else digits68    }6970    /**71     * Masked display: groups of bullets matching the network layout with the72     * real last-4 shown, e.g. "•••• •••• •••• 4242" (Amex: "•••• •••••• •2345").73     * Falls back to a plain masked group when the stored number is short.74     */75    fun maskedPan(rawNumber: String?, network: String? = null, last4: String? = null): String {76        val digits = (rawNumber ?: "").filter { it.isDigit() }77        // Prefer the stored last4 (MF-3) over deriving it from the raw PAN at render.78        val storedTail = last4?.filter { it.isDigit() }?.takeIf { it.isNotEmpty() }79        if (digits.isEmpty() && storedTail == null) return ""80        val net = network ?: detectNetwork(digits)81        val tail = storedTail ?: if (digits.length >= 4) digits.takeLast(4) else digits82        val groups = if (net == "amex") intArrayOf(4, 6, 5) else intArrayOf(4, 4, 4, 4)83        // Build a bullet template of total length, then overlay the last-4.84        val total = groups.sum()85        val visibleFrom = (total - tail.length).coerceAtLeast(0)86        val chars = CharArray(total) { idx -> if (idx >= visibleFrom) tail[idx - visibleFrom] else '•' }87        val out = StringBuilder()88        var i = 089        for (g in groups) {90            if (out.isNotEmpty()) out.append(' ')91            out.append(chars, i, g)92            i += g93        }94        return out.toString()95    }9697    /** Auto-format expiry as "MM/YY" or "MM/YYYY": inserts "/" after 2 digits. */98    fun formatExpiry(raw: String): String {99        val digits = raw.filter { it.isDigit() }.take(6)100        return when {101            digits.length <= 2 -> digits102            else -> digits.substring(0, 2) + "/" + digits.substring(2)103        }104    }105106    /** Combine stored expMonth/expYear into "MM/YY" or browser-style "MM/YYYY". */107    fun displayExpiry(expMonth: String?, expYear: String?): String {108        val mm = (expMonth ?: "").filter { it.isDigit() }.takeLast(2).padStart(2, '0').takeIf { expMonth?.isNotBlank() == true } ?: ""109        val year = (expYear ?: "").filter { it.isDigit() }.take(4)110        return if (mm.isNotBlank() && year.isNotBlank()) "$mm/$year" else (mm.takeIf { it.isNotBlank() } ?: year)111    }112113    fun expiryMonth(formatted: String): String =114        formatted.filter { it.isDigit() }.take(2)115116    fun expiryYear(formatted: String): String =117        formatted.filter { it.isDigit() }.drop(2).take(4)118119    fun combineExpiry(month: String, year: String): String {120        val mm = month.filter { it.isDigit() }.take(2)121        val yy = year.filter { it.isDigit() }.take(4)122        return if (mm.isNotBlank() && yy.isNotBlank()) "$mm/$yy" else mm.ifBlank { yy }123    }124125    /**126     * Validate a "MM/YY" or "MM/YYYY" expiry: month 01-12 and year >= current127     * 2-digit year. `currentYear2`/`currentMonth` are injected for testability.128     */129    fun isExpiryValid(formatted: String, currentYear2: Int, currentMonth: Int): Boolean {130        val digits = formatted.filter { it.isDigit() }131        if (digits.length != 4 && digits.length != 6) return false132        val mm = digits.substring(0, 2).toIntOrNull() ?: return false133        val yy = digits.takeLast(2).toIntOrNull() ?: return false134        if (mm !in 1..12) return false135        if (yy < currentYear2) return false136        if (yy == currentYear2 && mm < currentMonth) return false137        return true138    }139}