2 Commits

Author SHA1 Message Date
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
6 changed files with 69 additions and 18 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "me.khodak.claudeusage"
minSdk = 26
targetSdk = 34
versionCode = 7
versionName = "1.6"
versionCode = 9
versionName = "1.8"
}
signingConfigs {
+8
View File
@@ -43,6 +43,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)
}
}
@@ -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")
}
@@ -10,29 +10,22 @@ 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 val securePrefs = createSecurePrefs(context)
private val prefs = context.getSharedPreferences("claude_prefs", Context.MODE_PRIVATE)
fun saveCookies(cookies: String) {
securePrefs.edit().putString(KEY_COOKIES, cookies).apply()
try {
securePrefs.edit().putString(KEY_COOKIES, cookies).apply()
} catch (_: Exception) {}
}
fun getCookies(): String? = securePrefs.getString(KEY_COOKIES, null)
fun getCookies(): String? = try {
securePrefs.getString(KEY_COOKIES, null)
} catch (_: Exception) { null }
fun clearSession() {
securePrefs.edit().clear().apply()
try { securePrefs.edit().clear().apply() } catch (_: Exception) {}
prefs.edit().remove(KEY_ORG_ID).remove(KEY_SESSION_START).apply()
}
@@ -84,5 +77,35 @@ class PreferencesManager(context: Context) {
private const val KEY_USAGE_DATA = "usage_data"
private const val KEY_ACTIVE_WEEK = "active_week"
private const val KEY_ACTIVE_MASK = "active_mask"
private fun createSecurePrefs(context: Context): android.content.SharedPreferences {
// First attempt: normal open
return 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 (_: Exception) {
// Keystore key was invalidated (e.g., user added/changed biometrics or screen lock).
// Delete the stale encrypted file and recreate — user will need to re-login.
try {
context.deleteSharedPreferences("claude_secure")
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 (_: Exception) {
context.getSharedPreferences("claude_secure_fallback", Context.MODE_PRIVATE)
}
}
}
}
}