Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
3694df7
Show new logs at bottom
brill594 May 19, 2026
93927be
Revert "Show new logs at bottom"
brill594 May 19, 2026
63395b5
Merge branch 'BenderBlog:main' into main
brill594 Aug 3, 2026
5243e2e
feat: add direct Wear companion sync
brill594 Aug 3, 2026
e792cc6
fix: finish Wear pairing progress state
brill594 Aug 4, 2026
c81571b
fix(wear): persist pairing and sync payment cache
brill594 Aug 4, 2026
e5705b6
feat(wear): proxy payment authentication through phone
brill594 Aug 4, 2026
2416c39
Squashed 'wearos/' content from commit 081019cb
brill594 Aug 4, 2026
f3662f3
feat: integrate Wear OS companion app
brill594 Aug 4, 2026
349e670
build: wire Wear OS into monorepo
brill594 Aug 4, 2026
d895501
refactor(wear): remove unused standalone data fetchers
brill594 Aug 4, 2026
f60ffce
refactor(wear): migrate client to native compose
brill594 Aug 4, 2026
c4fdf48
fix(wear): confirm pairing after sync import
brill594 Aug 5, 2026
9aee38b
fix(wear): refine payment qr controls
brill594 Aug 5, 2026
e76494e
feat(wear): prioritize on-watch payment auth
brill594 Aug 5, 2026
845b011
fix(wear): stabilize payment auth lifecycle
brill594 Aug 5, 2026
a89a456
perf(wear): open payment qr from cache
brill594 Aug 5, 2026
742f013
fix(wear): limit payment flow battery usage
brill594 Aug 5, 2026
bcaaa3d
feat(wear): integrate companion sync, agenda tiles, and phone payment…
brill594 Oct 11, 2026
88a68cd
feat(wear): update native companion integration
brill594 Oct 11, 2026
0caaa97
chore(wear): streamline companion build checks
brill594 Oct 11, 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
40 changes: 40 additions & 0 deletions .github/workflows/check_wearos.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: Check Wear OS

on:
pull_request:
paths:
- "wearos/**"
- "lib/page/setting/wear_companion_sync_page.dart"
- "lib/repository/wear_companion_sync.dart"
- "android/app/src/main/kotlin/io/github/benderblog/traintime_pda/MainActivity.kt"
- "android/app/src/main/kotlin/io/github/benderblog/traintime_pda/WearCompanionTransport.kt"
- ".github/workflows/check_wearos.yaml"
push:
branches:
- main
paths:
- "wearos/**"
- "lib/page/setting/wear_companion_sync_page.dart"
- "lib/repository/wear_companion_sync.dart"
- "android/app/src/main/kotlin/io/github/benderblog/traintime_pda/MainActivity.kt"
- "android/app/src/main/kotlin/io/github/benderblog/traintime_pda/WearCompanionTransport.kt"
- ".github/workflows/check_wearos.yaml"

jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive

- name: Set up Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17

- name: Build native Wear OS app
working-directory: wearos/android
run: ./gradlew assembleDebug
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ XDYou,代码名称为 Traintime PDA,是为西电学生设计的开源信息
13. 上课前提醒。
14. 完备的国际化支持:支持繁体中文和英语。
15. 宿舍水机支持。
16. 提供原生 Compose Wear OS 配套应用,可同步课程与付款码,并在断开手机时使用缓存数据。

## 其他特性

