diff --git a/.github/workflows/check_wearos.yaml b/.github/workflows/check_wearos.yaml new file mode 100644 index 000000000..1f5cac7f5 --- /dev/null +++ b/.github/workflows/check_wearos.yaml @@ -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 diff --git a/README.md b/README.md index 1fcbdd01c..21fa7400d 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ XDYou,代码名称为 Traintime PDA,是为西电学生设计的开源信息 13. 上课前提醒。 14. 完备的国际化支持:支持繁体中文和英语。 15. 宿舍水机支持。 +16. 提供原生 Compose Wear OS 配套应用,可同步课程与付款码,并在断开手机时使用缓存数据。 ## 其他特性 @@ -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 授权。 diff --git a/android/app/build.gradle b/android/app/build.gradle index 52e75d29c..3bb2e06d6 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -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] @@ -140,4 +141,4 @@ android.applicationVariants.all { variant -> output.versionCodeOverride = variant.versionCode * 10 + abiVersionCode } } -} \ No newline at end of file +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a08f490cc..9185431db 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -9,6 +9,18 @@ android:networkSecurityConfig="@xml/network_security_config" tools:replace="android:label"> + + + + + + + () + + 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("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("nodeId") + val path = call.argument("messagePath") + val payload = call.argument("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("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("nodeId") + val payload = call.argument("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 } } diff --git a/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/WearCompanionTransport.kt b/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/WearCompanionTransport.kt new file mode 100644 index 000000000..b34f0cfd6 --- /dev/null +++ b/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/WearCompanionTransport.kt @@ -0,0 +1,105 @@ +package io.github.benderblog.traintime_pda + +import org.json.JSONObject +import java.util.UUID +import android.content.Context +import com.google.android.gms.wearable.MessageEvent +import com.google.android.gms.wearable.Wearable +import com.google.android.gms.wearable.WearableListenerService + +internal object WearCompanionTransport { + const val CHANNEL = "io.github.benderblog.traintime_pda/wear_companion_phone" + const val SYNC_PATH = "/traintime_pda_wear_os/sync/v1" + const val SYNC_ACK_PATH = "/traintime_pda_wear_os/sync/ack/v1" + const val REQUEST_PATH = "/traintime_pda_wear_os/request/v1" + const val PAYMENT_REQUEST_PATH = "/traintime_pda_wear_os/payment/request/v1" + const val PAYMENT_RESPONSE_PATH = "/traintime_pda_wear_os/payment/response/v1" + private const val PREFS = "wear_companion_transport" + private const val PAYLOAD = "latest_payload" + private const val PAIRED_WATCH_NODE_ID = "paired_watch_node_id" + @Volatile + private var paymentProxyActive = false + + fun profileIdForAccount(context: Context, account: String): String { + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val previous = prefs.getString("profile_account", null) + val stored = prefs.getString("profile_id", null) + if (previous == account && stored != null) return stored + val id = UUID.randomUUID().toString() + prefs.edit().apply { + if (previous != null && previous != account) remove(PAYLOAD) + putString("profile_account", account) + putString("profile_id", id) + }.apply() + return id + } + + fun sanitizedPayload(context: Context, payload: String): String { + val json = JSONObject(payload) + val legacyAccount = json.optJSONObject("credentials")?.optString("idsAccount") + if (!json.has("profileId") && !legacyAccount.isNullOrEmpty()) { + json.put("profileId", profileIdForAccount(context, legacyAccount)) + } + json.remove("credentials") + return json.toString() + } + + fun cachePayload(context: Context, payload: String) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit().putString(PAYLOAD, sanitizedPayload(context, payload)).apply() + } + + fun cachedPayload(context: Context): String? { + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val stored = prefs.getString(PAYLOAD, null) ?: return null + return try { + sanitizedPayload(context, stored).also { safe -> + if (safe != stored) prefs.edit().putString(PAYLOAD, safe).apply() + } + } catch (_: Exception) { + prefs.edit().remove(PAYLOAD).apply() + null + } + } + + fun rememberPairedWatch(context: Context, nodeId: String) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit().putString(PAIRED_WATCH_NODE_ID, nodeId).apply() + } + + fun pairedWatchNodeId(context: Context): String? = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(PAIRED_WATCH_NODE_ID, null) + + fun setPaymentProxyActive(active: Boolean) { + paymentProxyActive = active + } + + fun isPaymentProxyActive(): Boolean = paymentProxyActive +} + +/** Answers a paired watch with the last phone-generated snapshot. */ +class WearCompanionListenerService : WearableListenerService() { + override fun onMessageReceived(event: MessageEvent) { + if (event.sourceNodeId != WearCompanionTransport.pairedWatchNodeId(this)) return + when (event.path) { + WearCompanionTransport.REQUEST_PATH -> { + val payload = WearCompanionTransport.cachedPayload(this) ?: return + Wearable.getMessageClient(this).sendMessage( + event.sourceNodeId, + WearCompanionTransport.SYNC_PATH, + payload.toByteArray(Charsets.UTF_8), + ) + } + WearCompanionTransport.PAYMENT_REQUEST_PATH -> { + if (WearCompanionTransport.isPaymentProxyActive()) return + Wearable.getMessageClient(this).sendMessage( + event.sourceNodeId, + WearCompanionTransport.PAYMENT_RESPONSE_PATH, + "{\"ok\":false,\"error\":\"phone_app_required\"}" + .toByteArray(Charsets.UTF_8), + ) + } + } + } +} diff --git a/lib/controller/homepage_controller.dart b/lib/controller/homepage_controller.dart index 6df0af445..368c080af 100644 --- a/lib/controller/homepage_controller.dart +++ b/lib/controller/homepage_controller.dart @@ -27,6 +27,7 @@ import 'package:watermeter/repository/notification/course_reminder_service.dart' import 'package:watermeter/repository/preference.dart' as preference; import 'package:watermeter/repository/system_calendar_sync_service.dart'; import 'package:watermeter/repository/widget_state_sync.dart'; +import 'package:watermeter/repository/wear_companion_sync.dart'; enum ArrangementState { fetching, fetched, error, none } @@ -146,6 +147,13 @@ class HomepageController { preference.getString(preference.Preference.idsAccount).isNotEmpty && preference.getString(preference.Preference.idsPassword).isNotEmpty; await syncWidgetLoginState(hasCredential); + if (Platform.isAndroid && hasCredential) { + try { + await const WearCompanionSyncService().cacheLatestSnapshot(); + } catch (e, s) { + log.handle(e, s, '[HomepageController][WearCompanion] Cache failed'); + } + } } List _sortArrangements(Iterable data) { diff --git a/lib/main.dart b/lib/main.dart index aaeb6319c..eae5ea2e6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -29,6 +29,7 @@ import 'package:watermeter/repository/notification/notification_registrar.dart'; import 'package:watermeter/repository/preference.dart' as preference; import 'package:watermeter/page/homepage/home.dart'; import 'package:watermeter/page/login/login_window.dart'; +import 'package:watermeter/repository/wear_companion_sync.dart'; import 'package:watermeter/themes/font_setting.dart'; void main() async { @@ -57,6 +58,7 @@ void main() async { preference.prefs = await SharedPreferencesWithCache.create( cacheOptions: const SharedPreferencesWithCacheOptions(), ); + await const WearCompanionSyncService().startPaymentProxy(); // Load package info. preference.packageInfo = await PackageInfo.fromPlatform(); diff --git a/lib/page/setting/groups/core_section.dart b/lib/page/setting/groups/core_section.dart index 8068686fe..bc68cf831 100644 --- a/lib/page/setting/groups/core_section.dart +++ b/lib/page/setting/groups/core_section.dart @@ -28,6 +28,7 @@ import 'package:watermeter/repository/logger.dart'; import 'package:watermeter/repository/network_client.dart'; import 'package:watermeter/repository/preference.dart' as preference; import 'package:watermeter/repository/widget_state_sync.dart'; +import 'package:watermeter/page/setting/wear_companion_sync_page.dart'; class CoreSection extends StatelessWidget { const CoreSection({super.key}); @@ -37,6 +38,14 @@ class CoreSection extends StatelessWidget { return SectionSettingScaffold( items: SettingSegmentedList( items: [ + if (Platform.isAndroid) + ListTile( + leading: const Icon(Icons.watch_outlined), + title: const Text('XDYou Wear'), + subtitle: const Text('配对 Wear OS 手表并同步数据'), + trailing: const Icon(Icons.navigate_next), + onTap: () => context.push(const WearCompanionSyncPage()), + ), ListTile( leading: const Icon(MingCuteIcons.mgc_document_line), title: Text(context.t.setting.checkLogger), diff --git a/lib/page/setting/wear_companion_sync_page.dart b/lib/page/setting/wear_companion_sync_page.dart new file mode 100644 index 000000000..ceb414e4a --- /dev/null +++ b/lib/page/setting/wear_companion_sync_page.dart @@ -0,0 +1,174 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +import 'package:flutter/material.dart'; +import 'package:watermeter/page/login/ids_reauth_dialog.dart'; +import 'package:watermeter/repository/wear_companion_sync.dart'; +import 'package:watermeter/repository/ids_session/ids_reauth_client.dart'; + +class WearCompanionSyncPage extends StatefulWidget { + const WearCompanionSyncPage({super.key}); + + @override + State createState() => _WearCompanionSyncPageState(); +} + +class _WearCompanionSyncPageState extends State { + final _service = const WearCompanionSyncService(); + late Future> _nodesFuture = _service.connectedNodes(); + String? _sendingNodeId; + String? _completedNodeId; + String? _status; + + void _reload() { + setState(() { + _status = null; + _nodesFuture = _service.connectedNodes(); + }); + } + + Future _pair(WearNode node) async { + if (_sendingNodeId != null) return; + setState(() { + _sendingNodeId = node.id; + _completedNodeId = null; + _status = '正在向 ${node.name} 同步…'; + }); + final previousReAuthHandler = activeIDSReAuthHandler; + Future pairingReAuthHandler(IDSReAuthClient client) async { + if (!mounted) throw const IDSReAuthRequiredException(); + return showIDSReAuthDialog(context, client); + } + + activeIDSReAuthHandler = pairingReAuthHandler; + try { + final paymentQrSynced = await _service.pairAndSync(node); + if (!mounted) return; + setState(() { + _sendingNodeId = null; + _completedNodeId = node.id; + _status = paymentQrSynced + ? '配对与缓存数据同步完成;付款码时间请查看手表' + : '配对与课表同步完成;付款码请在手表下拉刷新'; + }); + } catch (error) { + if (!mounted) return; + setState(() { + _sendingNodeId = null; + _status = error.toString(); + }); + } finally { + if (identical(activeIDSReAuthHandler, pairingReAuthHandler)) { + activeIDSReAuthHandler = previousReAuthHandler; + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('同步到 XDYou Wear'), + actions: [ + IconButton(onPressed: _reload, icon: const Icon(Icons.refresh)), + ], + ), + body: FutureBuilder>( + future: _nodesFuture, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return _MessageView( + icon: Icons.watch_off_outlined, + message: '无法查找手表:${snapshot.error}', + onRetry: _reload, + ); + } + final nodes = snapshot.data ?? const []; + if (nodes.isEmpty) { + return _MessageView( + icon: Icons.watch_off_outlined, + message: '未找到已连接的 Wear OS 手表\n请先在系统中连接手表,并打开手表端的配对页面', + onRetry: _reload, + ); + } + return ListView( + padding: const EdgeInsets.all(16), + children: [ + const Text('请先在手表端打开“配对手机”,然后选择设备:'), + const SizedBox(height: 12), + for (final node in nodes) + Card( + child: ListTile( + leading: const Icon(Icons.watch_outlined), + title: Text(node.name), + subtitle: Text(node.isNearby ? '附近设备' : '已连接设备'), + trailing: _sendingNodeId == node.id + ? const SizedBox.square( + dimension: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : node.isPaired || _completedNodeId == node.id + ? OutlinedButton.icon( + onPressed: _sendingNodeId == null + ? () => _pair(node) + : null, + icon: const Icon(Icons.sync), + label: const Text('同步'), + ) + : FilledButton( + onPressed: _sendingNodeId == null + ? () => _pair(node) + : null, + child: const Text('配对'), + ), + ), + ), + if (_status != null) ...[ + const SizedBox(height: 12), + Text(_status!, textAlign: TextAlign.center), + ], + ], + ); + }, + ), + ); + } +} + +class _MessageView extends StatelessWidget { + final IconData icon; + final String message; + final VoidCallback onRetry; + + const _MessageView({ + required this.icon, + required this.message, + required this.onRetry, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 52), + const SizedBox(height: 12), + Text(message, textAlign: TextAlign.center), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: const Text('重新查找'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/repository/wear_companion_sync.dart b/lib/repository/wear_companion_sync.dart new file mode 100644 index 000000000..b33676840 --- /dev/null +++ b/lib/repository/wear_companion_sync.dart @@ -0,0 +1,175 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:watermeter/repository/preference.dart' as preference; +import 'package:watermeter/repository/ids_session/classtable_session.dart'; +import 'package:watermeter/repository/ids_session/school_card_session.dart'; +import 'package:watermeter/repository/ids_session/ids_reauth_client.dart'; +import 'package:watermeter/repository/ids_session/sysj_session.dart'; + +/// Phone-side endpoint of the XDYou Wear companion protocol. +class WearCompanionSyncService { + static const channelName = + 'io.github.benderblog.traintime_pda/wear_companion_phone'; + static const syncPath = '/traintime_pda_wear_os/sync/v1'; + static const _channel = MethodChannel(channelName); + + const WearCompanionSyncService(); + + String get phoneAccount => + preference.getString(preference.Preference.idsAccount); + + Future startPaymentProxy() async { + _channel.setMethodCallHandler((call) async { + if (call.method != 'receivePaymentQrRequest') { + throw MissingPluginException('Unknown companion call ${call.method}'); + } + final nodeId = call.arguments; + if (nodeId is! String || nodeId.isEmpty) return; + Map response; + try { + final bytes = await SchoolCardSession().getQRCode(); + response = { + 'ok': true, + 'pngBase64': base64Encode(bytes), + 'fetchedAtEpochMs': DateTime.now().millisecondsSinceEpoch, + }; + } on IDSReAuthRequiredException { + response = {'ok': false, 'error': 'phone_authentication_required'}; + } on IDSReAuthCancelledException { + response = {'ok': false, 'error': 'phone_authentication_cancelled'}; + } catch (_) { + response = {'ok': false, 'error': 'phone_payment_request_failed'}; + } + await _channel.invokeMethod('sendPaymentQrResponse', { + 'nodeId': nodeId, + 'payload': jsonEncode(response), + }); + }); + } + + Map buildSnapshot({ + required String sessionId, + Uint8List? paymentQr, + DateTime? paymentQrFetchedAt, + }) { + final account = phoneAccount; + final password = preference.getString(preference.Preference.idsPassword); + final classTable = ClassTableSession().getCache()?.$2; + if (account.isEmpty || password.isEmpty) { + throw StateError('请先在手机端登录 IDS'); + } + if (classTable == null) { + throw StateError('手机端暂无课表缓存,请先刷新首页'); + } + + final experiments = SysjSession().getCache()?.$2; + return { + 'schemaVersion': 1, + 'sessionId': sessionId, + 'schedule': { + 'classTable': classTable.toJson(), + if (experiments != null) + 'otherExperiments': experiments.map((item) => item.toJson()).toList(), + }, + if (paymentQr != null) + 'paymentQr': { + 'pngBase64': base64Encode(paymentQr), + 'fetchedAtEpochMs': + (paymentQrFetchedAt ?? DateTime.now()).millisecondsSinceEpoch, + }, + 'generatedAtEpochMs': DateTime.now().millisecondsSinceEpoch, + }; + } + + Future> _buildSnapshotWithCachedPaymentQr({ + required String sessionId, + }) async { + final snapshot = buildSnapshot(sessionId: sessionId); + try { + final cached = await _channel + .invokeMapMethod('getCachedPaymentQr', { + 'idsAccount': phoneAccount, + }) + .timeout(const Duration(seconds: 2)); + if (cached?['profileId'] is String) { + snapshot['profileId'] = cached!['profileId']; + } + if (cached != null && + cached['pngBase64'] is String && + (cached['pngBase64'] as String).isNotEmpty && + cached['fetchedAtEpochMs'] is num && + (cached['fetchedAtEpochMs'] as num) >= 0) { + snapshot['paymentQr'] = { + 'pngBase64': cached['pngBase64'], + 'fetchedAtEpochMs': cached['fetchedAtEpochMs'], + }; + } + } catch (_) { + // An optional cached code must never block offline schedule synchronization. + } + return snapshot; + } + + Future> connectedNodes() async { + final raw = await _channel.invokeListMethod( + 'getConnectedWearNodes', + ); + return (raw ?? const []) + .map((item) { + final map = Map.from(item as Map); + return WearNode( + id: map['id']! as String, + name: map['name']! as String, + isNearby: map['isNearby'] == true, + isPaired: map['isPaired'] == true, + ); + }) + .toList(growable: false); + } + + Future pairAndSync(WearNode node) async { + final snapshot = await _buildSnapshotWithCachedPaymentQr( + sessionId: 'direct-pairing', + ); + snapshot['directPairing'] = true; + final payload = jsonEncode(snapshot); + await _channel + .invokeMethod('sendSyncPayload', { + 'nodeId': node.id, + 'messagePath': syncPath, + 'payload': payload, + }) + .timeout( + const Duration(seconds: 20), + onTimeout: () => throw StateError('同步超时,请保持手表应用在前台后重试'), + ); + return snapshot.containsKey('paymentQr'); + } + + /// Updates the native cache used to answer a bound watch in the background. + Future cacheLatestSnapshot() async { + final snapshot = await _buildSnapshotWithCachedPaymentQr( + sessionId: 'background-sync', + ); + final payload = jsonEncode(snapshot); + await _channel.invokeMethod('cacheSyncPayload', {'payload': payload}); + } +} + +class WearNode { + final String id; + final String name; + final bool isNearby; + final bool isPaired; + + const WearNode({ + required this.id, + required this.name, + required this.isNearby, + required this.isPaired, + }); +} diff --git a/wearos/.gitignore b/wearos/.gitignore new file mode 100644 index 000000000..62a79a361 --- /dev/null +++ b/wearos/.gitignore @@ -0,0 +1,31 @@ +# Android / Gradle +*.iml +.idea/ +.gradle/ +local.properties +**/build/ +captures/ +.externalNativeBuild/ +.cxx/ +*.APK +*.apk +*.aab + +# OS / editor +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# Legacy Flutter leftovers (if any reappear) +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +.fvm/ diff --git a/wearos/LICENSE b/wearos/LICENSE new file mode 100644 index 000000000..a612ad981 --- /dev/null +++ b/wearos/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/wearos/WEAR_SYNC_INTEGRATION.md b/wearos/WEAR_SYNC_INTEGRATION.md new file mode 100644 index 000000000..43f301e34 --- /dev/null +++ b/wearos/WEAR_SYNC_INTEGRATION.md @@ -0,0 +1,103 @@ +# WearOS companion sync integration + +XDYou Wear is a companion-only native Wear OS app (Jetpack Compose for Wear OS). +Pairing and subsequent synchronization use the Wear OS Data Layer; camera/QR +pairing is intentionally not used. + +The phone app lives at the repository root. The Wear OS target lives in +`wearos/android/` as a standalone Gradle project (no Flutter embedding). + +## Direct pairing + +1. Open the watch app while unpaired. The watch accepts a first pairing while + the pairing page is in the foreground. +2. Open `设置 > XDYou Wear` on the Android phone. +3. The phone obtains connected watches from `NodeClient.connectedNodes`. +4. Select a watch and tap `配对`. +5. The phone sends the cached schedule/payment envelope through + `MessageClient` to `/traintime_pda_wear_os/sync/v1`. +6. After a successful import, the watch remembers the source phone node and + acknowledges the matching session on + `/traintime_pda_wear_os/sync/ack/v1`. +7. The phone records the watch as paired only after receiving that + acknowledgement. A Data Layer enqueue alone is not treated as success. + +Wear OS Data Layer only transports messages between applications with the same +package name and signing identity. Requiring the unpaired watch app to be open +prevents an unexpected first import from a background installation. + +The watch registers its `MessageClient` listener only while the activity is in +the foreground (no resident / background polling service). + +## Later synchronization + +The watch sends `/traintime_pda_wear_os/request/v1` to its remembered phone. +The phone's `WearCompanionListenerService` responds with the last snapshot even +when the Flutter activity is not running. A normal phone homepage refresh +updates that native snapshot. + +For a payment QR, a normal page open immediately displays the last local copy +without networking. Pulling down asks the connected paired phone through the +system Wear OS Data Layer. Phone discovery and response together have a +10-second limit. The phone payment proxy requires XDYou to be in the foreground; +its background listener reports `phone_app_required` when unavailable. A failed +response or timeout retains the local copy and asks the user to retry on the +phone. Authentication runs only on the phone. + +Manual sync waits and unfinished payment requests are cancelled when the watch +activity leaves the foreground. Data Layer sends and listeners operate only +while the watch activity is resumed. Cancelled sessions cannot resume later. +A displayed payment QR keeps the +screen on only while resumed, for at most 60 seconds (15 seconds when Android's +`PowerManager` reports power-save mode). Other pages follow the system screen +timeout. No periodic jobs, foreground services or manual wake locks are added. + +If the phone is disconnected, the watch continues to use its local class-table +and experiment caches. A successfully fetched payment QR is also cached on the +watch; an offline copy is marked below the QR with its fetch time because it +may have expired. Fetch times appear briefly in a bubble below the code. + +## Agenda tile + +After installing the watch app, add **XDYou 课表** from the watch's tile picker. +The tile shares the app's class-table and experiment caches. It shows the +current event, or the next event from today and tomorrow, with its name, time +and location. Tapping the tile opens the full agenda, including when the app +was previously displaying a payment code. + +Course start/end transitions are supplied as Wear OS timeline validity periods; +the system switches the displayed entry without a polling loop. The system +may request a new local timeline after its 24-hour freshness interval. All tile +requests read local files on an IO coroutine and never create HTTP or Data Layer +clients. Importing a new phone snapshot or logging out requests a tile update. +If there is no usable cache, the tile asks the user to open the app and sync. +If the system has not refreshed an expired timeline, its fallback asks the user +to open the agenda rather than displaying a finished event as current. + +Only the resumed watch app can receive a new phone snapshot. Local time-based +transitions continue while the app is closed, but school-side schedule changes +still require foreground synchronization. The tile uses the same device time +zone, semester filtering and agenda builder as the app. + +## Envelope + +The JSON envelope uses schema version `1` and contains: + +- `sessionId`: `direct-pairing` for first pairing or `background-sync` later. +- `directPairing`: `true` only for the first direct-pairing message. +- `profileId`: random phone-generated cache scope; it carries no login credentials. +- `schedule.classTable`: the phone's cached `ClassTableData.toJson()` value. +- `schedule.otherExperiments`: optional cached experiment list. +- `paymentQr`: optional phone-fetched PNG and fetch time. +- `generatedAtEpochMs`: phone snapshot creation time. + +The watch decodes the complete envelope before replacing local caches. A +failed or missing synchronization therefore does not remove usable offline +data. + +## Build + +```bash +cd wearos/android +./gradlew assembleDebug +``` diff --git a/wearos/android/.gitignore b/wearos/android/.gitignore new file mode 100644 index 000000000..7760dbbdf --- /dev/null +++ b/wearos/android/.gitignore @@ -0,0 +1,10 @@ +/.gradle +/captures/ +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app +key.properties +**/*.keystore +**/*.jks diff --git a/wearos/android/app/build.gradle b/wearos/android/app/build.gradle new file mode 100644 index 000000000..61f4ac726 --- /dev/null +++ b/wearos/android/app/build.gradle @@ -0,0 +1,92 @@ +plugins { + id "com.android.application" + id "org.jetbrains.kotlin.android" + id "org.jetbrains.kotlin.plugin.compose" +} + +def keystoreProperties = new Properties() +def keystorePropertiesFile = rootProject.file('key.properties') +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) +} + +android { + namespace = "io.github.benderblog.traintime_pda" + compileSdk = 36 + ndkVersion = "28.2.13676358" + + defaultConfig { + applicationId "io.github.benderblog.traintime_pda" + minSdk 28 + targetSdk 34 + versionCode 43 + versionName "1.5.13" + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + compose true + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + + dependenciesInfo { + includeInApk = false + includeInBundle = false + } + + signingConfigs { + release { + keyAlias keystoreProperties['keyAlias'] + keyPassword keystoreProperties['keyPassword'] + storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : file('key.jks') + storePassword keystoreProperties['storePassword'] + } + } + + buildTypes { + release { + minifyEnabled true + shrinkResources true + signingConfig keystoreProperties['storeFile'] ? signingConfigs.release : signingConfigs.debug + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + debug { + applicationIdSuffix "" + } + } +} + +dependencies { + def composeBom = platform("androidx.compose:compose-bom:2026.06.01") + implementation composeBom + + implementation "androidx.core:core-ktx:1.17.0" + implementation "androidx.activity:activity-compose:1.13.0" + implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.10.0" + implementation "androidx.lifecycle:lifecycle-runtime-compose:2.10.0" + implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0" + implementation "androidx.wear.compose:compose-material:1.6.2" + implementation "androidx.wear.compose:compose-foundation:1.6.2" + implementation "androidx.wear.tiles:tiles:1.6.2" + implementation "androidx.wear.protolayout:protolayout:1.4.2" + implementation "androidx.compose.ui:ui" + implementation "androidx.compose.foundation:foundation" + implementation "androidx.compose.runtime:runtime" + implementation "com.google.android.gms:play-services-wearable:20.0.1" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.10.2" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.10.2" +} diff --git a/wearos/android/app/proguard-rules.pro b/wearos/android/app/proguard-rules.pro new file mode 100644 index 000000000..3ef754291 --- /dev/null +++ b/wearos/android/app/proguard-rules.pro @@ -0,0 +1,28 @@ + +## Gson rules +# Gson uses generic type information stored in a class file when working with fields. Proguard +# removes such information by default, so configure it to keep all of it. +-keepattributes Signature + +# For using GSON @Expose annotation +-keepattributes *Annotation* + +# Gson specific classes +-dontwarn sun.misc.** +#-keep class com.google.gson.stream.** { *; } + +# Prevent proguard from stripping interface information from TypeAdapter, TypeAdapterFactory, +# JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter) +-keep class * extends com.google.gson.TypeAdapter +-keep class * implements com.google.gson.TypeAdapterFactory +-keep class * implements com.google.gson.JsonSerializer +-keep class * implements com.google.gson.JsonDeserializer + +# Prevent R8 from leaving Data object members always null +-keepclassmembers,allowobfuscation class * { + @com.google.gson.annotations.SerializedName ; +} + +# Retain generic signatures of TypeToken and its subclasses with R8 version 3.0 and higher. +-keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken +-keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken \ No newline at end of file diff --git a/wearos/android/app/src/main/AndroidManifest.xml b/wearos/android/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..db13b275d --- /dev/null +++ b/wearos/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/wearos/android/app/src/main/ic_launcher-playstore.png b/wearos/android/app/src/main/ic_launcher-playstore.png new file mode 100644 index 000000000..99b347f50 Binary files /dev/null and b/wearos/android/app/src/main/ic_launcher-playstore.png differ diff --git a/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/MainActivity.kt b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/MainActivity.kt new file mode 100644 index 000000000..ea738893c --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/MainActivity.kt @@ -0,0 +1,92 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +package io.github.benderblog.traintime_pda + +import android.os.Bundle +import android.content.Intent +import android.os.PowerManager +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.repeatOnLifecycle +import io.github.benderblog.traintime_pda.ui.WearApp +import io.github.benderblog.traintime_pda.ui.WearScreen +import io.github.benderblog.traintime_pda.ui.WearViewModel +import kotlinx.coroutines.delay + +class MainActivity : ComponentActivity() { + private val container by lazy { WearAppContainer(this) } + + private val viewModel: WearViewModel by viewModels { + WearViewModel.Factory(container) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (intent.getBooleanExtra(EXTRA_SHOW_AGENDA, false)) viewModel.showAgenda() + setContent { + val lifecycleOwner = LocalLifecycleOwner.current + val state by viewModel.state.collectAsStateWithLifecycle() + + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> viewModel.onForeground() + Lifecycle.Event.ON_PAUSE -> viewModel.onBackground() + else -> Unit + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + viewModel.onBackground() + } + } + + LaunchedEffect(lifecycleOwner, state.screen, state.qrResult) { + // Give scanners enough time to read the code without allowing an + // accidentally abandoned QR screen to keep the watch awake forever. + val keepOn = state.screen == WearScreen.QR && state.qrResult != null + if (keepOn) { + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { + val powerManager = getSystemService(PowerManager::class.java) + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + try { + delay( + if (powerManager.isPowerSaveMode) QR_POWER_SAVE_TIMEOUT_MS + else QR_SCREEN_ON_TIMEOUT_MS, + ) + } finally { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + + WearApp(viewModel = viewModel) + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + if (intent.getBooleanExtra(EXTRA_SHOW_AGENDA, false)) viewModel.showAgenda() + } + + companion object { + const val EXTRA_SHOW_AGENDA = "show_agenda" + private const val QR_SCREEN_ON_TIMEOUT_MS = 60_000L + private const val QR_POWER_SAVE_TIMEOUT_MS = 15_000L + } +} diff --git a/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/WearAppContainer.kt b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/WearAppContainer.kt new file mode 100644 index 000000000..5a2b6552f --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/WearAppContainer.kt @@ -0,0 +1,31 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +package io.github.benderblog.traintime_pda + +import android.content.Context +import io.github.benderblog.traintime_pda.data.WearCacheStore +import io.github.benderblog.traintime_pda.data.WearPreferences +import io.github.benderblog.traintime_pda.data.WearSyncImporter +import io.github.benderblog.traintime_pda.sync.WearCompanionClient +import androidx.wear.tiles.TileService +import io.github.benderblog.traintime_pda.tiles.AgendaTileService + +/** Process-scoped services for the Wear app. */ +class WearAppContainer(context: Context) { + private val appContext = context.applicationContext + + val preferences = WearPreferences(appContext) + val cache = WearCacheStore(appContext) + val importer = WearSyncImporter(preferences, cache) { + TileService.getUpdater(appContext).requestUpdate(AgendaTileService::class.java) + } + init { + if (preferences.clearLegacyAuthentication()) cache.clearPaymentQr() + cache.clearIdsCookies() + } + + val companionClient = WearCompanionClient(appContext, importer) + + fun needsPairing(): Boolean = !companionClient.isCompanionPaired() +} diff --git a/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/data/WearCacheStore.kt b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/data/WearCacheStore.kt new file mode 100644 index 000000000..b975c9c25 --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/data/WearCacheStore.kt @@ -0,0 +1,127 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +package io.github.benderblog.traintime_pda.data + +import android.content.Context +import io.github.benderblog.traintime_pda.domain.ClassTableData +import io.github.benderblog.traintime_pda.domain.ExperimentData +import org.json.JSONArray +import java.io.File + +/** + * Local schedule / payment caches under [Context.getFilesDir], matching the + * Flutter Wear paths (`ClassTable.json`, `OtherExperiment.json`, `WearPaymentQr.png`). + */ +class WearCacheStore(private val root: File) { + constructor(context: Context) : this(context.applicationContext.filesDir) + + val classTableFile: File get() = File(root, CLASS_TABLE_FILE) + val experimentFile: File get() = File(root, EXPERIMENT_FILE) + val paymentQrFile: File get() = File(root, PAYMENT_QR_FILE) + + + fun writeClassTable(data: ClassTableData, rawJson: String? = null) { + // Prefer the original phone JSON when available to avoid re-serialization drift. + if (rawJson != null) { + writeClassTableRaw(rawJson) + } else { + // Minimal write — phone always sends full JSON; used mainly in tests. + writeClassTableRaw( + org.json.JSONObject() + .put("semesterLength", data.semesterLength) + .put("semesterCode", data.semesterCode) + .put("termStartDay", data.termStartDay) + .put("classDetail", org.json.JSONArray()) + .put("userDefinedDetail", org.json.JSONArray()) + .put("notArranged", org.json.JSONArray()) + .put("timeArrangement", org.json.JSONArray()) + .put("classChanges", org.json.JSONArray()) + .toString(), + ) + } + } + + fun writeClassTableRaw(rawJson: String) { + writeAtomically(classTableFile, rawJson.toByteArray()) + } + + fun readClassTable(): ClassTableData? { + if (!classTableFile.exists()) return null + return try { + ClassTableData.fromJsonString(classTableFile.readText()) + } catch (_: Exception) { + null + } + } + + fun writeExperimentsRaw(rawJson: String) { + writeAtomically(experimentFile, rawJson.toByteArray()) + } + + fun writeExperiments(list: List) { + // Tests / local writes only — phone import uses writeExperimentsRaw. + val array = JSONArray() + writeExperimentsRaw(array.toString()) + } + + fun readExperiments(): List? { + if (!experimentFile.exists()) return null + return try { + ExperimentData.listFromJsonArray(JSONArray(experimentFile.readText())) + } catch (_: Exception) { + null + } + } + + fun writePaymentQr(bytes: ByteArray, fetchedAtEpochMs: Long) { + // A schedule snapshot can carry an older phone code than the watch's last refresh. + if (paymentQrFile.exists() && paymentQrFile.lastModified() > fetchedAtEpochMs) return + writeAtomically(paymentQrFile, bytes) + paymentQrFile.setLastModified(fetchedAtEpochMs) + } + + fun readPaymentQr(): Pair? { + if (!paymentQrFile.exists()) return null + return try { + paymentQrFile.readBytes() to paymentQrFile.lastModified() + } catch (_: Exception) { + null + } + } + + fun clearPaymentQr() { + if (paymentQrFile.exists()) paymentQrFile.delete() + } + + fun clearCampusCaches() { + if (classTableFile.exists()) classTableFile.delete() + if (experimentFile.exists()) experimentFile.delete() + } + + fun clearIdsCookies() { + File(root, "cookie").takeIf { it.exists() }?.deleteRecursively() + } + + fun classTableExists(): Boolean = classTableFile.exists() + + private fun writeAtomically(target: File, bytes: ByteArray) { + target.parentFile?.mkdirs() + val temporary = File(target.parentFile, ".${target.name}.tmp") + temporary.outputStream().use { stream -> + stream.write(bytes) + stream.flush() + stream.fd.sync() + } + if (!temporary.renameTo(target)) { + target.delete() + check(temporary.renameTo(target)) { "Unable to replace ${target.name}" } + } + } + + companion object { + const val CLASS_TABLE_FILE = "ClassTable.json" + const val EXPERIMENT_FILE = "OtherExperiment.json" + const val PAYMENT_QR_FILE = "WearPaymentQr.png" + } +} diff --git a/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/data/WearPreferences.kt b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/data/WearPreferences.kt new file mode 100644 index 000000000..2a5550452 --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/data/WearPreferences.kt @@ -0,0 +1,64 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +package io.github.benderblog.traintime_pda.data + +import android.content.Context +import android.content.SharedPreferences + +/** + * Semester and display preferences. + * + * Keys match the former Flutter [Preference] enum so values remain readable if a + * user upgrades from the Flutter Wear build (flutter.* prefix is also probed). + */ +class WearPreferences(context: Context) { + private val prefs: SharedPreferences = + context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + private val flutterPrefs: SharedPreferences = + context.applicationContext.getSharedPreferences( + FLUTTER_PREFS_NAME, + Context.MODE_PRIVATE, + ) + + var currentSemester: String + get() = readString(KEY_CURRENT_SEMESTER) + set(value) = writeString(KEY_CURRENT_SEMESTER, value) + + var agendaSwipeHintShown: Boolean + get() = prefs.getBoolean(KEY_AGENDA_SWIPE_HINT_SHOWN, false) + set(value) = prefs.edit().putBoolean(KEY_AGENDA_SWIPE_HINT_SHOWN, value).apply() + + var profileId: String? + get() = prefs.getString("profileId", null) + set(value) = prefs.edit().putString("profileId", value).apply() + + fun clearLegacyAuthentication(): Boolean { + val keys = listOf("idsAccount", "idsPassword", "role", "idsBrowserFingerprint", "schoolCardOpenId", "schoolCardOpenIdFetchedAt") + val existed = keys.any { prefs.contains(it) || flutterPrefs.contains("flutter.$it") } + prefs.edit().apply { keys.forEach { remove(it) } }.apply() + flutterPrefs.edit().apply { keys.forEach { remove("flutter.$it") } }.apply() + return existed + } + + fun contains(key: String): Boolean = + prefs.contains(key) || flutterPrefs.contains("flutter.$key") + + private fun readString(key: String): String { + val local = prefs.getString(key, null) + if (!local.isNullOrEmpty()) return local + return flutterPrefs.getString("flutter.$key", "") ?: "" + } + + private fun writeString(key: String, value: String) { + prefs.edit().putString(key, value).apply() + } + + companion object { + const val PREFS_NAME = "wear_app_prefs" + private const val FLUTTER_PREFS_NAME = "FlutterSharedPreferences" + const val KEY_CURRENT_SEMESTER = "currentSemester" + private const val KEY_AGENDA_SWIPE_HINT_SHOWN = "agendaSwipeHintShown" + } +} diff --git a/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/data/WearSyncImporter.kt b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/data/WearSyncImporter.kt new file mode 100644 index 000000000..6f3b893ea --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/data/WearSyncImporter.kt @@ -0,0 +1,80 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +package io.github.benderblog.traintime_pda.data + +import io.github.benderblog.traintime_pda.protocol.WearCompanionSyncEnvelope +import io.github.benderblog.traintime_pda.protocol.WearPaymentQrSyncPayload +import io.github.benderblog.traintime_pda.protocol.WearScheduleSyncPayload +import org.json.JSONObject + +/** + * Imports a phone-produced envelope into local prefs + file caches. + * Schedule is never fetched by the watch; only this path updates it. + */ +class WearSyncImporter( + private val preferences: WearPreferences, + private val cache: WearCacheStore, + private val onScheduleChanged: () -> Unit = {}, +) { + fun importEnvelope(envelope: WearCompanionSyncEnvelope, rawPayload: String? = null) { + envelope.profileId?.let { profileId -> + if (preferences.profileId != null && preferences.profileId != profileId) { + cache.clearCampusCaches() + cache.clearPaymentQr() + } + preferences.profileId = profileId + } + importSchedule(envelope.schedule, rawPayload) + envelope.paymentQr?.let { importPaymentQr(it) } + onScheduleChanged() + } + + fun importSchedule(payload: WearScheduleSyncPayload, rawPayload: String? = null) { + val rawClassTable = rawPayload?.let { + try { + JSONObject(it).optJSONObject("schedule")?.optJSONObject("classTable")?.toString() + } catch (_: Exception) { + null + } + } + if (rawClassTable != null) { + cache.writeClassTableRaw(rawClassTable) + } else { + cache.writeClassTable(payload.classTable) + } + if (payload.classTable.semesterCode.isNotEmpty()) { + preferences.currentSemester = payload.classTable.semesterCode + } + + val experiments = payload.otherExperiments + if (experiments != null) { + val rawExperiments = rawPayload?.let { + try { + JSONObject(it).optJSONObject("schedule") + ?.optJSONArray("otherExperiments") + ?.toString() + } catch (_: Exception) { + null + } + } + if (rawExperiments != null) { + cache.writeExperimentsRaw(rawExperiments) + } else { + cache.writeExperiments(experiments) + } + } + } + + fun importPaymentQr(payload: WearPaymentQrSyncPayload) { + cache.writePaymentQr(payload.bytes, payload.fetchedAtEpochMs) + } + + fun logout() { + cache.clearCampusCaches() + cache.clearPaymentQr() + preferences.currentSemester = "" + preferences.profileId = null + onScheduleChanged() + } +} diff --git a/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/domain/ClassTableModels.kt b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/domain/ClassTableModels.kt new file mode 100644 index 000000000..cfd92e4a8 --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/domain/ClassTableModels.kt @@ -0,0 +1,273 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +package io.github.benderblog.traintime_pda.domain + +import org.json.JSONArray +import org.json.JSONObject + +enum class Source { + EMPTY, + SCHOOL, + USER, + ; + + companion object { + fun fromJson(value: String?): Source = when (value) { + "school" -> SCHOOL + "user" -> USER + "empty" -> EMPTY + else -> EMPTY + } + } + + fun toJson(): String = when (this) { + EMPTY -> "empty" + SCHOOL -> "school" + USER -> "user" + } +} + +data class ClassDetail( + val name: String, + val code: String? = null, + val number: String? = null, +) { + companion object { + fun fromJson(json: JSONObject): ClassDetail = ClassDetail( + name = json.getString("name"), + code = json.optStringOrNull("code"), + number = json.optStringOrNull("number"), + ) + } +} + +data class NotArrangementClassDetail( + val name: String, + val code: String? = null, + val number: String? = null, + val teacher: String? = null, +) { + companion object { + fun fromJson(json: JSONObject): NotArrangementClassDetail = + NotArrangementClassDetail( + name = json.getString("name"), + code = json.optStringOrNull("code"), + number = json.optStringOrNull("number"), + teacher = json.optStringOrNull("teacher"), + ) + } +} + +data class TimeArrangement( + val index: Int, + val weekList: List, + val teacher: String? = null, + val day: Int, + val start: Int, + val stop: Int, + val source: Source, + val classroom: String? = null, +) { + companion object { + fun fromJson(json: JSONObject): TimeArrangement { + val weekArray = json.getJSONArray("week_list") + val weekList = buildList(weekArray.length()) { + for (i in 0 until weekArray.length()) { + add(weekArray.getBoolean(i)) + } + } + return TimeArrangement( + index = json.getInt("index"), + weekList = weekList, + teacher = json.optStringOrNull("teacher"), + day = json.getInt("day"), + start = json.getInt("start"), + stop = json.getInt("stop"), + source = Source.fromJson(json.optString("source")), + classroom = json.optStringOrNull("classroom"), + ) + } + } +} + +enum class ChangeType { + CHANGE, + STOP, + PATCH, + ; + + companion object { + fun fromJson(value: String?): ChangeType = when (value) { + "stop" -> STOP + "patch" -> PATCH + else -> CHANGE + } + } +} + +data class ClassChange( + val type: ChangeType, + val classCode: String, + val classNumber: String, + val className: String, + val originalAffectedWeeks: List?, + val newAffectedWeeks: List?, + val originalTeacherData: String?, + val newTeacherData: String?, + val originalClassRange: List, + val newClassRange: List, + val originalWeek: Int?, + val newWeek: Int?, + val originalClassroom: String?, + val newClassroom: String?, +) { + companion object { + fun fromJson(json: JSONObject): ClassChange = ClassChange( + type = ChangeType.fromJson(json.optString("type")), + classCode = json.getString("classCode"), + classNumber = json.getString("classNumber"), + className = json.getString("className"), + originalAffectedWeeks = json.optBooleanList("originalAffectedWeeks"), + newAffectedWeeks = json.optBooleanList("newAffectedWeeks"), + originalTeacherData = json.optStringOrNull("originalTeacherData"), + newTeacherData = json.optStringOrNull("newTeacherData"), + originalClassRange = json.optIntList("originalClassRange"), + newClassRange = json.optIntList("newClassRange"), + originalWeek = json.optIntOrNull("originalWeek"), + newWeek = json.optIntOrNull("newWeek"), + originalClassroom = json.optStringOrNull("originalClassroom"), + newClassroom = json.optStringOrNull("newClassroom"), + ) + } +} + +data class ClassTableData( + val semesterLength: Int = 1, + val semesterCode: String = "", + val termStartDay: String = "", + val classDetail: List = emptyList(), + val userDefinedDetail: List = emptyList(), + val notArranged: List = emptyList(), + val timeArrangement: List = emptyList(), + val classChanges: List = emptyList(), +) { + fun getClassDetail(arrangement: TimeArrangement): ClassDetail = when (arrangement.source) { + Source.SCHOOL -> classDetail[arrangement.index] + Source.USER -> userDefinedDetail[arrangement.index] + Source.EMPTY -> error("empty source has no class detail") + } + + companion object { + fun fromJson(json: JSONObject): ClassTableData = ClassTableData( + semesterLength = json.optInt("semesterLength", 1), + semesterCode = json.optString("semesterCode", ""), + termStartDay = json.optString("termStartDay", ""), + classDetail = json.optObjectList("classDetail") { ClassDetail.fromJson(it) }, + userDefinedDetail = json.optObjectList("userDefinedDetail") { ClassDetail.fromJson(it) }, + notArranged = json.optObjectList("notArranged") { + NotArrangementClassDetail.fromJson(it) + }, + timeArrangement = json.optObjectList("timeArrangement") { + TimeArrangement.fromJson(it) + }, + classChanges = json.optObjectList("classChanges") { ClassChange.fromJson(it) }, + ) + + fun fromJsonString(raw: String): ClassTableData = fromJson(JSONObject(raw)) + } +} + +data class ExperimentData( + val type: String = "others", + val name: String, + val classroom: String, + val timeRanges: List>, + val teacher: String, + val reference: String? = null, +) { + companion object { + fun fromJson(json: JSONObject): ExperimentData { + val ranges = json.getJSONArray("timeRanges") + val parsed = buildList(ranges.length()) { + for (i in 0 until ranges.length()) { + val item = ranges.getJSONObject(i) + // Dart record serialization uses $1 / $2 keys. + val start = parseIsoMillis(item.getString("\$1")) + val end = parseIsoMillis(item.getString("\$2")) + add(start to end) + } + } + return ExperimentData( + type = json.optString("type", "others"), + name = json.getString("name"), + classroom = json.getString("classroom"), + timeRanges = parsed, + teacher = json.getString("teacher"), + reference = json.optStringOrNull("reference"), + ) + } + + fun listFromJsonArray(array: JSONArray): List = + buildList(array.length()) { + for (i in 0 until array.length()) { + add(fromJson(array.getJSONObject(i))) + } + } + + private fun parseIsoMillis(value: String): Long { + // Accept both local and Z-suffixed ISO-8601 timestamps from Dart. + val normalized = value + .replace(' ', 'T') + .let { if (it.endsWith('Z')) it else it } + return java.time.OffsetDateTime.parse( + if (normalized.endsWith('Z') || normalized.contains('+') || + normalized.matches(Regex(".*[+-]\\d{2}:\\d{2}$")) + ) { + normalized + } else { + // Local datetime without offset: treat as system-local wall clock. + return java.time.LocalDateTime.parse(normalized.take(19)) + .atZone(java.time.ZoneId.systemDefault()) + .toInstant() + .toEpochMilli() + }, + ).toInstant().toEpochMilli() + } + } +} + +internal fun JSONObject.optStringOrNull(key: String): String? { + if (!has(key) || isNull(key)) return null + val value = optString(key, "") + return value.ifEmpty { null } +} + +internal fun JSONObject.optIntOrNull(key: String): Int? { + if (!has(key) || isNull(key)) return null + return getInt(key) +} + +internal fun JSONObject.optBooleanList(key: String): List? { + if (!has(key) || isNull(key)) return null + val array = getJSONArray(key) + return buildList(array.length()) { + for (i in 0 until array.length()) add(array.getBoolean(i)) + } +} + +internal fun JSONObject.optIntList(key: String): List { + if (!has(key) || isNull(key)) return emptyList() + val array = getJSONArray(key) + return buildList(array.length()) { + for (i in 0 until array.length()) add(array.getInt(i)) + } +} + +internal fun JSONObject.optObjectList(key: String, map: (JSONObject) -> T): List { + if (!has(key) || isNull(key)) return emptyList() + val array = getJSONArray(key) + return buildList(array.length()) { + for (i in 0 until array.length()) add(map(array.getJSONObject(i))) + } +} diff --git a/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/domain/TimeList.kt b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/domain/TimeList.kt new file mode 100644 index 000000000..322a2ebb3 --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/domain/TimeList.kt @@ -0,0 +1,21 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +package io.github.benderblog.traintime_pda.domain + +/** Class-period start/end times. Even indices are starts, odd are ends. */ +object TimeList { + val values: List = listOf( + "08:30", "09:15", + "09:20", "10:05", + "10:25", "11:10", + "11:15", "12:00", + "14:00", "14:45", + "14:50", "15:35", + "15:55", "16:40", + "16:45", "17:30", + "19:00", "19:45", + "19:50", "20:35", + "20:40", "21:25", + ) +} diff --git a/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/domain/WearAgendaBuilder.kt b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/domain/WearAgendaBuilder.kt new file mode 100644 index 000000000..626ae8d01 --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/domain/WearAgendaBuilder.kt @@ -0,0 +1,168 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +package io.github.benderblog.traintime_pda.domain + +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit + +enum class WearAgendaKind { + COURSE, + OTHER_EXPERIMENT, +} + +data class WearAgendaItem( + val kind: WearAgendaKind, + val title: String, + val subtitle: String? = null, + val location: String? = null, + val start: LocalDateTime, + val end: LocalDateTime, +) + +data class WearHomeData( + val todayItems: List, + val tomorrowItems: List, +) + +data class WearAgendaDay(val date: LocalDate, val items: List) + +object WearAgendaBuilder { + private val termStartFormatter: DateTimeFormatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + + fun loadWeekData( + semesterCode: String, + classTable: ClassTableData?, + experiments: List?, + weekStart: LocalDate, + zone: ZoneId = ZoneId.systemDefault(), + ): List = (0L..6L).map { offset -> + val date = weekStart.plusDays(offset) + WearAgendaDay(date, loadHomeData(semesterCode, classTable, experiments, date.atStartOfDay(), zone).todayItems) + } + + fun courseItemsForDay(table: ClassTableData, day: LocalDate): List { + val weekIndex = weekIndexForDay(table, day) + if (weekIndex < 0 || weekIndex >= table.semesterLength) { + return emptyList() + } + + val items = mutableListOf() + for (arrangement in table.timeArrangement) { + if (arrangement.source == Source.EMPTY) continue + // Dart DateTime.weekday: Monday=1 ... Sunday=7 + if (arrangement.day != day.dayOfWeek.value) continue + if (arrangement.weekList.size <= weekIndex || !arrangement.weekList[weekIndex]) continue + + val startIndex = (arrangement.start - 1) * 2 + val endIndex = (arrangement.stop - 1) * 2 + 1 + if (startIndex < 0 || endIndex >= TimeList.values.size) continue + + val detail = try { + table.getClassDetail(arrangement) + } catch (_: Exception) { + continue + } + + items += WearAgendaItem( + kind = WearAgendaKind.COURSE, + title = detail.name, + subtitle = blankToNull(arrangement.teacher), + location = blankToNull(arrangement.classroom), + start = day.atClassTime(TimeList.values[startIndex]), + end = day.atClassTime(TimeList.values[endIndex]), + ) + } + return items.sortedWith(agendaComparator) + } + + fun experimentItemsForDay( + experiments: List, + day: LocalDate, + zone: ZoneId = ZoneId.systemDefault(), + ): List { + val items = mutableListOf() + for (experiment in experiments) { + for ((startMs, endMs) in experiment.timeRanges) { + val start = LocalDateTime.ofInstant(Instant.ofEpochMilli(startMs), zone) + val end = LocalDateTime.ofInstant(Instant.ofEpochMilli(endMs), zone) + if (start.toLocalDate() != day) continue + items += WearAgendaItem( + kind = WearAgendaKind.OTHER_EXPERIMENT, + title = experiment.name, + subtitle = blankToNull(experiment.teacher), + location = blankToNull(experiment.classroom), + start = start, + end = end, + ) + } + } + return items.sortedWith(agendaComparator) + } + + fun weekIndexForDay(table: ClassTableData, day: LocalDate): Int { + if (table.termStartDay.isEmpty()) return -1 + val start = parseTermStart(table.termStartDay) ?: return -1 + val delta = ChronoUnit.DAYS.between(start, day) + return if (delta < 0) -1 else (delta / 7).toInt() + } + + fun loadHomeData( + semesterCode: String, + classTable: ClassTableData?, + experiments: List?, + now: LocalDateTime = LocalDateTime.now(), + zone: ZoneId = ZoneId.systemDefault(), + ): WearHomeData { + val today = now.toLocalDate() + val tomorrow = today.plusDays(1) + val todayItems = mutableListOf() + val tomorrowItems = mutableListOf() + + val table = classTable?.takeIf { + it.semesterCode.isEmpty() || it.semesterCode == semesterCode || semesterCode.isEmpty() + } + if (table != null && (semesterCode.isEmpty() || table.semesterCode == semesterCode)) { + todayItems += courseItemsForDay(table, today) + tomorrowItems += courseItemsForDay(table, tomorrow) + } + if (experiments != null) { + todayItems += experimentItemsForDay(experiments, today, zone) + tomorrowItems += experimentItemsForDay(experiments, tomorrow, zone) + } + todayItems.sortWith(agendaComparator) + tomorrowItems.sortWith(agendaComparator) + return WearHomeData( + todayItems = todayItems.toList(), + tomorrowItems = tomorrowItems.toList(), + ) + } + + private fun parseTermStart(raw: String): LocalDate? = try { + LocalDateTime.parse(raw, termStartFormatter).toLocalDate() + } catch (_: Exception) { + try { + LocalDate.parse(raw.take(10)) + } catch (_: Exception) { + null + } + } + + private fun LocalDate.atClassTime(hhmm: String): LocalDateTime { + val hour = (hhmm[0] - '0') * 10 + (hhmm[1] - '0') + val minute = (hhmm[3] - '0') * 10 + (hhmm[4] - '0') + return LocalDateTime.of(this, LocalTime.of(hour, minute)) + } + + private fun blankToNull(value: String?): String? = + value?.takeIf { it.isNotEmpty() } + + private val agendaComparator = + compareBy({ it.start }, { it.end }) +} diff --git a/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/payment/PaymentQrRepository.kt b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/payment/PaymentQrRepository.kt new file mode 100644 index 000000000..061a55dec --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/payment/PaymentQrRepository.kt @@ -0,0 +1,87 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +package io.github.benderblog.traintime_pda.payment + +import io.github.benderblog.traintime_pda.data.WearCacheStore +import io.github.benderblog.traintime_pda.sync.WearCompanionClient +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull + +data class PaymentQrResult( + val bytes: ByteArray, + val fromCache: Boolean, + val fetchedAtEpochMs: Long, + val refreshError: String? = null, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is PaymentQrResult) return false + return fromCache == other.fromCache && + refreshError == other.refreshError && + fetchedAtEpochMs == other.fetchedAtEpochMs && + bytes.contentEquals(other.bytes) + } + + override fun hashCode(): Int { + var result = bytes.contentHashCode() + result = 31 * result + fromCache.hashCode() + result = 31 * result + fetchedAtEpochMs.hashCode() + result = 31 * result + (refreshError?.hashCode() ?: 0) + return result + } +} + +/** Payment codes are refreshed by the paired phone; the watch only stores images. */ +class PaymentQrRepository( + private val cache: WearCacheStore, + private val companionClient: WearCompanionClient, +) { + suspend fun load(forceRefresh: Boolean = false): PaymentQrResult = withContext(Dispatchers.IO) { + loadPaymentQr( + forceRefresh = forceRefresh, + readCache = { + cache.readPaymentQr()?.let { (bytes, fetchedAt) -> + PaymentQrResult(bytes, fromCache = true, fetchedAtEpochMs = fetchedAt) + } + }, + requestFromPhone = { + val response = companionClient.requestPaymentQrFromPhone() + if (!response.ok || response.pngBytes == null || response.fetchedAtEpochMs == null) { + throw IllegalStateException(response.error ?: "phone_payment_request_failed") + } + cache.writePaymentQr(response.pngBytes, response.fetchedAtEpochMs) + PaymentQrResult(response.pngBytes, false, response.fetchedAtEpochMs) + }, + ) + } +} + +internal suspend fun loadPaymentQr( + forceRefresh: Boolean, + readCache: () -> PaymentQrResult?, + requestFromPhone: suspend () -> PaymentQrResult, +): PaymentQrResult { + currentCoroutineContext().ensureActive() + if (!forceRefresh) readCache()?.let { return it } + return try { + withTimeoutOrNull(10_000L) { requestFromPhone() } + ?: throw IllegalStateException("phone_timeout") + } catch (cancelled: CancellationException) { + throw cancelled + } catch (error: Exception) { + currentCoroutineContext().ensureActive() + val message = when (error.message) { + "phone_authentication_required", "phone_authentication_cancelled" -> "请在手机端 XDYou 完成认证后重试" + "phone_app_required" -> "请打开手机端 XDYou 后重试" + "phone_timeout" -> "手机响应超时,请打开手机端 XDYou 后重试" + "Companion phone is disconnected", "No companion phone is paired" -> "请连接手机并打开 XDYou 后重试" + else -> "手机刷新失败,请在手机端 XDYou 重试" + } + readCache()?.copy(refreshError = message) ?: throw IllegalStateException(message) + } +} diff --git a/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/protocol/WearCompanionSync.kt b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/protocol/WearCompanionSync.kt new file mode 100644 index 000000000..7f447e799 --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/protocol/WearCompanionSync.kt @@ -0,0 +1,157 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +package io.github.benderblog.traintime_pda.protocol + +import io.github.benderblog.traintime_pda.domain.ClassTableData +import io.github.benderblog.traintime_pda.domain.ExperimentData +import org.json.JSONObject +import java.util.Base64 + +/** Message paths shared with the phone companion (must stay stable). */ +object WearCompanionPaths { + const val SYNC = "/traintime_pda_wear_os/sync/v1" + const val SYNC_ACK = "/traintime_pda_wear_os/sync/ack/v1" + const val REQUEST = "/traintime_pda_wear_os/request/v1" + const val PAYMENT_REQUEST = "/traintime_pda_wear_os/payment/request/v1" + const val PAYMENT_RESPONSE = "/traintime_pda_wear_os/payment/response/v1" + const val PREFS = "wear_companion_transport" + const val PAIRED_PHONE_NODE_ID = "paired_phone_node_id" + const val DIRECT_PAIRING_TTL_MS = 5 * 60 * 1000L + const val SCHEMA_VERSION = 1 +} + +data class WearScheduleSyncPayload( + val classTable: ClassTableData, + val otherExperiments: List? = null, +) + +data class WearPaymentQrSyncPayload( + val bytes: ByteArray, + val fetchedAtEpochMs: Long, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is WearPaymentQrSyncPayload) return false + return fetchedAtEpochMs == other.fetchedAtEpochMs && bytes.contentEquals(other.bytes) + } + + override fun hashCode(): Int = 31 * bytes.contentHashCode() + fetchedAtEpochMs.hashCode() +} + +data class WearCompanionSyncEnvelope( + val sessionId: String, + val profileId: String? = null, + val schedule: WearScheduleSyncPayload, + val paymentQr: WearPaymentQrSyncPayload? = null, + val directPairing: Boolean = false, + val generatedAtEpochMs: Long? = null, +) { + companion object { + fun decode(payload: String): WearCompanionSyncEnvelope = + fromJson(JSONObject(payload)) + + fun fromJson(json: JSONObject): WearCompanionSyncEnvelope { + val version = json.optInt("schemaVersion", -1) + if (version != WearCompanionPaths.SCHEMA_VERSION) { + throw WearSyncFormatException("Unsupported Wear sync schema version.") + } + val sessionId = json.optString("sessionId", "") + if (sessionId.isEmpty()) { + throw WearSyncFormatException("Wear sync session is missing.") + } + return WearCompanionSyncEnvelope( + sessionId = sessionId, + profileId = json.optString("profileId", "").ifEmpty { null }, + schedule = parseSchedule(json.optJSONObject("schedule")), + paymentQr = parsePaymentQr(json.optJSONObject("paymentQr")), + directPairing = json.optBoolean("directPairing", false), + generatedAtEpochMs = if (json.has("generatedAtEpochMs")) { + json.getLong("generatedAtEpochMs") + } else { + null + }, + ) + } + + private fun parseSchedule(json: JSONObject?): WearScheduleSyncPayload { + if (json == null) { + throw WearSyncFormatException("Wear sync schedule is required.") + } + val classTableJson = json.optJSONObject("classTable") + ?: throw WearSyncFormatException("Wear sync class table is required.") + val experimentsJson = json.optJSONArray("otherExperiments") + val experiments = experimentsJson?.let { ExperimentData.listFromJsonArray(it) } + return WearScheduleSyncPayload( + classTable = ClassTableData.fromJson(classTableJson), + otherExperiments = experiments, + ) + } + + private fun parsePaymentQr(json: JSONObject?): WearPaymentQrSyncPayload? { + if (json == null) return null + val encoded = json.optString("pngBase64", "") + if (encoded.isEmpty() || !json.has("fetchedAtEpochMs")) { + throw WearSyncFormatException("Wear sync payment QR is invalid.") + } + return try { + WearPaymentQrSyncPayload( + bytes = Base64.getDecoder().decode(encoded), + fetchedAtEpochMs = json.getLong("fetchedAtEpochMs"), + ) + } catch (_: IllegalArgumentException) { + throw WearSyncFormatException("Wear sync payment QR is invalid.") + } + } + } +} + +class WearSyncFormatException(message: String) : Exception(message) + +/** Response from the phone payment proxy. */ +data class WearPaymentQrResponse( + val ok: Boolean, + val pngBytes: ByteArray? = null, + val fetchedAtEpochMs: Long? = null, + val error: String? = null, +) { + companion object { + fun decode(payload: String): WearPaymentQrResponse { + val json = JSONObject(payload) + val ok = json.optBoolean("ok", false) + if (!ok) { + return WearPaymentQrResponse( + ok = false, + error = json.optString("error", "phone_payment_request_failed"), + ) + } + val encoded = json.optString("pngBase64", "") + val fetchedAt = json.optLong("fetchedAtEpochMs", -1L) + if (encoded.isEmpty() || fetchedAt < 0) { + return WearPaymentQrResponse(ok = false, error = "invalid_payment_qr_response") + } + return WearPaymentQrResponse( + ok = true, + pngBytes = Base64.getDecoder().decode(encoded), + fetchedAtEpochMs = fetchedAt, + ) + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is WearPaymentQrResponse) return false + return ok == other.ok && + fetchedAtEpochMs == other.fetchedAtEpochMs && + error == other.error && + (pngBytes?.contentEquals(other.pngBytes ?: ByteArray(0)) ?: (other.pngBytes == null)) + } + + override fun hashCode(): Int { + var result = ok.hashCode() + result = 31 * result + (pngBytes?.contentHashCode() ?: 0) + result = 31 * result + (fetchedAtEpochMs?.hashCode() ?: 0) + result = 31 * result + (error?.hashCode() ?: 0) + return result + } +} diff --git a/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/sync/WearCompanionClient.kt b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/sync/WearCompanionClient.kt new file mode 100644 index 000000000..9dc884d97 --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/sync/WearCompanionClient.kt @@ -0,0 +1,207 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +package io.github.benderblog.traintime_pda.sync + +import android.content.Context +import android.util.Log +import com.google.android.gms.wearable.MessageClient +import com.google.android.gms.wearable.MessageEvent +import com.google.android.gms.wearable.Wearable +import io.github.benderblog.traintime_pda.data.WearSyncImporter +import io.github.benderblog.traintime_pda.protocol.WearCompanionPaths +import io.github.benderblog.traintime_pda.protocol.WearCompanionSyncEnvelope +import io.github.benderblog.traintime_pda.protocol.WearPaymentQrResponse +import kotlinx.coroutines.async +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.withTimeout +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Wear Data Layer client. Listens only while the activity is in the foreground + * (registered/unregistered from the Activity lifecycle) to avoid resident listeners. + */ +class WearCompanionClient( + context: Context, + private val importer: WearSyncImporter, +) : MessageClient.OnMessageReceivedListener { + private val appContext = context.applicationContext + private val messageClient: MessageClient = Wearable.getMessageClient(appContext) + + private val _imports = MutableSharedFlow( + extraBufferCapacity = 4, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val imports: SharedFlow = _imports.asSharedFlow() + + private val _paymentResponses = MutableSharedFlow( + extraBufferCapacity = 2, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val paymentResponses: SharedFlow = _paymentResponses.asSharedFlow() + + @Volatile + private var directPairingExpiresAtEpochMs: Long = 0 + + @Volatile + private var pendingSyncPayload: String? = null + private val listening = AtomicBoolean(false) + + fun beginDirectPairing(): Long { + directPairingExpiresAtEpochMs = + System.currentTimeMillis() + WearCompanionPaths.DIRECT_PAIRING_TTL_MS + return directPairingExpiresAtEpochMs + } + + fun isCompanionPaired(): Boolean = pairedPhoneNodeId() != null + + fun startListening() { + synchronized(listening) { + if (!listening.compareAndSet(false, true)) return + messageClient.addListener(this) + } + pendingSyncPayload?.let { payload -> + pendingSyncPayload = null + handleSyncPayload(payload, pairedPhoneNodeId()) + } + } + + fun stopListening() { + synchronized(listening) { + if (!listening.compareAndSet(true, false)) return + messageClient.removeListener(this) + } + } + + fun isForeground(): Boolean = listening.get() + + private fun requireForeground() { + check(isForeground()) { "Wear app is not in the foreground" } + } + + private fun sendForegroundMessage(nodeId: String, path: String, data: ByteArray) = + synchronized(listening) { + requireForeground() + messageClient.sendMessage(nodeId, path, data) + } + + suspend fun requestCompanionSync() { + requireForeground() + val nodeId = pairedPhoneNodeId() + ?: throw IllegalStateException("No companion phone is paired") + sendForegroundMessage(nodeId, WearCompanionPaths.REQUEST, ByteArray(0)).await() + } + + suspend fun requestPaymentQrFromPhone(timeoutMs: Long = 20_000L): WearPaymentQrResponse { + requireForeground() + val nodeId = pairedPhoneNodeId() + ?: throw IllegalStateException("No companion phone is paired") + return withTimeout(timeoutMs) { + val connected = Wearable.getNodeClient(appContext).connectedNodes.await() + if (connected.none { it.id == nodeId }) { + throw IllegalStateException("Companion phone is disconnected") + } + coroutineScope { + // Subscribe before send to avoid missing a fast response. + val deferred = async(start = CoroutineStart.UNDISPATCHED) { + paymentResponses.first() + } + sendForegroundMessage(nodeId, WearCompanionPaths.PAYMENT_REQUEST, ByteArray(0)) + .await() + deferred.await() + } + } + } + + override fun onMessageReceived(event: MessageEvent) { + if (!isForeground()) return + when (event.path) { + WearCompanionPaths.PAYMENT_RESPONSE -> { + if (event.sourceNodeId != pairedPhoneNodeId()) return + val payload = event.data.toString(Charsets.UTF_8) + val response = try { + WearPaymentQrResponse.decode(payload) + } catch (e: Exception) { + Log.w(TAG, "Invalid payment response", e) + WearPaymentQrResponse(ok = false, error = "invalid_payment_qr_response") + } + _paymentResponses.tryEmit(response) + } + WearCompanionPaths.SYNC -> { + val payload = event.data.toString(Charsets.UTF_8) + if (!isActiveSyncPayload(payload, event.sourceNodeId)) return + handleSyncPayload(payload, event.sourceNodeId) + } + } + } + + private fun handleSyncPayload(payload: String, sourceNodeId: String?) { + try { + val envelope = WearCompanionSyncEnvelope.decode(payload) + importer.importEnvelope(envelope, payload) + sourceNodeId?.takeIf { isForeground() }?.let { nodeId -> + rememberPairedPhone(nodeId) + sendForegroundMessage( + nodeId, + WearCompanionPaths.SYNC_ACK, + envelope.sessionId.toByteArray(Charsets.UTF_8), + ).addOnFailureListener { error -> + Log.w(TAG, "Failed to acknowledge sync", error) + } + } + clearActiveSyncSession() + _imports.tryEmit(envelope) + } catch (e: Exception) { + Log.w(TAG, "Failed to import sync payload", e) + // Keep payload for a later retry only when channel was unavailable in Flutter; + // with native import we surface failure by not emitting. + } + } + + private fun isActiveSyncPayload(payload: String, sourceNodeId: String): Boolean { + return try { + val json = org.json.JSONObject(payload) + if (json.optInt("schemaVersion") != WearCompanionPaths.SCHEMA_VERSION) return false + val paired = pairedPhoneNodeId() + if (paired != null) return paired == sourceNodeId + json.optBoolean("directPairing", false) && + (listening.get() || System.currentTimeMillis() <= directPairingExpiresAtEpochMs) + } catch (_: Exception) { + false + } + } + + private fun rememberPairedPhone(nodeId: String) { + appContext.getSharedPreferences(WearCompanionPaths.PREFS, Context.MODE_PRIVATE) + .edit() + .putString(WearCompanionPaths.PAIRED_PHONE_NODE_ID, nodeId) + .apply() + } + + fun pairedPhoneNodeId(): String? = + appContext.getSharedPreferences(WearCompanionPaths.PREFS, Context.MODE_PRIVATE) + .getString(WearCompanionPaths.PAIRED_PHONE_NODE_ID, null) + + fun clearPairedPhone() { + appContext.getSharedPreferences(WearCompanionPaths.PREFS, Context.MODE_PRIVATE) + .edit() + .remove(WearCompanionPaths.PAIRED_PHONE_NODE_ID) + .apply() + } + + private fun clearActiveSyncSession() { + directPairingExpiresAtEpochMs = 0 + pendingSyncPayload = null + } + + companion object { + private const val TAG = "WearCompanionClient" + } +} diff --git a/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/tiles/AgendaTileService.kt b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/tiles/AgendaTileService.kt new file mode 100644 index 000000000..bb2844b94 --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/tiles/AgendaTileService.kt @@ -0,0 +1,183 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +package io.github.benderblog.traintime_pda.tiles + +import androidx.wear.protolayout.ActionBuilders +import androidx.wear.protolayout.ColorBuilders.argb +import androidx.wear.protolayout.DimensionBuilders.dp +import androidx.wear.protolayout.DimensionBuilders.expand +import androidx.wear.protolayout.DimensionBuilders.sp +import androidx.wear.protolayout.LayoutElementBuilders +import androidx.wear.protolayout.ModifiersBuilders +import androidx.wear.protolayout.ResourceBuilders +import androidx.wear.protolayout.TimelineBuilders +import androidx.wear.tiles.RequestBuilders +import androidx.wear.tiles.TileBuilders +import androidx.wear.tiles.TileService +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture +import io.github.benderblog.traintime_pda.MainActivity +import io.github.benderblog.traintime_pda.data.WearCacheStore +import io.github.benderblog.traintime_pda.data.WearPreferences +import io.github.benderblog.traintime_pda.domain.WearAgendaBuilder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.guava.future +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +/** System-managed, cache-only provider; it never creates a network client. */ +class AgendaTileService : TileService() { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + override fun onTileRequest(requestParams: RequestBuilders.TileRequest): ListenableFuture = + scope.future { + val cache = WearCacheStore(this@AgendaTileService) + val preferences = WearPreferences(this@AgendaTileService) + val zone = ZoneId.systemDefault() + val now = LocalDateTime.now(zone) + val table = cache.readClassTable() + val experiments = cache.readExperiments() + val timeline = TimelineBuilders.Timeline.Builder() + .addTimelineEntry( + TimelineBuilders.TimelineEntry.Builder() + .setLayout(layout("XDYou 课表", "打开应用查看课表", "本地课表需刷新")) + .build(), + ) + + if (table == null && experiments == null) { + timeline.addTimelineEntry( + TimelineBuilders.TimelineEntry.Builder() + .setLayout(layout("XDYou 课表", "请先同步课表", "打开应用完成配对和同步")) + .setValidity(interval(now, now.toLocalDate().plusDays(2).atStartOfDay(), zone)) + .build(), + ) + } else { + val data = WearAgendaBuilder.loadHomeData( + preferences.currentSemester, table, experiments, now, zone, + ) + val entries = AgendaTileTimeline.build( + data.todayItems + data.tomorrowItems, + now, + now.toLocalDate().plusDays(2).atStartOfDay(), + ) + for (entry in entries) { + val item = entry.item + val title = item?.title ?: "暂无后续安排" + val time = item?.let { + "${it.start.format(DateTimeFmt)}–${it.end.format(TimeFmt)}" + } ?: "今天和明天没有剩余安排" + timeline.addTimelineEntry( + TimelineBuilders.TimelineEntry.Builder() + .setValidity(interval(entry.start, entry.end, zone)) + .setLayout( + layout( + if (entry.ongoing) "进行中" else "XDYou · 接下来", + title, + time, + item?.location, + ), + ) + .build(), + ) + } + } + TileBuilders.Tile.Builder() + .setResourcesVersion(RESOURCES_VERSION) + .setTileTimeline(timeline.build()) + // Rebuild tomorrow's local timeline daily; course transitions are system-rendered. + .setFreshnessIntervalMillis(24 * 60 * 60 * 1000L) + .build() + } + + override fun onTileResourcesRequest( + requestParams: RequestBuilders.ResourcesRequest, + ): ListenableFuture = Futures.immediateFuture( + ResourceBuilders.Resources.Builder().setVersion(RESOURCES_VERSION).build(), + ) + + override fun onDestroy() { + scope.cancel() + super.onDestroy() + } + + private fun interval(start: LocalDateTime, end: LocalDateTime, zone: ZoneId) = + TimelineBuilders.TimeInterval.Builder() + .setStartMillis(start.atZone(zone).toInstant().toEpochMilli()) + .setEndMillis(end.atZone(zone).toInstant().toEpochMilli()) + .build() + + private fun layout( + header: String, + title: String, + detail: String, + location: String? = null, + ): LayoutElementBuilders.Layout { + val content = LayoutElementBuilders.Column.Builder() + .setWidth(expand()) + .setHorizontalAlignment(LayoutElementBuilders.HORIZONTAL_ALIGN_CENTER) + .addContent(text(header, 12f, ACCENT)) + .addContent(LayoutElementBuilders.Spacer.Builder().setHeight(dp(8f)).build()) + .addContent(text(title, 18f, WHITE, 2)) + .addContent(LayoutElementBuilders.Spacer.Builder().setHeight(dp(6f)).build()) + .addContent(text(detail, 12f, WHITE)) + location?.let { content.addContent(text(it, 12f, SECONDARY)) } + content.addContent(LayoutElementBuilders.Spacer.Builder().setHeight(dp(8f)).build()) + .addContent(text("查看课表 ›", 12f, ACCENT)) + + val clickable = ModifiersBuilders.Clickable.Builder() + .setId("open_agenda") + .setOnClick( + ActionBuilders.LaunchAction.Builder() + .setAndroidActivity( + ActionBuilders.AndroidActivity.Builder() + .setPackageName(packageName) + .setClassName(MainActivity::class.java.name) + .addKeyToExtraMapping(MainActivity.EXTRA_SHOW_AGENDA, ActionBuilders.booleanExtra(true)) + .build(), + ) + .build(), + ) + .build() + return LayoutElementBuilders.Layout.Builder().setRoot( + LayoutElementBuilders.Box.Builder() + .setWidth(expand()) + .setHeight(expand()) + .setModifiers( + ModifiersBuilders.Modifiers.Builder() + .setClickable(clickable) + .setPadding(ModifiersBuilders.Padding.Builder().setAll(dp(24f)).build()) + .setSemantics( + ModifiersBuilders.Semantics.Builder() + .setContentDescription(listOfNotNull(header, title, detail, location, "查看课表").joinToString(",")) + .build(), + ) + .build(), + ) + .addContent(content.build()) + .build(), + ).build() + } + + private fun text(value: String, size: Float, color: Int, lines: Int = 1) = + LayoutElementBuilders.Text.Builder() + .setText(value) + .setFontStyle(LayoutElementBuilders.FontStyle.Builder().setSize(sp(size)).setColor(argb(color)).build()) + .setMaxLines(lines) + .setMultilineAlignment(LayoutElementBuilders.TEXT_ALIGN_CENTER) + .setOverflow(LayoutElementBuilders.TEXT_OVERFLOW_ELLIPSIZE) + .build() + + private companion object { + const val RESOURCES_VERSION = "agenda-1" + const val ACCENT = 0xFF70D6FF.toInt() + const val WHITE = 0xFFFFFFFF.toInt() + const val SECONDARY = 0xFFB0B0B0.toInt() + val DateTimeFmt: DateTimeFormatter = DateTimeFormatter.ofPattern("MM-dd HH:mm") + val TimeFmt: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm") + } +} diff --git a/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/tiles/AgendaTileTimeline.kt b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/tiles/AgendaTileTimeline.kt new file mode 100644 index 000000000..e470a5eea --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/tiles/AgendaTileTimeline.kt @@ -0,0 +1,40 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +package io.github.benderblog.traintime_pda.tiles + +import io.github.benderblog.traintime_pda.domain.WearAgendaItem +import java.time.LocalDateTime + +data class AgendaTileEntry( + val start: LocalDateTime, + val end: LocalDateTime, + val item: WearAgendaItem?, + val ongoing: Boolean, +) + +object AgendaTileTimeline { + fun build( + items: List, + now: LocalDateTime, + until: LocalDateTime, + ): List { + if (until <= now) return emptyList() + val upcoming = items.filter { it.end > now && it.start < until && it.end > it.start } + .sortedWith(compareBy({ it.start }, { it.end })) + val boundaries = (listOf(now, until) + upcoming.flatMap { listOf(it.start, it.end) }) + .filter { it >= now && it <= until }.distinct().sorted() + val entries = mutableListOf() + for ((start, end) in boundaries.zipWithNext()) { + val active = upcoming.firstOrNull { it.start <= start && it.end > start } + val item = active ?: upcoming.firstOrNull { it.start > start } + val previous = entries.lastOrNull() + if (previous != null && previous.item == item && previous.ongoing == (active != null)) { + entries[entries.lastIndex] = previous.copy(end = end) + } else { + entries += AgendaTileEntry(start, end, item, active != null) + } + } + return entries + } +} diff --git a/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ui/PressFeedback.kt b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ui/PressFeedback.kt new file mode 100644 index 000000000..0f5d8d487 --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ui/PressFeedback.kt @@ -0,0 +1,27 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +package io.github.benderblog.traintime_pda.ui + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer + +@Composable +internal fun Modifier.pressFeedback(source: MutableInteractionSource): Modifier { + val pressed by source.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (pressed) 0.96f else 1f, + animationSpec = tween(120), + label = "pressFeedback", + ) + return graphicsLayer { + scaleX = scale + scaleY = scale + } +} diff --git a/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ui/PullRefresh.kt b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ui/PullRefresh.kt new file mode 100644 index 000000000..ef994ece9 --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ui/PullRefresh.kt @@ -0,0 +1,90 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +package io.github.benderblog.traintime_pda.ui + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material.CircularProgressIndicator +import androidx.wear.compose.material.MaterialTheme + +@Composable +internal fun PullRefresh( + refreshing: Boolean, + onRefresh: () -> Unit, + content: @Composable () -> Unit, +) { + val threshold = with(LocalDensity.current) { 48.dp.toPx() } + val busy by rememberUpdatedState(refreshing) + val refresh by rememberUpdatedState(onRefresh) + var pull by remember { mutableFloatStateOf(0f) } + val connection = remember(threshold) { + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + if (source != NestedScrollSource.UserInput || busy || pull == 0f || available.y >= 0f) return Offset.Zero + val consumed = available.y.coerceAtLeast(-pull * 2f) + pull += consumed / 2f + return Offset(0f, consumed) + } + + override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { + // Only the distance left after the page reaches its top starts a refresh. + if (source != NestedScrollSource.UserInput || busy || available.y <= 0f) return Offset.Zero + pull = (pull + available.y / 2f).coerceAtMost(threshold * 1.5f) + return Offset(0f, available.y) + } + + override suspend fun onPreFling(available: Velocity): Velocity { + val released = pull + pull = 0f + if (!busy && released >= threshold) refresh() + return if (released > 0f) available else Velocity.Zero + } + } + } + val offset by animateFloatAsState( + targetValue = if (refreshing) threshold else pull, + animationSpec = tween(160), + label = "pullRefresh", + ) + Box(Modifier.fillMaxSize().nestedScroll(connection)) { + Box(Modifier.fillMaxSize()) { content() } + if (offset > 1f) { + Box( + Modifier.align(Alignment.TopCenter).padding(top = 14.dp) + .graphicsLayer { alpha = (offset / threshold).coerceIn(0f, 1f) } + .background(MaterialTheme.colors.surface, RoundedCornerShape(20.dp)) + .padding(horizontal = 10.dp, vertical = 6.dp), + contentAlignment = Alignment.Center, + ) { + if (refreshing) CircularProgressIndicator(Modifier.size(18.dp)) + else CircularProgressIndicator( + progress = (pull / threshold).coerceIn(0f, 1f), + modifier = Modifier.size(18.dp), + ) + } + } + } +} diff --git a/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ui/WearApp.kt b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ui/WearApp.kt new file mode 100644 index 000000000..9d00e7ed6 --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ui/WearApp.kt @@ -0,0 +1,87 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +package io.github.benderblog.traintime_pda.ui + +import androidx.compose.foundation.background +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.activity.compose.BackHandler +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.wear.compose.material.MaterialTheme +import androidx.wear.compose.material.Colors +import io.github.benderblog.traintime_pda.ui.screens.HomeScreen +import io.github.benderblog.traintime_pda.ui.screens.AgendaScreen +import io.github.benderblog.traintime_pda.ui.screens.PairingScreen +import io.github.benderblog.traintime_pda.ui.screens.QrScreen + +private val WearColorPalette = Colors( + primary = Color(0xFF70D6FF), + primaryVariant = Color(0xFF38BDF2), + secondary = Color(0xFFA7E8F0), + secondaryVariant = Color(0xFF62CEDB), + background = Color.Black, + surface = Color(0xFF1C1C1E), + error = Color(0xFFFF6B6B), + onPrimary = Color.Black, + onSecondary = Color.Black, + onBackground = Color.White, + onSurface = Color.White, + onError = Color.Black, +) + +@Composable +fun WearApp(viewModel: WearViewModel) { + val state by viewModel.state.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + if (state.screen == WearScreen.PAIRING) { + viewModel.beginPairing() + } + } + + BackHandler(enabled = state.screen == WearScreen.QR) { + viewModel.closeQr() + } + BackHandler(enabled = state.screen == WearScreen.AGENDA) { + viewModel.showAgenda() + } + + MaterialTheme(colors = WearColorPalette) { + androidx.compose.foundation.layout.Box( + modifier = Modifier.background(MaterialTheme.colors.background), + ) { + when (state.screen) { + WearScreen.PAIRING -> PairingScreen( + starting = state.pairingStarting, + status = state.pairingStatus, + ) + WearScreen.HOME -> HomeScreen( + data = state.homeData, + loading = state.homeLoading || state.syncing, + error = state.homeError, + onRefresh = viewModel::requestManualSync, + onOpenAgenda = viewModel::openAgenda, + onOpenQr = viewModel::openQr, + ) + WearScreen.AGENDA -> AgendaScreen( + weeks = state.agendaWeeks, + initialWeek = state.agendaInitialWeek, + showSwipeHint = state.agendaShowSwipeHint, + error = state.agendaError, + ) + WearScreen.QR -> QrScreen( + loading = state.qrLoading, + result = state.qrResult, + error = state.qrError, + onBack = viewModel::closeQr, + onRetry = viewModel::retryQr, + ) + + } + } + } +} diff --git a/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ui/WearViewModel.kt b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ui/WearViewModel.kt new file mode 100644 index 000000000..b20a54b71 --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ui/WearViewModel.kt @@ -0,0 +1,352 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +package io.github.benderblog.traintime_pda.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import io.github.benderblog.traintime_pda.WearAppContainer +import io.github.benderblog.traintime_pda.domain.WearAgendaBuilder +import io.github.benderblog.traintime_pda.domain.WearHomeData +import io.github.benderblog.traintime_pda.domain.WearAgendaDay +import io.github.benderblog.traintime_pda.payment.PaymentQrRepository +import io.github.benderblog.traintime_pda.payment.PaymentQrResult +import kotlinx.coroutines.Job +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import java.time.LocalDate +import java.time.DayOfWeek +import java.time.temporal.ChronoUnit +import java.time.temporal.TemporalAdjusters + +enum class WearScreen { + PAIRING, + HOME, + AGENDA, + QR, +} + +data class WearUiState( + val screen: WearScreen = WearScreen.PAIRING, + val homeData: WearHomeData = WearHomeData(emptyList(), emptyList()), + val homeLoading: Boolean = false, + val homeError: String? = null, + val syncing: Boolean = false, + val agendaWeeks: List> = emptyList(), + val agendaInitialWeek: Int = 0, + val agendaShowSwipeHint: Boolean = false, + val agendaError: String? = null, + val pairingStarting: Boolean = true, + val pairingStatus: String = "请在手机端打开“设置 > XDYou Wear”,选择这块手表", + val qrLoading: Boolean = false, + val qrResult: PaymentQrResult? = null, + val qrError: String? = null, + +) + +class WearViewModel( + private val container: WearAppContainer, +) : ViewModel() { + private val paymentRepo = PaymentQrRepository( + cache = container.cache, + companionClient = container.companionClient, + ) + + private val _state = MutableStateFlow( + WearUiState( + screen = if (container.needsPairing()) WearScreen.PAIRING else WearScreen.HOME, + ), + ) + val state: StateFlow = _state.asStateFlow() + + private var pendingSyncJob: Job? = null + private var qrJob: Job? = null + private var qrFlowGeneration = 0L + + init { + if (!container.needsPairing()) { + refreshHome() + } + viewModelScope.launch { + container.companionClient.imports.collect { + onImportReceived() + } + } + } + + fun onForeground() { + container.companionClient.startListening() + + } + + fun onBackground() { + container.companionClient.stopListening() + pendingSyncJob?.cancel() + if (_state.value.screen == WearScreen.QR && + (_state.value.qrLoading || _state.value.qrResult == null) + ) { + qrFlowGeneration++ + cancelActiveQrWork() + _state.update { + it.copy( + screen = WearScreen.QR, + qrLoading = false, + qrError = "操作已暂停,请下拉刷新后重试", + ) + } + } + } + + fun beginPairing() { + viewModelScope.launch { + _state.update { + it.copy( + screen = WearScreen.PAIRING, + pairingStarting = true, + pairingStatus = "请在手机端打开“设置 > XDYou Wear”,选择这块手表", + ) + } + try { + container.companionClient.beginDirectPairing() + _state.update { it.copy(pairingStarting = false) } + } catch (e: Exception) { + _state.update { + it.copy( + pairingStarting = false, + pairingStatus = "无法开始配对:${e.message ?: e}", + ) + } + } + } + } + + private fun onImportReceived() { + _state.update { + it.copy( + screen = WearScreen.HOME, + homeError = null, + syncing = false, + ) + } + refreshHome() + } + + fun refreshHome() { + viewModelScope.launch { + _state.update { it.copy(homeLoading = true, homeError = null) } + try { + val data = WearAgendaBuilder.loadHomeData( + semesterCode = container.preferences.currentSemester, + classTable = container.cache.readClassTable(), + experiments = container.cache.readExperiments(), + ) + _state.update { + it.copy(homeData = data, homeLoading = false, homeError = null) + } + } catch (e: Exception) { + _state.update { + it.copy( + homeLoading = false, + homeError = e.message ?: e.toString(), + ) + } + } + } + } + + fun openAgenda() { + try { + val table = container.cache.readClassTable() + val experiments = container.cache.readExperiments() + val currentWeek = LocalDate.now().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) + val termStart = table?.termStartDay?.let { raw -> + runCatching { LocalDate.parse(raw.take(10)) }.getOrNull() + } + val start = termStart?.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) ?: currentWeek + val weekCount = if (termStart != null) table.semesterLength.coerceAtLeast(1) else 1 + val weeks = (0 until weekCount).map { week -> + WearAgendaBuilder.loadWeekData( + semesterCode = container.preferences.currentSemester, + classTable = table, + experiments = experiments, + weekStart = start.plusWeeks(week.toLong()), + ) + } + val initialWeek = ChronoUnit.WEEKS.between(start, currentWeek).toInt().coerceIn(0, weeks.lastIndex) + val showSwipeHint = !container.preferences.agendaSwipeHintShown + container.preferences.agendaSwipeHintShown = true + _state.update { + it.copy( + screen = WearScreen.AGENDA, + agendaWeeks = weeks, + agendaInitialWeek = initialWeek, + agendaShowSwipeHint = showSwipeHint, + agendaError = null, + ) + } + } catch (_: Exception) { + _state.update { + it.copy(screen = WearScreen.AGENDA, agendaWeeks = emptyList(), agendaError = "无法读取日程,请返回首页下拉同步") + } + } + } + + fun requestManualSync() { + if (pendingSyncJob?.isActive == true) return + pendingSyncJob = viewModelScope.launch { + _state.update { it.copy(syncing = true, homeError = null) } + try { + withTimeout(15_000L) { + coroutineScope { + val wait = async(start = CoroutineStart.UNDISPATCHED) { + container.companionClient.imports.first() + } + container.companionClient.requestCompanionSync() + wait.await() + } + } + } catch (_: TimeoutCancellationException) { + _state.update { it.copy(homeError = "同步超时,请确认手机已连接") } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (e: Exception) { + // Keep previous cache; surface a soft error while offline data remains usable. + _state.update { + it.copy(homeError = e.message ?: "同步失败") + } + } finally { + _state.update { it.copy(syncing = false) } + } + } + } + + fun logout() { + container.importer.logout() + container.companionClient.clearPairedPhone() + _state.update { + WearUiState( + screen = WearScreen.PAIRING, + pairingStarting = true, + pairingStatus = "请在手机端打开“设置 > XDYou Wear”,选择这块手表", + ) + } + beginPairing() + } + + fun openQr() { + val flowId = startNewQrFlow() + _state.update { + it.copy( + screen = WearScreen.QR, + qrLoading = true, + qrResult = null, + qrError = null, + ) + } + loadQr(flowId, forceRefresh = false) + } + + fun closeQr() { + qrFlowGeneration++ + cancelActiveQrWork() + _state.update { + it.copy( + screen = WearScreen.HOME, + qrLoading = false, + qrResult = null, + qrError = null, + ) + } + } + + fun showAgenda() { + if (container.needsPairing()) return + closeQr() + refreshHome() + } + + fun retryQr() { + val flowId = startNewQrFlow() + _state.update { + it.copy( + qrLoading = true, + qrError = null, + ) + } + loadQr(flowId, forceRefresh = true) + } + + private fun loadQr(flowId: Long, forceRefresh: Boolean) { + qrJob = viewModelScope.launch { + try { + val result = paymentRepo.load(forceRefresh = forceRefresh) + ensureActive() + if (!isQrFlowActive(flowId)) return@launch + _state.update { + it.copy( + qrLoading = false, + qrResult = result, + qrError = null, + ) + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (e: Exception) { + if (!isQrFlowActive(flowId)) return@launch + _state.update { + it.copy( + qrLoading = false, + qrResult = null, + qrError = e.message ?: "付款码获取失败", + ) + } + } + } + } + + private fun startNewQrFlow(): Long { + cancelActiveQrWork() + qrFlowGeneration++ + return qrFlowGeneration + } + + private fun isQrFlowActive(flowId: Long): Boolean = + qrFlowGeneration == flowId && + _state.value.screen == WearScreen.QR + + private fun cancelActiveQrWork() { + val cancellation = CancellationException("付款码页面已关闭") + qrJob?.cancel(cancellation) + qrJob = null + + } + + override fun onCleared() { + qrFlowGeneration++ + cancelActiveQrWork() + container.companionClient.stopListening() + super.onCleared() + } + + class Factory(private val container: WearAppContainer) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(WearViewModel::class.java)) { + return WearViewModel(container) as T + } + throw IllegalArgumentException("Unknown ViewModel: ${modelClass.name}") + } + } +} diff --git a/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ui/screens/AgendaScreen.kt b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ui/screens/AgendaScreen.kt new file mode 100644 index 000000000..301fa3bcb --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ui/screens/AgendaScreen.kt @@ -0,0 +1,89 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +package io.github.benderblog.traintime_pda.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.wear.compose.material.MaterialTheme +import androidx.wear.compose.material.Text +import io.github.benderblog.traintime_pda.domain.WearAgendaDay +import java.time.format.DateTimeFormatter +import java.util.Locale +import kotlinx.coroutines.delay + +private val DayFmt = DateTimeFormatter.ofPattern("EEEE M/d", Locale.CHINESE) +private val RangeFmt = DateTimeFormatter.ofPattern("M/d") + +@Composable +fun AgendaScreen( + weeks: List>, + initialWeek: Int, + showSwipeHint: Boolean, + error: String?, +) { + if (weeks.isEmpty()) { + Column( + modifier = Modifier.fillMaxSize().padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(error ?: "暂无日程", textAlign = TextAlign.Center) + } + return + } + val pager = rememberPagerState(initialPage = initialWeek) { weeks.size } + var hintVisible by remember(showSwipeHint) { mutableStateOf(showSwipeHint) } + LaunchedEffect(showSwipeHint) { + if (showSwipeHint) { + delay(3_000) + hintVisible = false + } + } + Box(Modifier.fillMaxSize()) { + HorizontalPager(state = pager, modifier = Modifier.fillMaxSize()) { page -> + val days = weeks[page] + Column( + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()) + .padding(horizontal = 28.dp, vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text("第 ${page + 1} 周", style = MaterialTheme.typography.title3) + Text( + "${days.first().date.format(RangeFmt)} – ${days.last().date.format(RangeFmt)}", + style = MaterialTheme.typography.caption1, + ) + days.forEach { day -> + AgendaSection(day.date.format(DayFmt), day.items) + } + } + } + if (hintVisible) { + Text( + "左右滑动切换周", + fontSize = 10.sp, + modifier = Modifier.align(Alignment.TopCenter).padding(top = 10.dp) + .background(MaterialTheme.colors.surface, RoundedCornerShape(16.dp)) + .padding(horizontal = 8.dp, vertical = 3.dp), + ) + } + } +} diff --git a/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ui/screens/HomeScreen.kt b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ui/screens/HomeScreen.kt new file mode 100644 index 000000000..047153f6b --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ui/screens/HomeScreen.kt @@ -0,0 +1,217 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +package io.github.benderblog.traintime_pda.ui.screens + +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.clickable +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.wear.compose.material.Chip +import androidx.wear.compose.material.ChipDefaults +import androidx.wear.compose.material.MaterialTheme +import androidx.wear.compose.material.Text +import io.github.benderblog.traintime_pda.domain.WearAgendaItem +import io.github.benderblog.traintime_pda.domain.WearAgendaKind +import io.github.benderblog.traintime_pda.domain.WearHomeData +import io.github.benderblog.traintime_pda.ui.PullRefresh +import io.github.benderblog.traintime_pda.ui.pressFeedback +import java.time.format.DateTimeFormatter + +private val TimeFmt: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm") + +@Composable +fun HomeScreen( + data: WearHomeData, + loading: Boolean, + error: String?, + onRefresh: () -> Unit, + onOpenAgenda: () -> Unit, + onOpenQr: () -> Unit, +) { + PullRefresh(refreshing = loading, onRefresh = onRefresh) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(start = 28.dp, top = 32.dp, end = 28.dp, bottom = 28.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Column( + modifier = Modifier + .widthIn(max = 280.dp) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + val agendaInteraction = remember { MutableInteractionSource() } + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(18.dp)) + .background(MaterialTheme.colors.surface) + .clickable( + interactionSource = agendaInteraction, + indication = LocalIndication.current, + onClick = onOpenAgenda, + ) + .pressFeedback(agendaInteraction) + .padding(12.dp), + ) { + Text("日程", style = MaterialTheme.typography.title3) + AgendaSection(title = "今天", items = data.todayItems) + AgendaSection(title = "明天", items = data.tomorrowItems) + Text("查看其他日期 ›", color = MaterialTheme.colors.primary, style = MaterialTheme.typography.caption2) + } + Spacer(Modifier.height(8.dp)) + CampusCard(onOpenQr = onOpenQr) + if (error != null) { + Spacer(Modifier.height(6.dp)) + Text( + text = error.take(80), + color = MaterialTheme.colors.error, + style = MaterialTheme.typography.caption2, + textAlign = TextAlign.Center, + ) + } + } + } + } +} + +@Composable +internal fun AgendaSection(title: String, items: List) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalAlignment = Alignment.Start, + ) { + Text( + text = title, + style = MaterialTheme.typography.title3, + fontWeight = FontWeight.SemiBold, + ) + if (items.isEmpty()) { + Text( + text = "没有安排", + style = MaterialTheme.typography.body2, + modifier = Modifier.padding(vertical = 8.dp), + ) + } else { + items.forEach { item -> + AgendaCard(item) + } + } + } +} + +@Composable +private fun AgendaCard(item: WearAgendaItem) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clip(RoundedCornerShape(18.dp)) + .background(MaterialTheme.colors.surface) + .padding(12.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + KindPill(item.kind) + Spacer(Modifier.padding(horizontal = 3.dp)) + Text( + text = item.title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.body1, + modifier = Modifier.weight(1f), + ) + } + Spacer(Modifier.height(6.dp)) + Text( + text = "${item.start.format(TimeFmt)}-${item.end.format(TimeFmt)}", + style = MaterialTheme.typography.body2, + ) + item.location?.let { + Text( + text = it, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.caption1, + ) + } + item.subtitle?.let { + Text( + text = it, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.caption2, + ) + } + } +} + +@Composable +private fun KindPill(kind: WearAgendaKind) { + val (label, color) = when (kind) { + WearAgendaKind.COURSE -> "课" to MaterialTheme.colors.primary + WearAgendaKind.OTHER_EXPERIMENT -> "实" to Color(0xFF4CAF50) + } + Text( + text = label, + color = color, + fontSize = 11.sp, + modifier = Modifier + .clip(RoundedCornerShape(999.dp)) + .background(color.copy(alpha = 0.22f)) + .padding(horizontal = 7.dp, vertical = 2.dp), + ) +} + +@Composable +private fun CampusCard(onOpenQr: () -> Unit) { + val interaction = remember { MutableInteractionSource() } + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(18.dp)) + .background(MaterialTheme.colors.surface) + .padding(14.dp), + ) { + Text( + text = "校园卡", + style = MaterialTheme.typography.title3, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(8.dp)) + Chip( + onClick = onOpenQr, + interactionSource = interaction, + label = { Text("付款码") }, + colors = ChipDefaults.primaryChipColors(), + modifier = Modifier.fillMaxWidth().pressFeedback(interaction), + ) + } +} diff --git a/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ui/screens/PairingScreen.kt b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ui/screens/PairingScreen.kt new file mode 100644 index 000000000..9642593f6 --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ui/screens/PairingScreen.kt @@ -0,0 +1,68 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +package io.github.benderblog.traintime_pda.ui.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.wear.compose.material.CircularProgressIndicator +import androidx.wear.compose.material.MaterialTheme +import androidx.wear.compose.material.Text + +@Composable +fun PairingScreen( + starting: Boolean, + status: String, +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 28.dp, vertical = 36.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top, + ) { + Spacer(Modifier.height(8.dp)) + if (starting) { + CircularProgressIndicator(modifier = Modifier.size(40.dp)) + } else { + Text( + text = "⌚", + fontSize = 40.sp, + ) + } + Spacer(Modifier.height(12.dp)) + Text( + text = "等待手机配对", + style = MaterialTheme.typography.title3, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(10.dp)) + Text( + text = status, + style = MaterialTheme.typography.body2, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + if (starting) { + Spacer(Modifier.height(12.dp)) + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + } +} diff --git a/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ui/screens/QrScreen.kt b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ui/screens/QrScreen.kt new file mode 100644 index 000000000..bac3f6d0e --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ui/screens/QrScreen.kt @@ -0,0 +1,197 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +package io.github.benderblog.traintime_pda.ui.screens + +import android.graphics.BitmapFactory +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.rememberScrollableState +import androidx.compose.foundation.gestures.scrollable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.wear.compose.material.Button +import androidx.wear.compose.material.ButtonDefaults +import androidx.wear.compose.material.CircularProgressIndicator +import androidx.wear.compose.material.MaterialTheme +import androidx.wear.compose.material.Text +import io.github.benderblog.traintime_pda.payment.PaymentQrResult +import io.github.benderblog.traintime_pda.ui.PullRefresh +import io.github.benderblog.traintime_pda.ui.pressFeedback +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import kotlinx.coroutines.delay + +private val CacheFmt: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss") + +@Composable +fun QrScreen( + loading: Boolean, + result: PaymentQrResult?, + error: String?, + onBack: () -> Unit, + onRetry: () -> Unit, +) { + PullRefresh(refreshing = loading, onRefresh = onRetry) { + when { + loading && result == null -> { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 34.dp, vertical = 28.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = "正在获取付款码", + textAlign = TextAlign.Center, + style = MaterialTheme.typography.body2, + ) + } + } + error != null && result == null -> { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(28.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text("付款码获取失败", textAlign = TextAlign.Center) + Spacer(Modifier.height(6.dp)) + Text( + text = error.take(100), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.caption2, + color = MaterialTheme.colors.error, + ) + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + val retryInteraction = remember { MutableInteractionSource() } + val backInteraction = remember { MutableInteractionSource() } + Button( + onClick = onRetry, + interactionSource = retryInteraction, + modifier = Modifier.pressFeedback(retryInteraction), + ) { Text("重试") } + Button( + onClick = onBack, + colors = ButtonDefaults.secondaryButtonColors(), + interactionSource = backInteraction, + modifier = Modifier.pressFeedback(backInteraction), + ) { Text("返回") } + } + } + } + result != null -> { + var showTime by remember(result) { mutableStateOf(true) } + LaunchedEffect(result) { + delay(3_000) + showTime = false + } + Box( + modifier = Modifier + .fillMaxSize() + .scrollable(rememberScrollableState { 0f }, Orientation.Vertical), + ) { + Box( + modifier = Modifier + .align(Alignment.Center) + .fillMaxWidth(0.78f) + .aspectRatio(1f) + .clip(RoundedCornerShape(18.dp)) + .background(Color.White) + .padding(12.dp), + ) { + val bitmap = remember(result.bytes) { + BitmapFactory.decodeByteArray(result.bytes, 0, result.bytes.size) + } + if (bitmap != null) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = "付款码", + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxSize(), + ) + } + } + result.refreshError?.let { message -> + Text( + text = message, + fontSize = 10.sp, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colors.error, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(horizontal = 52.dp, vertical = 16.dp), + ) + } + if (showTime) Column( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = 40.dp, vertical = 6.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + val label = remember(result.fetchedAtEpochMs, result.fromCache) { + val time = Instant.ofEpochMilli(result.fetchedAtEpochMs) + .atZone(ZoneId.systemDefault()) + .toLocalDateTime() + if (result.fromCache) "缓存 ${CacheFmt.format(time)} · 可能失效" + else "更新 ${CacheFmt.format(time)}" + } + Text( + text = label, + fontSize = 10.sp, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background(if (result.fromCache) Color(0xFF7A3E00) else Color.Black) + .padding(horizontal = 8.dp, vertical = 2.dp), + ) + } + } + } + else -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + } + } +} diff --git a/wearos/android/app/src/main/res/drawable-hdpi/splash.png b/wearos/android/app/src/main/res/drawable-hdpi/splash.png new file mode 100644 index 000000000..907c1f23b Binary files /dev/null and b/wearos/android/app/src/main/res/drawable-hdpi/splash.png differ diff --git a/wearos/android/app/src/main/res/drawable-mdpi/splash.png b/wearos/android/app/src/main/res/drawable-mdpi/splash.png new file mode 100644 index 000000000..bde9d099b Binary files /dev/null and b/wearos/android/app/src/main/res/drawable-mdpi/splash.png differ diff --git a/wearos/android/app/src/main/res/drawable-nodpi/agenda_tile_preview.png b/wearos/android/app/src/main/res/drawable-nodpi/agenda_tile_preview.png new file mode 100644 index 000000000..72fb2e11b Binary files /dev/null and b/wearos/android/app/src/main/res/drawable-nodpi/agenda_tile_preview.png differ diff --git a/wearos/android/app/src/main/res/drawable-round-nodpi/agenda_tile_preview.png b/wearos/android/app/src/main/res/drawable-round-nodpi/agenda_tile_preview.png new file mode 100644 index 000000000..4200f98a6 Binary files /dev/null and b/wearos/android/app/src/main/res/drawable-round-nodpi/agenda_tile_preview.png differ diff --git a/wearos/android/app/src/main/res/drawable-v21/background.png b/wearos/android/app/src/main/res/drawable-v21/background.png new file mode 100644 index 000000000..3107d37fa Binary files /dev/null and b/wearos/android/app/src/main/res/drawable-v21/background.png differ diff --git a/wearos/android/app/src/main/res/drawable-v21/launch_background.xml b/wearos/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 000000000..3cc4948a1 --- /dev/null +++ b/wearos/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/wearos/android/app/src/main/res/drawable-xhdpi/splash.png b/wearos/android/app/src/main/res/drawable-xhdpi/splash.png new file mode 100644 index 000000000..8fbabe8db Binary files /dev/null and b/wearos/android/app/src/main/res/drawable-xhdpi/splash.png differ diff --git a/wearos/android/app/src/main/res/drawable-xxhdpi/splash.png b/wearos/android/app/src/main/res/drawable-xxhdpi/splash.png new file mode 100644 index 000000000..3a96ecb0c Binary files /dev/null and b/wearos/android/app/src/main/res/drawable-xxhdpi/splash.png differ diff --git a/wearos/android/app/src/main/res/drawable-xxxhdpi/splash.png b/wearos/android/app/src/main/res/drawable-xxxhdpi/splash.png new file mode 100644 index 000000000..dc11926a9 Binary files /dev/null and b/wearos/android/app/src/main/res/drawable-xxxhdpi/splash.png differ diff --git a/wearos/android/app/src/main/res/drawable/background.png b/wearos/android/app/src/main/res/drawable/background.png new file mode 100644 index 000000000..3107d37fa Binary files /dev/null and b/wearos/android/app/src/main/res/drawable/background.png differ diff --git a/wearos/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/wearos/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 000000000..d7c0716c6 --- /dev/null +++ b/wearos/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + diff --git a/wearos/android/app/src/main/res/drawable/launch_background.xml b/wearos/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 000000000..3cc4948a1 --- /dev/null +++ b/wearos/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/wearos/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/wearos/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..7353dbd1f --- /dev/null +++ b/wearos/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/wearos/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/wearos/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 000000000..7353dbd1f --- /dev/null +++ b/wearos/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/wearos/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/wearos/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 000000000..90c78dba5 Binary files /dev/null and b/wearos/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/wearos/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/wearos/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 000000000..1dd93f39e Binary files /dev/null and b/wearos/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/wearos/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/wearos/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 000000000..e5456743e Binary files /dev/null and b/wearos/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/wearos/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/wearos/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 000000000..d3fc77cc4 Binary files /dev/null and b/wearos/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/wearos/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/wearos/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 000000000..bd0e745b5 Binary files /dev/null and b/wearos/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/wearos/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/wearos/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 000000000..d92607415 Binary files /dev/null and b/wearos/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/wearos/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/wearos/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 000000000..41ab98468 Binary files /dev/null and b/wearos/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/wearos/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/wearos/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 000000000..8bf55e5d3 Binary files /dev/null and b/wearos/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/wearos/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/wearos/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 000000000..e04db79e8 Binary files /dev/null and b/wearos/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/wearos/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/wearos/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 000000000..f466fe588 Binary files /dev/null and b/wearos/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/wearos/android/app/src/main/res/values-night-v31/styles.xml b/wearos/android/app/src/main/res/values-night-v31/styles.xml new file mode 100644 index 000000000..95f3e559d --- /dev/null +++ b/wearos/android/app/src/main/res/values-night-v31/styles.xml @@ -0,0 +1,8 @@ + + + + diff --git a/wearos/android/app/src/main/res/values-night/styles.xml b/wearos/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 000000000..34cc96bf3 --- /dev/null +++ b/wearos/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,7 @@ + + + + diff --git a/wearos/android/app/src/main/res/values-v31/styles.xml b/wearos/android/app/src/main/res/values-v31/styles.xml new file mode 100644 index 000000000..95f3e559d --- /dev/null +++ b/wearos/android/app/src/main/res/values-v31/styles.xml @@ -0,0 +1,8 @@ + + + + diff --git a/wearos/android/app/src/main/res/values/ic_launcher_background.xml b/wearos/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 000000000..d11d9ca20 --- /dev/null +++ b/wearos/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #0089DC + \ No newline at end of file diff --git a/wearos/android/app/src/main/res/values/strings.xml b/wearos/android/app/src/main/res/values/strings.xml new file mode 100644 index 000000000..6ae56bc88 --- /dev/null +++ b/wearos/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + XDYou 课表 + 离线查看正在进行和接下来的课程、实验安排 + diff --git a/wearos/android/app/src/main/res/values/styles.xml b/wearos/android/app/src/main/res/values/styles.xml new file mode 100644 index 000000000..627b44932 --- /dev/null +++ b/wearos/android/app/src/main/res/values/styles.xml @@ -0,0 +1,10 @@ + + + + diff --git a/wearos/android/app/src/main/res/xml/data_extraction_rules.xml b/wearos/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 000000000..4437632d3 --- /dev/null +++ b/wearos/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/wearos/android/app/src/main/res/xml/network_security_config.xml b/wearos/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 000000000..e79c8d039 --- /dev/null +++ b/wearos/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,6 @@ + + + + xidian.edu.cn + + diff --git a/wearos/android/build.gradle b/wearos/android/build.gradle new file mode 100644 index 000000000..f60f590d7 --- /dev/null +++ b/wearos/android/build.gradle @@ -0,0 +1,12 @@ +// Root build for the native XDYou Wear OS client. +// Flutter is no longer used on the watch target. + +plugins { + id "com.android.application" version "8.13.2" apply false + id "org.jetbrains.kotlin.android" version "2.2.20" apply false + id "org.jetbrains.kotlin.plugin.compose" version "2.2.20" apply false +} + +tasks.register("clean", Delete) { + delete rootProject.layout.buildDirectory +} diff --git a/wearos/android/gradle.properties b/wearos/android/gradle.properties new file mode 100644 index 000000000..86d4a2a49 --- /dev/null +++ b/wearos/android/gradle.properties @@ -0,0 +1,4 @@ +android.enableJetifier=true +android.useAndroidX=true +org.gradle.jvmargs=-Xmx4096M -Dkotlin.daemon.jvm.options\="-Xmx4096M" -XX:+HeapDumpOnOutOfMemoryError +kotlin.code.style=official diff --git a/wearos/android/gradle/wrapper/gradle-wrapper.jar b/wearos/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..13372aef5 Binary files /dev/null and b/wearos/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/wearos/android/gradle/wrapper/gradle-wrapper.properties b/wearos/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..088b874c2 --- /dev/null +++ b/wearos/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Thu Apr 16 09:43:18 CST 2026 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/wearos/android/gradlew b/wearos/android/gradlew new file mode 100755 index 000000000..9d82f7891 --- /dev/null +++ b/wearos/android/gradlew @@ -0,0 +1,160 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/wearos/android/gradlew.bat b/wearos/android/gradlew.bat new file mode 100644 index 000000000..8a0b282aa --- /dev/null +++ b/wearos/android/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/wearos/android/settings.gradle b/wearos/android/settings.gradle new file mode 100644 index 000000000..4af8f432b --- /dev/null +++ b/wearos/android/settings.gradle @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "xdyou-wear" +include ":app"