Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
name: Release

# Tag-driven: push a tag like `v1.0.0+5` (versionName `+` versionCode) and this builds,
# signs, and attaches the universal APKs to a GitHub release for that tag.
# - versionCode (the integer after `+`) is what the in-app updater compares.
# - versionName (before `+`) is display-only; change it freely.
on:
push:
tags:
- 'v*'

permissions:
contents: write

jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Parse version from tag
id: ver
run: |
TAG="${GITHUB_REF_NAME}" # e.g. v1.0.0+5
NAME="${TAG#v}"; NAME="${NAME%%+*}" # 1.0.0
CODE="${TAG##*+}" # 5
if ! [[ "$CODE" =~ ^[0-9]+$ ]]; then
echo "::error::Tag must be v<versionName>+<versionCode>, e.g. v1.0.0+5 (got '$TAG')"
exit 1
fi
echo "name=$NAME" >> "$GITHUB_OUTPUT"
echo "code=$CODE" >> "$GITHUB_OUTPUT"
echo "Building versionName=$NAME versionCode=$CODE"

- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'

- uses: gradle/actions/setup-gradle@v4

- name: Decode release keystore
env:
KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }}
run: echo "$KEYSTORE_BASE64" | base64 --decode > "${{ github.workspace }}/app/release-ci.keystore"

- name: Build signed release + debug universal APKs
env:
RELEASE_STORE_FILE: ${{ github.workspace }}/app/release-ci.keystore
RELEASE_STORE_PASSWORD: ${{ secrets.RELEASE_STORE_PASSWORD }}
RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
run: |
./gradlew --no-daemon \
-PappVersionName=${{ steps.ver.outputs.name }} \
-PappVersionCode=${{ steps.ver.outputs.code }} \
:app:assembleRelease

- name: Stage APK for upload
id: stage
run: |
V="${{ steps.ver.outputs.name }}+${{ steps.ver.outputs.code }}"
mkdir -p dist
# Only the signed universal release APK is published — it's what the in-app updater
# installs (must keep "universal" in the name so the updater's asset selector picks it).
# End users get diagnostics from the in-app export, so no debug APK is distributed.
cp app/build/outputs/apk/release/app-universal-release.apk "dist/pulseloop-${V}-universal.apk"
ls -la dist