Expand Down Expand Up @@ -64,6 +65,23 @@ Engine • hash 59d54a2b2896a6bbf356c94b7fac7b9e235bdacd (revision
Tools • Dart 3.13.0 • DevTools 2.60.0
```

### 仓库结构与 Wear OS 构建

主应用位于仓库根目录,原生 Kotlin + Compose for Wear OS 应用位于 [`wearos/android/`](./wearos/android)。两端通信协议需要同步演进,因此 Wear OS 源码直接维护在同一仓库中,不使用额外 submodule。

主应用仍使用仓库内 Flutter SDK,首次拉取后需要初始化子模块:

```bash
git submodule update --init --recursive
```

构建 Wear OS 应用:

```bash
cd wearos/android
./gradlew assembleRelease
```

## 授权信息

本程序源代码按照 MPLv2 授权,部分文件有 MIT / Apache-2.0 授权。
Expand Down
3 changes: 2 additions & 1 deletion android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ dependencies {
implementation 'androidx.glance:glance-preview:1.1.1'
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0'
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-core:1.9.0'
implementation 'com.google.android.gms:play-services-wearable:19.0.0'
}

ext.abiCodes = ["x86_64": 1, "armeabi-v7a": 2, "arm64-v8a": 3]
Expand All @@ -140,4 +141,4 @@ android.applicationVariants.all { variant ->
output.versionCodeOverride = variant.versionCode * 10 + abiVersionCode
}
}
}
}
12 changes: 12 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@
android:networkSecurityConfig="@xml/network_security_config"
tools:replace="android:label">

<service
android:name=".WearCompanionListenerService"
android:exported="true">
<intent-filter>
<action android:name="com.google.android.gms.wearable.MESSAGE_RECEIVED" />
<data
android:host="*"
android:pathPrefix="/traintime_pda_wear_os/request"
android:scheme="wear" />
</intent-filter>
</service>

<activity android:name="MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:exported="true"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,204 @@
package io.github.benderblog.traintime_pda

import android.os.Bundle
import android.os.Handler
import android.os.Looper
import androidx.core.view.WindowCompat
import com.google.android.gms.wearable.MessageClient
import com.google.android.gms.wearable.MessageEvent
import com.google.android.gms.wearable.Wearable
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import org.json.JSONObject


class MainActivity : FlutterActivity() {
class MainActivity : FlutterActivity(), MessageClient.OnMessageReceivedListener {
private var companionChannel: MethodChannel? = null
private val mainHandler = Handler(Looper.getMainLooper())
private val pendingSyncResults = mutableMapOf<String, PendingSync>()

private data class PendingSync(
val sessionId: String,
val result: MethodChannel.Result,
val timeout: Runnable,
)

override fun onCreate(savedInstanceState: Bundle?) {
// Enable edge-to-edge display
WindowCompat.enableEdgeToEdge(window)
super.onCreate(savedInstanceState)
WearCompanionTransport.cachedPayload(this)
}

override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
companionChannel = MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
WearCompanionTransport.CHANNEL,
).also { channel ->
channel.setMethodCallHandler { call, result ->
when (call.method) {
"getCachedPaymentQr" -> {
val account = call.argument<String>("idsAccount")
if (account.isNullOrBlank()) {
result.success(null)
} else {
val profileId = WearCompanionTransport.profileIdForAccount(this, account)
val snapshot = WearCompanionTransport.cachedPayload(this)?.let { JSONObject(it) }
val qr = if (snapshot?.optString("profileId") == profileId) snapshot.optJSONObject("paymentQr") else null
result.success(mapOf(
"profileId" to profileId,
"pngBase64" to qr?.optString("pngBase64"),
"fetchedAtEpochMs" to qr?.optLong("fetchedAtEpochMs", -1L),
))
}
}
"getConnectedWearNodes" -> {
Wearable.getNodeClient(this).connectedNodes
.addOnSuccessListener { nodes ->
val pairedNodeId = WearCompanionTransport.pairedWatchNodeId(this)
result.success(nodes.map { node ->
mapOf(
"id" to node.id,
"name" to node.displayName,
"isNearby" to node.isNearby,
"isPaired" to (node.id == pairedNodeId),
)
})
}
.addOnFailureListener { error ->
result.error("nodes_unavailable", error.message, null)
}
}
"sendSyncPayload" -> {
val nodeId = call.argument<String>("nodeId")
val path = call.argument<String>("messagePath")
val payload = call.argument<String>("payload")
if (nodeId.isNullOrBlank() ||
path != WearCompanionTransport.SYNC_PATH ||
payload.isNullOrBlank()
) {
result.error("invalid_arguments", "Invalid Wear sync request", null)
return@setMethodCallHandler
}
val safePayload = WearCompanionTransport.sanitizedPayload(this, payload)
WearCompanionTransport.cachePayload(this, safePayload)
val sessionId = try {
JSONObject(payload).optString("sessionId")
} catch (_: Exception) {
""
}
if (sessionId.isBlank()) {
result.error("invalid_arguments", "Sync session is missing", null)
return@setMethodCallHandler
}
pendingSyncResults.remove(nodeId)?.let { pending ->
mainHandler.removeCallbacks(pending.timeout)
pending.result.error(
"sync_replaced",
"A newer sync replaced this request",
null,
)
}
val timeout = Runnable {
val pending = pendingSyncResults.remove(nodeId)
pending?.result?.error(
"sync_not_confirmed",
"手表未确认数据导入,请保持手表配对页面开启后重试",
null,
)
}
pendingSyncResults[nodeId] = PendingSync(sessionId, result, timeout)
mainHandler.postDelayed(timeout, SYNC_ACK_TIMEOUT_MS)
Wearable.getMessageClient(this)
.sendMessage(nodeId, path, safePayload.toByteArray(Charsets.UTF_8))
.addOnFailureListener { error ->
mainHandler.post {
val pending = pendingSyncResults.remove(nodeId)
if (pending != null) {
mainHandler.removeCallbacks(pending.timeout)
pending.result.error("send_failed", error.message, null)
}
}
}
}
"cacheSyncPayload" -> {
val payload = call.argument<String>("payload")
if (payload.isNullOrBlank()) {
result.error("invalid_arguments", "Sync payload is empty", null)
} else {
WearCompanionTransport.cachePayload(this, payload)
result.success(null)
}
}
"sendPaymentQrResponse" -> {
val nodeId = call.argument<String>("nodeId")
val payload = call.argument<String>("payload")
if (nodeId.isNullOrBlank() || payload.isNullOrBlank() ||
nodeId != WearCompanionTransport.pairedWatchNodeId(this)
) {
result.error("invalid_arguments", "Invalid payment response", null)
} else {
Wearable.getMessageClient(this)
.sendMessage(
nodeId,
WearCompanionTransport.PAYMENT_RESPONSE_PATH,
payload.toByteArray(Charsets.UTF_8),
)
.addOnSuccessListener { result.success(null) }
.addOnFailureListener { error ->
result.error("send_failed", error.message, null)
}
}
}
else -> result.notImplemented()
}
}
}
}

override fun onResume() {
super.onResume()
WearCompanionTransport.setPaymentProxyActive(true)
Wearable.getMessageClient(this).addListener(this)
}

override fun onPause() {
Wearable.getMessageClient(this).removeListener(this)
WearCompanionTransport.setPaymentProxyActive(false)
super.onPause()
}

override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
companionChannel?.setMethodCallHandler(null)
companionChannel = null
super.cleanUpFlutterEngine(flutterEngine)
}

override fun onMessageReceived(event: MessageEvent) {
if (event.path == WearCompanionTransport.SYNC_ACK_PATH) {
val sessionId = event.data.toString(Charsets.UTF_8)
mainHandler.post {
val pending = pendingSyncResults[event.sourceNodeId]
if (pending != null && pending.sessionId == sessionId) {
pendingSyncResults.remove(event.sourceNodeId)
mainHandler.removeCallbacks(pending.timeout)
WearCompanionTransport.rememberPairedWatch(this, event.sourceNodeId)
pending.result.success(null)
}
}
return
}
if (event.path != WearCompanionTransport.PAYMENT_REQUEST_PATH ||
event.sourceNodeId != WearCompanionTransport.pairedWatchNodeId(this)
) return
runOnUiThread {
companionChannel?.invokeMethod("receivePaymentQrRequest", event.sourceNodeId)
}
}

companion object {
private const val SYNC_ACK_TIMEOUT_MS = 15_000L
}
}
Loading