31e18ed5e9
Build APK / build (push) Successful in 1m29s
Security: - LoginActivity WebView now enforces a host allow-list in shouldOverrideUrlLoading: only claude.ai + required SSO/CDN hosts (Google, Apple, Cloudflare, gstatic, recaptcha) can navigate; everything else is blocked. market://intent:// still blocked; about:/data: allowed. Device-verified: claude.ai login + Cloudflare challenge still load. Tests (33, pure-JVM JUnit4, no device needed): - Extracted shouldRecordHistory() pure throttle decision (regression guard for the empty-history-chart bug) + HistoryThrottleTest. - UsageDataTest (mergedWith last-good/partial-union, computed props), PaceCalcTest, PeakHoursTest. - Added junit:junit:4.13.2 as testImplementation only. Build quality: - widget_layout.xml: suppress false-positive UseAppTint lint on the widget refresh button (app:tint doesn't work in RemoteViews; android:tint is correct here) so lintDebug is clean. Verified locally: 33 unit tests pass, lintDebug 0 errors, signed assembleRelease OK (apksigner verified, signer identity unchanged), emulator smoke test launches + renders without crash.
214 lines
9.2 KiB
Kotlin
214 lines
9.2 KiB
Kotlin
package me.khodak.claudeusage
|
|
|
|
import android.annotation.SuppressLint
|
|
import android.appwidget.AppWidgetManager
|
|
import android.content.ComponentName
|
|
import android.os.Bundle
|
|
import android.view.View
|
|
import android.webkit.*
|
|
import android.widget.Toast
|
|
import androidx.appcompat.app.AppCompatActivity
|
|
import androidx.lifecycle.lifecycleScope
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.launch
|
|
import me.khodak.claudeusage.data.PreferencesManager
|
|
import me.khodak.claudeusage.databinding.ActivityLoginBinding
|
|
|
|
class LoginActivity : AppCompatActivity() {
|
|
|
|
private lateinit var binding: ActivityLoginBinding
|
|
private lateinit var prefs: PreferencesManager
|
|
private var loginHandled = false
|
|
|
|
// JS injected before page scripts run — hides WebView fingerprints
|
|
private val antiDetectionJs = """
|
|
(function() {
|
|
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
|
window.chrome = { runtime: {} };
|
|
Object.defineProperty(navigator, 'plugins', { get: () => [1,2,3] });
|
|
Object.defineProperty(navigator, 'languages', { get: () => ['en-US','en'] });
|
|
})();
|
|
""".trimIndent()
|
|
|
|
@SuppressLint("SetJavaScriptEnabled")
|
|
override fun onCreate(savedInstanceState: Bundle?) {
|
|
super.onCreate(savedInstanceState)
|
|
binding = ActivityLoginBinding.inflate(layoutInflater)
|
|
setContentView(binding.root)
|
|
prefs = PreferencesManager(this)
|
|
|
|
setupTabs()
|
|
setupWebView()
|
|
setupCookiePanel()
|
|
}
|
|
|
|
private fun setupTabs() {
|
|
binding.tabBrowser.setOnClickListener {
|
|
binding.panelBrowser.visibility = View.VISIBLE
|
|
binding.panelCookie.visibility = View.GONE
|
|
binding.tabBrowser.backgroundTintList = android.content.res.ColorStateList.valueOf(0xFFCC785C.toInt())
|
|
binding.tabBrowser.setTextColor(0xFFFFFFFF.toInt())
|
|
binding.tabCookie.backgroundTintList = android.content.res.ColorStateList.valueOf(0xFF2A2A2A.toInt())
|
|
binding.tabCookie.setTextColor(0xFF888888.toInt())
|
|
}
|
|
binding.tabCookie.setOnClickListener {
|
|
binding.panelBrowser.visibility = View.GONE
|
|
binding.panelCookie.visibility = View.VISIBLE
|
|
binding.tabCookie.backgroundTintList = android.content.res.ColorStateList.valueOf(0xFFCC785C.toInt())
|
|
binding.tabCookie.setTextColor(0xFFFFFFFF.toInt())
|
|
binding.tabBrowser.backgroundTintList = android.content.res.ColorStateList.valueOf(0xFF2A2A2A.toInt())
|
|
binding.tabBrowser.setTextColor(0xFF888888.toInt())
|
|
}
|
|
}
|
|
|
|
@SuppressLint("SetJavaScriptEnabled")
|
|
private fun setupWebView() {
|
|
CookieManager.getInstance().removeAllCookies(null)
|
|
CookieManager.getInstance().flush()
|
|
|
|
binding.btnDone.setOnClickListener { attemptCookieCapture(force = true) }
|
|
|
|
with(binding.webView) {
|
|
settings.apply {
|
|
javaScriptEnabled = true
|
|
domStorageEnabled = true
|
|
databaseEnabled = false
|
|
javaScriptCanOpenWindowsAutomatically = false
|
|
setSupportMultipleWindows(false)
|
|
// Standard Android Chrome UA — less suspicious than desktop
|
|
userAgentString = "Mozilla/5.0 (Linux; Android 13; Pixel 7) " +
|
|
"AppleWebKit/537.36 (KHTML, like Gecko) " +
|
|
"Chrome/120.0.6099.230 Mobile Safari/537.36"
|
|
}
|
|
CookieManager.getInstance().setAcceptCookie(true)
|
|
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
|
|
|
|
webViewClient = object : WebViewClient() {
|
|
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
|
|
val uri = request.url
|
|
val url = uri.toString()
|
|
// Keep blocking app-redirect schemes outright.
|
|
if (url.startsWith("market://") || url.startsWith("intent://")) return true
|
|
val scheme = uri.scheme?.lowercase()
|
|
// about:blank and data: URIs are used by the login page itself (e.g. blank
|
|
// bootstrap frames, inline images). They have no host to allow-list, so let them load.
|
|
if (scheme == "about" || scheme == "data") return false
|
|
// Host ALLOW-LIST: only navigate to claude.ai and its SSO/CDN providers.
|
|
// OAuth runs as full-page redirects in THIS WebView (multiple windows disabled,
|
|
// JS-opened windows disabled), so the list MUST include the Google/Apple SSO
|
|
// and Cloudflare challenge hosts or sign-in breaks. Anything else is blocked.
|
|
return !isHostAllowed(uri.host)
|
|
}
|
|
|
|
override fun onPageStarted(view: WebView, url: String, favicon: android.graphics.Bitmap?) {
|
|
// Inject before page scripts
|
|
view.evaluateJavascript(antiDetectionJs, null)
|
|
}
|
|
|
|
override fun onPageFinished(view: WebView, url: String) {
|
|
// Re-inject after page load
|
|
view.evaluateJavascript(antiDetectionJs, null)
|
|
|
|
if (url.contains("claude.ai")) {
|
|
binding.btnDone.visibility = View.VISIBLE
|
|
}
|
|
if (loginHandled) return
|
|
val onMain = url.startsWith("https://claude.ai") &&
|
|
!url.contains("/login") && !url.contains("/auth") &&
|
|
!url.contains("/sign") && !url.contains("/verify")
|
|
if (onMain) attemptCookieCapture(force = false)
|
|
}
|
|
}
|
|
loadUrl("https://claude.ai/login")
|
|
}
|
|
}
|
|
|
|
private fun setupCookiePanel() {
|
|
binding.btnSaveCookie.setOnClickListener {
|
|
val cookie = binding.etCookie.text.toString().trim()
|
|
if (cookie.length < 10) {
|
|
Toast.makeText(this, "Cookie too short — paste the full string", Toast.LENGTH_SHORT).show()
|
|
return@setOnClickListener
|
|
}
|
|
handleLoginSuccess(cookie)
|
|
}
|
|
}
|
|
|
|
private fun attemptCookieCapture(force: Boolean) {
|
|
if (loginHandled) return
|
|
CookieManager.getInstance().flush()
|
|
val cookies = CookieManager.getInstance().getCookie("https://claude.ai") ?: ""
|
|
if (cookies.length > 10) {
|
|
loginHandled = true
|
|
handleLoginSuccess(cookies)
|
|
} else if (force) {
|
|
Toast.makeText(this, "No session found — finish logging in first, or use the Paste Cookie tab", Toast.LENGTH_LONG).show()
|
|
}
|
|
}
|
|
|
|
private fun handleLoginSuccess(cookies: String) {
|
|
prefs.saveCookies(cookies)
|
|
prefs.saveSessionStart(System.currentTimeMillis())
|
|
prefs.markTodayActive()
|
|
Toast.makeText(this, "Signed in — loading usage…", Toast.LENGTH_SHORT).show()
|
|
|
|
UsageUpdateWorker.triggerImmediateRefresh(this)
|
|
UsageUpdateWorker.schedulePeriodicRefresh(this)
|
|
|
|
lifecycleScope.launch(Dispatchers.IO) {
|
|
try {
|
|
val data = UsageRepository(prefs).fetchUsage()
|
|
prefs.saveUsageData(data)
|
|
val mgr = AppWidgetManager.getInstance(this@LoginActivity)
|
|
val ids = mgr.getAppWidgetIds(ComponentName(this@LoginActivity, ClaudeUsageWidget::class.java))
|
|
ids.forEach { id -> ClaudeUsageWidget.updateWidget(this@LoginActivity, mgr, id, data) }
|
|
} catch (_: Exception) {}
|
|
}
|
|
|
|
setResult(RESULT_OK)
|
|
finish()
|
|
}
|
|
|
|
override fun onBackPressed() {
|
|
if (binding.panelBrowser.visibility == View.VISIBLE && binding.webView.canGoBack())
|
|
binding.webView.goBack()
|
|
else super.onBackPressed()
|
|
}
|
|
|
|
override fun onDestroy() {
|
|
binding.webView.destroy()
|
|
super.onDestroy()
|
|
}
|
|
|
|
/**
|
|
* True if [host] belongs to an allowed domain (exact match or a subdomain via dot-suffix).
|
|
* A null/blank host is never allowed. accounts.google.com, challenges.cloudflare.com, etc. are
|
|
* covered by their parent-domain suffixes (google.com, cloudflare.com).
|
|
*/
|
|
private fun isHostAllowed(host: String?): Boolean {
|
|
if (host.isNullOrBlank()) return false
|
|
val h = host.lowercase()
|
|
return ALLOWED_DOMAINS.any { d -> h == d || h.endsWith(".$d") }
|
|
}
|
|
|
|
companion object {
|
|
// WebView host allow-list. Login navigation is restricted to claude.ai and the SSO/CDN
|
|
// hosts it redirects through. This MUST include the SSO providers (Google, Apple) and
|
|
// Cloudflare challenge hosts — OAuth happens as same-WebView full-page redirects, so
|
|
// dropping any of these breaks sign-in. Subdomains are matched by dot-suffix.
|
|
private val ALLOWED_DOMAINS = listOf(
|
|
"claude.ai",
|
|
"anthropic.com",
|
|
"google.com", // covers accounts.google.com
|
|
"gstatic.com",
|
|
"googleusercontent.com",
|
|
"googleapis.com",
|
|
"apple.com",
|
|
"icloud.com",
|
|
"cloudflare.com", // covers challenges.cloudflare.com
|
|
"cloudflareinsights.com",
|
|
"recaptcha.net"
|
|
)
|
|
}
|
|
}
|