From 97fe63025d262fee1f29e9de9347f664fa8bd01c Mon Sep 17 00:00:00 2001 From: blackluga Date: Sun, 27 Sep 2026 17:00:11 +0500 Subject: [PATCH] chats: throttle BML name lookups Real-name lookups use BML's account validation, the same search as the Transfer page, which BML rate-limits per user ("searched too many times"). Look up only names the saved data can't answer (accounts sent to that aren't contacts, contacts without a separate real name), at most 3 per run, one run per 6 hours, 15 per day, and back off for a day at the first refusal. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FQUrmJjrypeoPsubpzuezC --- .../sh/sar/basedbank/util/AccountNameStore.kt | 58 +++++++++++++++---- .../java/sh/sar/basedbank/util/ChatStore.kt | 12 +++- 2 files changed, 56 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/sh/sar/basedbank/util/AccountNameStore.kt b/app/src/main/java/sh/sar/basedbank/util/AccountNameStore.kt index eec1880..4311001 100644 --- a/app/src/main/java/sh/sar/basedbank/util/AccountNameStore.kt +++ b/app/src/main/java/sh/sar/basedbank/util/AccountNameStore.kt @@ -20,8 +20,16 @@ object AccountNameStore { private const val REFRESH_AFTER_MS = 30L * 24 * 60 * 60 * 1000 /** Failed lookups (closed or foreign accounts) are retried after this long. */ private const val RETRY_FAILED_AFTER_MS = 24L * 60 * 60 * 1000 - /** Upper bound on lookups per call, so a big contact list is resolved over several syncs. */ - private const val MAX_LOOKUPS_PER_RUN = 20 + /* + * BML's account validation is the same search the Transfer page uses, and BML rate-limits it + * per user ("searched too many times"). Lookups are a background nicety, so they get a small + * budget and back off at the first refusal, leaving the user's own searches alone. + */ + private const val MAX_LOOKUPS_PER_RUN = 3 + private const val MIN_RUN_INTERVAL_MS = 6L * 60 * 60 * 1000 + private const val MAX_LOOKUPS_PER_DAY = 15 + private const val BACKOFF_MS = 24L * 60 * 60 * 1000 + private const val BUDGET_PREFS = "chat_name_lookup_budget" data class Info(val name: String, val currency: String) @@ -32,34 +40,60 @@ object AccountNameStore { load(context).filterValues { it.name.isNotBlank() }.mapValues { Info(it.value.name, it.value.currency) } } - /** Looks up names for [accounts] that are unknown or stale. Network I/O — call on Dispatchers.IO. */ + /** + * Looks up names for [accounts] (most important first) that are unknown or stale, within the + * budget above. Network I/O — call on Dispatchers.IO. + */ fun resolve(context: Context, session: BmlSession, accounts: Collection) { val now = System.currentTimeMillis() + val budget = context.getSharedPreferences(BUDGET_PREFS, Context.MODE_PRIVATE) + if (now < budget.getLong("blockedUntil", 0L)) return + if (now - budget.getLong("lastRunAt", 0L) < MIN_RUN_INTERVAL_MS) return + val today = now / (24L * 60 * 60 * 1000) + val usedToday = if (budget.getLong("day", -1L) == today) budget.getInt("count", 0) else 0 + val allowed = minOf(MAX_LOOKUPS_PER_RUN, MAX_LOOKUPS_PER_DAY - usedToday) + if (allowed <= 0) return + val known = synchronized(lock) { load(context) } val due = accounts.filter { it.isNotBlank() }.distinct().filter { account -> val e = known[account] ?: return@filter true val age = now - e.checkedAt if (e.name.isBlank()) age > RETRY_FAILED_AFTER_MS else age > REFRESH_AFTER_MS - }.take(MAX_LOOKUPS_PER_RUN) + }.take(allowed) if (due.isEmpty()) return val client = BmlValidateClient() - // null = network error: leave the account due for next time. A failed lookup is stored blank. - val found = due.associateWith { account -> - try { - val v = client.validateAccount(session, account) - Entry(v?.name?.trim().orEmpty(), v?.currency.orEmpty(), now) - } catch (_: Exception) { null } + val found = mutableMapOf() + var used = 0 + for (account in due) { + used++ + val v = try { client.validateAccount(session, account) } catch (_: Exception) { null } + if (v == null || v.name.isBlank()) { + // Refused, limited, or unknown: stop for the day rather than keep asking. The + // account is marked failed so it's retried later, not first in line tomorrow. + found[account] = Entry("", "", now) + budget.edit().putLong("blockedUntil", now + BACKOFF_MS).apply() + break + } + found[account] = Entry(v.name.trim(), v.currency, now) } + budget.edit() + .putLong("lastRunAt", now) + .putLong("day", today) + .putInt("count", usedToday + used) + .apply() synchronized(lock) { val current = load(context).toMutableMap() - found.forEach { (account, entry) -> if (entry != null) current[account] = entry } + current.putAll(found) save(context, current) } } - fun clearAll(context: Context) = synchronized(lock) { File(context.filesDir, FILE_NAME).delete() } + fun clearAll(context: Context) = synchronized(lock) { + File(context.filesDir, FILE_NAME).delete() + context.getSharedPreferences(BUDGET_PREFS, Context.MODE_PRIVATE).edit().clear().apply() + } private fun load(context: Context): Map { val file = File(context.filesDir, FILE_NAME) diff --git a/app/src/main/java/sh/sar/basedbank/util/ChatStore.kt b/app/src/main/java/sh/sar/basedbank/util/ChatStore.kt index b435d1e..d443f51 100644 --- a/app/src/main/java/sh/sar/basedbank/util/ChatStore.kt +++ b/app/src/main/java/sh/sar/basedbank/util/ChatStore.kt @@ -208,9 +208,17 @@ object ChatStore { // Learn real names for contacts and people we've sent to, so name-only transfers match them. val session = app.anyBmlSession() ?: return + // Only what the saved data can't answer, most useful first: accounts sent to that aren't + // contacts, then contacts saved without a separate real name. SWIFT (foreign) beneficiaries + // can't be looked up through BML. + val contactAccounts = contacts.mapTo(HashSet()) { it.benefAccount } val sentTo = synchronized(lock) { load(context).messages.map { it.peerAccount } } - // SWIFT (foreign) beneficiaries can't be looked up through BML. - val toResolve = contacts.filter { it.benefType != "S" }.map { it.benefAccount } + sentTo + .filter { it.isNotBlank() && it !in contactAccounts } + val nameless = contacts.filter { + it.benefType != "S" && it.benefAccount.isNotBlank() && + (it.benefName.isBlank() || normalise(it.benefName) == normalise(it.benefNickName)) + }.map { it.benefAccount } + val toResolve = sentTo + nameless AccountNameStore.resolve(context, session, toResolve) }