13 Commits

Author SHA1 Message Date
amir 6f3c5e6ea1 v1.10: all widget text white and bold
Make every TextView in both widget layouts fully white (#FFFFFF) with
textStyle=bold — SESSION/WEEKLY labels, session/weekly sub-labels,
status line, and last-updated timestamp.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 21:16:24 +00:00
amir 895a4ff3cd releases/latest: add v1.9 source zip
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 20:43:57 +00:00
amir e2747597e2 releases/latest: add v1.9 APK
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 20:40:14 +00:00
amir 6934017519 security: restrict network to system CAs, tighten WebView capabilities; v1.9
- AndroidManifest: add networkSecurityConfig to explicitly trust only system
  CAs, preventing user-installed CA cert MITM attacks on claude.ai sessions
- LoginActivity: set javaScriptCanOpenWindowsAutomatically=false (not needed
  for claude.ai login) and databaseEnabled=false (deprecated WebSQL)
- build.gradle.kts: enable buildConfig generation (required for
  BuildConfig.DEBUG guards already used in UsageRepository)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 20:00:01 +00:00
amir ee68b11ad0 v1.9: fix Android 16 status loss, bigger widget icons/fonts, security fixes
Android 16 bug: EncryptedSharedPreferences threw on ANY exception (Keystore
busy during screen-lock/BG wakeup) and the code deleted the encrypted prefs
file on any failure, permanently erasing session cookies. Now only
KeyPermanentlyInvalidatedException (biometric/PIN change) triggers delete;
transient failures preserve the file for the next session.

Also prevents saving cookies to plain-text fallback prefs if encrypted prefs
are unavailable.

WorkManager periodic (15 min, requires network) added alongside AlarmManager
as a Doze-mode backup for Android 16, where inexact alarms can be batched up
to 75 min.

UI: sync icon 24→32dp (large widget), 20→28dp (small); reset-time font
9→11sp (large), 8→10sp (small).

Security:
- All Log.d response-body and URL-bearing logs gated behind BuildConfig.DEBUG
- Cookie header value stripped of CRLF to prevent HTTP header injection
- LoginActivity coroutine migrated from bare CoroutineScope to lifecycleScope
- Widget removed from keyguard (lock-screen) category — usage data is sensitive

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 03:15:44 +00:00
amir 695c54f03c v1.8: fix black screen on resume and crash on sync
- onResume() no longer triggers a refresh every time — only fetches when
  data is >5 min stale, so returning to app shows cached data instantly
  without a loading spinner
- Fix CancellationException being swallowed by catch(Exception) in
  refreshUsage(), which caused updates to run on a destroyed activity
- EncryptedSharedPreferences key invalidation (caused by enabling/changing
  biometrics or screen lock) now deletes the stale encrypted file and
  recreates it cleanly, rather than silently using empty fallback prefs
- Wrap all securePrefs read/write ops in try-catch so a mid-session
  Keystore failure degrades gracefully instead of crashing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 20:59:10 +00:00
amir 3dc0448942 v1.7: fix widget losing data after screen lock/reboot
Two root causes:
- Alarms don't survive reboot — BootReceiver now restarts alarm + triggers
  an immediate fetch on BOOT_COMPLETED
- onUpdate() drew from cached prefs but never fetched fresh data — now
  triggers an immediate refresh so the widget is live on every launcher redraw

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 18:39:56 +00:00
amir 8d1cf21966 v1.6: fix HIGH security vuln — remove plaintext cookie backup
- Remove KEY_COOKIES_BACKUP plaintext fallback from PreferencesManager
- getCookies() now fails closed (force re-login) if EncryptedSharedPreferences unavailable
- Set android:allowBackup="false" to prevent adb backup extraction of session data
- Add missing gradle-wrapper.jar to repo

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 18:28:26 +00:00
amir b3b69dd2b2 v1.5: time-based rotation angle — constant speed regardless of IPC load 2026-05-22 16:10:09 +00:00
amir 8965477cc7 v1.4: faster rotation (12deg/frame = ~1 full spin per second) 2026-05-22 16:02:33 +00:00
amir f6f7accfa5 v1.3: smooth 30fps rotation (6° steps), guaranteed minimum one full spin per tap 2026-05-22 16:00:17 +00:00
amir d86ffabb98 v1.2: fix rotation — use full updateWidget calls with currentRotation state instead of unreliable partiallyUpdateAppWidget 2026-05-22 15:55:47 +00:00
amir fafa5a3bb7 v1.2: refresh icon rotates while fetching (12fps via partial RemoteViews updates) 2026-05-22 15:49:13 +00:00
19 changed files with 387 additions and 88 deletions
+3 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "me.khodak.claudeusage"
minSdk = 26
targetSdk = 34
versionCode = 2
versionName = "1.1"
versionCode = 11
versionName = "1.10"
}
signingConfigs {
@@ -44,6 +44,7 @@ android {
buildFeatures {
viewBinding = true
buildConfig = true
}
}
+10 -1
View File
@@ -5,12 +5,13 @@
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
android:allowBackup="true"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.ClaudeUsage"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="false">
<activity
@@ -43,6 +44,14 @@
android:name=".AlarmReceiver"
android:exported="false" />
<receiver
android:name=".BootReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<receiver
android:name="androidx.work.impl.background.systemalarm.RescheduleReceiver"
android:exported="false"
@@ -0,0 +1,15 @@
package me.khodak.claudeusage
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import me.khodak.claudeusage.data.PreferencesManager
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
if (!PreferencesManager(context).isLoggedIn()) return
UsageUpdateWorker.schedulePeriodicRefresh(context)
UsageUpdateWorker.triggerImmediateRefresh(context)
}
}
@@ -20,6 +20,7 @@ class ClaudeUsageWidget : AppWidgetProvider() {
ids.forEach { updateWidget(context, manager, it) }
if (PreferencesManager(context).isLoggedIn()) {
UsageUpdateWorker.schedulePeriodicRefresh(context)
UsageUpdateWorker.triggerImmediateRefresh(context)
}
}
@@ -39,6 +40,7 @@ class ClaudeUsageWidget : AppWidgetProvider() {
companion object {
const val ACTION_REFRESH = "me.khodak.claudeusage.ACTION_REFRESH"
@Volatile internal var isRefreshing = false
@Volatile internal var currentRotation = 0f
fun updateWidget(context: Context, manager: AppWidgetManager, widgetId: Int) {
val prefs = PreferencesManager(context)
@@ -149,6 +151,7 @@ class ClaudeUsageWidget : AppWidgetProvider() {
if (status.isNotBlank()) status else if (updatedMs > 0) formatTime(updatedMs) else "")
v.setInt(R.id.btn_refresh, "setColorFilter",
if (isRefreshing) 0xFFCC785C.toInt() else 0xFF999999.toInt())
v.setFloat(R.id.btn_refresh, "setRotation", currentRotation)
return v
}
@@ -230,6 +233,7 @@ class ClaudeUsageWidget : AppWidgetProvider() {
)
v.setInt(R.id.btn_refresh, "setColorFilter",
if (isRefreshing) 0xFFCC785C.toInt() else 0xFF999999.toInt())
v.setFloat(R.id.btn_refresh, "setRotation", currentRotation)
return v
}
@@ -8,7 +8,7 @@ import android.view.View
import android.webkit.*
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import kotlinx.coroutines.CoroutineScope
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import me.khodak.claudeusage.data.PreferencesManager
@@ -72,8 +72,8 @@ class LoginActivity : AppCompatActivity() {
settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
databaseEnabled = true
javaScriptCanOpenWindowsAutomatically = true
databaseEnabled = false
javaScriptCanOpenWindowsAutomatically = false
setSupportMultipleWindows(false)
// Standard Android Chrome UA — less suspicious than desktop
userAgentString = "Mozilla/5.0 (Linux; Android 13; Pixel 7) " +
@@ -145,7 +145,7 @@ class LoginActivity : AppCompatActivity() {
UsageUpdateWorker.triggerImmediateRefresh(this)
UsageUpdateWorker.schedulePeriodicRefresh(this)
CoroutineScope(Dispatchers.IO).launch {
lifecycleScope.launch(Dispatchers.IO) {
try {
val data = UsageRepository(prefs).fetchUsage()
prefs.saveUsageData(data)
@@ -68,7 +68,10 @@ class MainActivity : AppCompatActivity() {
val cached = prefs.getUsageData()
updateUI(cached)
if (prefs.isLoggedIn()) {
refreshUsage()
val staleMs = 5 * 60 * 1000L
if ((cached?.lastUpdated ?: 0L) < System.currentTimeMillis() - staleMs) {
refreshUsage()
}
}
}
@@ -79,6 +82,7 @@ class MainActivity : AppCompatActivity() {
val data = try {
repo.fetchUsage()
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e
prefs.getUsageData()?.copy(errorMessage = "Network error")
?: UsageData(errorMessage = "Network error")
}
@@ -3,6 +3,7 @@ package me.khodak.claudeusage
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import me.khodak.claudeusage.BuildConfig
import me.khodak.claudeusage.data.PreferencesManager
import me.khodak.claudeusage.data.UsageData
import okhttp3.OkHttpClient
@@ -67,13 +68,13 @@ class UsageRepository(private val prefs: PreferencesManager) {
val usageUrl = "https://claude.ai/api/organizations/$orgId/usage"
val resp = client.newCall(buildRequest(usageUrl, cookies)).execute()
val code = resp.code
Log.d(TAG, "GET $usageUrl$code")
if (BuildConfig.DEBUG) Log.d(TAG, "GET $usageUrl$code")
if (code == 401 || code == 403) {
prefs.clearSession()
return@withContext UsageData(errorMessage = "Session expired — please sign in again")
}
val body = resp.body?.string() ?: ""
debugBuf.append("$usageUrl\n$code: ${body.take(400)}\n\n")
if (BuildConfig.DEBUG) debugBuf.append("$usageUrl\n$code: ${body.take(400)}\n\n")
val utilData = tryParseUtilizationBody(body)
if (utilData != null) {
return@withContext base.copy(
@@ -98,7 +99,7 @@ class UsageRepository(private val prefs: PreferencesManager) {
val req = buildRequest(url, cookies)
val resp = client.newCall(req).execute()
val code = resp.code
Log.d(TAG, "GET $url$code")
if (BuildConfig.DEBUG) Log.d(TAG, "GET $url$code")
if (code == 401 || code == 403) {
prefs.clearSession()
@@ -107,8 +108,10 @@ class UsageRepository(private val prefs: PreferencesManager) {
val rateLimitData = extractRateLimitHeaders(resp.headers)
val body = resp.body?.string() ?: ""
debugBuf.append("$url\n$code: ${body.take(300)}\n\n")
Log.d(TAG, "Body: ${body.take(300)}")
if (BuildConfig.DEBUG) {
debugBuf.append("$url\n$code: ${body.take(300)}\n\n")
Log.d(TAG, "Body: ${body.take(300)}")
}
val parsed = tryParseUsageBody(body, rateLimitData)
if (parsed.hasRateLimitData) {
@@ -121,8 +124,8 @@ class UsageRepository(private val prefs: PreferencesManager) {
)
}
} catch (e: Exception) {
Log.w(TAG, "Endpoint $url failed: ${e.message}")
debugBuf.append("$url\n→ ERROR: ${e.message}\n\n")
if (BuildConfig.DEBUG) Log.w(TAG, "Endpoint $url failed: ${e.message}")
if (BuildConfig.DEBUG) debugBuf.append("$url\n→ ERROR: ${e.message}\n\n")
}
}
@@ -131,7 +134,7 @@ class UsageRepository(private val prefs: PreferencesManager) {
val req = buildRequest("https://claude.ai/api/me", cookies)
val resp = client.newCall(req).execute()
val body = resp.body?.string() ?: ""
debugBuf.append("https://claude.ai/api/me\n${resp.code}: ${body.take(400)}\n\n")
if (BuildConfig.DEBUG) debugBuf.append("https://claude.ai/api/me\n${resp.code}: ${body.take(400)}\n\n")
if (resp.isSuccessful && body.isNotBlank() && !body.startsWith("<")) {
val json = JSONObject(body)
val parsed = tryParseOrgForUsage(json)
@@ -145,7 +148,7 @@ class UsageRepository(private val prefs: PreferencesManager) {
}
}
} catch (e: Exception) {
debugBuf.append("https://claude.ai/api/me → ERROR: ${e.message}\n\n")
if (BuildConfig.DEBUG) debugBuf.append("https://claude.ai/api/me → ERROR: ${e.message}\n\n")
}
// Step 4: try page HTML for __NEXT_DATA__
@@ -169,7 +172,7 @@ class UsageRepository(private val prefs: PreferencesManager) {
val marker = """<script id="__NEXT_DATA__" type="application/json">"""
val start = html.indexOf(marker)
if (start < 0) {
debugBuf.append("HTML: no __NEXT_DATA__ found\n")
if (BuildConfig.DEBUG) debugBuf.append("HTML: no __NEXT_DATA__ found\n")
return null
}
val jsonStart = start + marker.length
@@ -177,10 +180,10 @@ class UsageRepository(private val prefs: PreferencesManager) {
if (jsonEnd < 0) return null
val nextData = JSONObject(html.substring(jsonStart, jsonEnd))
val topKeys = nextData.keys().asSequence().toList()
debugBuf.append("HTML __NEXT_DATA__ top keys: $topKeys\n")
if (BuildConfig.DEBUG) debugBuf.append("HTML __NEXT_DATA__ top keys: $topKeys\n")
tryExtractFromNextData(nextData)
} catch (e: Exception) {
debugBuf.append("HTML scrape error: ${e.message}\n")
if (BuildConfig.DEBUG) debugBuf.append("HTML scrape error: ${e.message}\n")
null
}
}
@@ -200,7 +203,7 @@ class UsageRepository(private val prefs: PreferencesManager) {
obj = (obj as? JSONObject)?.opt(key)
}
val o = obj as? JSONObject ?: continue
debugBuf.append("NextData at [${path.joinToString(".")}]: ${o.toString().take(300)}\n")
if (BuildConfig.DEBUG) debugBuf.append("NextData at [${path.joinToString(".")}]: ${o.toString().take(300)}\n")
val usage = tryParseOrgForUsage(o) ?: tryParseUsageBody(o.toString(), UsageData())
if (usage.hasRateLimitData) return usage
}
@@ -210,9 +213,9 @@ class UsageRepository(private val prefs: PreferencesManager) {
private fun fetchOrgInfo(cookies: String): Pair<String?, UsageData?> {
return try {
val resp = client.newCall(buildRequest("https://claude.ai/api/organizations", cookies)).execute()
Log.d(TAG, "Orgs → ${resp.code}")
if (BuildConfig.DEBUG) Log.d(TAG, "Orgs → ${resp.code}")
val body = resp.body?.string() ?: return Pair(null, null)
debugBuf.append("https://claude.ai/api/organizations\n${resp.code}: ${body.take(400)}\n\n")
if (BuildConfig.DEBUG) debugBuf.append("https://claude.ai/api/organizations\n${resp.code}: ${body.take(400)}\n\n")
if (body.isBlank() || body.startsWith("<")) return Pair(null, null)
val arr = JSONArray(body)
val org = arr.optJSONObject(0) ?: return Pair(null, null)
@@ -332,7 +335,7 @@ class UsageRepository(private val prefs: PreferencesManager) {
.header("Accept", "application/json, */*")
.header("Accept-Language", "en-US,en;q=0.9")
.header("Referer", "https://claude.ai/")
.header("Cookie", cookies)
.header("Cookie", cookies.replace("\r", "").replace("\n", ""))
.get()
.build()
@@ -8,6 +8,11 @@ import android.content.Context
import android.content.Intent
import android.os.SystemClock
import androidx.work.*
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.khodak.claudeusage.data.PreferencesManager
import java.util.concurrent.TimeUnit
@@ -22,21 +27,50 @@ class UsageUpdateWorker(
prefs.markTodayActive()
// Try API — save result (even on failure, local data is preserved)
try {
val data = UsageRepository(prefs).fetchUsage()
prefs.saveUsageData(data)
} catch (_: Exception) {
// API failed — don't save, keep existing cached data
coroutineScope {
val animJob = launch { rotateRefreshIcon() }
try {
val data = UsageRepository(prefs).fetchUsage()
prefs.saveUsageData(data)
} catch (_: Exception) {}
animJob.cancel()
animJob.join()
}
// Always push widget update using latest prefs + cached api data
pushWidgetUpdate()
return Result.success()
}
private suspend fun rotateRefreshIcon() {
val manager = AppWidgetManager.getInstance(context)
val ids = manager.getAppWidgetIds(ComponentName(context, ClaudeUsageWidget::class.java))
val startMs = System.currentTimeMillis()
val msPerRotation = 800L // one full rotation every 0.8 seconds
fun angleAt(now: Long) = ((now - startMs) % msPerRotation) * 360f / msPerRotation
try {
while (true) {
ClaudeUsageWidget.currentRotation = angleAt(System.currentTimeMillis())
ids.forEach { id -> ClaudeUsageWidget.updateWidget(context, manager, id) }
delay(16) // aim for ~60fps; IPC speed sets the real ceiling
}
} finally {
// Finish the current rotation cleanly — run until at least one full spin
withContext(NonCancellable) {
val minEndMs = startMs + msPerRotation
while (System.currentTimeMillis() < minEndMs) {
ClaudeUsageWidget.currentRotation = angleAt(System.currentTimeMillis())
ids.forEach { id -> ClaudeUsageWidget.updateWidget(context, manager, id) }
delay(16)
}
}
}
}
private fun pushWidgetUpdate() {
ClaudeUsageWidget.isRefreshing = false
ClaudeUsageWidget.currentRotation = 0f
val manager = AppWidgetManager.getInstance(context)
val ids = manager.getAppWidgetIds(ComponentName(context, ClaudeUsageWidget::class.java))
ids.forEach { id -> ClaudeUsageWidget.updateWidget(context, manager, id) }
@@ -44,21 +78,39 @@ class UsageUpdateWorker(
companion object {
private const val WORK_ONE_SHOT = "claude_oneshot"
private const val WORK_PERIODIC = "claude_periodic"
private const val ALARM_CODE = 1001
private const val INTERVAL_MS = 5 * 60 * 1000L
fun schedulePeriodicRefresh(context: Context) {
// 5-min alarm for fast updates when the device is active/awake
val am = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
am.setAndAllowWhileIdle(
AlarmManager.ELAPSED_REALTIME_WAKEUP,
SystemClock.elapsedRealtime() + INTERVAL_MS,
alarmIntent(context)
)
// WorkManager periodic as a Doze/background backup (Android 16 reliability).
// WorkManager uses JobScheduler which survives Doze; AlarmManager may be batched
// up to 75 min in Doze mode. KEEP = don't reset the 15-min timer on every alarm.
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
WORK_PERIODIC,
ExistingPeriodicWorkPolicy.KEEP,
PeriodicWorkRequestBuilder<UsageUpdateWorker>(15, TimeUnit.MINUTES)
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.build()
)
}
fun cancelPeriodicRefresh(context: Context) =
fun cancelPeriodicRefresh(context: Context) {
(context.getSystemService(Context.ALARM_SERVICE) as AlarmManager)
.cancel(alarmIntent(context))
WorkManager.getInstance(context).cancelUniqueWork(WORK_PERIODIC)
}
fun triggerImmediateRefresh(context: Context) {
WorkManager.getInstance(context).enqueueUniqueWork(
@@ -10,35 +10,26 @@ class PreferencesManager(context: Context) {
private val gson = Gson()
private val securePrefs = try {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context, "claude_secure", masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
} catch (e: Exception) {
context.getSharedPreferences("claude_secure_fallback", Context.MODE_PRIVATE)
}
private var usingFallbackPrefs = false
private val securePrefs = createSecurePrefs(context, onFallback = { usingFallbackPrefs = true })
private val prefs = context.getSharedPreferences("claude_prefs", Context.MODE_PRIVATE)
fun saveCookies(cookies: String) {
securePrefs.edit().putString(KEY_COOKIES, cookies).apply()
// Plaintext backup — survives EncryptedSharedPreferences key rotation on reinstall
prefs.edit().putString(KEY_COOKIES_BACKUP, cookies).apply()
// Never store cookies in plain-text fallback prefs
if (usingFallbackPrefs) return
try {
securePrefs.edit().putString(KEY_COOKIES, cookies).apply()
} catch (_: Exception) {}
}
fun getCookies(): String? =
fun getCookies(): String? = try {
securePrefs.getString(KEY_COOKIES, null)
?: prefs.getString(KEY_COOKIES_BACKUP, null)
} catch (_: Exception) { null }
fun clearSession() {
securePrefs.edit().clear().apply()
prefs.edit().remove(KEY_ORG_ID).remove(KEY_SESSION_START)
.remove(KEY_COOKIES_BACKUP).apply()
try { securePrefs.edit().clear().apply() } catch (_: Exception) {}
prefs.edit().remove(KEY_ORG_ID).remove(KEY_SESSION_START).apply()
}
fun saveOrgId(id: String) = prefs.edit().putString(KEY_ORG_ID, id).apply()
@@ -84,11 +75,52 @@ class PreferencesManager(context: Context) {
companion object {
private const val KEY_COOKIES = "session_cookies"
private const val KEY_COOKIES_BACKUP = "session_cookies_backup"
private const val KEY_ORG_ID = "org_id"
private const val KEY_SESSION_START = "session_start"
private const val KEY_USAGE_DATA = "usage_data"
private const val KEY_ACTIVE_WEEK = "active_week"
private const val KEY_ACTIVE_MASK = "active_mask"
fun createSecurePrefs(context: Context, onFallback: (() -> Unit)? = null): android.content.SharedPreferences {
return try {
buildEncryptedPrefs(context)
} catch (e: Exception) {
if (isKeyPermanentlyInvalidated(e)) {
// Key permanently gone (biometric/PIN changed) — must wipe; user must re-login.
try {
context.deleteSharedPreferences("claude_secure")
buildEncryptedPrefs(context)
} catch (_: Exception) {
onFallback?.invoke()
context.getSharedPreferences("claude_secure_fallback", Context.MODE_PRIVATE)
}
} else {
// Transient failure (Keystore busy, cold boot, screen locked during BG work).
// Do NOT delete the encrypted file — it will be readable next session.
onFallback?.invoke()
context.getSharedPreferences("claude_secure_fallback", Context.MODE_PRIVATE)
}
}
}
private fun buildEncryptedPrefs(context: Context): android.content.SharedPreferences {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
return EncryptedSharedPreferences.create(
context, "claude_secure", masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
private fun isKeyPermanentlyInvalidated(e: Exception): Boolean {
var t: Throwable? = e
while (t != null) {
if (t is android.security.keystore.KeyPermanentlyInvalidatedException) return true
t = t.cause
}
return false
}
}
}
+15 -11
View File
@@ -25,8 +25,8 @@
<ImageButton
android:id="@+id/btn_refresh"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_width="32dp"
android:layout_height="32dp"
android:src="@android:drawable/ic_menu_rotate"
android:background="@android:color/transparent"
android:tint="#999999"
@@ -54,7 +54,7 @@
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="SESSION"
android:textColor="#555555"
android:textColor="#FFFFFF"
android:textSize="9sp"
android:textStyle="bold" />
@@ -86,8 +86,9 @@
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:text=""
android:textColor="#666666"
android:textSize="9sp" />
android:textColor="#FFFFFF"
android:textSize="11sp"
android:textStyle="bold" />
<!-- 7-day window bar -->
<LinearLayout
@@ -102,7 +103,7 @@
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="WEEKLY"
android:textColor="#555555"
android:textColor="#FFFFFF"
android:textSize="9sp"
android:textStyle="bold" />
@@ -134,8 +135,9 @@
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:text=""
android:textColor="#666666"
android:textSize="9sp" />
android:textColor="#FFFFFF"
android:textSize="11sp"
android:textStyle="bold" />
<!-- Footer -->
<LinearLayout
@@ -151,8 +153,9 @@
android:layout_height="wrap_content"
android:layout_weight="1"
android:text=""
android:textColor="#CC785C"
android:textColor="#FFFFFF"
android:textSize="9sp"
android:textStyle="bold"
android:singleLine="true"
android:ellipsize="end" />
@@ -161,8 +164,9 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text=""
android:textColor="#444444"
android:textSize="9sp" />
android:textColor="#FFFFFF"
android:textSize="9sp"
android:textStyle="bold" />
</LinearLayout>
@@ -25,8 +25,8 @@
<ImageButton
android:id="@+id/btn_refresh"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_width="28dp"
android:layout_height="28dp"
android:src="@android:drawable/ic_menu_rotate"
android:background="@android:color/transparent"
android:contentDescription="Refresh" />
@@ -46,7 +46,7 @@
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="SESSION"
android:textColor="#555555"
android:textColor="#FFFFFF"
android:textSize="8sp"
android:textStyle="bold" />
@@ -65,8 +65,9 @@
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:text=""
android:textColor="#555555"
android:textSize="8sp" />
android:textColor="#FFFFFF"
android:textSize="10sp"
android:textStyle="bold" />
</LinearLayout>
@@ -94,7 +95,7 @@
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="WEEKLY"
android:textColor="#555555"
android:textColor="#FFFFFF"
android:textSize="8sp"
android:textStyle="bold" />
@@ -113,8 +114,9 @@
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:text=""
android:textColor="#555555"
android:textSize="8sp" />
android:textColor="#FFFFFF"
android:textSize="10sp"
android:textStyle="bold" />
</LinearLayout>
@@ -135,8 +137,9 @@
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text=""
android:textColor="#CC785C"
android:textColor="#FFFFFF"
android:textSize="8sp"
android:textStyle="bold"
android:maxLines="1" />
</LinearLayout>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>
+1 -1
View File
@@ -11,6 +11,6 @@
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="1800000"
android:initialLayout="@layout/widget_layout"
android:widgetCategory="home_screen|keyguard"
android:widgetCategory="home_screen"
android:description="@string/widget_description"
android:previewLayout="@layout/widget_layout" />
Binary file not shown.
+2 -4
View File
@@ -1,7 +1,5 @@
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
Vendored Regular → Executable
+92 -10
View File
@@ -1,13 +1,34 @@
#!/bin/sh
#
# Gradle start up script for UN*X
#
#!/usr/bin/env sh
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
DEFAULT_JVM_OPTS=""
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
@@ -48,16 +69,23 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME"
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH."
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
@@ -81,14 +109,68 @@ if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=$((i+1))
done
case $i in
(0) set -- ;;
(1) set -- "$args0" ;;
(2) set -- "$args0" "$args1" ;;
(3) set -- "$args0" "$args1" "$args2" ;;
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=$(save "$@")
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$@"
if $JAVACMD --add-opens java.base/java.lang=ALL-UNNAMED -version ; then
DEFAULT_JVM_OPTS="--add-opens java.base/java.lang=ALL-UNNAMED $DEFAULT_JVM_OPTS"
fi
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
cd "$(dirname "$0")"
fi
exec "$JAVACMD" "$@"
Vendored
+84
View File
@@ -0,0 +1,84 @@
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windows variants
if not "%OS%" == "Windows_NT" goto win9xME_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
Binary file not shown.
Binary file not shown.