Skip to content

Commit 1888645

Browse files
nqmgamingclaude
andcommitted
fix(virustotal): tell the user when the key is rejected or the quota is spent
Every non-2xx response collapsed into ERROR, so a 429 read as "HTTP 429: Too Many Requests" and a mistyped key read as "HTTP 401: Unauthorized" — neither tells anyone what to do. Both are the common failures: the free tier allows 4 requests a minute, which a batch install trips immediately, and a key with one wrong character is indistinguishable from a broken service. Adds INVALID_API_KEY and RATE_LIMITED, mapped in one place and surfaced in both the sheet and the dialog. A rejected key now gets the same Add key / Get key actions a missing one already had, and the rate-limit line quotes Retry-After when VT sends it as a delay in seconds. The upload path returns Result<String> and cannot carry a status, so it throws a typed VtHttpException instead — which also stops the retry loop from spending two more requests against a quota that is already exhausted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 1ea1896 commit 1888645

8 files changed

Lines changed: 87 additions & 15 deletions

File tree

app/src/main/java/app/pwhs/universalinstaller/data/remote/VirusTotalService.kt

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,7 @@ class VirusTotalService(
6161
when (response.status.value) {
6262
200 -> parseFileStats(response.bodyAsText())
6363
404 -> VtResult(status = VtStatus.NOT_FOUND)
64-
else -> VtResult(
65-
status = VtStatus.ERROR,
66-
errorMessage = "HTTP ${response.status.value}: ${response.status.description}",
67-
)
64+
else -> httpFailure(response)
6865
}
6966
}.getOrElse { e ->
7067
Timber.e(e, "VirusTotal hash lookup failed")
@@ -108,7 +105,7 @@ class VirusTotalService(
108105
}
109106
}
110107
if (response.status.value !in 200..299) {
111-
error("upload_url HTTP ${response.status.value}: ${response.status.description}")
108+
throwTyped(response, "upload_url")
112109
}
113110
return JSONObject(response.bodyAsText()).getString("data")
114111
}
@@ -167,11 +164,14 @@ class VirusTotalService(
167164
}
168165
}
169166
if (response.status.value !in 200..299) {
170-
error("Upload HTTP ${response.status.value}: ${response.status.description}")
167+
throwTyped(response, "Upload")
171168
}
172169
return JSONObject(response.bodyAsText()).getJSONObject("data").getString("id")
173170
} catch (e: java.io.IOException) {
174171
// Transient I/O error (EOFException, SocketException, etc.) — retry
172+
// A rejected key or an exhausted quota will not fix itself on retry, and each
173+
// attempt costs another request against the same quota.
174+
if (e is VtHttpException) throw e
175175
Timber.w(e, "Upload I/O error on attempt ${attempt + 1}")
176176
lastException = e
177177
}
@@ -211,11 +211,10 @@ class VirusTotalService(
211211
}
212212
}
213213
if (response.status.value !in 200..299) {
214-
return@runCatching VtResult(
215-
status = VtStatus.ERROR,
216-
errorMessage = "Poll HTTP ${response.status.value}",
217-
analysisId = analysisId,
218-
)
214+
// Polling burns a request each time, so this is where the free tier's
215+
// 4/minute is most likely to bite; report it as such rather than as a
216+
// nameless poll failure.
217+
return@runCatching httpFailure(response).copy(analysisId = analysisId)
219218
}
220219
parseAnalysisResponse(response.bodyAsText(), analysisId)
221220
}.getOrElse { e ->
@@ -322,6 +321,45 @@ class VirusTotalService(
322321
)
323322
}
324323