- name: Create / update release and attach APKs
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: PulseLoop ${{ steps.ver.outputs.name }} (${{ steps.ver.outputs.code }})
generate_release_notes: true
files: dist/*.apk
32 changes: 26 additions & 6 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,19 @@ android {
applicationId = "com.pulseloop"
minSdk = 26
targetSdk = 35
versionCode = 4
versionName = "1.0.0"
// versionCode/versionName are overridable from Gradle properties so the release CI
// can drive them straight from the git tag (e.g. -PappVersionCode=5 -PappVersionName=1.0.0).
// Local builds fall back to the literals below.
versionCode = (project.findProperty("appVersionCode") as String?)?.toIntOrNull() ?: 5
versionName = (project.findProperty("appVersionName") as String?) ?: "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

// Repo the self-updater polls for new releases.
buildConfigField("String", "GITHUB_REPO", "\"foureight84/PulseLoopAndroid\"")
}

buildFeatures {
buildConfig = true
}

splits {
Expand All @@ -30,10 +40,13 @@ android {

signingConfigs {
create("release") {
storeFile = file("pulseloop-release.keystore")
storePassword = "pulseloop"
keyAlias = "pulseloop"
keyPassword = "pulseloop"
// Keystore + passwords are overridable from the environment so CI can supply a
// decoded keystore + secrets without committing them. Local builds fall back to
// the checked-out keystore and the existing literals.
storeFile = file(System.getenv("RELEASE_STORE_FILE") ?: "pulseloop-release.keystore")
storePassword = System.getenv("RELEASE_STORE_PASSWORD") ?: "pulseloop"
keyAlias = System.getenv("RELEASE_KEY_ALIAS") ?: "pulseloop"
keyPassword = System.getenv("RELEASE_KEY_PASSWORD") ?: "pulseloop"
}
}

Expand All @@ -43,6 +56,13 @@ android {
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
signingConfig = signingConfigs.getByName("release")
}
debug {
// Distinct applicationId so a debug build installs ALONGSIDE the release app
// (com.pulseloop.debug) instead of replacing it — which would wipe the release
// app's data, since the two are signed with different keys.
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
}
}

lint {
Expand Down
30 changes: 30 additions & 0 deletions app/proguard-rules.pro
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# kotlinx.serialization — keep generated serializers so JSON parsing survives R8.
# (Modern kotlinx-serialization ships consumer rules, but these make it explicit.)
-keepattributes RuntimeVisibleAnnotations,AnnotationDefault

-if @kotlinx.serialization.Serializable class **
-keepclassmembers class <1> {
static <1>$Companion Companion;
}
-if @kotlinx.serialization.Serializable class ** {
static **$* *;
}
-keepclassmembers class <2>$<3> {
kotlinx.serialization.KSerializer serializer(...);
}
-if @kotlinx.serialization.Serializable class ** {
public static ** INSTANCE;
}
-keepclassmembers class <1> {
public static <1> INSTANCE;
kotlinx.serialization.KSerializer serializer(...);
}

# Self-update models parsed from the GitHub releases API.
-keep,includedescriptorclasses class com.pulseloop.update.**$$serializer { *; }
-keepclassmembers class com.pulseloop.update.** {
*** Companion;
}
-keepclasseswithmembers class com.pulseloop.update.** {
kotlinx.serialization.KSerializer serializer(...);
}
5 changes: 5 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_HEALTH" />

<!-- Self-update: fetch the GitHub release manifest/APK and install it -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />

<application
android:name=".PulseLoopApplication"
android:allowBackup="false"
android:label="PulseLoop"
android:supportsRtl="true"
Expand Down
15 changes: 15 additions & 0 deletions app/src/main/java/com/pulseloop/PulseLoopApplication.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.pulseloop

import android.app.Application
import com.pulseloop.diagnostics.CrashLogger

/**
* Application entry point. Installs the crash logger as early as possible so uncaught
* exceptions from anywhere in the app are persisted for the next diagnostics export.
*/
class PulseLoopApplication : Application() {
override fun onCreate() {
super.onCreate()
CrashLogger.install(this)
}
}
44 changes: 44 additions & 0 deletions app/src/main/java/com/pulseloop/diagnostics/CrashLogger.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.pulseloop.diagnostics

import android.content.Context
import java.io.File
import java.io.PrintWriter
import java.io.StringWriter
import java.time.Instant

/**
* Persists uncaught-exception stack traces to filesDir so the next diagnostics export can
* include them — closing the "user hit a crash but the export has no trace" gap. Writes a
* plain file synchronously (the DB may be in a bad state during a crash) and chains to the
* previously-installed handler so the system still shows its crash dialog.
*/
object CrashLogger {
private const val DIR = "crashes"
private const val MAX_FILES = 10

fun install(context: Context) {
val appContext = context.applicationContext
val previous = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
try { writeCrash(appContext, thread, throwable) } catch (_: Throwable) {}
previous?.uncaughtException(thread, throwable)
}
}

private fun writeCrash(context: Context, thread: Thread, throwable: Throwable) {
val dir = File(context.filesDir, DIR).apply { mkdirs() }
val sw = StringWriter()
PrintWriter(sw).use { throwable.printStackTrace(it) }
val stamp = Instant.now().toString().replace(":", "-")
File(dir, "crash-$stamp.txt").writeText("at: ${Instant.now()}\nthread: ${thread.name}\n\n$sw")
// Keep only the most recent reports.
dir.listFiles()?.sortedByDescending { it.lastModified() }?.drop(MAX_FILES)?.forEach { it.delete() }
}

