forked from thijooree/android
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FQUrmJjrypeoPsubpzuezC
This commit is contained in:
@@ -20,8 +20,16 @@ object AccountNameStore {
|
|||||||
private const val REFRESH_AFTER_MS = 30L * 24 * 60 * 60 * 1000
|
private const val REFRESH_AFTER_MS = 30L * 24 * 60 * 60 * 1000
|
||||||
/** Failed lookups (closed or foreign accounts) are retried after this long. */
|
/** Failed lookups (closed or foreign accounts) are retried after this long. */
|
||||||
private const val RETRY_FAILED_AFTER_MS = 24L * 60 * 60 * 1000
|
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)
|
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) }
|
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<String>) {
|
fun resolve(context: Context, session: BmlSession, accounts: Collection<String>) {
|
||||||
val now = System.currentTimeMillis()
|
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 known = synchronized(lock) { load(context) }
|
||||||
val due = accounts.filter { it.isNotBlank() }.distinct().filter { account ->
|
val due = accounts.filter { it.isNotBlank() }.distinct().filter { account ->
|
||||||
val e = known[account] ?: return@filter true
|
val e = known[account] ?: return@filter true
|
||||||
val age = now - e.checkedAt
|
val age = now - e.checkedAt
|
||||||
if (e.name.isBlank()) age > RETRY_FAILED_AFTER_MS else age > REFRESH_AFTER_MS
|
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
|
if (due.isEmpty()) return
|
||||||
|
|
||||||
val client = BmlValidateClient()
|
val client = BmlValidateClient()
|
||||||
// null = network error: leave the account due for next time. A failed lookup is stored blank.
|
val found = mutableMapOf<String, Entry>()
|
||||||
val found = due.associateWith { account ->
|
var used = 0
|
||||||
try {
|
for (account in due) {
|
||||||
val v = client.validateAccount(session, account)
|
used++
|
||||||
Entry(v?.name?.trim().orEmpty(), v?.currency.orEmpty(), now)
|
val v = try { client.validateAccount(session, account) } catch (_: Exception) { null }
|
||||||
} 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) {
|
synchronized(lock) {
|
||||||
val current = load(context).toMutableMap()
|
val current = load(context).toMutableMap()
|
||||||
found.forEach { (account, entry) -> if (entry != null) current[account] = entry }
|
current.putAll(found)
|
||||||
save(context, current)
|
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<String, Entry> {
|
private fun load(context: Context): Map<String, Entry> {
|
||||||
val file = File(context.filesDir, FILE_NAME)
|
val file = File(context.filesDir, FILE_NAME)
|
||||||
|
|||||||
@@ -208,9 +208,17 @@ object ChatStore {
|
|||||||
|
|
||||||
// Learn real names for contacts and people we've sent to, so name-only transfers match them.
|
// Learn real names for contacts and people we've sent to, so name-only transfers match them.
|
||||||
val session = app.anyBmlSession() ?: return
|
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 } }
|
val sentTo = synchronized(lock) { load(context).messages.map { it.peerAccount } }
|
||||||
// SWIFT (foreign) beneficiaries can't be looked up through BML.
|
.filter { it.isNotBlank() && it !in contactAccounts }
|
||||||
val toResolve = contacts.filter { it.benefType != "S" }.map { it.benefAccount } + sentTo
|
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)
|
AccountNameStore.resolve(context, session, toResolve)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user