324+
/**
325+
* Throw a [VtHttpException] carrying the mapped status. Retrying an upload after a 401 or a
326+
* 429 only burns the remaining quota, so these must be distinguishable from an I/O failure.
327+
*/
328+
private fun throwTyped(response: HttpResponse, what: String): Nothing {
329+
val mapped = httpFailure(response)
330+
throw VtHttpException(
331+
vtStatus = mapped.status,
332+
message = "$what HTTP ${response.status.value}: ${response.status.description}",
333+
)
334+
}
335+
336+
/** Carries an actionable [VtStatus] out of the upload path, which returns Result<String>. */
337+
class VtHttpException(val vtStatus: VtStatus, message: String) : java.io.IOException(message)
338+
339+
/**
340+
* Map an unsuccessful response to a status the UI can act on.
341+
*
342+
* 401/403 and 429 used to fall into the generic ERROR branch and surface as
343+
* "HTTP 429: Too Many Requests", which tells the user nothing they can do. Both are common:
344+
* the free tier allows only 4 requests a minute, which a batch install trips immediately, and
345+
* a key with one wrong character looks identical to a broken service.
346+
*/
347+
private fun httpFailure(response: HttpResponse): VtResult = when (response.status.value) {
348+
401, 403 -> VtResult(status = VtStatus.INVALID_API_KEY)
349+
429 -> VtResult(
350+
status = VtStatus.RATE_LIMITED,
351+
// Retry-After is optional and may be an HTTP date rather than a delay in seconds.
352+
// The UI phrases it as "in N seconds", so pass it on only when it really is N.
353+
errorMessage = response.headers["Retry-After"]
354+
?.takeIf { it.isNotBlank() && it.all(Char::isDigit) }
355+
.orEmpty(),
356+
)
357+
else -> VtResult(
358+
status = VtStatus.ERROR,
359+
errorMessage = "HTTP ${response.status.value}: ${response.status.description}",
360+
)
361+
}
362+
325363
companion object {
326364
const val BASE_URL = "https://www.virustotal.com/api/v3"
327365
private const val HEADER_KEY = "x-apikey"

app/src/main/java/app/pwhs/universalinstaller/domain/model/VtResult.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ enum class VtStatus {
66
SUSPICIOUS,
77
NOT_FOUND,
88
NO_API_KEY,
9+
/** HTTP 401/403 — the key is wrong, revoked, or lacks access. Distinct from a missing key. */
10+
INVALID_API_KEY,
11+
/** HTTP 429 — free tier allows 4 requests/minute and 500/day. Retrying later works. */
12+
RATE_LIMITED,
913
ERROR,
1014
TOO_LARGE,
1115
SCANNING, // hashing + hash lookup

app/src/main/java/app/pwhs/universalinstaller/presentation/install/ApkInfoContent.kt

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -605,7 +605,8 @@ private fun VirusTotalCard(
605605
val vtColor = when (status) {
606606
VtStatus.CLEAN -> MaterialTheme.colorScheme.primary
607607
VtStatus.MALICIOUS, VtStatus.ERROR -> MaterialTheme.colorScheme.error
608-
VtStatus.SUSPICIOUS, VtStatus.NO_API_KEY, VtStatus.TOO_LARGE -> extendedColors.warning
608+
VtStatus.SUSPICIOUS, VtStatus.NO_API_KEY, VtStatus.INVALID_API_KEY,
609+
VtStatus.RATE_LIMITED, VtStatus.TOO_LARGE -> extendedColors.warning
609610
else -> MaterialTheme.colorScheme.onSurfaceVariant
610611
}
611612
// Status line — without this, NO_API_KEY / ERROR / TOO_LARGE left the card silent
@@ -616,6 +617,11 @@ private fun VirusTotalCard(
616617
VtStatus.SUSPICIOUS -> stringResource(R.string.apk_info_vt_suspicious, vt.suspicious)
617618
VtStatus.NOT_FOUND -> stringResource(R.string.apk_info_vt_not_found)
618619
VtStatus.NO_API_KEY -> stringResource(R.string.apk_info_vt_no_api_key)
620+
VtStatus.INVALID_API_KEY -> stringResource(R.string.apk_info_vt_invalid_key)
621+
// The Retry-After header is optional, so the countdown wording is too.
622+
VtStatus.RATE_LIMITED -> vt.errorMessage.takeIf { it.isNotBlank() }
623+
?.let { stringResource(R.string.apk_info_vt_rate_limited_retry, it) }
624+
?: stringResource(R.string.apk_info_vt_rate_limited)
619625
VtStatus.ERROR -> vt.errorMessage.takeIf { it.isNotBlank() } ?: stringResource(R.string.apk_info_vt_error)
620626
VtStatus.TOO_LARGE -> stringResource(R.string.apk_info_vt_too_large, vt.errorMessage.orEmpty())
621627
VtStatus.SCANNING -> stringResource(R.string.apk_info_vt_scanning)
@@ -643,7 +649,7 @@ private fun VirusTotalCard(
643649
}
644650
// Telling someone their key is missing is only half an answer — the fix is two
645651
// screens away and they are mid-install. Offer both steps here.
646-
if (status == VtStatus.NO_API_KEY) {
652+
if (status == VtStatus.NO_API_KEY || status == VtStatus.INVALID_API_KEY) {
647653
Spacer(Modifier.height(8.dp))
648654
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
649655
FilledTonalButton(

app/src/main/java/app/pwhs/universalinstaller/presentation/install/InstallViewModel.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1499,7 +1499,15 @@ class InstallViewModel(
14991499
virusTotalNotifier.notifyUploading(scanNotifId, fileName, pct)
15001500
}
15011501
val analysisId = uploadResult.getOrElse { e ->
1502-
finishScanWithError(e.message ?: "Upload failed", fileName)
1502+
// A rejected key or an exhausted quota is actionable; anything else is not.
1503+
if (e is VirusTotalService.VtHttpException) {
1504+
finishScan(
1505+
VtResult(status = e.vtStatus, errorMessage = e.message.orEmpty()),
1506+
fileName,
1507+
)
1508+
} else {
1509+
finishScanWithError(e.message ?: "Upload failed", fileName)
1510+
}
15031511
return@launch
15041512
}
15051513

app/src/main/java/app/pwhs/universalinstaller/presentation/install/dialog/DialogMenuContent.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,10 @@ private fun androidx.compose.foundation.lazy.LazyListScope.securityTab(
535535
VtStatus.QUEUED -> stringResource(R.string.apk_info_vt_queued)
536536
VtStatus.ANALYZING -> stringResource(R.string.apk_info_vt_analyzing)
537537
VtStatus.NO_API_KEY -> stringResource(R.string.apk_info_vt_no_api_key)
538+
VtStatus.INVALID_API_KEY -> stringResource(R.string.apk_info_vt_invalid_key)
539+
VtStatus.RATE_LIMITED -> vtErrorMsg?.takeIf { it.isNotBlank() }
540+
?.let { stringResource(R.string.apk_info_vt_rate_limited_retry, it) }
541+
?: stringResource(R.string.apk_info_vt_rate_limited)
538542
VtStatus.TOO_LARGE -> stringResource(R.string.apk_info_vt_too_large, vtErrorMsg.orEmpty())
539543
VtStatus.ERROR -> vtErrorMsg ?: stringResource(R.string.apk_info_vt_error)
540544
else -> stringResource(R.string.dialog_menu_virustotal_desc)
@@ -547,6 +551,8 @@ private fun androidx.compose.foundation.lazy.LazyListScope.securityTab(
547551
// and crucially not the neutral grey that made the no-key state invisible.
548552
VtStatus.SUSPICIOUS,
549553
VtStatus.NO_API_KEY,
554+
VtStatus.INVALID_API_KEY,
555+
VtStatus.RATE_LIMITED,
550556
VtStatus.TOO_LARGE -> extendedColors.warning
551557
else -> MaterialTheme.colorScheme.onSurfaceVariant
552558
}
@@ -573,7 +579,8 @@ private fun androidx.compose.foundation.lazy.LazyListScope.securityTab(
573579
}
574580
// Without a key, tapping Check only rewrites the same "no key" line the user
575581
// is already reading. Send them where the key is entered instead.
576-
vtResult?.status == VtStatus.NO_API_KEY -> {
582+
vtResult?.status == VtStatus.NO_API_KEY ||
583+
vtResult?.status == VtStatus.INVALID_API_KEY -> {
577584
context.startActivity(
578585
android.content.Intent(
579586
context,

app/src/main/res/values-vi/strings.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -776,4 +776,7 @@
776776
<string name="manage_action_installer_source_sub">Cài thủ công / Nguồn không xác định</string>
777777
<string name="installer_engine_root_request">Nhấn để cấp quyền root</string>
778778
<string name="dialog_failed_fallback_install">Cài đặt qua trình cài đặt hệ thống</string>
779+
<string name="apk_info_vt_invalid_key">VirusTotal từ chối khóa API này. Mở Cài đặt → Advanced → Khóa API VirusTotal và kiểm tra xem đã dán đầy đủ chưa.</string>
780+
<string name="apk_info_vt_rate_limited">Đã đạt giới hạn của VirusTotal — gói miễn phí chỉ cho 4 yêu cầu mỗi phút. Hãy thử lại sau giây lát.</string>
781+
<string name="apk_info_vt_rate_limited_retry">Đã đạt giới hạn của VirusTotal — gói miễn phí chỉ cho 4 yêu cầu mỗi phút. Hãy thử lại sau %1$s giây.</string>
779782
</resources>

app/src/main/res/values-zh/strings.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -776,4 +776,7 @@
776776
<string name="setting_security_strict_sub">在文件有扫描结果之前,VirusTotal 扫描会成为主按钮;安装未扫描的文件时会要求确认。需要 API 密钥。</string>
777777
<string name="setting_vt_strict_check_title">严格的 VirusTotal 检查</string>
778778
<string name="setting_vt_strict_check_summary">如果文件未扫描,在安装前警告</string>
779+
<string name="apk_info_vt_invalid_key">VirusTotal 拒绝了此 API 密钥。请打开“设置”→ Advanced →“VirusTotal API 密钥”,确认已完整粘贴。</string>
780+
<string name="apk_info_vt_rate_limited">已达到 VirusTotal 的速率限制 — 免费版每分钟仅允许 4 次请求。请稍后重试。</string>
781+
<string name="apk_info_vt_rate_limited_retry">已达到 VirusTotal 的速率限制 — 免费版每分钟仅允许 4 次请求。请在 %1$s 秒后重试。</string>
779782
</resources>

app/src/main/res/values/strings.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -782,4 +782,7 @@
782782
<string name="manage_action_check_vt_sub">Scan the installed APK</string>
783783
<string name="manage_action_installer_source">Installed by %1$s</string>
784784
<string name="manage_action_installer_source_sub">Sideloaded / Unknown source</string>
785+
<string name="apk_info_vt_invalid_key">VirusTotal rejected this API key. Open Settings → Advanced → VirusTotal API Key and check it was pasted in full.</string>
786+
<string name="apk_info_vt_rate_limited">VirusTotal rate limit reached — the free tier allows 4 requests per minute. Try again shortly.</string>
787+
<string name="apk_info_vt_rate_limited_retry">VirusTotal rate limit reached — the free tier allows 4 requests per minute. Try again in %1$s seconds.</string>
785788
</resources>

0 commit comments

Comments
 (0)