/** Most-recent crash reports (newest first) as (filename, trace) pairs, for the export. */
fun recentCrashes(context: Context, max: Int = 5): List<Pair<String, String>> {
val dir = File(context.filesDir, DIR)
val files = dir.listFiles()?.sortedByDescending { it.lastModified() } ?: return emptyList()
return files.take(max).map { it.name to it.readText() }
}
}
56 changes: 43 additions & 13 deletions app/src/main/java/com/pulseloop/diagnostics/DiagnosticsExporter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,24 @@ object DiagnosticsExporter {

/**
* Serialize a diagnostics report to pretty-printed JSON.
*
* [mask] (default true) scrubs PHI/PII: physiological values, the ring serial suffix, and
* MAC addresses are removed while opcodes / decoded kinds / control frames / UUIDs / errors
* are kept (see [DiagnosticsRedactor]). Pass false only to capture full unmasked BLE frames
* for deep protocol debugging.
*/
suspend fun exportJSON(db: PulseLoopDatabase, maxLogs: Int = 500): String {
suspend fun exportJSON(context: Context, db: PulseLoopDatabase, mask: Boolean = true, maxLogs: Int = 500): String {
val device = db.deviceDao().current()
val logs = db.wearableLogDao().recent(maxLogs)
val root = buildJsonObject {
put("generatedAt", Instant.now().toString())
put("redacted", mask)

putJsonObject("app") {
put("version", "1.0.0")
put("version", com.pulseloop.BuildConfig.VERSION_NAME)
put("versionCode", com.pulseloop.BuildConfig.VERSION_CODE)
put("buildType", if (com.pulseloop.BuildConfig.DEBUG) "debug" else "release")
put("applicationId", com.pulseloop.BuildConfig.APPLICATION_ID)
put("platform", "Android")
put("sdkVersion", Build.VERSION.SDK_INT)
}
Expand All @@ -37,7 +46,7 @@ object DiagnosticsExporter {
put("osVersion", Build.VERSION.RELEASE)
if (device != null) {
put("wearableType", device.deviceTypeRaw)
put("wearableName", device.name)
put("wearableName", if (mask) DiagnosticsRedactor.maskRingName(device.name) else device.name)
put("capabilities", device.capabilitiesRaw)
put("firmware", device.firmwareVersion ?: "?")
put("lastSyncAt", device.lastSyncAt?.let { Instant.ofEpochMilli(it).toString() } ?: "")
Expand All @@ -50,39 +59,60 @@ object DiagnosticsExporter {
put("at", Instant.ofEpochMilli(log.timestamp).toString())
put("category", log.categoryRaw)
put("level", log.levelRaw)
put("message", log.message)
log.metadataJSON?.let { if (it != "null") put("metadata", it) }
put("message", if (mask) DiagnosticsRedactor.scrubText(log.message) else log.message)
log.metadataJSON?.let {
if (it != "null") put("metadata", if (mask) DiagnosticsRedactor.scrubText(it) else it)
}
if (log.deviceTypeRaw.isNotEmpty()) put("deviceType", log.deviceTypeRaw)
}
}
}

// Raw BLE packets for protocol debugging
// Raw BLE packets for protocol debugging. When masking, health-measurement frames
// are reduced to their opcode (the values live in the payload); control frames stay
// whole so connection/pairing flow is still fully visible.
val packets = db.rawPacketDao().recent(200)
putJsonArray("rawPackets") {
packets.forEach { pkt ->
val kind = pkt.decodedKind ?: ""
addJsonObject {
put("at", Instant.ofEpochMilli(pkt.timestamp).toString())
put("direction", pkt.directionRaw)
put("hex", pkt.hexPayload)
put("decoded", pkt.decodedKind ?: "")
put("hex", if (mask) DiagnosticsRedactor.maskPacketHex(pkt.hexPayload, kind) else pkt.hexPayload)
put("decoded", kind)
}
}
}

// Recent crash stack traces (uncaught exceptions persisted by CrashLogger).
putJsonArray("crashes") {
CrashLogger.recentCrashes(context).forEach { (name, trace) ->
addJsonObject {
put("file", name)
put("trace", trace)
}
}
}

// This process's own logcat — app logs + in-process BluetoothGatt callbacks.
// MAC addresses are scrubbed when masking.
val logcat = LogcatCapture.ownProcessLog()
put("logcat", if (mask) DiagnosticsRedactor.scrubText(logcat) else logcat)
}
return json.encodeToString(JsonObject.serializer(), root)
}

suspend fun exportFile(context: Context, db: PulseLoopDatabase): File {
val report = exportJSON(db)
suspend fun exportFile(context: Context, db: PulseLoopDatabase, mask: Boolean = true): File {
val report = exportJSON(context, db, mask)
val stamp = Instant.now().toString().replace(":", "-")
val file = File(context.cacheDir, "pulseloop-diagnostics-$stamp.json")
val suffix = if (mask) "" else "-full"
val file = File(context.cacheDir, "pulseloop-diagnostics-$stamp$suffix.json")
file.writeText(report)
return file
}

suspend fun shareIntent(context: Context, db: PulseLoopDatabase): Intent {
val file = exportFile(context, db)
suspend fun shareIntent(context: Context, db: PulseLoopDatabase, mask: Boolean = true): Intent {
val file = exportFile(context, db, mask)
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
Expand Down
41 changes: 41 additions & 0 deletions app/src/main/java/com/pulseloop/diagnostics/DiagnosticsRedactor.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.pulseloop.diagnostics

/**
* Scrubs PHI/PII from a diagnostics report while keeping it useful for debugging.
*
* What's removed: physiological values (HR, SpO₂, BP, glucose, temperature, stress, HRV,
* sleep, activity), the ring's serial suffix, and BLE MAC addresses.
* What's kept: device + ring model, Android version, firmware, capabilities, command
* opcodes, decoded kinds, error/status frames, service UUIDs, timestamps — i.e. everything
* needed to follow the protocol flow and spot the error, just not the measured numbers.
*
* Health-measurement BLE frames carry the values in their payload, so those are reduced to
* the opcode byte. Control/protocol frames (acks, status, time-sync, battery, firmware, bind)
* carry no vitals and are kept whole — that's the data most connection/pairing bugs need.
*/
object DiagnosticsRedactor {
/** Decoded kinds whose BLE payload carries a physiological/health value → mask payload. */
private val HEALTH_KINDS = setOf(
"activity", "activity_bucket", "hr_sample", "spo2_progress", "spo2_result",
"sleep_timeline", "history_measurement", "stress_sample", "hrv_sample", "temperature_sample",
)

private val MAC = Regex("\\b([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}\\b")
private val RING_SERIAL_SUFFIX = Regex("_[0-9A-Fa-f]{3,}$")

/**
* For a health-measurement frame, keep the opcode byte and mask the rest (the values live
* in the payload). Non-health frames are returned unchanged. [hex] is contiguous lowercase.
*/
fun maskPacketHex(hex: String, kind: String): String {
if (kind !in HEALTH_KINDS || hex.length <= 2) return hex
val byteCount = hex.length / 2
return hex.substring(0, 2) + "··".repeat(byteCount - 1)
}

/** Mask BLE MAC addresses anywhere in free text (logcat, log messages, metadata). */
fun scrubText(text: String): String = MAC.replace(text, "··:··:··:··:··:··")

/** Strip the ring's serial suffix, keeping the model (e.g. "COLMI R10_1203" → "COLMI R10"). */
fun maskRingName(name: String): String = RING_SERIAL_SUFFIX.replace(name, "")
}
Loading