Fix empty usage-history chart + externalize signing secrets
Build APK / build (push) Successful in 2m18s
Build APK / build (push) Successful in 2m18s
History chart: recordHistory() threw away the previous point whenever a new reading landed within the 2-min de-dup window, but the foreground loop refreshes every 30s — so history could never grow past one point while the app was open and the chart stayed stuck on 'Collecting history…'. Now it throttles by SKIPPING a too-soon reading instead of replacing the last one, so points accumulate during normal use. Security: - Remove hardcoded release keystore passwords from build.gradle.kts; read from env vars / gitignored keystore.properties; CI injects from Gitea secrets (KEYSTORE_PASSWORD/KEY_PASSWORD). Signing identity unchanged. - Make the cookie-never-plaintext invariant explicit on the read path. - Drop custom ACTION_REFRESH from the exported widget intent-filter so other apps can't trigger refreshes; internal explicit PendingIntent still works. - Gate an unguarded Log.w behind BuildConfig.DEBUG.
This commit is contained in:
@@ -56,6 +56,20 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > app/claude-widget-release.keystore
|
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > app/claude-widget-release.keystore
|
||||||
|
|
||||||
|
# Signing passwords are no longer hardcoded in build.gradle.kts — inject them at build time.
|
||||||
|
- name: Write signing credentials
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
run: |
|
||||||
|
if [ -z "${{ secrets.KEYSTORE_PASSWORD }}" ] || [ -z "${{ secrets.KEY_PASSWORD }}" ]; then
|
||||||
|
echo "::error::KEYSTORE_PASSWORD / KEY_PASSWORD secrets not set — cannot sign release."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
{
|
||||||
|
echo "storePassword=${{ secrets.KEYSTORE_PASSWORD }}"
|
||||||
|
echo "keyPassword=${{ secrets.KEY_PASSWORD }}"
|
||||||
|
echo "keyAlias=${{ secrets.KEY_ALIAS || 'claudewidget' }}"
|
||||||
|
} > keystore.properties
|
||||||
|
|
||||||
- name: Build release APK
|
- name: Build release APK
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
run: ./gradlew :app:assembleRelease --no-daemon
|
run: ./gradlew :app:assembleRelease --no-daemon
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ captures/
|
|||||||
.cxx/
|
.cxx/
|
||||||
*.keystore
|
*.keystore
|
||||||
*.jks
|
*.jks
|
||||||
|
keystore.properties
|
||||||
|
|||||||
+34
-5
@@ -1,8 +1,21 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("org.jetbrains.kotlin.android")
|
id("org.jetbrains.kotlin.android")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Signing credentials are NEVER committed. They are read from (in order):
|
||||||
|
// 1. environment variables (KEYSTORE_PASSWORD / KEY_PASSWORD / KEY_ALIAS) — used by CI
|
||||||
|
// 2. keystore.properties at the repo root (gitignored) — used locally
|
||||||
|
// See keystore.properties.example. Debug builds need none of this.
|
||||||
|
val keystoreProps = Properties().apply {
|
||||||
|
val f = rootProject.file("keystore.properties")
|
||||||
|
if (f.exists()) f.inputStream().use { load(it) }
|
||||||
|
}
|
||||||
|
fun signingCred(envName: String, propName: String): String? =
|
||||||
|
System.getenv(envName) ?: keystoreProps.getProperty(propName)
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "me.khodak.claudeusage"
|
namespace = "me.khodak.claudeusage"
|
||||||
compileSdk = 34
|
compileSdk = 34
|
||||||
@@ -15,12 +28,22 @@ android {
|
|||||||
versionName = "1.18"
|
versionName = "1.18"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val releaseStorePassword = signingCred("KEYSTORE_PASSWORD", "storePassword")
|
||||||
|
val releaseKeyPassword = signingCred("KEY_PASSWORD", "keyPassword")
|
||||||
|
val releaseKeyAlias = signingCred("KEY_ALIAS", "keyAlias") ?: "claudewidget"
|
||||||
|
val hasSigningCreds = releaseStorePassword != null && releaseKeyPassword != null
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
create("release") {
|
create("release") {
|
||||||
storeFile = file("claude-widget-release.keystore")
|
// Only wire the keystore when credentials are present, so debug builds and
|
||||||
storePassword = "ClaudeWidget2026!"
|
// credential-less checkouts configure cleanly. Same keystore file + alias as before —
|
||||||
keyAlias = "claudewidget"
|
// signing identity is unchanged; only the password source moved out of version control.
|
||||||
keyPassword = "ClaudeWidget2026!"
|
if (hasSigningCreds) {
|
||||||
|
storeFile = file("claude-widget-release.keystore")
|
||||||
|
storePassword = releaseStorePassword
|
||||||
|
keyAlias = releaseKeyAlias
|
||||||
|
keyPassword = releaseKeyPassword
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,7 +52,13 @@ android {
|
|||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
isShrinkResources = true
|
isShrinkResources = true
|
||||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||||
signingConfig = signingConfigs.getByName("release")
|
// Sign only when creds were supplied; otherwise fail loudly at assembleRelease rather
|
||||||
|
// than silently shipping an unsigned APK. Tag builds in CI inject creds (see workflow).
|
||||||
|
if (hasSigningCreds) {
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
|
} else {
|
||||||
|
logger.warn("No signing credentials (KEYSTORE_PASSWORD/keystore.properties) — release APK will be unsigned.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,9 +32,11 @@
|
|||||||
<receiver
|
<receiver
|
||||||
android:name=".ClaudeUsageWidget"
|
android:name=".ClaudeUsageWidget"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
|
<!-- Only the system APPWIDGET_UPDATE action is exposed. ACTION_REFRESH is delivered via
|
||||||
|
the widget's own explicit PendingIntent (explicit broadcasts need no intent-filter),
|
||||||
|
so other apps can no longer trigger refreshes by sending that action externally. -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||||
<action android:name="me.khodak.claudeusage.ACTION_REFRESH" />
|
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="android.appwidget.provider"
|
android:name="android.appwidget.provider"
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ class UsageRepository(private val prefs: PreferencesManager) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "/usage failed: ${e.message}")
|
if (BuildConfig.DEBUG) Log.w(TAG, "/usage failed: ${e.message}")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: fallback endpoints (message-count style)
|
// Step 3: fallback endpoints (message-count style)
|
||||||
|
|||||||
@@ -24,9 +24,14 @@ class PreferencesManager(context: Context) {
|
|||||||
} catch (_: Exception) {}
|
} catch (_: Exception) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCookies(): String? = try {
|
fun getCookies(): String? {
|
||||||
securePrefs.getString(KEY_COOKIES, null)
|
// Cookies are never written in fallback (plaintext) mode — make that invariant explicit on
|
||||||
} catch (_: Exception) { null }
|
// the read side too, so any future write that bypasses the guard still can't surface here.
|
||||||
|
if (usingFallbackPrefs) return null
|
||||||
|
return try {
|
||||||
|
securePrefs.getString(KEY_COOKIES, null)
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
fun clearSession() {
|
fun clearSession() {
|
||||||
try { securePrefs.edit().clear().apply() } catch (_: Exception) {}
|
try { securePrefs.edit().clear().apply() } catch (_: Exception) {}
|
||||||
@@ -98,9 +103,11 @@ class PreferencesManager(context: Context) {
|
|||||||
if (data.fiveHourUtilization < 0f && data.weeklyUtilization < 0f) return
|
if (data.fiveHourUtilization < 0f && data.weeklyUtilization < 0f) return
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val history = getHistory().toMutableList()
|
val history = getHistory().toMutableList()
|
||||||
if (history.isNotEmpty() && now - history.last().epochMs < MIN_HISTORY_GAP_MS) {
|
// Throttle to at most one point per MIN_HISTORY_GAP_MS by SKIPPING a too-soon reading.
|
||||||
history.removeAt(history.size - 1) // collapse near-simultaneous readings
|
// (Previously we deleted the last point and re-added in place — but the foreground loop
|
||||||
}
|
// refreshes every 30s, well inside this 2-min window, so history could never grow past a
|
||||||
|
// single point while the app was open and the chart stayed on "Collecting history…".)
|
||||||
|
if (history.isNotEmpty() && now - history.last().epochMs < MIN_HISTORY_GAP_MS) return
|
||||||
history.add(
|
history.add(
|
||||||
UsageSnapshot(
|
UsageSnapshot(
|
||||||
epochMs = now,
|
epochMs = now,
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Release signing credentials for local builds.
|
||||||
|
# Copy to `keystore.properties` (which is gitignored) and fill in the real values.
|
||||||
|
# These are NEVER committed. CI injects the same values from Gitea Actions secrets.
|
||||||
|
#
|
||||||
|
# cp keystore.properties.example keystore.properties
|
||||||
|
#
|
||||||
|
# The keystore file itself (app/claude-widget-release.keystore) is also gitignored.
|
||||||
|
# Signing identity is unchanged from older builds — same keystore, same alias.
|
||||||
|
|
||||||
|
storePassword=CHANGE_ME
|
||||||
|
keyPassword=CHANGE_ME
|
||||||
|
keyAlias=claudewidget
|
||||||
Reference in New Issue
Block a user