Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6801a60183 | |||
| ee68b11ad0 | |||
| 695c54f03c | |||
| 3dc0448942 | |||
| 8d1cf21966 |
@@ -11,8 +11,8 @@ android {
|
|||||||
applicationId = "me.khodak.claudeusage"
|
applicationId = "me.khodak.claudeusage"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 6
|
versionCode = 10
|
||||||
versionName = "1.5"
|
versionName = "1.9"
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
@@ -44,6 +44,7 @@ android {
|
|||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
viewBinding = true
|
viewBinding = true
|
||||||
|
buildConfig = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="false"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
@@ -43,6 +43,14 @@
|
|||||||
android:name=".AlarmReceiver"
|
android:name=".AlarmReceiver"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".BootReceiver"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name="androidx.work.impl.background.systemalarm.RescheduleReceiver"
|
android:name="androidx.work.impl.background.systemalarm.RescheduleReceiver"
|
||||||
android:exported="false"
|
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) }
|
ids.forEach { updateWidget(context, manager, it) }
|
||||||
if (PreferencesManager(context).isLoggedIn()) {
|
if (PreferencesManager(context).isLoggedIn()) {
|
||||||
UsageUpdateWorker.schedulePeriodicRefresh(context)
|
UsageUpdateWorker.schedulePeriodicRefresh(context)
|
||||||
|
UsageUpdateWorker.triggerImmediateRefresh(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import android.view.View
|
|||||||
import android.webkit.*
|
import android.webkit.*
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import me.khodak.claudeusage.data.PreferencesManager
|
import me.khodak.claudeusage.data.PreferencesManager
|
||||||
@@ -145,7 +145,7 @@ class LoginActivity : AppCompatActivity() {
|
|||||||
UsageUpdateWorker.triggerImmediateRefresh(this)
|
UsageUpdateWorker.triggerImmediateRefresh(this)
|
||||||
UsageUpdateWorker.schedulePeriodicRefresh(this)
|
UsageUpdateWorker.schedulePeriodicRefresh(this)
|
||||||
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val data = UsageRepository(prefs).fetchUsage()
|
val data = UsageRepository(prefs).fetchUsage()
|
||||||
prefs.saveUsageData(data)
|
prefs.saveUsageData(data)
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package me.khodak.claudeusage
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import me.khodak.claudeusage.BuildConfig
|
||||||
import me.khodak.claudeusage.data.PreferencesManager
|
import me.khodak.claudeusage.data.PreferencesManager
|
||||||
import me.khodak.claudeusage.data.UsageData
|
import me.khodak.claudeusage.data.UsageData
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
@@ -67,13 +68,13 @@ class UsageRepository(private val prefs: PreferencesManager) {
|
|||||||
val usageUrl = "https://claude.ai/api/organizations/$orgId/usage"
|
val usageUrl = "https://claude.ai/api/organizations/$orgId/usage"
|
||||||
val resp = client.newCall(buildRequest(usageUrl, cookies)).execute()
|
val resp = client.newCall(buildRequest(usageUrl, cookies)).execute()
|
||||||
val code = resp.code
|
val code = resp.code
|
||||||
Log.d(TAG, "GET $usageUrl → $code")
|
if (BuildConfig.DEBUG) Log.d(TAG, "GET $usageUrl → $code")
|
||||||
if (code == 401 || code == 403) {
|
if (code == 401 || code == 403) {
|
||||||
prefs.clearSession()
|
prefs.clearSession()
|
||||||
return@withContext UsageData(errorMessage = "Session expired — please sign in again")
|
return@withContext UsageData(errorMessage = "Session expired — please sign in again")
|
||||||
}
|
}
|
||||||
val body = resp.body?.string() ?: ""
|
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)
|
val utilData = tryParseUtilizationBody(body)
|
||||||
if (utilData != null) {
|
if (utilData != null) {
|
||||||
return@withContext base.copy(
|
return@withContext base.copy(
|
||||||
@@ -98,7 +99,7 @@ class UsageRepository(private val prefs: PreferencesManager) {
|
|||||||
val req = buildRequest(url, cookies)
|
val req = buildRequest(url, cookies)
|
||||||
val resp = client.newCall(req).execute()
|
val resp = client.newCall(req).execute()
|
||||||
val code = resp.code
|
val code = resp.code
|
||||||
Log.d(TAG, "GET $url → $code")
|
if (BuildConfig.DEBUG) Log.d(TAG, "GET $url → $code")
|
||||||
|
|
||||||
if (code == 401 || code == 403) {
|
if (code == 401 || code == 403) {
|
||||||
prefs.clearSession()
|
prefs.clearSession()
|
||||||
@@ -107,8 +108,10 @@ class UsageRepository(private val prefs: PreferencesManager) {
|
|||||||
|
|
||||||
val rateLimitData = extractRateLimitHeaders(resp.headers)
|
val rateLimitData = extractRateLimitHeaders(resp.headers)
|
||||||
val body = resp.body?.string() ?: ""
|
val body = resp.body?.string() ?: ""
|
||||||
debugBuf.append("$url\n→ $code: ${body.take(300)}\n\n")
|
if (BuildConfig.DEBUG) {
|
||||||
Log.d(TAG, "Body: ${body.take(300)}")
|
debugBuf.append("$url\n→ $code: ${body.take(300)}\n\n")
|
||||||
|
Log.d(TAG, "Body: ${body.take(300)}")
|
||||||
|
}
|
||||||
|
|
||||||
val parsed = tryParseUsageBody(body, rateLimitData)
|
val parsed = tryParseUsageBody(body, rateLimitData)
|
||||||
if (parsed.hasRateLimitData) {
|
if (parsed.hasRateLimitData) {
|
||||||
@@ -121,8 +124,8 @@ class UsageRepository(private val prefs: PreferencesManager) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Endpoint $url failed: ${e.message}")
|
if (BuildConfig.DEBUG) Log.w(TAG, "Endpoint $url failed: ${e.message}")
|
||||||
debugBuf.append("$url\n→ ERROR: ${e.message}\n\n")
|
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 req = buildRequest("https://claude.ai/api/me", cookies)
|
||||||
val resp = client.newCall(req).execute()
|
val resp = client.newCall(req).execute()
|
||||||
val body = resp.body?.string() ?: ""
|
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("<")) {
|
if (resp.isSuccessful && body.isNotBlank() && !body.startsWith("<")) {
|
||||||
val json = JSONObject(body)
|
val json = JSONObject(body)
|
||||||
val parsed = tryParseOrgForUsage(json)
|
val parsed = tryParseOrgForUsage(json)
|
||||||
@@ -145,7 +148,7 @@ class UsageRepository(private val prefs: PreferencesManager) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} 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__
|
// 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 marker = """<script id="__NEXT_DATA__" type="application/json">"""
|
||||||
val start = html.indexOf(marker)
|
val start = html.indexOf(marker)
|
||||||
if (start < 0) {
|
if (start < 0) {
|
||||||
debugBuf.append("HTML: no __NEXT_DATA__ found\n")
|
if (BuildConfig.DEBUG) debugBuf.append("HTML: no __NEXT_DATA__ found\n")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
val jsonStart = start + marker.length
|
val jsonStart = start + marker.length
|
||||||
@@ -177,10 +180,10 @@ class UsageRepository(private val prefs: PreferencesManager) {
|
|||||||
if (jsonEnd < 0) return null
|
if (jsonEnd < 0) return null
|
||||||
val nextData = JSONObject(html.substring(jsonStart, jsonEnd))
|
val nextData = JSONObject(html.substring(jsonStart, jsonEnd))
|
||||||
val topKeys = nextData.keys().asSequence().toList()
|
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)
|
tryExtractFromNextData(nextData)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
debugBuf.append("HTML scrape error: ${e.message}\n")
|
if (BuildConfig.DEBUG) debugBuf.append("HTML scrape error: ${e.message}\n")
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,7 +203,7 @@ class UsageRepository(private val prefs: PreferencesManager) {
|
|||||||
obj = (obj as? JSONObject)?.opt(key)
|
obj = (obj as? JSONObject)?.opt(key)
|
||||||
}
|
}
|
||||||
val o = obj as? JSONObject ?: continue
|
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())
|
val usage = tryParseOrgForUsage(o) ?: tryParseUsageBody(o.toString(), UsageData())
|
||||||
if (usage.hasRateLimitData) return usage
|
if (usage.hasRateLimitData) return usage
|
||||||
}
|
}
|
||||||
@@ -210,9 +213,9 @@ class UsageRepository(private val prefs: PreferencesManager) {
|
|||||||
private fun fetchOrgInfo(cookies: String): Pair<String?, UsageData?> {
|
private fun fetchOrgInfo(cookies: String): Pair<String?, UsageData?> {
|
||||||
return try {
|
return try {
|
||||||
val resp = client.newCall(buildRequest("https://claude.ai/api/organizations", cookies)).execute()
|
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)
|
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)
|
if (body.isBlank() || body.startsWith("<")) return Pair(null, null)
|
||||||
val arr = JSONArray(body)
|
val arr = JSONArray(body)
|
||||||
val org = arr.optJSONObject(0) ?: return Pair(null, null)
|
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", "application/json, */*")
|
||||||
.header("Accept-Language", "en-US,en;q=0.9")
|
.header("Accept-Language", "en-US,en;q=0.9")
|
||||||
.header("Referer", "https://claude.ai/")
|
.header("Referer", "https://claude.ai/")
|
||||||
.header("Cookie", cookies)
|
.header("Cookie", cookies.replace("\r", "").replace("\n", ""))
|
||||||
.get()
|
.get()
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import me.khodak.claudeusage.data.PreferencesManager
|
import me.khodak.claudeusage.data.PreferencesManager
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
class UsageUpdateWorker(
|
class UsageUpdateWorker(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
@@ -77,21 +78,39 @@ class UsageUpdateWorker(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val WORK_ONE_SHOT = "claude_oneshot"
|
private const val WORK_ONE_SHOT = "claude_oneshot"
|
||||||
|
private const val WORK_PERIODIC = "claude_periodic"
|
||||||
private const val ALARM_CODE = 1001
|
private const val ALARM_CODE = 1001
|
||||||
private const val INTERVAL_MS = 5 * 60 * 1000L
|
private const val INTERVAL_MS = 5 * 60 * 1000L
|
||||||
|
|
||||||
fun schedulePeriodicRefresh(context: Context) {
|
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
|
val am = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||||
am.setAndAllowWhileIdle(
|
am.setAndAllowWhileIdle(
|
||||||
AlarmManager.ELAPSED_REALTIME_WAKEUP,
|
AlarmManager.ELAPSED_REALTIME_WAKEUP,
|
||||||
SystemClock.elapsedRealtime() + INTERVAL_MS,
|
SystemClock.elapsedRealtime() + INTERVAL_MS,
|
||||||
alarmIntent(context)
|
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)
|
(context.getSystemService(Context.ALARM_SERVICE) as AlarmManager)
|
||||||
.cancel(alarmIntent(context))
|
.cancel(alarmIntent(context))
|
||||||
|
WorkManager.getInstance(context).cancelUniqueWork(WORK_PERIODIC)
|
||||||
|
}
|
||||||
|
|
||||||
fun triggerImmediateRefresh(context: Context) {
|
fun triggerImmediateRefresh(context: Context) {
|
||||||
WorkManager.getInstance(context).enqueueUniqueWork(
|
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||||
|
|||||||
@@ -10,35 +10,26 @@ class PreferencesManager(context: Context) {
|
|||||||
|
|
||||||
private val gson = Gson()
|
private val gson = Gson()
|
||||||
|
|
||||||
private val securePrefs = try {
|
private var usingFallbackPrefs = false
|
||||||
val masterKey = MasterKey.Builder(context)
|
private val securePrefs = createSecurePrefs(context, onFallback = { usingFallbackPrefs = true })
|
||||||
.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()
|
// Never store cookies in plain-text fallback prefs
|
||||||
// Plaintext backup — survives EncryptedSharedPreferences key rotation on reinstall
|
if (usingFallbackPrefs) return
|
||||||
prefs.edit().putString(KEY_COOKIES_BACKUP, cookies).apply()
|
try {
|
||||||
|
securePrefs.edit().putString(KEY_COOKIES, cookies).apply()
|
||||||
|
} catch (_: Exception) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCookies(): String? =
|
fun getCookies(): String? = try {
|
||||||
securePrefs.getString(KEY_COOKIES, null)
|
securePrefs.getString(KEY_COOKIES, null)
|
||||||
?: prefs.getString(KEY_COOKIES_BACKUP, 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)
|
prefs.edit().remove(KEY_ORG_ID).remove(KEY_SESSION_START).apply()
|
||||||
.remove(KEY_COOKIES_BACKUP).apply()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveOrgId(id: String) = prefs.edit().putString(KEY_ORG_ID, id).apply()
|
fun saveOrgId(id: String) = prefs.edit().putString(KEY_ORG_ID, id).apply()
|
||||||
@@ -84,11 +75,52 @@ class PreferencesManager(context: Context) {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val KEY_COOKIES = "session_cookies"
|
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_ORG_ID = "org_id"
|
||||||
private const val KEY_SESSION_START = "session_start"
|
private const val KEY_SESSION_START = "session_start"
|
||||||
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"
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,8 @@
|
|||||||
|
|
||||||
<ImageButton
|
<ImageButton
|
||||||
android:id="@+id/btn_refresh"
|
android:id="@+id/btn_refresh"
|
||||||
android:layout_width="24dp"
|
android:layout_width="32dp"
|
||||||
android:layout_height="24dp"
|
android:layout_height="32dp"
|
||||||
android:src="@android:drawable/ic_menu_rotate"
|
android:src="@android:drawable/ic_menu_rotate"
|
||||||
android:background="@android:color/transparent"
|
android:background="@android:color/transparent"
|
||||||
android:tint="#999999"
|
android:tint="#999999"
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
android:layout_marginTop="3dp"
|
android:layout_marginTop="3dp"
|
||||||
android:text=""
|
android:text=""
|
||||||
android:textColor="#666666"
|
android:textColor="#666666"
|
||||||
android:textSize="9sp" />
|
android:textSize="11sp" />
|
||||||
|
|
||||||
<!-- 7-day window bar -->
|
<!-- 7-day window bar -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
@@ -135,7 +135,7 @@
|
|||||||
android:layout_marginTop="3dp"
|
android:layout_marginTop="3dp"
|
||||||
android:text=""
|
android:text=""
|
||||||
android:textColor="#666666"
|
android:textColor="#666666"
|
||||||
android:textSize="9sp" />
|
android:textSize="11sp" />
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
|
|||||||
@@ -25,8 +25,8 @@
|
|||||||
|
|
||||||
<ImageButton
|
<ImageButton
|
||||||
android:id="@+id/btn_refresh"
|
android:id="@+id/btn_refresh"
|
||||||
android:layout_width="20dp"
|
android:layout_width="28dp"
|
||||||
android:layout_height="20dp"
|
android:layout_height="28dp"
|
||||||
android:src="@android:drawable/ic_menu_rotate"
|
android:src="@android:drawable/ic_menu_rotate"
|
||||||
android:background="@android:color/transparent"
|
android:background="@android:color/transparent"
|
||||||
android:contentDescription="Refresh" />
|
android:contentDescription="Refresh" />
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
android:layout_marginStart="6dp"
|
android:layout_marginStart="6dp"
|
||||||
android:text=""
|
android:text=""
|
||||||
android:textColor="#555555"
|
android:textColor="#555555"
|
||||||
android:textSize="8sp" />
|
android:textSize="10sp" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@
|
|||||||
android:layout_marginStart="6dp"
|
android:layout_marginStart="6dp"
|
||||||
android:text=""
|
android:text=""
|
||||||
android:textColor="#555555"
|
android:textColor="#555555"
|
||||||
android:textSize="8sp" />
|
android:textSize="10sp" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,6 @@
|
|||||||
android:resizeMode="horizontal|vertical"
|
android:resizeMode="horizontal|vertical"
|
||||||
android:updatePeriodMillis="1800000"
|
android:updatePeriodMillis="1800000"
|
||||||
android:initialLayout="@layout/widget_layout"
|
android:initialLayout="@layout/widget_layout"
|
||||||
android:widgetCategory="home_screen|keyguard"
|
android:widgetCategory="home_screen"
|
||||||
android:description="@string/widget_description"
|
android:description="@string/widget_description"
|
||||||
android:previewLayout="@layout/widget_layout" />
|
android:previewLayout="@layout/widget_layout" />
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
+2
-4
@@ -1,7 +1,5 @@
|
|||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
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
|
zipStorePath=wrapper/dists
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
@@ -1,13 +1,34 @@
|
|||||||
#!/bin/sh
|
#!/usr/bin/env sh
|
||||||
#
|
|
||||||
# Gradle start up script for UN*X
|
##############################################################################
|
||||||
#
|
##
|
||||||
|
## 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_NAME="Gradle"
|
||||||
APP_BASE_NAME=`basename "$0"`
|
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.
|
# 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.
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
MAX_FD="maximum"
|
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.
|
# Determine the Java command to use to start the JVM.
|
||||||
if [ -n "$JAVA_HOME" ] ; then
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; 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"
|
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||||
else
|
else
|
||||||
JAVACMD="$JAVA_HOME/bin/java"
|
JAVACMD="$JAVA_HOME/bin/java"
|
||||||
fi
|
fi
|
||||||
if [ ! -x "$JAVACMD" ] ; then
|
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
|
fi
|
||||||
else
|
else
|
||||||
JAVACMD="java"
|
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
|
fi
|
||||||
|
|
||||||
# Increase the maximum file descriptors if we can.
|
# 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\""
|
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
# For Cygwin, switch paths to Windows format before running java
|
||||||
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
|
if $cygwin ; then
|
||||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
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
|
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
|
# 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" "$@"
|
exec "$JAVACMD" "$@"
|
||||||
|
|||||||
Vendored
+84
@@ -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
|
||||||
Reference in New Issue
Block a user