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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
.fvm
.fvmrc
# Miscellaneous
*.class
*.log
Expand Down
11 changes: 11 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,17 @@
</intent-filter>
</receiver>

<!-- Tasker integration: receive Broadcast intents from Tasker (or any app)
to buzz the WHOOP strap. Optional int extra "pattern" (default 2). -->
<receiver
android:name=".TaskerReceiver"
android:exported="true"
android:enabled="true">
<intent-filter>
<action android:name="wtf.openstrap.openstrap_edge.BUZZ_STRAP" />
</intent-filter>
</receiver>
Comment on lines +149 to +156

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Protect the exported buzz endpoint.

Any installed app can explicitly target this exported receiver with the documented action, repeatedly wake the app/service, and trigger strap haptics. Keep it exported for Tasker, but require a user-configured capability/token and apply a rate limit before persisting or invoking the channel.

  • android/app/src/main/AndroidManifest.xml#L149-L156: document and enforce the chosen external-caller protection model.
  • android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/TaskerReceiver.kt#L12-L50: validate the capability and pattern range before executing or starting the service.
📍 Affects 2 files
  • android/app/src/main/AndroidManifest.xml#L149-L156 (this comment)
  • android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/TaskerReceiver.kt#L12-L50
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@android/app/src/main/AndroidManifest.xml` around lines 149 - 156, Protect the
exported TaskerReceiver buzz endpoint by documenting and enforcing a
user-configured capability/token and rate limit in the AndroidManifest.xml
declaration and TaskerReceiver handler. In TaskerReceiver, validate the
capability and pattern range before persisting data, invoking the channel, or
starting the service; reject unauthorized, invalid, or rate-limited requests
while preserving Tasker accessibility.


<!-- 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. -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ object NativeChannels {
private const val EDGE_TRACKING_CHANNEL = "openstrap/edge_tracking"
private const val DEVICE_ACTIONS_CHANNEL = "openstrap/device_actions"
private const val ANDROID_BG_CHANNEL = "openstrap/android_background"
const val ACTION_DOUBLE_TAP = "wtf.openstrap.openstrap_edge.DOUBLE_TAP"

private var torchOn = false

Expand Down Expand Up @@ -107,7 +108,8 @@ object NativeChannels {
"capabilities" -> result.success(
listOf(
"media_play_pause", "media_next", "media_prev",
"volume_up", "volume_down", "ring_phone", "torch"
"volume_up", "volume_down", "ring_phone", "torch",
"broadcast_to_tasker"
)
)
"perform" -> result.success(perform(app, call.argument<String>("action") ?: ""))
Expand All @@ -126,6 +128,7 @@ object NativeChannels {
"volume_down" -> adjustVolume(ctx, AudioManager.ADJUST_LOWER)
"ring_phone" -> ringPhone(ctx)
"torch" -> toggleTorch(ctx)
"broadcast_to_tasker" -> sendTaskerBroadcast(ctx)
else -> return false
}
true
Expand Down Expand Up @@ -321,4 +324,17 @@ object NativeChannels {
vibrator.vibrate(500)
}
}

/** Send a broadcast intent so Tasker (or any automation app) can subscribe
* to band double-taps. The intent action is
* `wtf.openstrap.openstrap_edge.DOUBLE_TAP`; Tasker listens via
* Event → Intent Received.
*/
private fun sendTaskerBroadcast(ctx: Context) {
val intent = Intent(ACTION_DOUBLE_TAP).apply {
addFlags(Intent.FLAG_INCLUDE_STOPPED_PACKAGES)
}
ctx.sendBroadcast(intent)
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package wtf.openstrap.openstrap_edge

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import io.flutter.embedding.engine.FlutterEngineCache
import io.flutter.plugin.common.MethodChannel

class TaskerReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action
Log.i(TAG, "onReceive: action=$action")
if (action != ACTION_BUZZ_STRAP) return

val pattern = intent.getIntExtra(EXTRA_PATTERN, DEFAULT_PATTERN)
Log.i(TAG, "pattern=$pattern")

val engine = FlutterEngineCache.getInstance()
.get(EdgeApplication.ENGINE_ID)

if (engine != null) {
Log.i(TAG, "engine alive, invoking method channel")
val args = java.util.HashMap<String, Any>()
args["pattern"] = pattern
MethodChannel(
engine.dartExecutor.binaryMessenger,
CHANNEL
).invokeMethod("buzz_strap", args)
return
}

Log.i(TAG, "engine dead, persisting pending flag")
val prefs = context.getSharedPreferences(
"openstrap_runtime",
Context.MODE_PRIVATE
)
prefs.edit()
.putBoolean(PENDING_BUZZ_KEY, true)
.putInt(PENDING_PATTERN_KEY, pattern)
.apply()
Comment on lines +35 to +42

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

For shared_preferences version 2.3.3 on Android, does SharedPreferences.getInstance() read an arbitrary native preference file named "openstrap_runtime", or only the plugin-managed default store? Include the key-prefix behavior.

💡 Result:

For the shared_preferences plugin (version 2.3.3), the SharedPreferences.getInstance method interacts with a default, plugin-managed SharedPreferences file on Android, not an arbitrary file named "openstrap_runtime" [1][2][3]. By default, the plugin uses a specific SharedPreferences file internally [4]. It does not automatically target or read from other arbitrary native preference files like "openstrap_runtime". Regarding key-prefix behavior: 1. Default Prefix: By default, the plugin only reads and writes preferences that begin with the prefix "flutter." [5][3]. This filtering is handled automatically by the plugin [5][3]. 2. Custom Prefixes: You can change this behavior by calling setPrefix before instantiating any SharedPreferences object [5][3]. If you set the prefix to an empty string (""), the plugin will read all keys within that SharedPreferences file, which is often used for migrating existing native Android preferences to Flutter [5][3]. 3. Interaction with Async APIs: While newer APIs like SharedPreferencesAsync (available in later versions) allow specifying custom file names via SharedPreferencesAsyncAndroidOptions (e.g., using AndroidSharedPreferencesStoreOptions(fileName: '...')), the standard SharedPreferences.getInstance API in version 2.3.3 remains tied to the plugin's default storage configuration [6][1][2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant files first
ast-grep outline android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/TaskerReceiver.kt --view expanded || true
ast-grep outline lib/platform/tasker_bridge.dart --view expanded || true

# Read the relevant slices with line numbers
echo '--- TaskerReceiver.kt ---'
cat -n android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/TaskerReceiver.kt | sed -n '1,220p'

echo '--- tasker_bridge.dart ---'
cat -n lib/platform/tasker_bridge.dart | sed -n '1,220p'

Repository: OpenStrap/edge

Length of output: 4691


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate any shared_preferences usage and any native bridge for pending buzz
rg -n "SharedPreferences\.getInstance|openstrap_runtime|PENDING_BUZZ_KEY|PENDING_PATTERN_KEY|consumePendingBuzz|setPrefix|flutter\." android lib

Repository: OpenStrap/edge

Length of output: 8442


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find where shared_preferences plugin behavior is configured, if at all
rg -n "SharedPreferences\.setPrefix|SharedPreferencesAsync|AndroidSharedPreferencesStoreOptions|SharedPreferencesWithCache|shared_preferences" lib android

Repository: OpenStrap/edge

Length of output: 2622


Use the same store for deferred Tasker requests. SharedPreferences.getInstance() reads the plugin-managed FlutterSharedPreferences store (flutter. keys), so the openstrap_runtime entries written here are invisible to consumePendingBuzz().

  • android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/TaskerReceiver.kt#L35-L42: write to the same store/key scheme, or expose a native consume-and-clear bridge for openstrap_runtime.
  • lib/platform/tasker_bridge.dart#L31-L38: call that bridge instead of the Flutter preferences plugin.
📍 Affects 2 files
  • android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/TaskerReceiver.kt#L35-L42 (this comment)
  • lib/platform/tasker_bridge.dart#L31-L38
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/TaskerReceiver.kt`
around lines 35 - 42, The deferred Tasker request producer and consumer use
different preference stores. In TaskerReceiver.kt lines 35-42, align writes with
the store and key scheme consumed by consumePendingBuzz(), or implement a native
consume-and-clear bridge for openstrap_runtime; in
lib/platform/tasker_bridge.dart lines 31-38, replace the Flutter preferences
read with that matching native bridge or shared store access. Ensure deferred
requests are consumed and cleared consistently.


