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" ) } }