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>
This commit is contained in:
2026-05-22 20:59:10 +00:00
parent 3dc0448942
commit 695c54f03c
3 changed files with 45 additions and 18 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "me.khodak.claudeusage" applicationId = "me.khodak.claudeusage"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 8 versionCode = 9
versionName = "1.7" versionName = "1.8"
} }
signingConfigs { signingConfigs {
@@ -68,7 +68,10 @@ class MainActivity : AppCompatActivity() {
val cached = prefs.getUsageData() val cached = prefs.getUsageData()
updateUI(cached) updateUI(cached)
if (prefs.isLoggedIn()) { 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 { val data = try {
repo.fetchUsage() repo.fetchUsage()
} catch (e: Exception) { } catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e
prefs.getUsageData()?.copy(errorMessage = "Network error") prefs.getUsageData()?.copy(errorMessage = "Network error")
?: UsageData(errorMessage = "Network error") ?: UsageData(errorMessage = "Network error")
} }
@@ -10,29 +10,22 @@ class PreferencesManager(context: Context) {
private val gson = Gson() private val gson = Gson()
private val securePrefs = try { private val securePrefs = createSecurePrefs(context)
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 prefs = context.getSharedPreferences("claude_prefs", Context.MODE_PRIVATE) private val prefs = context.getSharedPreferences("claude_prefs", Context.MODE_PRIVATE)
fun saveCookies(cookies: String) { 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() { fun clearSession() {
securePrefs.edit().clear().apply() try { securePrefs.edit().clear().apply() } catch (_: Exception) {}
prefs.edit().remove(KEY_ORG_ID).remove(KEY_SESSION_START).apply() 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_USAGE_DATA = "usage_data"
private const val KEY_ACTIVE_WEEK = "active_week" private const val KEY_ACTIVE_WEEK = "active_week"
private const val KEY_ACTIVE_MASK = "active_mask" 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)
}
}
}
} }
} }