Skip to content

Commit 5e73906

Browse files
author
Lalit Sharma
committed
feat(wear): implement live/preview companion sync with rotary scrub and release docs
- add watch live-location to phone live-render pipeline with safe sun-only fallback - add preview route-gated payload publishing and two-way phone/watch scrub sync - add watch native eclipse renderer with live/preview mode switching and stale fallback handling - improve wearable Data Layer reliability with node caching, listener service fallback, and sendMessage bridge API - extend shared wearable preview payload contracts for travel vectors/contact anchors - add regression tests for wear live compute, preview payloads, scrub payload parsing, and totality glow blending - update workflows and docs for wear package/activity wiring and Windows disposable emulator workflow - bump mobile version to 1.1.26 and update changelog
1 parent db61a7d commit 5e73906

36 files changed

Lines changed: 3562 additions & 151 deletions

.github/workflows/eas-build.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -729,7 +729,7 @@ jobs:
729729
# uses: r0adkll/upload-google-play@v1
730730
# with:
731731
# serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
732-
# packageName: com.lallimaven.eclipsetimer.wear
732+
# packageName: com.lallimaven.eclipsetimer
733733
# releaseFiles: artifacts/submission/${{ steps.artifact_names.outputs.wear_aab }}
734734
# track: internal
735735
# whatsNewDirectory: ${{ steps.store_notes.outputs.play_whatsnew_dir }}
@@ -739,7 +739,7 @@ jobs:
739739
# uses: r0adkll/upload-google-play@v1
740740
# with:
741741
# serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
742-
# packageName: com.lallimaven.eclipsetimer.wear
742+
# packageName: com.lallimaven.eclipsetimer
743743
# releaseFiles: artifacts/submission/${{ steps.artifact_names.outputs.wear_aab }}
744744
# track: internal
745745

.github/workflows/wearos-emulator-screenshots.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@ jobs:
6262
permissions:
6363
contents: write
6464
env:
65-
BUNDLE_ID: com.lallimaven.eclipsetimer.wear
65+
BUNDLE_ID: com.lallimaven.eclipsetimer
66+
MAIN_ACTIVITY: com.lallimaven.eclipsetimer.wear.MainActivity
6667
SENTRY_DISABLE_AUTO_UPLOAD: "true"
6768
SENTRY_ALLOW_FAILURE: "true"
6869
SENTRY_CLI_EXECUTABLE: "/usr/bin/true"
@@ -441,7 +442,7 @@ jobs:
441442
mkdir -p "$shots_dir"
442443
printf "index,url,file\n" > "$shots_dir/manifest.csv"
443444
444-
adb -s "$emulator_serial" shell am start -W -n "$BUNDLE_ID/.MainActivity" >/dev/null 2>&1 || true
445+
adb -s "$emulator_serial" shell am start -W -n "$BUNDLE_ID/$MAIN_ACTIVITY" >/dev/null 2>&1 || true
445446
if ! wait_for_app_foreground "$BUNDLE_ID"; then
446447
echo "::warning::App did not enter the foreground after initial launch."
447448
fi

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.1.26] — 2026-02-24
9+
10+
### Added
11+
- Added end-to-end Wear companion live pipeline: watch GPS payload publishing, phone-side active-eclipse computation, and live render payload sync with sun-only fallback when no active eclipse is found.
12+
- Added Wear preview mode synchronization with strict phone `Preview` route gating, preview payload publishing, and two-way preview scrub messaging between phone and watch.
13+
- Added watch native eclipse renderer (`EclipseRenderView`) with sun/moon drawing and totality ring/corona transitions, plus stale-live fallback handling and status messaging.
14+
- Added a Windows disposable phone+Wear emulator workflow guide for path-length-safe local native testing (`documents/guides/windows-disposable-phone-wear-emulator.md`).
15+
16+
### Changed
17+
- Extended wearable shared preview payload schema with travel vector and contact progress anchors so phone and watch preview frames stay aligned while scrubbing.
18+
- Improved Android Data Layer reliability with node-id caching/retry send flow, a listener service fallback handshake, and JS bridge `sendMessage` support.
19+
- Updated Wear package/config wiring to use `com.lallimaven.eclipsetimer` package naming in watch build/docs/workflows and to require watch location permissions.
20+
- Updated phone preview visuals to blend totality ring/corona effects and synchronize scrub progress with watch input.
21+
- Excluded Android build output folders from Metro resolution to avoid duplicate module/path issues.
22+
- Bumped `apps/mobile` version to `1.1.26`.
23+
24+
### Tests
25+
- Added regression tests for wear live payload computation, preview payload generation, preview scrub payload parsing, and totality glow blending behavior.
26+
827
## [1.1.25] — 2026-02-24
928

