Skip to content

fix(battery): dedupe BLE scan restarts in MeterScanner + audit PID-aw… #51

fix(battery): dedupe BLE scan restarts in MeterScanner + audit PID-aw…

fix(battery): dedupe BLE scan restarts in MeterScanner + audit PID-aw… #51

Workflow file for this run

name: Firebase Test Lab
on:
workflow_dispatch:
inputs:
power_only:
description: 'Power/wake audit — skips UX matrix, extends tests to 20 min, full batterystats report'
required: false
default: false
type: boolean
test_type:
description: 'Physical device test type (ignored when power_only)'
required: false
default: 'robo'
type: choice
options: [robo, instrumented]
device_model:
description: 'Physical device model ID (default: oriole = Pixel 6)'
required: false
default: 'oriole'
api_level:
description: 'Android API level'
required: false
default: '33'
timeout:
description: 'Physical device timeout — ignored when power_only (forced to 20m)'
required: false
default: '5m'
push:
branches: [glucodroid]
paths:
- 'Common/src/**'
- 'Common/build.gradle'
env:
GRADLE_OPTS: -Xmx4g -XX:+HeapDumpOnOutOfMemoryError
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
FTL_BUCKET: glucodroid-ftl-results
# ─────────────────────────────────────────────────────────────────────────────
# Build once, share via artifact across all test jobs
# ─────────────────────────────────────────────────────────────────────────────
jobs:
build:
name: Build APK(s)
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: ${{ runner.os }}-gradle-
- name: Build debug APK
run: |
./gradlew :Common:assembleMobileLibre3SiDexNogoogleDebug \
--no-daemon --warning-mode=all \
-Pno_x86 -Pno_x86_64
- name: Build test APK (instrumented dispatch only)
if: inputs.test_type == 'instrumented' && inputs.power_only != true
run: |
./gradlew :Common:assembleMobileLibre3SiDexNogoogleDebugAndroidTest \
--no-daemon --warning-mode=all \
-Pno_x86 -Pno_x86_64
- name: Upload APKs
uses: actions/upload-artifact@v4
with:
name: apks
path: |
Common/build/outputs/apk/mobileLibre3SiDexNogoogle/debug/*.apk
Common/build/outputs/apk/androidTest/mobileLibre3SiDexNogoogle/debug/*.apk
retention-days: 1
if-no-files-found: warn
# ─────────────────────────────────────────────────────────────────────────────
# Virtual UX/UI — Robo crawl on two virtual form factors.
# Skipped when power_only is set (no value in UX noise during a power audit).
# ─────────────────────────────────────────────────────────────────────────────
virtual-ux:
name: UX — ${{ matrix.label }}
needs: build
if: github.event_name == 'push' || inputs.power_only != true
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
include:
- model: Pixel2
api: 28
label: Pixel2-API28
- model: Pixel3
api: 30
label: Pixel3-API30
steps:
- name: Download APKs
uses: actions/download-artifact@v4
with:
name: apks
path: apks/
- name: Authenticate with Google Cloud
run: |
KEY_FILE="$RUNNER_TEMP/gcp-sa-key.json"
echo '${{ secrets.GCP_SA_KEY }}' > "$KEY_FILE"
gcloud auth activate-service-account --key-file="$KEY_FILE" --quiet
PROJECT_ID=$(python3 -c "import json; print(json.load(open('$KEY_FILE'))['project_id'])")
echo "FIREBASE_PROJECT_ID=$PROJECT_ID" >> "$GITHUB_ENV"
gcloud config set project "$PROJECT_ID" --quiet
- name: Run UX Robo on virtual ${{ matrix.label }}
run: |
APP_APK=$(find apks -name "*.apk" ! -name "*androidTest*" ! -name "*unsigned*" | head -1)
RUN_DIR="glucodroid-ux-${{ matrix.label }}-$(date +%Y%m%d-%H%M%S)"
echo "FTL_RUN_DIR=$RUN_DIR" >> "$GITHUB_ENV"
gsutil mb -p "$FIREBASE_PROJECT_ID" -l us-central1 "gs://${FTL_BUCKET}" 2>/dev/null || true
gcloud firebase test android run \
--type robo \
--app "$APP_APK" \
--project "$FIREBASE_PROJECT_ID" \
--device "model=${{ matrix.model }},version=${{ matrix.api }},locale=en,orientation=portrait" \
--timeout 5m \
--results-bucket "$FTL_BUCKET" \
--results-dir "$RUN_DIR"
- name: Download UX results
if: always()
run: |
mkdir -p ftl-results
gsutil -m cp -r "gs://${FTL_BUCKET}/${FTL_RUN_DIR}/**" ftl-results/ 2>/dev/null || true
echo "Downloaded $(find ftl-results -type f | wc -l) file(s)"
- name: Summarise UX results
if: always()
run: |
python3 << 'PYEOF'
import os, glob, re, json
summary = os.environ.get("GITHUB_STEP_SUMMARY", "")
label = "${{ matrix.label }}"
bucket = os.environ.get("FTL_BUCKET", "")
run_dir = os.environ.get("FTL_RUN_DIR", "")
lines = [f"## UX Results — {label}\n",
f"**GCS:** `gs://{bucket}/{run_dir}`\n"]
actions_file = next(iter(glob.glob("ftl-results/**/actions.json", recursive=True)), None)
if actions_file:
try:
data = json.load(open(actions_file))
events = data.get("events", [])
screens = {e.get(k) for e in events for k in ("sourceScreenId","destinationScreenId") if e.get(k)}
errors = [e for e in events if e.get("executionResult","") in ("ERROR","FAILURE")]
lines.append(f"**Screens visited:** {len(screens)} **Actions taken:** {len(events)} **Robo errors:** {len(errors)}\n")
except Exception as ex:
lines.append(f"_(actions.json parse error: {ex})_\n")
logcat_files = glob.glob("ftl-results/**/logcat*", recursive=True)
crash_re = re.compile(r'FATAL EXCEPTION|crash|ANR in|Application Not Responding', re.I)
glucodroid_re = re.compile(r'cloud\.glucodroid')
crash_hits = [line.rstrip() for lf in logcat_files
for line in open(lf, errors='replace')
if crash_re.search(line) and glucodroid_re.search(line)]
if crash_hits:
lines.append(f"### ⚠️ Crashes / ANRs in cloud.glucodroid ({len(crash_hits)})")
lines.append("```")
lines.extend(crash_hits[:10])
lines.append("```\n")
else:
lines.append("### ✅ No app crashes or ANRs detected\n")
jank_re = re.compile(r'Skipped (\d+) frames', re.I)
jank_hits = [(int(m.group(1)), line.rstrip())
for lf in logcat_files
for line in open(lf, errors='replace')
if 'cloud.glucodroid' in line
for m in [jank_re.search(line)] if m]
if jank_hits:
lines.append(f"### ⚠️ Main-thread jank — {len(jank_hits)} event(s), "
f"{sum(n for n,_ in jank_hits)} frames total")
lines.append("```")
lines.extend(l for _, l in sorted(jank_hits, reverse=True)[:5])
lines.append("```\n")
screenshots = glob.glob("ftl-results/**/*.png", recursive=True)
lines.append(f"**Screenshots captured:** {len(screenshots)}")
all_files = sorted(f for f in glob.glob("ftl-results/**/*", recursive=True) if os.path.isfile(f))
if all_files:
lines.append(f"\n### Artifacts ({len(all_files)} files)")
for f in all_files[:20]:
lines.append(f"- `{f}`")
output = "\n".join(lines)
if summary:
open(summary, "a").write(output + "\n")
print(output)
PYEOF
- name: Upload UX artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: ux-${{ matrix.label }}
path: ftl-results/
retention-days: 14
if-no-files-found: warn
# ─────────────────────────────────────────────────────────────────────────────
# Virtual Power — wakelock / alarm / jank analysis.
# Skipped when power_only is set (physical-only audit; no value in virtual noise).
# ─────────────────────────────────────────────────────────────────────────────
virtual-power:
name: Power/Wake — virtual Pixel3 API30
needs: build
if: github.event_name == 'push' || inputs.power_only != true
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
- name: Download APKs
uses: actions/download-artifact@v4
with:
name: apks
path: apks/
- name: Authenticate with Google Cloud
run: |
KEY_FILE="$RUNNER_TEMP/gcp-sa-key.json"
echo '${{ secrets.GCP_SA_KEY }}' > "$KEY_FILE"
gcloud auth activate-service-account --key-file="$KEY_FILE" --quiet
PROJECT_ID=$(python3 -c "import json; print(json.load(open('$KEY_FILE'))['project_id'])")
echo "FIREBASE_PROJECT_ID=$PROJECT_ID" >> "$GITHUB_ENV"
gcloud config set project "$PROJECT_ID" --quiet
- name: Run power Robo (virtual Pixel3 API30)
run: |
APP_APK=$(find apks -name "*.apk" ! -name "*androidTest*" ! -name "*unsigned*" | head -1)
RUN_DIR="glucodroid-power-$(date +%Y%m%d-%H%M%S)"
echo "FTL_RUN_DIR=$RUN_DIR" >> "$GITHUB_ENV"
TIMEOUT="15m"
gsutil mb -p "$FIREBASE_PROJECT_ID" -l us-central1 "gs://${FTL_BUCKET}" 2>/dev/null || true
gcloud firebase test android run \
--type robo \
--app "$APP_APK" \
--project "$FIREBASE_PROJECT_ID" \
--device "model=Pixel3,version=30,locale=en,orientation=portrait" \
--timeout "$TIMEOUT" \
--results-bucket "$FTL_BUCKET" \
--results-dir "$RUN_DIR"
- name: Download power results
if: always()
run: |
mkdir -p ftl-results
gsutil -m cp -r "gs://${FTL_BUCKET}/${FTL_RUN_DIR}/**" ftl-results/ 2>/dev/null || true
echo "Downloaded $(find ftl-results -type f | wc -l) file(s)"
- name: Power / wake analysis
if: always()
run: |
python3 << 'PYEOF'
import os, glob, re
summary = os.environ.get("GITHUB_STEP_SUMMARY", "")
bucket = os.environ.get("FTL_BUCKET", "")
run_dir = os.environ.get("FTL_RUN_DIR", "")
lines = ["## Power / Wake Analysis — virtual Pixel3 API30\n",
f"**GCS:** `gs://{bucket}/{run_dir}`\n"]
logcat_files = glob.glob("ftl-results/**/logcat*", recursive=True)
# Build PID map for cloud.glucodroid process(es) from lines that explicitly
# mention the package, so we can also match tag-only BluetoothLeScanner
# lines emitted by the same PIDs.
pid_re = re.compile(r'^\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d+\s+(\d+)\s+\d+')
pkg_re = re.compile(r'cloud\.glucodroid(\.debug)?')
ble_start = re.compile(r'onScannerRegistered.*scannerId=(\d+)', re.I)
ble_stop = re.compile(r'stopScan|ScannerUnregistered')
app_pids = set()
for lf in logcat_files:
for line in open(lf, errors='replace'):
if pkg_re.search(line):
m = pid_re.match(line)
if m: app_pids.add(m.group(1))
ble_starts, ble_stops = [], []
for lf in logcat_files:
for line in open(lf, errors='replace'):
pid = (pid_re.match(line) or [None,None])[1]
is_app = pid and pid in app_pids
is_ble = 'BluetoothLeScanner' in line or 'bt_stack' in line
if not (is_app or (is_ble and pkg_re.search(line))):
continue
if ble_start.search(line):
ble_starts.append(line.rstrip())
elif ble_stop.search(line):
ble_stops.append(line.rstrip())
lines.append(f"_Tracked PIDs: {sorted(app_pids) or 'none'}_\n")
lines.append("### BLE Scan Lifecycle")
lines.append(f"- Scanner registered: **{len(ble_starts)}** stopped: **{len(ble_stops)}**")
if len(ble_starts) > len(ble_stops) + 1:
lines.append(f"- ⚠️ {len(ble_starts) - len(ble_stops)} unmatched start(s) — verify stopScan() on screen exit\n")
else:
lines.append("- ✅ Starts and stops balanced\n")
jank_re = re.compile(r'Skipped (\d+) frames', re.I)
jank_hits = [(int(m.group(1)), line.rstrip())
for lf in logcat_files
for line in open(lf, errors='replace')
if 'cloud.glucodroid' in line
for m in [jank_re.search(line)] if m]
total_skipped = sum(n for n, _ in jank_hits)
lines.append("### Main-Thread Jank (Choreographer)")
if jank_hits:
lines.append(f"- ⚠️ {len(jank_hits)} event(s), {total_skipped} frames skipped total")
for n, l in sorted(jank_hits, reverse=True)[:5]:
lines.append(f" - {n} frames: `{l[-120:]}`")
else:
lines.append("- ✅ No jank detected")
alarm_re = re.compile(r'AlarmManager.*set|setExact|setWindow|setRepeating', re.I)
alarm_hits = [line.rstrip() for lf in logcat_files
for line in open(lf, errors='replace')
if alarm_re.search(line) and 'cloud.glucodroid' in line]
lines.append(f"\n### App AlarmManager ({len(alarm_hits)} call(s))")
if alarm_hits:
lines.append("<details><summary>Alarm log lines</summary>\n\n```")
lines.extend(alarm_hits[:20])
lines.append("```\n</details>")
else:
lines.append("- No AlarmManager calls from app")
batt = next(iter(glob.glob("ftl-results/**/batterystats*", recursive=True)), None)
if batt:
data = open(batt).read()
wl = re.findall(r'Wake lock\s+([\w./:]+)\s+(.+)', data)
al = re.findall(r'Alarm\s+([\w./:]+)\s+(.+)', data)
cpu_wk = re.findall(r'CPU wakeups:\s+(\d+)', data)
lines.append(f"\n### Batterystats — Wakelocks ({len(wl)})")
for name, detail in wl[:20]:
lines.append(f"- `{name}`: {detail}")
if len(wl) > 20:
lines.append(f"- … and {len(wl)-20} more")
lines.append(f"\n### Batterystats — Alarms ({len(al)})")
for name, detail in al[:15]:
lines.append(f"- `{name}`: {detail}")
if cpu_wk:
lines.append(f"\n**CPU wakeups:** {cpu_wk[0]}")
else:
lines.append("\n_Batterystats not captured for virtual Robo. BLE/alarm/jank from logcat above._")
all_files = sorted(f for f in glob.glob("ftl-results/**/*", recursive=True) if os.path.isfile(f))
if all_files:
lines.append(f"\n### Artifacts ({len(all_files)} files)")
for f in all_files[:20]:
lines.append(f"- `{f}`")
output = "\n".join(lines)
if summary:
open(summary, "a").write(output + "\n")
print(output)
PYEOF
- name: Upload power artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: power-virtual-pixel3-api30
path: ftl-results/
retention-days: 14
if-no-files-found: warn
# ─────────────────────────────────────────────────────────────────────────────
# Physical device — manual dispatch only.
# power_only mode: forces 20 min Robo, full batterystats/wakelock report.
# Normal mode: respects test_type / timeout inputs.
# ─────────────────────────────────────────────────────────────────────────────
physical:
name: >
Physical ${{ inputs.power_only == true && 'power audit' || (inputs.test_type || 'robo') }} —
${{ inputs.device_model || 'oriole' }} API${{ inputs.api_level || '33' }}
needs: build
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Download APKs
uses: actions/download-artifact@v4
with:
name: apks
path: apks/
- name: Authenticate with Google Cloud
run: |
KEY_FILE="$RUNNER_TEMP/gcp-sa-key.json"
echo '${{ secrets.GCP_SA_KEY }}' > "$KEY_FILE"
gcloud auth activate-service-account --key-file="$KEY_FILE" --quiet
PROJECT_ID=$(python3 -c "import json; print(json.load(open('$KEY_FILE'))['project_id'])")
echo "FIREBASE_PROJECT_ID=$PROJECT_ID" >> "$GITHUB_ENV"
gcloud config set project "$PROJECT_ID" --quiet
- name: Run on physical device
env:
POWER_ONLY: ${{ inputs.power_only }}
run: |
DEVICE="${{ inputs.device_model || 'oriole' }}"
VERSION="${{ inputs.api_level || '33' }}"
TYPE="${{ inputs.test_type || 'robo' }}"
if [[ "$POWER_ONLY" == "true" ]]; then
TIMEOUT="20m"
TYPE="robo"
else
TIMEOUT="${{ inputs.timeout || '5m' }}"
fi
RUN_DIR="glucodroid-${TYPE}-$(date +%Y%m%d-%H%M%S)"
echo "FTL_RUN_DIR=$RUN_DIR" >> "$GITHUB_ENV"
echo "POWER_ONLY=$POWER_ONLY" >> "$GITHUB_ENV"
APP_APK=$(find apks -name "*.apk" ! -name "*androidTest*" ! -name "*unsigned*" | head -1)
TEST_APK=$(find apks -name "*androidTest*.apk" 2>/dev/null | head -1 || true)
gsutil mb -p "$FIREBASE_PROJECT_ID" -l us-central1 "gs://${FTL_BUCKET}" 2>/dev/null || true
COMMON_ARGS=(
--project "$FIREBASE_PROJECT_ID"
--device "model=${DEVICE},version=${VERSION},locale=en,orientation=portrait"
--timeout "$TIMEOUT"
--results-bucket "$FTL_BUCKET"
--results-dir "$RUN_DIR"
)
if [[ "$TYPE" == "instrumented" && -n "$TEST_APK" ]]; then
gcloud firebase test android run \
--type instrumentation \
--app "$APP_APK" \
--test "$TEST_APK" \
--environment-variables clearPackageData=true \
"${COMMON_ARGS[@]}"
else
gcloud firebase test android run \
--type robo \
--app "$APP_APK" \
"${COMMON_ARGS[@]}"
fi
- name: Download physical results
if: always()
run: |
mkdir -p ftl-results
gsutil -m cp -r "gs://${FTL_BUCKET}/${FTL_RUN_DIR}/**" ftl-results/ 2>/dev/null || true
echo "Downloaded $(find ftl-results -type f | wc -l) file(s)"
- name: Summarise physical results
if: always()
env:
POWER_ONLY: ${{ inputs.power_only }}
run: |
python3 << 'PYEOF'
import os, glob, re, json
from collections import defaultdict
summary = os.environ.get("GITHUB_STEP_SUMMARY", "")
power_only = os.environ.get("POWER_ONLY", "false").lower() == "true"
device = "${{ inputs.device_model || 'oriole' }}"
version = "${{ inputs.api_level || '33' }}"
bucket = os.environ.get("FTL_BUCKET", "")
run_dir = os.environ.get("FTL_RUN_DIR", "")
mode_label = "Power Audit" if power_only else "Results"
lines = [f"## Physical Device {mode_label} — {device} (API {version})\n",
f"**GCS:** `gs://{bucket}/{run_dir}`\n"]
# ── Test outcome (instrumented) ───────────────────────────────────
for xml in glob.glob("ftl-results/**/*.xml", recursive=True):
content = open(xml).read()
tests = len(re.findall(r'<testcase ', content))
failures = len(re.findall(r'<failure', content))
errors = len(re.findall(r'<error', content))
if tests:
status = "✅ PASSED" if not (failures+errors) else "❌ FAILED"
lines.append(f"**Tests:** {tests} run, {failures+errors} issue(s) — {status}\n")
break
# ── Robo coverage ─────────────────────────────────────────────────
actions_file = next(iter(glob.glob("ftl-results/**/actions.json", recursive=True)), None)
if actions_file:
try:
data = json.load(open(actions_file))
events = data.get("events", [])
screens = {e.get(k) for e in events
for k in ("sourceScreenId","destinationScreenId") if e.get(k)}
lines.append(f"**Screens visited:** {len(screens)} **Actions:** {len(events)}\n")
except Exception:
pass
# ── Batterystats (real data from physical device) ─────────────────
batt = next(iter(glob.glob("ftl-results/**/batterystats*", recursive=True)), None)
if batt:
data = open(batt).read()
wl = re.findall(r'Wake lock\s+([\w./:]+)\s+(.+)', data)
al = re.findall(r'Alarm\s+([\w./:]+)\s+(.+)', data)
cpu_wk = re.findall(r'CPU wakeups:\s+(\d+)', data)
screen = re.findall(r'Screen on:\s+([\dhms ]+)', data)
bg_cpu = re.findall(r'Background CPU:\s+(.+)', data)
limit = None if power_only else 15
lines.append(f"### Wakelocks ({len(wl)} total)")
for name, detail in (wl if power_only else wl[:limit]):
lines.append(f"- `{name}`: {detail}")
if not power_only and len(wl) > limit:
lines.append(f"- … and {len(wl)-limit} more (run with power_only for full list)")
if power_only:
# Group wakelocks by package for easier scanning
pkg_wl = defaultdict(list)
for name, detail in wl:
pkg = name.rsplit('/', 1)[0] if '/' in name else name.rsplit('.', 2)[0]
pkg_wl[pkg].append((name, detail))
if len(pkg_wl) > 1:
lines.append("\n**By package:**")
for pkg, entries in sorted(pkg_wl.items(), key=lambda x: -len(x[1]))[:10]:
lines.append(f"- `{pkg}`: {len(entries)} wakelock(s)")
al_limit = None if power_only else 10
lines.append(f"\n### Alarms ({len(al)} total)")
for name, detail in (al if power_only else al[:al_limit]):
lines.append(f"- `{name}`: {detail}")
if not power_only and len(al) > al_limit:
lines.append(f"- … and {len(al)-al_limit} more")
if power_only:
pkg_al = defaultdict(int)
for name, _ in al:
pkg = name.rsplit('/', 1)[0] if '/' in name else name.rsplit('.', 2)[0]
pkg_al[pkg] += 1
if pkg_al:
lines.append("\n**Alarm frequency by package:**")
for pkg, count in sorted(pkg_al.items(), key=lambda x: -x[1])[:10]:
lines.append(f"- `{pkg}`: {count} alarm(s)")
if cpu_wk:
lines.append(f"\n**CPU wakeups:** {cpu_wk[0]}")
if screen:
lines.append(f"**Screen-on time:** {screen[0].strip()}")
if bg_cpu and power_only:
lines.append(f"**Background CPU:** {bg_cpu[0].strip()}")
else:
lines.append("_No batterystats artifact — power data unavailable._")
# ── Jank (always shown in power_only, condensed otherwise) ───────
logcat_files = glob.glob("ftl-results/**/logcat*", recursive=True)
jank_re = re.compile(r'Skipped (\d+) frames', re.I)
jank_hits = [(int(m.group(1)), line.rstrip())
for lf in logcat_files
for line in open(lf, errors='replace')
if 'cloud.glucodroid' in line
for m in [jank_re.search(line)] if m]
if jank_hits or power_only:
total_skipped = sum(n for n, _ in jank_hits)
lines.append(f"\n### Main-Thread Jank")
if jank_hits:
lines.append(f"- ⚠️ {len(jank_hits)} event(s), {total_skipped} frames skipped")
for n, l in sorted(jank_hits, reverse=True)[:5]:
lines.append(f" - {n} frames: `{l[-120:]}`")
else:
lines.append("- ✅ No jank detected")
# ── BLE scan lifecycle ────────────────────────────────────────────
# Build PID set from any logcat line mentioning cloud.glucodroid so we
# can detect tag-only BluetoothLeScanner lines from the same process(es).
pid_re = re.compile(r'^\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d+\s+(\d+)\s+\d+')
pkg_re = re.compile(r'cloud\.glucodroid(\.debug)?')
ble_start = re.compile(r'onScannerRegistered.*scannerId=(\d+)', re.I)
ble_stop = re.compile(r'stopScan|ScannerUnregistered')
app_pids = set()
for lf in logcat_files:
for line in open(lf, errors='replace'):
if pkg_re.search(line):
m = pid_re.match(line)
if m: app_pids.add(m.group(1))
def _is_app_line(line):
m = pid_re.match(line)
return m and m.group(1) in app_pids
ble_starts = [line.rstrip() for lf in logcat_files
for line in open(lf, errors='replace')
if _is_app_line(line) and ble_start.search(line)]
ble_stops = [line.rstrip() for lf in logcat_files
for line in open(lf, errors='replace')
if _is_app_line(line) and ble_stop.search(line)]
lines.append(f"\n### BLE Scan Lifecycle")
lines.append(f"_Tracked PIDs: {sorted(app_pids) or 'none'}_")
lines.append(f"- Registered: **{len(ble_starts)}** stopped: **{len(ble_stops)}**")
if len(ble_starts) > len(ble_stops) + 1:
lines.append(f"- ⚠️ {len(ble_starts)-len(ble_stops)} unmatched start(s)")
# Surface the worst scan-restart bursts so they're actionable.
from datetime import datetime
_ts_re = re.compile(r'^(\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+)')
def _ts(s):
m = _ts_re.match(s)
if not m: return None
try: return datetime.strptime(m.group(1), '%m-%d %H:%M:%S.%f')
except ValueError: return None
_starts = [(t, l) for l in ble_starts if (t := _ts(l)) is not None]
if len(_starts) >= 3:
_starts.sort()
_gaps = [(_starts[i+1][0]-_starts[i][0]).total_seconds() for i in range(len(_starts)-1)]
_short = [g for g in _gaps if g < 60]
if len(_short) >= 3:
lines.append(f"- ⚠️ {len(_short)}/{len(_gaps)} scan restarts fired <60s apart — likely redundant startScan calls")
lines.append("<details><summary>Start timestamps</summary>\n\n```")
lines.extend(f"{t.strftime('%H:%M:%S.%f')[:-3]}" for t, _ in _starts[:30])
lines.append("```\n</details>")
# ── App AlarmManager calls ─────────────────────────────────────────
if power_only:
alarm_re = re.compile(r'AlarmManager.*set|setExact|setWindow|setRepeating', re.I)
alarm_hits = [line.rstrip() for lf in logcat_files
for line in open(lf, errors='replace')
if alarm_re.search(line) and 'cloud.glucodroid' in line]
lines.append(f"\n### App AlarmManager ({len(alarm_hits)} call(s))")
if alarm_hits:
lines.append("<details><summary>Alarm log lines</summary>\n\n```")
lines.extend(alarm_hits[:30])
lines.append("```\n</details>")
else:
lines.append("- No AlarmManager calls from app")
# ── Doze / background wake events ─────────────────────────────
doze_re = re.compile(r'DeviceIdle|Doze|BECOME_ACTIVE|IDLE_MAINTENANCE', re.I)
doze_hits = [line.rstrip() for lf in logcat_files
for line in open(lf, errors='replace')
if doze_re.search(line) and 'cloud.glucodroid' in line]
lines.append(f"\n### Doze / DeviceIdle interaction ({len(doze_hits)} event(s))")
if doze_hits:
lines.append("<details><summary>Doze log lines</summary>\n\n```")
lines.extend(doze_hits[:20])
lines.append("```\n</details>")
else:
lines.append("- No Doze interactions from app (good)")
# ── Artifact list ─────────────────────────────────────────────────
all_files = sorted(f for f in glob.glob("ftl-results/**/*", recursive=True) if os.path.isfile(f))
if all_files:
lines.append(f"\n### Artifacts ({len(all_files)} files)")
for f in all_files[:20]:
lines.append(f"- `{f}`")
output = "\n".join(lines)
if summary:
open(summary, "a").write(output + "\n")
print(output)
PYEOF
- name: Upload physical artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: physical-${{ inputs.device_model || 'oriole' }}-api${{ inputs.api_level || '33' }}
path: ftl-results/
retention-days: 14
if-no-files-found: warn