val svcIntent = Intent(context, EdgeTrackingService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(svcIntent)
} else {
context.startService(svcIntent)
}
}

companion object {
const val TAG = "TaskerReceiver"
const val ACTION_BUZZ_STRAP =
"wtf.openstrap.openstrap_edge.BUZZ_STRAP"
const val EXTRA_PATTERN = "pattern"
const val PENDING_BUZZ_KEY = "pending_tasker_buzz"
const val PENDING_PATTERN_KEY = "pending_tasker_buzz_pattern"
const val DEFAULT_PATTERN = 2
private const val CHANNEL = "openstrap/tasker"
}
}
8 changes: 8 additions & 0 deletions guides/BUZZ_MEANINGS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
* `0`: A quick buzz of the device
* `1`: An alarm, buzzes until acked by tapping the device
* `2`: A quick double buzz
* `3`: Nothing
* `4`: Nothing
* `5`: Nothing
* `6`: Nothing
* `7`: Nothing
6 changes: 4 additions & 2 deletions lib/ble/ble_engine.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2508,8 +2508,10 @@ class BleEngine {

Future<void> getBattery() => _send(Cmd.getBatteryLevel, const []);
Future<void> getHello() => _send(Cmd.getHelloHarvard, const [0x00]);
Future<void> buzz() =>
_send(Cmd.runHapticsPattern, const [hapticShortPulse, 0, 0, 0, 0]);
Future<void> buzz() => buzzPattern(hapticShortPulse);

Future<void> buzzPattern(int pattern) =>
_send(Cmd.runHapticsPattern, [pattern, 0, 0, 0, 0]);

/// Enable live foreground streams (makes the band emit live R10/R11 + optical).
/// Optical stays WRIST-GATED (0x6B only). This sends the toggle commands but
Expand Down
9 changes: 9 additions & 0 deletions lib/gestures/device_action.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ enum DeviceAction {
// (iOS can't reach other apps, but it can always do these).
markMoment,
workoutToggle,
// Native broadcast — sends an Android broadcast intent for Tasker to subscribe
// to (see NativeChannels.kt). Only offered on Android.
broadcastToTasker,
}

extension DeviceActionX on DeviceAction {
Expand Down Expand Up @@ -51,6 +54,8 @@ extension DeviceActionX on DeviceAction {
return 'mark_moment';
case DeviceAction.workoutToggle:
return 'workout_toggle';
case DeviceAction.broadcastToTasker:
return 'broadcast_to_tasker';
}
}

Expand All @@ -77,6 +82,8 @@ extension DeviceActionX on DeviceAction {
return 'Mark a moment';
case DeviceAction.workoutToggle:
return 'Start / stop workout';
case DeviceAction.broadcastToTasker:
return 'Broadcast to Tasker';
}
}