1029
### Added

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,7 @@ The Wear OS companion app is a separate native module (`apps/mobile/android/wear
341341

342342
Use the local simulator build/install/run guide in:
343343
- [documents/guides/setup-and-development.md#wear-os-companion-on-local-emulator](documents/guides/setup-and-development.md#wear-os-companion-on-local-emulator)
344+
- [documents/guides/windows-disposable-phone-wear-emulator.md](documents/guides/windows-disposable-phone-wear-emulator.md) for a disposable Windows workflow (`C:\e`) that runs phone and watch emulators together and deletes the copy afterward.
344345

345346
## Running Tests and Quality Checks
346347

apps/mobile/android/app/src/main/AndroidManifest.xml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@
3232
<data android:scheme="eclipsetimer"/>
3333
</intent-filter>
3434
</activity>
35+
<service android:name=".wearable.WearDataLayerListenerService" android:exported="true">
36+
<intent-filter>
37+
<action android:name="com.google.android.gms.wearable.BIND_LISTENER"/>
38+
</intent-filter>
39+
</service>
3540
<uses-library android:name="org.apache.http.legacy" android:required="false"/>
3641
</application>
37-
</manifest>
42+
</manifest>

apps/mobile/android/app/src/main/java/com/lallimaven/eclipsetimer/wearable/WearDataLayerBridge.kt

Lines changed: 107 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import android.content.Context
44
import android.util.Log
55
import com.google.android.gms.wearable.MessageClient
66
import com.google.android.gms.wearable.MessageEvent
7+
import com.google.android.gms.wearable.NodeClient
78
import com.google.android.gms.wearable.Wearable
89

910
object WearDataLayerBridge : MessageClient.OnMessageReceivedListener {
@@ -25,6 +26,10 @@ object WearDataLayerBridge : MessageClient.OnMessageReceivedListener {
2526

2627
private lateinit var appContext: Context
2728
private lateinit var messageClient: MessageClient
29+
private lateinit var nodeClient: NodeClient
30+
31+
@Volatile
32+
private var cachedWatchNodeId: String? = null
2833

2934
@Synchronized
3035
fun initialize(context: Context) {
@@ -34,6 +39,7 @@ object WearDataLayerBridge : MessageClient.OnMessageReceivedListener {
3439

3540
appContext = context.applicationContext
3641
messageClient = Wearable.getMessageClient(appContext)
42+
nodeClient = Wearable.getNodeClient(appContext)
3743
isInitialized = true
3844
startListening()
3945
Log.i(TAG, "Wear Data Layer bridge initialized.")
@@ -59,6 +65,8 @@ object WearDataLayerBridge : MessageClient.OnMessageReceivedListener {
5965
isListening = false
6066
}
6167

68+
fun isListeningInProcess(): Boolean = isInitialized && isListening
69+
6270
fun setIncomingMessageListener(listener: IncomingMessageListener?) {
6371
incomingMessageListener = listener
6472
}
@@ -74,28 +82,116 @@ object WearDataLayerBridge : MessageClient.OnMessageReceivedListener {
7482
return
7583
}
7684

77-
Wearable.getNodeClient(appContext).connectedNodes
85+
val knownWatchNodeId = cachedWatchNodeId
86+
if (!knownWatchNodeId.isNullOrBlank()) {
87+
if (shouldLogPayloadActivity(path)) {
88+
Log.i(
89+
TAG,
90+
"event=payload_send_attempt path=$path nodeId=$knownWatchNodeId strategy=cached_node_id",
91+
)
92+
}
93+
sendMessageToNode(
94+
nodeId = knownWatchNodeId,
95+
path = path,
96+
payload = payload,
97+
onSuccess = {
98+
if (shouldLogPayloadActivity(path)) {
99+
Log.i(TAG, "event=payload_send_success path=$path nodeId=$knownWatchNodeId")
100+
}
101+
onSuccess()
102+
},
103+
onFailure = {
104+
Log.w(
105+
TAG,
106+
"event=payload_send_failed path=$path nodeId=$knownWatchNodeId strategy=cached_node_id",
107+
it,
108+
)
109+
cachedWatchNodeId = null
110+
resolveNodeAndSend(path, payload, onSuccess, onError)
111+
},
112+
)
113+
return
114+
}
115+
116+
resolveNodeAndSend(path, payload, onSuccess, onError)
117+
}
118+
119+
private fun resolveNodeAndSend(
120+
path: String,
121+
payload: ByteArray,
122+
onSuccess: () -> Unit,
123+
onError: (String) -> Unit,
124+
) {
125+
nodeClient.connectedNodes
78126
.addOnSuccessListener { nodes ->
79127
val targetNode = nodes.firstOrNull()
80128
if (targetNode == null) {
129+
cachedWatchNodeId = null
130+
Log.w(TAG, "event=connectivity_no_watch_node path=$path")
81131
onError("No connected Wear OS nodes.")
82132
return@addOnSuccessListener
83133
}
84134

85-
messageClient.sendMessage(targetNode.id, path, payload)
86-
.addOnSuccessListener { onSuccess() }
87-
.addOnFailureListener { error ->
135+
cachedWatchNodeId = targetNode.id
136+
Log.i(TAG, "event=connectivity_node_resolved path=$path nodeId=${targetNode.id}")
137+
if (shouldLogPayloadActivity(path)) {
138+
Log.i(
139+
TAG,
140+
"event=payload_send_attempt path=$path nodeId=${targetNode.id} strategy=resolved_node",
141+
)
142+
}
143+
sendMessageToNode(
144+
nodeId = targetNode.id,
145+
path = path,
146+
payload = payload,
147+
onSuccess = {
148+
if (shouldLogPayloadActivity(path)) {
149+
Log.i(TAG, "event=payload_send_success path=$path nodeId=${targetNode.id}")
150+
}
151+
onSuccess()
152+
},
153+
onFailure = { error ->
154+
cachedWatchNodeId = null
155+
Log.w(TAG, "event=payload_send_failed path=$path nodeId=${targetNode.id}", error)
88156
onError(error.message ?: "Failed to send Data Layer message.")
89-
}
157+
},
158+
)
90159
}
91160
.addOnFailureListener { error ->
161+
cachedWatchNodeId = null
162+
Log.w(TAG, "event=connectivity_node_resolve_failed path=$path", error)
92163
onError(error.message ?: "Failed to query connected Wear OS nodes.")
93164
}
94165
}
95166

167+
private fun shouldLogPayloadActivity(path: String): Boolean = path != WearPaths.PREVIEW_SCRUB
168+
169+
private fun sendMessageToNode(
170+
nodeId: String,
171+
path: String,
172+
payload: ByteArray,
173+
onSuccess: () -> Unit,
174+
onFailure: (Exception) -> Unit,
175+
) {
176+
messageClient.sendMessage(nodeId, path, payload)
177+
.addOnSuccessListener { onSuccess() }
178+
.addOnFailureListener { error ->
179+
onFailure(error)
180+
}
181+
}
182+
96183
override fun onMessageReceived(messageEvent: MessageEvent) {
184+
if (messageEvent.sourceNodeId.isNotBlank()) {
185+
cachedWatchNodeId = messageEvent.sourceNodeId
186+
}
187+
97188
val payload = messageEvent.data.toString(Charsets.UTF_8)
98-
Log.d(TAG, "Message received on ${messageEvent.path}: $payload")
189+
if (shouldLogPayloadActivity(messageEvent.path)) {
190+
Log.d(
191+
TAG,
192+
"event=payload_received path=${messageEvent.path} sourceNodeId=${messageEvent.sourceNodeId} payload=$payload",
193+
)
194+
}
99195
incomingMessageListener?.onIncomingMessage(messageEvent.path, payload, messageEvent.sourceNodeId)
100196

101197
if (messageEvent.path == WearPaths.LIVE_LOCATION) {
@@ -104,7 +200,11 @@ object WearDataLayerBridge : MessageClient.OnMessageReceivedListener {
104200
WearPaths.LIVE_RENDER,
105201
ACK_PAYLOAD.toByteArray(Charsets.UTF_8),
106202
).addOnFailureListener { error ->
107-
Log.w(TAG, "Failed to send phase-0 ack to watch.", error)
203+
Log.w(
204+
TAG,
205+
"event=ack_send_failed path=${WearPaths.LIVE_RENDER} sourceNodeId=${messageEvent.sourceNodeId}",
206+
error,
207+
)
108208
}
109209
}
110210
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.lallimaven.eclipsetimer.wearable
2+
3+
import android.util.Log
4+
import com.google.android.gms.wearable.MessageEvent
5+
import com.google.android.gms.wearable.Wearable
6+
import com.google.android.gms.wearable.WearableListenerService
7+
8+
class WearDataLayerListenerService : WearableListenerService() {
9+
override fun onMessageReceived(messageEvent: MessageEvent) {
10+
if (messageEvent.path != WearPaths.LIVE_LOCATION) {
11+
super.onMessageReceived(messageEvent)
12+
return
13+
}
14+
15+
// When the React Native bridge listener is alive, let it own the handshake path.
16+
if (WearDataLayerBridge.isListeningInProcess()) {
17+
return
18+
}
19+
20+
Wearable.getMessageClient(applicationContext).sendMessage(
21+
messageEvent.sourceNodeId,
22+
WearPaths.LIVE_RENDER,
23+
PHASE0_ACK_PAYLOAD.toByteArray(Charsets.UTF_8),
24+
).addOnFailureListener { error ->
25+
Log.w(TAG, "Failed to send phase-0 ack from listener service.", error)
26+
}
27+
}
28+
29+
companion object {
30+
private const val TAG = "WearDataLayerService"
31+
private const val PHASE0_ACK_PAYLOAD = """{"type":"phase0-ack","source":"phone"}"""
32+
}
33+
}

apps/mobile/android/app/src/main/java/com/lallimaven/eclipsetimer/wearable/WearDataLayerModule.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,22 @@ class WearDataLayerModule(private val reactContext: ReactApplicationContext) :
4343
)
4444
}
4545

46+
@ReactMethod
47+
fun sendMessage(path: String, payload: String, promise: Promise) {
48+
val normalizedPath = path.trim()
49+
if (normalizedPath.isEmpty()) {
50+
promise.reject("E_WEAR_SEND_PATH", "Data Layer path cannot be empty.")
51+
return
52+
}
53+
54+
WearDataLayerBridge.sendMessageToWatch(
55+
path = normalizedPath,
56+
payload = payload.toByteArray(Charsets.UTF_8),
57+
onSuccess = { promise.resolve(true) },
58+
onError = { error -> promise.reject("E_WEAR_SEND", error) },
59+
)
60+
}
61+
4662
override fun onIncomingMessage(path: String, payload: String, sourceNodeId: String) {
4763
if (!reactContext.hasActiveReactInstance()) {
4864
return

apps/mobile/android/wear/build.gradle

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ android {
8484
compileSdk rootProject.ext.compileSdkVersion
8585

8686
defaultConfig {
87-
applicationId "com.lallimaven.eclipsetimer.wear"
87+
applicationId "com.lallimaven.eclipsetimer"
8888
minSdkVersion 26
8989
targetSdkVersion rootProject.ext.targetSdkVersion
9090
versionCode wearVersionCode
@@ -131,5 +131,6 @@ android {
131131
dependencies {
132132
implementation "androidx.core:core-ktx:1.15.0"
133133
implementation "androidx.activity:activity-ktx:1.10.1"
134+
implementation "com.google.android.gms:play-services-location:21.3.0"
134135
implementation "com.google.android.gms:play-services-wearable:19.0.0"
135136
}

apps/mobile/android/wear/src/main/AndroidManifest.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
22
<uses-feature android:name="android.hardware.type.watch" />
3+
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
4+
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
35
<application
46
android:allowBackup="true"
57
android:icon="@drawable/ic_watch_launcher"

0 commit comments

Comments
 (0)