ee68b11ad0
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>
130 lines
5.1 KiB
Kotlin
130 lines
5.1 KiB
Kotlin
package me.khodak.claudeusage
|
|
|
|
import android.app.AlarmManager
|
|
import android.app.PendingIntent
|
|
import android.appwidget.AppWidgetManager
|
|
import android.content.ComponentName
|
|
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
|
|
|
|
class UsageUpdateWorker(
|
|
private val context: Context,
|
|
workerParams: WorkerParameters
|
|
) : CoroutineWorker(context, workerParams) {
|
|
|
|
override suspend fun doWork(): Result {
|
|
val prefs = PreferencesManager(context)
|
|
if (!prefs.isLoggedIn()) return Result.success()
|
|
|
|
prefs.markTodayActive()
|
|
|
|
coroutineScope {
|
|
val animJob = launch { rotateRefreshIcon() }
|
|
try {
|
|
val data = UsageRepository(prefs).fetchUsage()
|
|
prefs.saveUsageData(data)
|
|
} catch (_: Exception) {}
|
|
animJob.cancel()
|
|
animJob.join()
|
|
}
|
|
|
|
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) }
|
|
}
|
|
|
|
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) {
|
|
(context.getSystemService(Context.ALARM_SERVICE) as AlarmManager)
|
|
.cancel(alarmIntent(context))
|
|
WorkManager.getInstance(context).cancelUniqueWork(WORK_PERIODIC)
|
|
}
|
|
|
|
fun triggerImmediateRefresh(context: Context) {
|
|
WorkManager.getInstance(context).enqueueUniqueWork(
|
|
WORK_ONE_SHOT,
|
|
ExistingWorkPolicy.REPLACE,
|
|
OneTimeWorkRequestBuilder<UsageUpdateWorker>().build()
|
|
)
|
|
}
|
|
|
|
private fun alarmIntent(context: Context) = PendingIntent.getBroadcast(
|
|
context, ALARM_CODE,
|
|
Intent(context, AlarmReceiver::class.java),
|
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
)
|
|
}
|
|
}
|