Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
7ab2701
Add backend URL to README
abdulsaheel Jun 13, 2026
2684ff3
Implement Edge Tracking foreground service for background BLE connection
abdulsaheel Jun 13, 2026
d2471a6
Fix background sync: hold BLE connection alive + persistent flusher +…
abdulsaheel Jun 13, 2026
dc96860
Merge pull request #3 from OpenStrap/fix/background-sync-keepalive
abdulsaheel Jun 13, 2026
0da7bc0
edge: concern-based IA — Today/Sleep/Heart/Body/Workouts, studio poli…
abdulsaheel Jun 13, 2026
59722ac
WIP: workouts page (exercise picker → live start/stop, list + summary…
abdulsaheel Jun 13, 2026
d494a8a
Merge pull request #4 from OpenStrap/main
abdulsaheel Jun 13, 2026
11b85dd
Merge pull request #5 from OpenStrap/metrics-hrv-explorer
abdulsaheel Jun 13, 2026
6d51297
edge: rename Concern→Metric/Sleep/Heart/Body screens, drop unused scr…
abdulsaheel Jun 14, 2026
4c82eeb
Merge branch 'metrics-hrv-explorer'
abdulsaheel Jun 14, 2026
a1dd15e
edge: Wear time screen — tap home tile → coverage ring, 24h strip, on…
abdulsaheel Jun 14, 2026
744829f
edge: redesign Workouts — training-summary hero + rich post-workout b…
abdulsaheel Jun 14, 2026
ff84b19
edge: move Workouts Start to a compact blazing pill in the top-right …
abdulsaheel Jun 14, 2026
0c879c0
edge: iOS widget shows three rings — Strain · Sleep · HRV (recovery r…
abdulsaheel Jun 14, 2026
192486c
edge: evenly distribute the three widget rings (equal-width columns)
abdulsaheel Jun 14, 2026
47075bf
edge: Trends & Fitness foundation — GenericTrendScreen + TrendMetricR…
abdulsaheel Jun 14, 2026
a8deb12
edge: Today composite Readiness hero + widget readiness (4 rings: Rea…
abdulsaheel Jun 14, 2026
f48e75e
edge: Heart tab — HRV/SDNN/LF-HF/CV + nocturnal dip as tappable trend…
abdulsaheel Jun 14, 2026
f439cb4
edge: Body tab — Fitness section (VO2max, Banister fitness/fatigue/fo…
abdulsaheel Jun 14, 2026
95e5cce
edge: Sleep tab Trends group (time asleep / efficiency / deep / REM /…
abdulsaheel Jun 14, 2026
7442a21
edge: interactive live workout screen — HR-reactive ember + zone ladd…
abdulsaheel Jun 14, 2026
2a90a19
edge: delete workouts — swipe-to-delete on the list + delete action i…
abdulsaheel Jun 14, 2026
5ee965b
edge: OTA self-update + admin alert banner on Home (v0.3.0)
abdulsaheel Jun 14, 2026
6836d7b
edge: fix release build for OTA — ota_update 7.x + core library desug…
abdulsaheel Jun 14, 2026
8c890b3
feat: sleep v2 UI, step goal, minute-detail retention gating
abdulsaheel Jun 15, 2026
526177d
chore: bump to 0.4.0+5 — sleep v2, step goal, minute-detail retention…
abdulsaheel Jun 15, 2026
6e3b668
fix: declare ota_update FileProvider in AndroidManifest — OTA crashed…
abdulsaheel Jun 15, 2026
91c9c83
Add dark mode ("Ember on Char") with smooth, full-app theme switching
abdulsaheel Jun 15, 2026
1503ffd
ci: also build + attach an unsigned iOS .ipa to releases
abdulsaheel Jun 15, 2026
c71a332
chore: bump to 0.4.3+8 (verify iOS .ipa ships in the release)
abdulsaheel Jun 15, 2026
6329372
feat: low-battery and charging OS notifications
abdulsaheel Jun 16, 2026
cfff608
Relabel sleep regularity as "Consistency" (not SRI)
abdulsaheel Jun 16, 2026
5b20a05
Map band double-tap to a configurable action
abdulsaheel Jun 16, 2026
c93e45f
Connection resilience + honest "no data" workouts (v0.4.4)
abdulsaheel Jun 17, 2026
5e7c261
Fix dark-mode rebuild storm that starved the background BLE connectio…
abdulsaheel Jun 17, 2026
4f60448
Fix release-build blank screen on launch (v0.4.6)
abdulsaheel Jun 18, 2026
5888004
Merge pull request #11 from OpenStrap/fix/release-blank-screen
abdulsaheel Jun 18, 2026
d04a7e0
feat(edge): pull-to-refresh on all screens — MetricScreen (Sleep/Hear…
abdulsaheel Jun 19, 2026
4c88ccf
feat(edge): per-nap hypnogram strip on the sleep-periods screen (rend…
abdulsaheel Jun 19, 2026
4c791b9
feat(edge): add lock-screen Band Battery widget
abdulsaheel Jun 19, 2026
6d16925
feat(edge): live-HR Heart tile + Android notification→strap-buzz relay
abdulsaheel Jun 19, 2026
2af9a19
Make iOS signing contributor-friendly
christianblessing-me Jun 17, 2026
de70f1b
Merge pull request #9 from christianblessing-me/main-fix-ios-pod-lock
abdulsaheel Jun 20, 2026
cf9792c
refactor(edge): decode R24 header only; cloud owns the sensor block
abdulsaheel Jun 20, 2026
bd099b0
sleep: show ultradian sleep-cycle wave (HRV) under the Stages breakdown
abdulsaheel Jun 20, 2026
7850da9
fix(recap): set sharePositionOrigin so iOS share sheet doesn't crash
abdulsaheel Jun 20, 2026
6f208d9
feat: AI Coach (BYOK agentic chat) + cycle tracking, HRV spot-check, …
abdulsaheel Jun 20, 2026
6585fde
Merge remote-tracking branch 'origin/main' into feat/wake-trigger
abdulsaheel Jun 20, 2026
4ec0992
Merge pull request #13 from OpenStrap/feat/wake-trigger
abdulsaheel Jun 20, 2026
32a8841
feat: community links in Profile + bump to v0.5.0
abdulsaheel Jun 20, 2026
c5132e7
Merge pull request #15 from OpenStrap/feat/wake-trigger
abdulsaheel Jun 20, 2026
cebf1b9
fix(stress): read HRV-stress response shape (Baevsky SI) + bump v0.5.1
abdulsaheel Jun 21, 2026
daa6f2a
Merge pull request #16 from OpenStrap/feat/wake-trigger
abdulsaheel Jun 21, 2026
0554bcc
chore: remove dead api_client methods
abdulsaheel Jun 21, 2026
f4179fc
perf(upload): single 60s flusher as the sole upload trigger
abdulsaheel Jun 21, 2026
e63a5d8
docs(upload): fix flusher doc comments per review
abdulsaheel Jun 21, 2026
611e816
Merge pull request #17 from OpenStrap/perf/upload-batching
abdulsaheel Jun 21, 2026
52b0810
feat(workouts): detected-vs-selected + phases + type confirm/correct …
abdulsaheel Jun 21, 2026
72bc60b
perf(upload): adaptive batch size + backfill fast-drain
abdulsaheel Jun 22, 2026
9b935d9
fix(ble): revive a stale link on foreground resume instead of trustin…
abdulsaheel Jun 22, 2026
bdb6628
fix(ble): opt flutter_blue_plus into iOS State Preservation & Restora…
abdulsaheel Jun 22, 2026
85a3f39
chore(ios): drop unused remote-notification background mode
abdulsaheel Jun 22, 2026
0206a1a
fix(ble): re-poll band state on fast foreground-reclaim
abdulsaheel Jun 22, 2026
65e7332
Merge pull request #18 from OpenStrap/perf/upload-batching
abdulsaheel Jun 22, 2026
de8201b
release: v0.5.2 — iOS background-BLE restoration + adaptive upload ba…
abdulsaheel Jun 22, 2026
580d08a
Update deployed backend URL in README to v2
abdulsaheel Jun 23, 2026
a95e7e5
feat: Find my band, Broadcast HR (phone as BLE HR monitor), Strava UI
shoshiiiin Jun 24, 2026
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
45 changes: 45 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,48 @@ jobs:
with:
files: ${{ steps.rename.outputs.apk }}
generate_release_notes: true

# Unsigned iOS .ipa for sideloading (AltStore / Sideloadly / TrollStore). It is
# NOT App Store signed — users re-sign it with their own Apple ID on install.
# Note: free-account sideloads re-sign with a different team id, so App Group
# features (home widget + Live Activity) may not work; there is no iOS OTA
# auto-update either (that path installs an .apk). Attached to the same release.
ios:
runs-on: macos-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4

- uses: subosito/flutter-action@v2
with:
channel: stable

- name: Write .env
run: echo "BACKEND_URL=${{ secrets.BACKEND_URL }}" > .env

- run: flutter pub get

- name: Build unsigned iOS app
run: flutter build ios --release --no-codesign --dart-define-from-file=.env

- name: Package unsigned IPA
id: ipa
run: |
cd build/ios/iphoneos
mkdir -p Payload
cp -R Runner.app Payload/Runner.app
IPA="$GITHUB_WORKSPACE/openstrap-edge-${GITHUB_REF_NAME}-unsigned.ipa"
zip -qr -y "$IPA" Payload
echo "ipa=openstrap-edge-${GITHUB_REF_NAME}-unsigned.ipa" >> "$GITHUB_OUTPUT"

- uses: actions/upload-artifact@v4
with:
name: openstrap-edge-ipa
path: ${{ steps.ipa.outputs.ipa }}

# Attach to the same Release the android job created/updates (upsert by tag).
- name: Attach IPA to release
uses: softprops/action-gh-release@v2
with:
files: ${{ steps.ipa.outputs.ipa }}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ app.*.map.json
# secrets / build-time env
.env
.env.local
ios/Config/Signing.xcconfig

# stray dumps / archives never belong in assets
*.gz
Expand Down
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ catches up. It's allowed to miss; it's built to not care.
| `lib/state/` | `AppState`, the one source of truth |
| `lib/ui/` | every screen: today, sleep, activity, the live workout, recovery, stress, trends, journal, coach, records, notifications, the shareable recap, profile, onboarding |
| `lib/widget/`, `lib/live/` | the home-screen widget and the iOS Live Activity bridges |
| `ios/OpenStrapWidget/` | the actual widget and Dynamic Island Live Activity (needs the `group.wtf.openstrap` App Group) |
| `ios/OpenStrapWidget/` | the actual widget and Dynamic Island Live Activity (needs an App Group you configure for your Apple team) |

## Running it

Expand All @@ -152,8 +152,12 @@ at a time.
The CI workflow builds a release APK and pulls the backend URL from a repo
secret.

On iOS, the widget and Live Activity need the App Group set up, `NSSupportsLiveActivities`
turned on, and the background task id registered.
On iOS, the widget and Live Activity need signing configured for your Apple team
and a matching App Group. See `guides/IOS_INSTALLATION.md`; the repo ships with
placeholder bundle IDs and App Group values plus a gitignored local signing
override, so it is not tied to one developer account.
Use Profile or Release builds for normal iPhone home-screen relaunch testing;
Flutter Debug builds must be launched through Flutter tooling or Xcode.

## Your backend, or mine

Expand All @@ -166,6 +170,8 @@ with your health data except make the decoders and the analytics better over tim
selling it and I'm not interested in it. But you don't have to trust me on that, that's
what the self-host option is for.

## Already Deployed Backend URL : https://openstrap-backend-v2.abdulsaheel81.workers.dev

## The stack, briefly

`flutter_blue_plus` for Bluetooth, `sqflite` for the local store, `http` and `provider`
Expand All @@ -174,4 +180,4 @@ and `shared_preferences` for the plumbing, `workmanager` for the background sync
`share_plus` for the look of it.


# Please raise Fixes, Lets make it better together
# Please raise Fixes, Lets make it better together
7 changes: 7 additions & 0 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ android {
ndkVersion = flutter.ndkVersion

compileOptions {
// Required by ota_update 7.x (desugars java.time/java.nio APIs on older API levels).
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
Expand Down Expand Up @@ -73,3 +75,8 @@ android {
flutter {
source = "../.."
}

dependencies {
// Backs isCoreLibraryDesugaringEnabled (required by ota_update 7.x).
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
}
59 changes: 56 additions & 3 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- BLE: Android 12+ uses BLUETOOTH_SCAN/CONNECT; older needs location for scan. -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
Expand All @@ -9,12 +10,24 @@
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Edge Tracking foreground service: keeps the strap connection alive in the background. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Band-gesture "ring my phone" haptic. Normal permission, no runtime prompt. -->
<uses-permission android:name="android.permission.VIBRATE" />
<!-- OTA self-update: download + launch the system installer for the new APK. -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- Notification relay: let the user pick from their installed apps. Android 11+
hides the package list without this; OpenStrap ships as a sideloaded APK (not
Play-distributed), so broad visibility is acceptable here. -->
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />

<application
android:label="Edge"
android:name="${applicationName}"
android:name=".EdgeApplication"
android:icon="@mipmap/launcher_icon">
<activity
android:name=".MainActivity"
Expand All @@ -38,6 +51,46 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Edge Tracking foreground service (background BLE drain). -->
<service
android:name=".EdgeTrackingService"
android:exported="false"
android:foregroundServiceType="connectedDevice" />

<!-- Notification relay (notification_listener_service plugin). The system binds
this to deliver posted notifications; gated behind the user granting
"Notification access" in system settings. Class lives in the plugin. -->
<service
android:name="notification.listener.service.NotificationListener"
android:label="OpenStrap notification relay"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
android:exported="true">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>

<!-- OTA self-update (ota_update 7.x). The plugin's LIBRARY manifest only
declares permissions — the host app MUST declare this FileProvider +
receiver itself, or the install handoff throws "Couldn't find meta-data
for provider with authority ...ota_update_provider" and crashes at 100%. -->
<provider
android:name="sk.fourq.otaupdate.OtaUpdateFileProvider"
android:authorities="${applicationId}.ota_update_provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/filepaths" />
</provider>
<receiver
android:name="sk.fourq.otaupdate.InstallResultReceiver"
android:exported="false">
<intent-filter>
<action android:name="${applicationId}.ACTION_INSTALL_COMPLETE" />
</intent-filter>
</receiver>

<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package wtf.openstrap.openstrap_edge

import android.app.Application
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.FlutterEngineCache
import io.flutter.embedding.engine.dart.DartExecutor

/**
* Pre-warms a single long-lived [FlutterEngine] and caches it. MainActivity attaches
* to THIS engine (via getCachedEngineId) instead of creating its own, and does NOT
* destroy it when the Activity is finished (shouldDestroyEngineWithHost = false).
*
* Why: when the user swipes the app from recents, Android destroys the Activity and,
* with a default Activity-owned engine, tears the engine down too (onDetachedFromEngine
* in logcat). That kills the Dart VM — and with it flutter_blue_plus's BLE connection
* AND the notification-relay stream (the native listener keeps firing but "FlutterJNI
* detached … could not send"). By retaining the engine here and keeping the process
* alive with the EdgeTracking foreground service, the Dart side keeps running headless
* after task removal, so the relay can still buzz the band.
*
* The trade-off is RAM: the app stays warm in memory. That's the intended cost of a
* persistent foreground BLE companion, and matches the existing foreground-service model.
*/
class EdgeApplication : Application() {
companion object {
const val ENGINE_ID = "openstrap_main_engine"
}

override fun onCreate() {
super.onCreate()
// Constructor auto-registers plugins (GeneratedPluginRegistrant) → flutter_blue_plus,
// notification_listener_service, shared_preferences, etc. are all available headless.
val engine = FlutterEngine(this)
// Register platform channels on the engine BEFORE Dart starts, so they exist even
// when no Activity is attached (headless calls like EdgeTracking.start must work).
NativeChannels.register(engine, applicationContext)
engine.dartExecutor.executeDartEntrypoint(
DartExecutor.DartEntrypoint.createDefault()
)
FlutterEngineCache.getInstance().put(ENGINE_ID, engine)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package wtf.openstrap.openstrap_edge

import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat

/**
* Foreground service that keeps the app process alive while backgrounded so the live
* flutter_blue_plus connection keeps draining the strap. Silent, low-priority, ongoing
* notification titled "Edge Tracking". Not sticky — like NOOP, it doesn't resurrect
* after an OS kill; the app re-starts it when reopened.
*/
class EdgeTrackingService : Service() {
companion object {
private const val CHANNEL_ID = "edge_tracking"
private const val NOTIF_ID = 4201
}

override fun onCreate() {
super.onCreate()
createChannel()
}

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val notif = buildNotification()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE)
} else {
startForeground(NOTIF_ID, notif)
}
return START_NOT_STICKY
}

override fun onBind(intent: Intent?): IBinder? = null

private fun createChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val ch = NotificationChannel(
CHANNEL_ID,
"Edge Tracking",
NotificationManager.IMPORTANCE_LOW,
)
ch.description = "Keeps your strap syncing in the background"
ch.setShowBadge(false)
(getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
.createNotificationChannel(ch)
}
}

private fun buildNotification(): Notification {
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Edge Tracking")
.setContentText("Keeping your strap in sync")
.setSmallIcon(android.R.drawable.stat_sys_data_bluetooth)
.setOngoing(true)
.setSilent(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,17 @@ package wtf.openstrap.openstrap_edge

import io.flutter.embedding.android.FlutterActivity

class MainActivity : FlutterActivity()
/**
* Attaches to the long-lived engine pre-warmed in [EdgeApplication] rather than spinning
* up its own, and refuses to destroy that engine when the Activity is finished. Combined
* with the EdgeTracking foreground service keeping the process alive, this lets the Dart
* side (BLE connection + notification relay) keep running after the app is swiped from
* recents — instead of Android tearing the engine down (onDetachedFromEngine).
*
* Platform channels are registered on the engine in EdgeApplication (NativeChannels), not
* here, so they exist even while no Activity is attached (headless channel calls work).
*/
class MainActivity : FlutterActivity() {
override fun getCachedEngineId(): String = EdgeApplication.ENGINE_ID
override fun shouldDestroyEngineWithHost(): Boolean = false
}
Loading