CardFieldUtils.kt

5.4 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 (digits.first()) {18            '4' -> "visa"19            '5' -> "mastercard"20            '3' -> "amex"21            '6' -> "discover"22            else -> "unknown"23        }24    }2526    /** Human label for a network id, e.g. "visa" -> "Visa". Blank for unknown. */27    fun networkLabel(network: String?): String = when (network) {28        "visa" -> "Visa"29        "mastercard" -> "Mastercard"30        "amex" -> "Amex"31        "discover" -> "Discover"32        else -> ""33    }3435    /** Max PAN digits by network: Amex = 15, everyone else = 16. */36    fun panMaxDigits(network: String?): Int = if (network == "amex") 15 else 163738    /** CVC length by network: Amex = 4, everyone else = 3. */39    fun cvcMaxDigits(network: String?): Int = if (network == "amex") 4 else 34041    /**42     * Group a raw PAN into display chunks. Amex uses 4-6-5; all others 4-4-4-4.43     * Input may contain spaces; only digits are kept. Caps at the network max.44     */45    fun formatPan(rawNumber: String, network: String? = null): String {46        val net = network ?: detectNetwork(rawNumber)47        val digits = rawNumber.filter { it.isDigit() }.take(panMaxDigits(net))48        val groups = if (net == "amex") intArrayOf(4, 6, 5) else intArrayOf(4, 4, 4, 4)49        val out = StringBuilder()50        var i = 051        for (g in groups) {52            if (i >= digits.length) break53            if (out.isNotEmpty()) out.append(' ')54            out.append(digits.substring(i, minOf(i + g, digits.length)))55            i += g56        }57        return out.toString()58    }5960    /** Raw digits only (for storage / network detection). */61    fun digitsOnly(value: String): String = value.filter { it.isDigit() }6263    /** Last 4 digits of a stored/raw PAN, or "" if fewer than 4. */64    fun last4(rawNumber: String?): String {65        val digits = (rawNumber ?: "").filter { it.isDigit() }66        return if (digits.length >= 4) digits.takeLast(4) else digits67    }6869    /**70     * Masked display: groups of bullets matching the network layout with the71     * real last-4 shown, e.g. "•••• •••• •••• 4242" (Amex: "•••• •••••• •2345").72     * Falls back to a plain masked group when the stored number is short.73     */74    fun maskedPan(rawNumber: String?, network: String? = null, last4: String? = null): String {75        val digits = (rawNumber ?: "").filter { it.isDigit() }76        // Prefer the stored last4 (MF-3) over deriving it from the raw PAN at render.77        val storedTail = last4?.filter { it.isDigit() }?.takeIf { it.isNotEmpty() }78        if (digits.isEmpty() && storedTail == null) return ""79        val net = network ?: detectNetwork(digits)80        val tail = storedTail ?: if (digits.length >= 4) digits.takeLast(4) else digits81        val groups = if (net == "amex") intArrayOf(4, 6, 5) else intArrayOf(4, 4, 4, 4)82        // Build a bullet template of total length, then overlay the last-4.83        val total = groups.sum()84        val visibleFrom = (total - tail.length).coerceAtLeast(0)85        val chars = CharArray(total) { idx -> if (idx >= visibleFrom) tail[idx - visibleFrom] else '•' }86        val out = StringBuilder()87        var i = 088        for (g in groups) {89            if (out.isNotEmpty()) out.append(' ')90            out.append(chars, i, g)91            i += g92        }93        return out.toString()94    }9596    /** Auto-format expiry as "MM/YY": inserts "/" after 2 digits, caps at 4 digits. */97    fun formatExpiry(raw: String): String {98        val digits = raw.filter { it.isDigit() }.take(4)99        return when {100            digits.length <= 2 -> digits101            else -> digits.substring(0, 2) + "/" + digits.substring(2)102        }103    }104105    /** Combine stored expMonth/expYear into "MM/YY" for display. */106    fun displayExpiry(expMonth: String?, expYear: String?): String {107        val mm = (expMonth ?: "").filter { it.isDigit() }.takeLast(2).padStart(2, '0').takeIf { expMonth?.isNotBlank() == true } ?: ""108        val yy = (expYear ?: "").filter { it.isDigit() }.takeLast(2)109        return if (mm.isNotBlank() && yy.isNotBlank()) "$mm/$yy" else (mm.takeIf { it.isNotBlank() } ?: yy)110    }111112    /**113     * Validate a "MM/YY" expiry: month 01–12 and 2-digit year >= current 2-digit114     * year. `currentYear2`/`currentMonth` are injected for testability.115     */116    fun isExpiryValid(formatted: String, currentYear2: Int, currentMonth: Int): Boolean {117        val digits = formatted.filter { it.isDigit() }118        if (digits.length != 4) return false119        val mm = digits.substring(0, 2).toIntOrNull() ?: return false120        val yy = digits.substring(2, 4).toIntOrNull() ?: return false121        if (mm !in 1..12) return false122        if (yy < currentYear2) return false123        if (yy == currentYear2 && mm < currentMonth) return false124        return true125    }126}