Releases: GPTmadeit/split-shot-timer
Release list
SPLIT v0.2.1 — the watch app opens
The watch app did not open in v0.1.0 or v0.2.0. It does now. Releases can also finally be installed as updates. Both problems were reproduced and fixed against a Wear OS 6 emulator rather than reasoned about.
Important
Upgrading from v0.1.0 or v0.2.0? Uninstall first.
adb uninstall com.carlb.split
Those builds were each signed with a different throwaway key, so Android rejects this one with INSTALL_FAILED_UPDATE_INCOMPATIBLE. This is a one-time break — from v0.2.1 onward every build shares one signing key and updates install in place, keeping your settings and logged strings.
Fixed
The watch app crashed instantly on launch
RecoilGate registered the accelerometer at SENSOR_DELAY_FASTEST (0 µs). Since Android 12 any sampling rate above 200 Hz requires HIGH_SAMPLING_RATE_SENSORS, which was never declared — so registerListener threw straight out of the foreground service:
SecurityException: To use the sampling rate of 0 microseconds, app needs to
declare the normal permission HIGH_SAMPLING_RATE_SENSORS
at RecoilGate.start(RecoilGate.kt:55)
at TimerEngine.openMic(TimerEngine.kt:95)
at TimerService.onStartCommand(TimerService.kt:82)
START_STICKY then had the system recreate the service into the identical crash, so the app opened and vanished, forever.
Four changes, because the permission alone would only fix today's instance:
HIGH_SAMPLING_RATE_SENSORSis declared.RecoilGate.start()can no longer throw — it falls back to a slower sampling rate and reports itself unavailable rather than dying.- The accelerometer is started only when the recoil gate is enabled. It ships off by default, so a disabled optional feature was taking down the whole timer.
START_NOT_STICKY, andstartForeground()happens before anything that can fail.
Releases could not be installed as updates
Every CI run generated a fresh ~/.android/debug.keystore, so each release carried a different signer and Android refused the upgrade. Builds now use a committed signing key, verified identical across both apps.
That key is deliberately public — its credentials are the Android SDK's standard debug constants and the file is in the repository. It exists so upgrades work, not to prove anything about origin. Verify downloads against SHA256SUMS.txt.
Added
- Emulator smoke test in CI — installs both APKs, launches them, fails if the process dies or logs a fatal exception. Both apps compiled, passed 42 unit tests and passed Android Lint while crash-looping; no static check could see this.
- Signing-key stability check in CI — fails the build if the APK signer drifts, which is what silently broke updates last time.
Changed
- README badge now distinguishes emulator launch verified from live fire tested (still no).
- New troubleshooting entries for
INSTALL_FAILED_UPDATE_INCOMPATIBLEand the app closing on launch.
Removed / Breaking
Nothing removed. No API or wire-format change — a v0.2.1 watch and a v0.2.0 phone still interoperate. The signing key change is not a code break but does require the one-time uninstall above.
Install
Install the watch APK on the watch and the phone APK on the phone. They share an applicationId, so putting both on one device replaces one with the other.
adb -s <watch-serial> install -r split-wear-v0.2.1-debug.apk
adb -s <phone-serial> install -r split-mobile-v0.2.1-debug.apk
adb devices lists serials. Verify your download:
sha256sum -c SHA256SUMS.txt
What was actually verified
| Check | Result |
|---|---|
./gradlew build (debug + release, both modules) |
pass |
| Unit tests | 42 / 42 |
| Android Lint | no issues on :wear and :mobile |
| ktlint | 0 violations |
| Watch app launches on Wear OS 6 (API 36) emulator | pass — process alive, no crash |
| Foreground service holds the mic | pass — isForeground=true, types=0x80 |
| Timer arms, sounds the tone, runs the clock | pass |
| Phone app launches | pass — no crash |
| In-place update over a previous v0.2.1 install | pass |
| Both APKs share one signing key | pass (df77d1c4…) |
| Old-key upgrade correctly rejected | reproduced, hence the uninstall note |
Known limitations
No live fire has ever been recorded through this code, and it has not run on a physical watch. An emulator has no microphone worth the name and no gunshots.
The four detector constants remain reasoned estimates, not measurements:
| Constant | Value | Basis |
|---|---|---|
| Clip-run threshold | 3 samples | estimate |
| Echo blanking | 60 ms | estimate |
| Recoil window | ±40 ms | estimate |
| Recoil threshold | 18 m/s² | estimate |
Also unchanged: no instrumented tests beyond the launch smoke check; the browser prototype cannot truly pin an unprocessed audio source; compileSdk stays pinned at 36 until platform 37 ships.
A detection accuracy report from a real range session is still the most valuable thing anyone can contribute.
Full changelog: v0.2.0...v0.2.1
SPLIT v0.2.0 — repository and quality release
A repository and quality release. Detection, timing and app behaviour are unchanged from v0.1.0 — this release is tests, CI, documentation and licensing.
▶ Try it in your browser · no install, works on a phone
Added
- 42 unit tests covering
:core— split derivation, split σ, standards grading, USPSA hit factor, the watch↔phone wire contract round-trip, timer delay modes, and drill library invariants. Expectations are hand-computed, not taken from the implementation. - Continuous integration — unit tests, both APK assemblies, Android Lint, ktlint, and a structural check on the browser prototype, on every push and PR.
- Security automation — CodeQL (
security-extended), gitleaks across full git history, dependency review on PRs, and a weekly scheduled scan. - Release automation — tagging
v*verifies, builds, and attaches APKs plusSHA256SUMS.txt. - Dependabot for Gradle and GitHub Actions, with grouped updates.
- Documentation —
ARCHITECTURE.md,CONTRIBUTING.md,SECURITY.md,SUPPORT.md,CODE_OF_CONDUCT.md,ROADMAP.md,CHANGELOG.md. The README was rewritten for someone who has never held a shot timer, and now covers requirements, quick start, usage, configuration, troubleshooting and an FAQ. - Issue templates for bugs, features, and a dedicated detection-accuracy report; plus a PR template.
- MIT licence, so the repository has a detected licence.
- Branding — banner and social preview assets.
Changed
- Kotlin sources formatted with ktlint 1.8.0 (
intellij_idearuleset — chosen so CI agrees with what Android Studio produces on save). Whitespace only; the full build and all 42 tests pass unchanged. .gitignorehardened for signing material (*.jks,*.keystore,keystore.properties),.envfiles and service-account JSON..gitattributesadded, normalising line endings to LF.
Fixed
gradlewwas not executable. The repository was authored on Windows, which carries no POSIX executable bit, so it was committed as mode100644. Every Linux and macOS clone hit./gradlew: Permission denied— including CI, which is how it surfaced. Now100755.- Two unit test expectations that were wrong when first written (an incorrect hand-computed variance, and a drill invariant that treated independent ceilings as a time budget). Both were test bugs; the implementation was correct.
Removed
Nothing.
Breaking changes
None. The Data Layer wire format is unchanged, so a v0.2.0 watch and a v0.1.0 phone still interoperate.
While the version is 0.x, the wire format may change in any minor release.
Install
Both APKs are debug-signed — fine for sideloading, not for Play distribution. Publishing requires your own signing key.
adb -s <watch-serial> install -r split-wear-v0.2.0-debug.apk
adb -s <phone-serial> install -r split-mobile-v0.2.0-debug.apk
Run adb devices to list serials. Verify your download against SHA256SUMS.txt:
sha256sum -c SHA256SUMS.txt
The two apps share an applicationId on purpose — that is what makes Play deliver the watch APK to a paired watch when the phone app installs.
What was actually verified
| Check | Result |
|---|---|
./gradlew build (debug + release, both modules) |
pass |
| Unit tests | 42 / 42 pass |
| Android Lint | no issues on :wear and :mobile |
| ktlint | 0 violations |
| Packaged APK manifests | com.carlb.split, versionName 0.2.0, versionCode 2 |
APK signatures (apksigner verify) |
both verify |
| Secret scan across full history | clean |
| Browser prototype | loads over HTTPS, pure ASCII, full string runs correctly |
Known limitations
Nothing has been run on real hardware, and no live fire has ever been recorded through this code.
The four detector constants are reasoned estimates, not measurements:
| Constant | Value | Basis |
|---|---|---|
| Clip-run threshold | 3 samples | estimate |
| Echo blanking | 60 ms | estimate |
| Recoil window | ±40 ms | estimate |
| Recoil threshold | 18 m/s² | estimate |
Also:
- No instrumented tests. Unit tests cover
:corelogic only — the audio, sensor and UI layers have no automated coverage. - The browser prototype cannot truly pin an unprocessed audio source; browsers apply AGC and noise suppression by default and the page can only request they be off.
compileSdkis pinned at 36 with dependencies held back to match, because platform 37 is not published yet. Dependabot will open PRs bumping past those pins; they are expected to fail CI until the platform ships.
If you take this to a range, a detection accuracy report is by far the most valuable thing you can contribute.
Full changelog: v0.1.0...v0.2.0
SPLIT v0.1.0 — first pre-release
A shot timer for Pixel Watch 4. The watch beeps, listens and times; the phone is a durable replica.
▶ Try it in your browser — no install, works on a phone.
Why a watch microphone can do this at all
A 9mm at the shooter's ear runs 160–165 dB SPL. The MEMS capsule in the watch clips around 120–130 dB, so every shot overdrives it by 30–45 dB and the amplitude coming off the ADC is meaningless — it is simply railed.
That does not matter, because a shot timer needs an arrival time, not a level. And the saturation turns out to be the best discriminator available: your own muzzle rails the converter for milliseconds, while the shooter two bays over arrives ~30 dB down and never rails at all.
What's in it
- Sample-accurate timing.
AudioRecord.getTimestamp()gives a HAL(framePosition, nanoTime)anchor, so onset resolution is one sample (20.8 µs @ 48 kHz) rather than one buffer (~10 ms). The start instant isAudioTrack.getTimestamp()rewound to frame 0, making first-shot time a difference of two HAL timestamps rather than two thread wakeups. - AGC defeated.
AudioSource.UNPROCESSEDwhere supported, elseVOICE_RECOGNITION. Leave AGC live and gain ducks after round one, so every subsequent split is measured through a moving target. - Neighbour rejection via clip-run length, plus an optional accelerometer recoil gate (off by default — read the caveats first).
- Two transports.
MessageClientmirrors a running string (best effort, droppable).DataClientcarries completed strings (replicated, survives the phone sitting in a range bag out of Bluetooth range all session). The watch writes locally before it ever reaches for the phone. - Nine drills — Bill, Failure to Stop, F.A.S.T., 1‑Reload‑1, Blake, El Presidente, Casino, Dot Torture, Freestyle — with enforced shot counts and pars, split σ, and USPSA hit factor.
- Wear Compose M3 on the watch, Material 3 Expressive on the phone, physics-based motion throughout.
Install
Both APKs are debug-signed — fine for sideloading, not for Play. Publishing needs your own signing key.
adb -s <watch-serial> install -r split-wear-v0.1.0-debug.apk
adb -s <phone-serial> install -r split-mobile-v0.1.0-debug.apk
The two share an applicationId on purpose: that is what makes Play deliver the watch APK to a paired watch when the phone app installs.
⚠️ Read before trusting a number this produces
Nothing here has run on hardware, and no live fire has been recorded through it. ./gradlew build passes clean — both modules, debug and release, Android Lint reporting no issues — and the manifests are verified against the packaged APKs. That is the entire extent of the validation.
The detector constants are reasoned starting points, not measured ones:
| Constant | Value | Basis |
|---|---|---|
| Clip-run threshold | 3 samples | estimate |
| Echo blanking | 60 ms | estimate |
| Recoil window | ±40 ms | estimate |
| Recoil threshold | 18 m/s² | estimate |
Expect to characterise all four against your own gun and your own range. The recoil gate in particular depends on grip and which wrist you wear the watch on — support-hand on a two-handed grip reads cleanly, one-handed off-hand may read nothing.
Known limitations
compileSdkis pinned at 36 and several dependencies are held back accordingly; platform 37 is not published yet.material3is on1.5.0-alpha18because the Expressive API is compiledinternalin 1.4.0 stable. Details in the README.- The browser prototype cannot truly pin
UNPROCESSED— browsers apply AGC and noise suppression by default and the page can only request they be turned off. - No licence file, so default copyright applies.