Expand All @@ -103,6 +110,8 @@ extension DeviceActionX on DeviceAction {
return 'Tag the current moment in your journal.';
case DeviceAction.workoutToggle:
return 'Begin or end a workout from your wrist.';
case DeviceAction.broadcastToTasker:
return 'Fire a broadcast intent so Tasker can trigger any automation.';
}
}

Expand Down
40 changes: 40 additions & 0 deletions lib/platform/tasker_bridge.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter/services.dart';

class TaskerBridge {
static const _ch = MethodChannel('openstrap/tasker');
static const _pendingKey = 'pending_tasker_buzz';
static const _pendingPatternKey = 'pending_tasker_buzz_pattern';

final Future<void> Function(int pattern) buzzPattern;

TaskerBridge({required this.buzzPattern}) {
_ch.setMethodCallHandler(_onMethodCall);
}

Future<void> _onMethodCall(MethodCall call) async {
if (call.method == 'buzz_strap') {
try {
final args = call.arguments;
debugPrint('[tasker] _onMethodCall args=$args (${args.runtimeType})');
final map = args is Map ? args : <dynamic, dynamic>{};
final pattern = (map['pattern'] as int?) ?? 2;
debugPrint('[tasker] buzz pattern=$pattern');
await buzzPattern(pattern);
} catch (e, st) {
debugPrint('[tasker] buzz failed: $e\n$st');
}
}
}

static Future<int?> consumePendingBuzz() async {
final prefs = await SharedPreferences.getInstance();
final pending = prefs.getBool(_pendingKey) ?? false;
if (!pending) return null;
final pattern = prefs.getInt(_pendingPatternKey) ?? 2;
await prefs.remove(_pendingKey);
await prefs.remove(_pendingPatternKey);
return pattern;
Comment on lines +31 to +38

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not acknowledge a pending buzz before BLE is ready.

_init() starts openSession() without awaiting it, then immediately consumes and deletes the pending request. engine.buzzPattern() can therefore run before a connection exists, where its write is skipped and the request is lost.

  • lib/platform/tasker_bridge.dart#L31-L38: split read from acknowledgement; do not clear the request during retrieval.
  • lib/state/app_state.dart#L1283-L1291: deliver only after the engine is connected, then acknowledge/clear after a successful send.
📍 Affects 2 files
  • lib/platform/tasker_bridge.dart#L31-L38 (this comment)
  • lib/state/app_state.dart#L1283-L1291
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/platform/tasker_bridge.dart` around lines 31 - 38, In
lib/platform/tasker_bridge.dart lines 31-38, update consumePendingBuzz to
retrieve the pending pattern without removing either preference, and provide a
separate acknowledgement/clear operation. In lib/state/app_state.dart lines
1283-1291, defer delivery until the BLE engine is connected, invoke
engine.buzzPattern(), and acknowledge the pending request only after the send
succeeds.

}
}
22 changes: 22 additions & 0 deletions lib/state/app_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import '../health/health_export.dart';
import '../import/noop_import.dart';
import '../import/whoop_import.dart';
import '../gestures/gesture_dispatcher.dart';
import '../platform/tasker_bridge.dart';
import '../data/models.dart';
import '../live/live_activity.dart';
import '../live/breathing_live_activity.dart';
Expand Down Expand Up @@ -125,6 +126,12 @@ class AppState extends ChangeNotifier {
buzz: () => engine.buzz(),
isConnected: () => engine.isConnected,
);

/// Tasker integration bridge — listens for Android broadcast intents from
/// Tasker and buzzes the strap. Wired in the constructor.
late final TaskerBridge taskerBridge = TaskerBridge(
buzzPattern: (p) => engine.buzzPattern(p),
);
Sample? lastSynced;
// REAL device time (epoch SECONDS) of the newest record we hold — the band's
// own clock, NOT when the BLE frame arrived. During a flash backfill, frames
Expand Down Expand Up @@ -650,6 +657,7 @@ class AppState extends ChangeNotifier {
// skip the headless BLE path (it would fight FBP for the peripheral) — route
// them to a catch-up pull over the existing live connection instead.
IosBgTask.foregroundPull = foregroundCatchUp;
taskerBridge; // force init: register the method channel handler
_init();
// Notification taps → request a tab switch (the shell listens to navRequest).
_tapSub = NotificationService.instance.taps.listen(_handleTapRoute);
Expand Down Expand Up @@ -1272,6 +1280,15 @@ class AppState extends ChangeNotifier {
openSession();
}
}
unawaited(_checkPendingTaskerBuzz());
}

Future<void> _checkPendingTaskerBuzz() async {
if (!Platform.isAndroid) return;
final pattern = await TaskerBridge.consumePendingBuzz();
if (pattern == null) return;
_log('[tasker] consuming pending buzz (pattern=$pattern) from headless intent');
await engine.buzzPattern(pattern);
}

/// (Re)register standing scheduled reminders per the user's prefs. Idempotent;
Expand Down Expand Up @@ -2022,6 +2039,11 @@ class AppState extends ChangeNotifier {
await engine.runAlarm();
}

Future<void> testBuzzPattern(int pattern) async {
if (!isConnected) throw Exception('Connect to your strap first');
await engine.buzzPattern(pattern);
}

Future<void> disableAlarm() async {
if (!isConnected) throw Exception('Connect to your strap first');
await engine.disableAlarm();
Expand Down
47 changes: 28 additions & 19 deletions lib/ui/profile/gesture_section.dart
Original file line number Diff line number Diff line change
Expand Up @@ -59,25 +59,34 @@ class GestureSettingsCard extends StatelessWidget {
Text('What happens when you double-tap the band.',
style: AppText.captionMuted),
const SizedBox(height: Sp.x4),
...options.map((a) {
final selected = a == settings.doubleTap;
return ListRow(
title: a.label,
subtitle: a.blurb,
trailing: selected
? AppIcon(OsIcon.check, size: 20, color: AppColors.positive)
: const SizedBox(width: 20),
onTap: () {
settings.setDoubleTap(a);
Navigator.of(sheetCtx).pop();
},
);
}),
if (options.length <= 1) ...[
const SizedBox(height: Sp.x3),
Text('No band actions are available on this device yet.',
style: AppText.caption),
],
Flexible(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
...options.map((a) {
final selected = a == settings.doubleTap;
return ListRow(
title: a.label,
subtitle: a.blurb,
trailing: selected
? AppIcon(OsIcon.check, size: 20, color: AppColors.positive)
: const SizedBox(width: 20),
onTap: () {
settings.setDoubleTap(a);
Navigator.of(sheetCtx).pop();
},
);
}),
if (options.length <= 1) ...[
const SizedBox(height: Sp.x3),
Text('No band actions are available on this device yet.',
style: AppText.caption),
],
],
),
),
),
],
),
),
Expand Down
Loading