diff --git a/.github/workflows/check_wearos.yaml b/.github/workflows/check_wearos.yaml new file mode 100644 index 00000000..a9a3c404 --- /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: + test: + 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: Test and build native Wear OS app + working-directory: wearos/android + run: ./gradlew testDebugUnitTest assembleDebug diff --git a/README.md b/README.md index ebd71ae8..ecb595e5 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ XDYou,代码名称为 Traintime PDA,是为西电学生设计的开源信息 13. 上课前提醒。 14. 完备的国际化支持:支持繁体中文和英语。 15. 宿舍水机支持。 +16. 提供原生 Compose Wear OS 配套应用,可同步课程与付款码,并在断开手机时使用缓存数据。 ## 其他特性 @@ -66,6 +67,23 @@ Engine • hash fcf463a2242790d1fdcd9d044f533080f5022e18 (revision 4c525dac5e) ( Tools • Dart 3.12.0 • DevTools 2.57.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 testDebugUnitTest assembleRelease +``` + ## 授权信息 本程序源代码按照 MPLv2 授权,部分文件有 MIT / Apache-2.0 授权。 diff --git a/android/app/build.gradle b/android/app/build.gradle index 26e8def3..adb10dd2 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 a08f490c..9185431d 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) } + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + companionChannel = MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + WearCompanionTransport.CHANNEL, + ).also { channel -> + channel.setMethodCallHandler { call, result -> + when (call.method) { + "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 + } + WearCompanionTransport.cachePayload(this, payload) + 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, payload.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 00000000..68f77561 --- /dev/null +++ b/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/WearCompanionTransport.kt @@ -0,0 +1,70 @@ +package io.github.benderblog.traintime_pda + +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 cachePayload(context: Context, payload: String) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit().putString(PAYLOAD, payload).apply() + } + + fun cachedPayload(context: Context): String? = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(PAYLOAD, 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/main.dart b/lib/main.dart index fa09a30d..ce5521ee 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -27,6 +27,7 @@ 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/xidian_ids/ids_session.dart'; +import 'package:watermeter/repository/wear_companion_sync.dart'; import 'package:home_widget/home_widget.dart'; void main() async { @@ -55,6 +56,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/homepage/refresh.dart b/lib/page/homepage/refresh.dart index adf5495a..8918c807 100644 --- a/lib/page/homepage/refresh.dart +++ b/lib/page/homepage/refresh.dart @@ -4,6 +4,8 @@ // Refresh formula for homepage. +import 'dart:io'; + import 'package:watermeter/controller/classtable_controller.dart'; import 'package:watermeter/controller/energy_controller.dart'; import 'package:watermeter/controller/exam_controller.dart'; @@ -17,6 +19,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'; import 'package:watermeter/repository/xidian_ids/ids_session.dart'; import 'package:watermeter/repository/xidian_ids/ids_reauth_client.dart'; @@ -99,7 +102,16 @@ Future update({ } // Sync login state to iOS widget - final hasCredential = preference.getString(preference.Preference.idsAccount).isNotEmpty && + final hasCredential = + 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) { + // Wear sync must never make the phone's normal refresh fail. + log.handle(e, s, '[homepage Update][WearCompanion] Cache failed'); + } + } } diff --git a/lib/page/login/login_window.dart b/lib/page/login/login_window.dart index fc084f99..0c8f650a 100644 --- a/lib/page/login/login_window.dart +++ b/lib/page/login/login_window.dart @@ -58,6 +58,7 @@ class _LoginWindowState extends State { bool _couldNotView = true; Widget contentColumn() => Column( + mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ TextField( @@ -302,42 +303,63 @@ class _LoginWindowState extends State { @override Widget build(BuildContext context) { + final isLandscape = width / height > 1.0; return Scaffold( - body: Padding( - padding: EdgeInsets.only( - left: width / height > 1.0 ? width * 0.25 : widthOfSquare, - right: width / height > 1.0 ? width * 0.25 : widthOfSquare, - top: kToolbarHeight, - ), - child: width / height > 1.0 - ? Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const AppIconWidget().gestures( - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => const AboutPage(), - ), - ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + final horizontalPadding = isLandscape + ? width * 0.25 + : widthOfSquare; + const verticalPadding = 16.0; + return SingleChildScrollView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + padding: EdgeInsets.symmetric( + horizontal: horizontalPadding, + vertical: verticalPadding, + ), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: max( + 0, + constraints.maxHeight - verticalPadding * 2, ), - const SizedBox(width: 48), - Expanded(child: contentColumn()), - ], - ) - : Column( - children: [ - const AppIconWidget() - .padding(vertical: kToolbarHeight * 0.75) - .gestures( - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => const AboutPage(), + ), + child: isLandscape + ? Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const AppIconWidget().gestures( + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const AboutPage(), + ), + ), ), - ), + const SizedBox(width: 48), + Expanded(child: contentColumn()), + ], + ) + : Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const AppIconWidget() + .padding(vertical: kToolbarHeight * 0.75) + .gestures( + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const AboutPage(), + ), + ), + ), + contentColumn(), + ], ), - contentColumn(), - ], - ).center(), + ), + ); + }, + ), ), ); } diff --git a/lib/page/setting/setting.dart b/lib/page/setting/setting.dart index 99685716..5d9382d1 100644 --- a/lib/page/setting/setting.dart +++ b/lib/page/setting/setting.dart @@ -23,6 +23,7 @@ import 'package:watermeter/page/public_widget/re_x_card.dart'; import 'package:watermeter/page/setting/dialogs/change_color_dialog.dart'; import 'package:watermeter/page/setting/dialogs/change_localization_dialog.dart'; import 'package:watermeter/page/setting/dialogs/aircon_imei_dialog.dart'; +import 'package:watermeter/page/setting/wear_companion_sync_page.dart'; import 'package:watermeter/page/setting/dialogs/schoolnet_password_dialog.dart'; import 'package:watermeter/page/setting/dialogs/semester_switch_dialog.dart'; import 'package:watermeter/page/setting/dialogs/update_dialog.dart'; @@ -198,6 +199,19 @@ class _SettingWindowState extends State { remaining: const [], bottomRow: Column( children: [ + if (Platform.isAndroid) ...[ + ListTile( + title: const Text('XDYou Wear'), + subtitle: const Text('配对手表并同步登录状态、课表和实验安排'), + trailing: const Icon(Icons.watch_outlined), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const WearCompanionSyncPage(), + ), + ), + ), + const Divider(), + ], ListTile( title: Text( FlutterI18n.translate( 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 00000000..4f3e2af1 --- /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/xidian_ids/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 00000000..90f3625b --- /dev/null +++ b/lib/repository/wear_companion_sync.dart @@ -0,0 +1,167 @@ +// 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/xidian_ids/classtable_session.dart'; +import 'package:watermeter/repository/xidian_ids/school_card_session.dart'; +import 'package:watermeter/repository/xidian_ids/ids_reauth_client.dart'; +import 'package:watermeter/repository/xidian_ids/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(); + + 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 = preference.getString(preference.Preference.idsAccount); + final password = preference.getString(preference.Preference.idsPassword); + final semester = preference.getString( + preference.Preference.currentSemester, + ); + 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, + // The watch uses these credentials only for the payment-code exception. + 'credentials': { + 'idsAccount': account, + 'idsPassword': password, + if (preference.contains(preference.Preference.role)) + 'isPostGraduate': preference.getBool(preference.Preference.role), + 'currentSemester': semester, + }, + '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> _buildSnapshotWithPaymentQr({ + required String sessionId, + }) async { + Uint8List? paymentQr; + DateTime? fetchedAt; + try { + paymentQr = await SchoolCardSession().getQRCode(); + fetchedAt = DateTime.now(); + } catch (_) { + // Schedule and credential sync must remain usable if payment auth expires. + } + return buildSnapshot( + sessionId: sessionId, + paymentQr: paymentQr, + paymentQrFetchedAt: fetchedAt, + ); + } + + 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 _buildSnapshotWithPaymentQr( + sessionId: 'direct-pairing', + ); + snapshot['directPairing'] = true; + final payload = jsonEncode(snapshot); + await _channel.invokeMethod('sendSyncPayload', { + 'nodeId': node.id, + 'messagePath': syncPath, + 'payload': payload, + }); + return snapshot.containsKey('paymentQr'); + } + + /// Updates the native cache used to answer a bound watch in the background. + Future cacheLatestSnapshot() async { + final snapshot = await _buildSnapshotWithPaymentQr( + 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 00000000..62a79a36 --- /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 00000000..a612ad98 --- /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 00000000..7ac70470 --- /dev/null +++ b/wearos/WEAR_SYNC_INTEGRATION.md @@ -0,0 +1,74 @@ +# 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 credential/schedule 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 requests a fresh code, first using the +synchronized account/password, persisted IDS cookies, browser fingerprint and +short-lived school-card openid on the watch. Automatic slider verification and +an on-watch SMS MFA page are supported; the phone proxy and the previous local +copy remain refresh fallbacks. Transient network errors do not clear a valid +IDS cookie. + +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. + +## 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. +- `credentials`: IDS account/password, role and semester. On the watch these + credentials are retained only for the payment-code exception. +- `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 testDebugUnitTest assembleDebug +``` diff --git a/wearos/android/.gitignore b/wearos/android/.gitignore new file mode 100644 index 00000000..7760dbbd --- /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 00000000..a11fa06c --- /dev/null +++ b/wearos/android/app/build.gradle @@ -0,0 +1,106 @@ +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" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + 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 "" + } + } + + testOptions { + unitTests { + includeAndroidResources = true + } + } +} + +dependencies { + def composeBom = platform("androidx.compose:compose-bom:2026.06.01") + implementation composeBom + androidTestImplementation 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.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 "com.squareup.okhttp3:okhttp:4.12.0" + implementation "com.squareup.okhttp3:okhttp-urlconnection:4.12.0" + implementation "org.jsoup:jsoup:1.18.3" + + testImplementation "junit:junit:4.13.2" + testImplementation "org.jetbrains.kotlin:kotlin-test:2.2.20" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2" + testImplementation "com.google.truth:truth:1.4.4" + testImplementation "org.json:json:20240303" +} diff --git a/wearos/android/app/proguard-rules.pro b/wearos/android/app/proguard-rules.pro new file mode 100644 index 00000000..3ef75429 --- /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/debug/AndroidManifest.xml b/wearos/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..fbb2ef83 --- /dev/null +++ b/wearos/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/wearos/android/app/src/main/AndroidManifest.xml b/wearos/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..d1cf50e1 --- /dev/null +++ b/wearos/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + 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 00000000..99b347f5 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 00000000..0a4cc7f4 --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/MainActivity.kt @@ -0,0 +1,74 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +package io.github.benderblog.traintime_pda + +import android.os.Bundle +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 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) + 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(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) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + try { + delay(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) + } + } + + private companion object { + const val QR_SCREEN_ON_TIMEOUT_MS = 60_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 00000000..ebdfc1dc --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/WearAppContainer.kt @@ -0,0 +1,22 @@ +// 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 + +/** 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) + 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 00000000..952f87ed --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/data/WearCacheStore.kt @@ -0,0 +1,125 @@ +// 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) + val cookieDir: File get() = File(root, "cookie/general") + + 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) { + 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() { + if (cookieDir.exists()) cookieDir.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 00000000..f751cb50 --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/data/WearPreferences.kt @@ -0,0 +1,137 @@ +// 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 +import java.security.SecureRandom + +/** + * Credential / semester 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 idsAccount: String + get() = readString(KEY_IDS_ACCOUNT) + set(value) = writeString(KEY_IDS_ACCOUNT, value) + + var idsPassword: String + get() = readString(KEY_IDS_PASSWORD) + set(value) = writeString(KEY_IDS_PASSWORD, value) + + var currentSemester: String + get() = readString(KEY_CURRENT_SEMESTER) + set(value) = writeString(KEY_CURRENT_SEMESTER, value) + + var isPostGraduate: Boolean? + get() = if (contains(KEY_ROLE)) readBool(KEY_ROLE) else null + set(value) { + if (value == null) { + prefs.edit().remove(KEY_ROLE).apply() + } else { + prefs.edit().putBoolean(KEY_ROLE, value).apply() + } + } + + var isUserDefinedSemester: Boolean + get() = readBool(KEY_IS_USER_DEFINED_SEMESTER) + set(value) = prefs.edit().putBoolean(KEY_IS_USER_DEFINED_SEMESTER, value).apply() + + val schoolCardOpenId: String? + get() = prefs.getString(KEY_SCHOOL_CARD_OPEN_ID, null)?.ifEmpty { null } + + val schoolCardOpenIdFetchedAt: Long? + get() = if (prefs.contains(KEY_SCHOOL_CARD_OPEN_ID_FETCHED_AT)) { + prefs.getLong(KEY_SCHOOL_CARD_OPEN_ID_FETCHED_AT, 0L).takeIf { it > 0L } + } else { + null + } + + fun contains(key: String): Boolean = + prefs.contains(key) || flutterPrefs.contains("flutter.$key") + + fun clearCredentials() { + prefs.edit() + .remove(KEY_IDS_ACCOUNT) + .remove(KEY_IDS_PASSWORD) + .remove(KEY_CURRENT_SEMESTER) + .remove(KEY_ROLE) + .remove(KEY_IS_USER_DEFINED_SEMESTER) + .remove(KEY_SCHOOL_CARD_OPEN_ID) + .remove(KEY_SCHOOL_CARD_OPEN_ID_FETCHED_AT) + .apply() + flutterPrefs.edit() + .remove("flutter.$KEY_IDS_ACCOUNT") + .remove("flutter.$KEY_IDS_PASSWORD") + .remove("flutter.$KEY_CURRENT_SEMESTER") + .remove("flutter.$KEY_ROLE") + .remove("flutter.$KEY_IS_USER_DEFINED_SEMESTER") + .apply() + } + + fun hasPaymentCredentials(): Boolean = + idsAccount.isNotEmpty() && idsPassword.isNotEmpty() + + fun getOrCreateIdsBrowserFingerprint(): String { + val stored = prefs.getString(KEY_IDS_BROWSER_FINGERPRINT, null) + if (stored != null && stored.matches(Regex("^[0-9A-F]{32}$"))) return stored + val bytes = ByteArray(16).also { SecureRandom().nextBytes(it) } + val generated = bytes.joinToString("") { byte -> "%02X".format(byte.toInt() and 0xFF) } + prefs.edit().putString(KEY_IDS_BROWSER_FINGERPRINT, generated).apply() + return generated + } + + fun storeSchoolCardOpenId(value: String, fetchedAtEpochMs: Long) { + prefs.edit() + .putString(KEY_SCHOOL_CARD_OPEN_ID, value) + .putLong(KEY_SCHOOL_CARD_OPEN_ID_FETCHED_AT, fetchedAtEpochMs) + .apply() + } + + fun clearSchoolCardOpenId() { + prefs.edit() + .remove(KEY_SCHOOL_CARD_OPEN_ID) + .remove(KEY_SCHOOL_CARD_OPEN_ID_FETCHED_AT) + .apply() + } + + 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() + } + + private fun readBool(key: String): Boolean { + if (prefs.contains(key)) return prefs.getBoolean(key, false) + return flutterPrefs.getBoolean("flutter.$key", false) + } + + companion object { + const val PREFS_NAME = "wear_app_prefs" + private const val FLUTTER_PREFS_NAME = "FlutterSharedPreferences" + const val KEY_IDS_ACCOUNT = "idsAccount" + const val KEY_IDS_PASSWORD = "idsPassword" + const val KEY_CURRENT_SEMESTER = "currentSemester" + const val KEY_ROLE = "role" + const val KEY_IS_USER_DEFINED_SEMESTER = "isUserDefinedSemester" + private const val KEY_IDS_BROWSER_FINGERPRINT = "idsBrowserFingerprint" + private const val KEY_SCHOOL_CARD_OPEN_ID = "schoolCardOpenId" + private const val KEY_SCHOOL_CARD_OPEN_ID_FETCHED_AT = "schoolCardOpenIdFetchedAt" + } +} 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 00000000..c6ef671f --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/data/WearSyncImporter.kt @@ -0,0 +1,105 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +package io.github.benderblog.traintime_pda.data + +import io.github.benderblog.traintime_pda.ids.IdsLoginState +import io.github.benderblog.traintime_pda.ids.SchoolCardSession +import io.github.benderblog.traintime_pda.protocol.WearCompanionSyncEnvelope +import io.github.benderblog.traintime_pda.protocol.WearCredentialSyncPayload +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, +) { + fun importEnvelope(envelope: WearCompanionSyncEnvelope, rawPayload: String? = null) { + importCredentials(envelope.credentials) + importSchedule(envelope.schedule, rawPayload) + envelope.paymentQr?.let { importPaymentQr(it) } + } + + fun importCredentials(payload: WearCredentialSyncPayload) { + val accountChanged = preferences.idsAccount != payload.idsAccount + if (accountChanged) { + clearUserScopedState(clearPaymentQr = true) + } + preferences.idsAccount = payload.idsAccount + preferences.idsPassword = payload.idsPassword + payload.isPostGraduate?.let { preferences.isPostGraduate = it } + val semester = payload.currentSemester + if (!semester.isNullOrEmpty()) { + preferences.currentSemester = semester + preferences.isUserDefinedSemester = false + } + } + + 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 + preferences.isUserDefinedSemester = false + } + + 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() { + preferences.clearCredentials() + cache.clearIdsCookies() + SchoolCardSession.resetOpenId() + preferences.clearSchoolCardOpenId() + cache.clearCampusCaches() + cache.clearPaymentQr() + IdsLoginState.state = IdsLoginState.State.MANUAL + } + + private fun clearUserScopedState(clearPaymentQr: Boolean) { + cache.clearIdsCookies() + SchoolCardSession.resetOpenId() + preferences.clearSchoolCardOpenId() + cache.clearCampusCaches() + if (clearPaymentQr) cache.clearPaymentQr() + IdsLoginState.state = IdsLoginState.State.NONE + preferences.currentSemester = "" + preferences.isPostGraduate = null + preferences.isUserDefinedSemester = false + } +} 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 00000000..cfd92e4a --- /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 00000000..896d6436 --- /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:55", "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 00000000..8da14885 --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/domain/WearAgendaBuilder.kt @@ -0,0 +1,155 @@ +// 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, +) + +object WearAgendaBuilder { + private val termStartFormatter: DateTimeFormatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + + 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/ids/IdsCrypto.kt b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ids/IdsCrypto.kt new file mode 100644 index 00000000..2905baf7 --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ids/IdsCrypto.kt @@ -0,0 +1,59 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +package io.github.benderblog.traintime_pda.ids + +import java.util.Base64 +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +/** + * IDS AES-CBC helpers matching the Go / Dart login payload: + * 64-byte fixed prefix, fixed 16-dot IV, PKCS5/7 padding, Base64 output. + */ +object IdsCrypto { + const val PASSWORD_PREFIX = + "................................................................" + const val FIXED_IV = "................" + private const val CAPTCHA_KEY_SIZE = 16 + + fun aesEncrypt(toEnc: String, key: String): String { + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + val keySpec = SecretKeySpec(key.toByteArray(Charsets.UTF_8), "AES") + val ivSpec = IvParameterSpec(FIXED_IV.toByteArray(Charsets.UTF_8)) + cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec) + val encrypted = cipher.doFinal((PASSWORD_PREFIX + toEnc).toByteArray(Charsets.UTF_8)) + return Base64.getEncoder().encodeToString(encrypted) + } + + fun encryptCaptchaPayload(payload: String, keyBytes: ByteArray): String { + require(keyBytes.size >= CAPTCHA_KEY_SIZE) { + "Captcha image is too short to contain AES key." + } + val key = keyBytes.copyOfRange(keyBytes.size - CAPTCHA_KEY_SIZE, keyBytes.size) + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + val keySpec = SecretKeySpec(key, "AES") + val ivSpec = IvParameterSpec(FIXED_IV.toByteArray(Charsets.UTF_8)) + cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec) + val encrypted = cipher.doFinal((PASSWORD_PREFIX + payload).toByteArray(Charsets.UTF_8)) + return Base64.getEncoder().encodeToString(encrypted) + } + + fun buildUsernameLoginPayload( + username: String, + password: String, + salt: String, + execution: String, + ): Map = mapOf( + "username" to username, + "password" to aesEncrypt(password, salt), + "rememberMe" to "true", + "cllt" to "userNameLogin", + "dllt" to "generalLogin", + "_eventId" to "submit", + "captcha" to "", + "lt" to "", + "execution" to execution, + ) +} diff --git a/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ids/IdsLoginState.kt b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ids/IdsLoginState.kt new file mode 100644 index 00000000..954eca50 --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ids/IdsLoginState.kt @@ -0,0 +1,21 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +package io.github.benderblog.traintime_pda.ids + +object IdsLoginState { + enum class State { + NONE, + REQUESTING, + SUCCESS, + FAIL, + PASSWORD_WRONG, + MANUAL, + } + + @Volatile + var state: State = State.NONE + + val offline: Boolean + get() = state != State.SUCCESS && state != State.MANUAL +} diff --git a/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ids/IdsReAuth.kt b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ids/IdsReAuth.kt new file mode 100644 index 00000000..7df2f2a0 --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ids/IdsReAuth.kt @@ -0,0 +1,213 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +package io.github.benderblog.traintime_pda.ids + +import okhttp3.FormBody +import okhttp3.OkHttpClient +import okhttp3.Request +import org.json.JSONObject +import java.net.URI + +class WearIDSProtocolException(message: String) : Exception(message) +class WearIDSReAuthCodeRejectedException(message: String) : Exception(message) +class WearIDSReAuthExpiredException(message: String) : Exception(message) +class WearIDSReAuthCancelledException : Exception("已取消短信认证") + +data class WearIDSSmsDelivery( + val message: String, + val recipient: String?, + val retryAfterSeconds: Int, +) + +/** + * IDS SMS multi-factor re-authentication for the payment path. + */ +class WearIDSReAuthClient( + private val client: OkHttpClient, + val challengeUri: URI, + val username: String, + val service: String, + private val browserFingerprint: String, +) { + var recipientDescription: String? = null + private set + + private var prepared = false + + private val isMultifactor: String + get() = challengeUri.query + ?.split('&') + ?.mapNotNull { + val parts = it.split('=', limit = 2) + if (parts.size == 2 && parts[0] == "isMultifactor") parts[1] else null + } + ?.firstOrNull() + ?: "true" + + fun prepare() { + if (prepared) return + val challengeResponse = client.newCall( + Request.Builder().url(challengeUri.toString()).get().build(), + ).execute() + challengeResponse.use { + if (it.code != 200) { + throw WearIDSReAuthExpiredException("二次认证已失效,请重新登录") + } + } + val fingerprintUrl = okhttp3.HttpUrl.Builder() + .scheme("https") + .host("ids.xidian.edu.cn") + .addPathSegments("authserver/bfp/info") + .addQueryParameter("bfp", browserFingerprint) + .addQueryParameter("_", System.currentTimeMillis().toString()) + .build() + client.newCall(Request.Builder().url(fingerprintUrl).get().build()).execute().use { + if (it.code !in 200..399) { + throw WearIDSProtocolException("无法注册统一认证设备指纹") + } + } + val form = FormBody.Builder() + .add("isMultifactor", isMultifactor) + .add("reAuthType", "3") + .add("service", service) + .build() + val response = client.newCall( + Request.Builder() + .url("https://ids.xidian.edu.cn/authserver/reAuthCheck/changeReAuthType.do") + .post(form) + .build(), + ).execute() + response.use { + val json = responseJson(it.body?.string()) + if (json.optString("code") != "1") { + throw WearIDSProtocolException( + json.optString("message").ifEmpty { "无法切换到短信二次认证" }, + ) + } + val data = json.optJSONObject("data") + recipientDescription = data?.optString("reAuthUserNameInput")?.ifEmpty { null } + } + prepared = true + } + + fun sendSms(): WearIDSSmsDelivery { + prepare() + val form = FormBody.Builder() + .add("userName", username) + .add("authCodeTypeName", "reAuthDynamicCodeType") + .build() + val response = client.newCall( + Request.Builder() + .url( + "https://ids.xidian.edu.cn/authserver/dynamicCode/" + + "getDynamicCodeByReauth.do", + ) + .post(form) + .build(), + ).execute() + response.use { + val json = responseJson(it.body?.string()) + val result = json.optString("res") + if (result != "success" && result != "code_time_fail") { + throw WearIDSProtocolException( + json.optString("returnMessage").ifEmpty { "短信验证码发送失败" }, + ) + } + val rawSeconds = json.optString("codeTime").toIntOrNull() ?: 0 + val seconds = if (rawSeconds < 0) 0 else rawSeconds + val mobile = json.optString("mobile").ifEmpty { null } + return WearIDSSmsDelivery( + message = json.optString("returnMessage").ifEmpty { "验证码已发送" }, + recipient = if (mobile.isNullOrEmpty()) { + recipientDescription + } else { + maskPhoneNumber(mobile) + }, + retryAfterSeconds = seconds, + ) + } + } + + fun submitSms(code: String, trustDevice: Boolean): URI { + prepare() + val normalized = code.trim() + if (normalized.isEmpty()) { + throw WearIDSReAuthCodeRejectedException("请输入短信验证码") + } + val form = FormBody.Builder() + .add("service", service) + .add("reAuthType", "3") + .add("isMultifactor", isMultifactor) + .add("password", "") + .add("dynamicCode", normalized) + .add("uuid", "") + .add("answer1", "") + .add("answer2", "") + .add("otpCode", "") + .add("skipTmpReAuth", trustDevice.toString()) + .build() + val response = client.newCall( + Request.Builder() + .url("https://ids.xidian.edu.cn/authserver/reAuthCheck/reAuthSubmit.do") + .post(form) + .build(), + ).execute() + val json = response.use { responseJson(it.body?.string()) } + when (json.optString("code")) { + "reAuth_failed" -> throw WearIDSReAuthCodeRejectedException( + json.optString("msg").ifEmpty { "验证码错误" }, + ) + "reAuth_unauthorized" -> throw WearIDSReAuthExpiredException( + json.optString("msg").ifEmpty { "二次认证已失效" }, + ) + "reAuth_success" -> Unit + else -> throw WearIDSProtocolException("统一认证返回了未知的二次认证状态") + } + + val loginRequest = Request.Builder() + .url( + "https://ids.xidian.edu.cn/authserver/login?service=${ + java.net.URLEncoder.encode(service, Charsets.UTF_8.name()) + }", + ) + .get() + .build() + // Do not follow redirects so we can read Location. + val loginClient = client.newBuilder() + .followRedirects(false) + .followSslRedirects(false) + .build() + loginClient.newCall(loginRequest).execute().use { loginResponse -> + val location = loginResponse.header("Location") + if ((loginResponse.code != 301 && loginResponse.code != 302) || location == null) { + throw WearIDSProtocolException("二次认证成功,但没有收到业务系统登录票据") + } + val uri = URI("https://ids.xidian.edu.cn").resolve(location) + if (uri.host == "ids.xidian.edu.cn" && + uri.path == "/authserver/reAuthCheck/reAuthLoginView.do" + ) { + throw WearIDSReAuthExpiredException("二次认证未完成,请重新登录") + } + return uri + } + } + + private fun responseJson(data: String?): JSONObject { + if (data.isNullOrBlank()) { + throw WearIDSProtocolException("统一认证返回了非 JSON 响应") + } + return try { + JSONObject(data) + } catch (_: Exception) { + throw WearIDSProtocolException("统一认证返回了非 JSON 响应") + } + } + + companion object { + fun maskPhoneNumber(value: String): String { + if (value.length < 7) return "****" + return value.take(3) + "****" + value.takeLast(4) + } + } +} diff --git a/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ids/IdsSession.kt b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ids/IdsSession.kt new file mode 100644 index 00000000..fd85f736 --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ids/IdsSession.kt @@ -0,0 +1,236 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +package io.github.benderblog.traintime_pda.ids + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import okhttp3.FormBody +import okhttp3.OkHttpClient +import okhttp3.Request +import org.jsoup.Jsoup +import java.util.concurrent.TimeUnit + +class PasswordWrongException(message: String) : Exception(message) +class LoginFailedException(message: String) : Exception(message) + +/** + * Minimal IDS session used only for the school-card payment QR path. + * Class table / experiments stay cache-only and never call this. + */ +open class IdsSession( + protected val cookieJar: PersistentCookieJar, + protected val username: String, + protected val password: String, + protected val browserFingerprint: String, +) { + protected val client: OkHttpClient = OkHttpClient.Builder() + .cookieJar(cookieJar) + .followRedirects(false) + .followSslRedirects(false) + .connectTimeout(20, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + private val lock = Mutex() + + suspend fun checkAndLogin( + target: String, + sliderCaptcha: suspend (String) -> Unit, + ): String = lock.withLock { + val loginUrl = + "https://ids.xidian.edu.cn/authserver/login?service=${urlEncode(target)}&type=userNameLogin" + val first = executeGet(loginUrl) + if (first.code == 401) { + throw PasswordWrongException(parsePasswordWrongMsg(first.body)) + } + if (first.code == 301 || first.code == 302) { + return first.location + ?: throw LoginFailedException("登录重定向缺少 Location") + } + registerBrowserFingerprint() + + val continueForm = Jsoup.parse(first.body).select("form#continue") + if (continueForm.isNotEmpty()) { + val fields = mutableMapOf() + for (input in continueForm[0].select("input")) { + val name = input.attr("name") + if (name.isNotEmpty()) fields[name] = input.attr("value") + } + val cont = executePost( + "https://ids.xidian.edu.cn/authserver/login", + fields, + ) + if (cont.code == 301 || cont.code == 302) { + return cont.location + ?: throw LoginFailedException("继续登录缺少 Location") + } + } + + return login( + username = username, + password = password, + target = target, + sliderCaptcha = sliderCaptcha, + ) + } + + suspend fun login( + username: String, + password: String, + target: String?, + sliderCaptcha: suspend (String) -> Unit, + ): String { + val query = buildString { + append("type=userNameLogin") + if (target != null) append("&service=").append(urlEncode(target)) + } + val page = executeGet("https://ids.xidian.edu.cn/authserver/login?$query") + registerBrowserFingerprint() + val doc = Jsoup.parse(page.body) + val hiddenInputs = doc.select("input[type=hidden]") + + val salt = hiddenInputs.firstOrNull { it.id() == "pwdEncryptSalt" }?.attr("value") + ?: throw LoginFailedException("未找到密码加密盐") + val execution = hiddenInputs.firstOrNull { + it.attr("name") == "execution" || it.id() == "execution" + }?.attr("value") + ?: throw LoginFailedException("未找到 execution") + + val cookieHeader = cookieJar.loadForRequest( + okhttp3.HttpUrl.Builder() + .scheme("https") + .host("ids.xidian.edu.cn") + .addPathSegment("authserver") + .build(), + ).joinToString("; ") { "${it.name}=${it.value}" } + + try { + sliderCaptcha(cookieHeader) + } catch (_: CaptchaSolveFailedException) { + throw LoginFailedException("验证码校验失败") + } + + val payload = IdsCrypto.buildUsernameLoginPayload( + username = username, + password = password, + salt = salt, + execution = execution, + ) + val postUrl = if (target != null) { + "https://ids.xidian.edu.cn/authserver/login?service=${urlEncode(target)}" + } else { + "https://ids.xidian.edu.cn/authserver/login" + } + val data = executePost(postUrl, payload) + val location = data.location + if (location != null && + (data.code == 301 || data.code == 302 || hasCastgcCookie()) + ) { + return location + } + + val contForm = Jsoup.parse(data.body).select("form#continue") + if (contForm.isNotEmpty()) { + val fields = mutableMapOf() + for (input in contForm[0].select("input")) { + val name = input.attr("name") + if (name.isNotEmpty()) fields[name] = input.attr("value") + } + val cont = executePost( + "https://ids.xidian.edu.cn/authserver/login", + fields, + ) + val contLocation = cont.location + if (contLocation != null && + (cont.code == 301 || cont.code == 302 || hasCastgcCookie()) + ) { + return contLocation + } + } + if (data.code == 401) { + throw PasswordWrongException(parsePasswordWrongMsg(data.body)) + } + throw LoginFailedException("登录失败,响应状态码:${data.code}。") + } + + fun clearCookieJar() { + cookieJar.clear() + } + + fun cancelRequests() { + client.dispatcher.cancelAll() + } + + private fun registerBrowserFingerprint() { + val url = okhttp3.HttpUrl.Builder() + .scheme("https") + .host("ids.xidian.edu.cn") + .addPathSegments("authserver/bfp/info") + .addQueryParameter("bfp", browserFingerprint) + .addQueryParameter("_", System.currentTimeMillis().toString()) + .build() + executeGet(url.toString()) + } + + protected fun executeGet(url: String): HttpResult { + val request = Request.Builder().url(url).get().build() + client.newCall(request).execute().use { response -> + return HttpResult( + code = response.code, + body = response.body?.string().orEmpty(), + location = response.header("Location"), + ) + } + } + + protected fun executePost(url: String, fields: Map): HttpResult { + val form = FormBody.Builder().also { builder -> + fields.forEach { (k, v) -> builder.add(k, v) } + }.build() + val request = Request.Builder().url(url).post(form).build() + client.newCall(request).execute().use { response -> + return HttpResult( + code = response.code, + body = response.body?.string().orEmpty(), + location = response.header("Location"), + ) + } + } + + protected fun hasCastgcCookie(): Boolean = + cookieJar.hasCookie("CASTGC") + + private fun parsePasswordWrongMsg(html: String): String { + val form = Jsoup.parse(html).getElementById("showErrorTip") + var msg = form?.text()?.ifBlank { null } ?: "登录遇到问题" + if (msg.contains(Regex("(用户名|密码).*误"))) { + msg = "用户名或密码有误" + } + return msg + } + + private fun urlEncode(value: String): String = + java.net.URLEncoder.encode(value, Charsets.UTF_8.name()) + + data class HttpResult( + val code: Int, + val body: String, + val location: String?, + ) + + companion object { + private const val TAG = "IdsSession" + } +} + +/** Absolute-resolve a relative Location against the IDS host. */ +fun resolveIdsLocation(location: String, base: String = "https://ids.xidian.edu.cn"): String { + return java.net.URI(base).resolve(location).toString() +} + +fun isReAuthChallenge(location: String): Boolean { + val uri = java.net.URI(resolveIdsLocation(location)) + return uri.host == "ids.xidian.edu.cn" && + uri.path == "/authserver/reAuthCheck/reAuthLoginView.do" +} diff --git a/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ids/PersistentCookieJar.kt b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ids/PersistentCookieJar.kt new file mode 100644 index 00000000..242ae115 --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ids/PersistentCookieJar.kt @@ -0,0 +1,104 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +package io.github.benderblog.traintime_pda.ids + +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +/** + * Simple host-scoped cookie jar persisted under [storageDir]. + * Used only for the payment-code IDS session on the watch. + */ +class PersistentCookieJar(private val storageDir: File) : CookieJar { + private val memory = ConcurrentHashMap>() + + init { + storageDir.mkdirs() + loadFromDisk() + } + + @Synchronized + override fun saveFromResponse(url: HttpUrl, cookies: List) { + val host = url.host + val bucket = memory.getOrPut(host) { mutableListOf() } + for (cookie in cookies) { + bucket.removeAll { it.name == cookie.name && it.path == cookie.path } + if (cookie.expiresAt > System.currentTimeMillis()) { + bucket.add(cookie) + } + } + persistHost(host) + } + + @Synchronized + override fun loadForRequest(url: HttpUrl): List { + val now = System.currentTimeMillis() + val result = mutableListOf() + for ((host, cookies) in memory) { + if (!url.host.endsWith(host) && host != url.host) continue + val alive = cookies.filter { it.expiresAt > now && it.matches(url) } + result += alive + } + return result + } + + @Synchronized + fun clear() { + memory.clear() + if (storageDir.exists()) { + storageDir.listFiles()?.forEach { it.delete() } + } + } + + fun hasCookie(name: String, hostHint: String = "ids.xidian.edu.cn"): Boolean { + val cookies = memory[hostHint] ?: return false + return cookies.any { it.name == name && it.expiresAt > System.currentTimeMillis() } + } + + private fun persistHost(host: String) { + val file = File(storageDir, host.replace('.', '_') + ".cookies") + val cookies = memory[host].orEmpty() + file.writeText( + cookies.joinToString("\n") { cookie -> + listOf( + cookie.name, + cookie.value, + cookie.domain, + cookie.path, + cookie.expiresAt.toString(), + cookie.secure.toString(), + cookie.httpOnly.toString(), + ).joinToString("\t") + }, + ) + } + + private fun loadFromDisk() { + val files = storageDir.listFiles() ?: return + for (file in files) { + try { + val lines = file.readLines().filter { it.isNotBlank() } + for (line in lines) { + val parts = line.split('\t') + if (parts.size < 7) continue + val builder = Cookie.Builder() + .name(parts[0]) + .value(parts[1]) + .domain(parts[2]) + .path(parts[3]) + .expiresAt(parts[4].toLong()) + if (parts[5].toBoolean()) builder.secure() + if (parts[6].toBoolean()) builder.httpOnly() + val cookie = builder.build() + memory.getOrPut(parts[2]) { mutableListOf() }.add(cookie) + } + } catch (_: Exception) { + // Ignore corrupt cookie files. + } + } + } +} diff --git a/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ids/SchoolCardSession.kt b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ids/SchoolCardSession.kt new file mode 100644 index 00000000..3aabd711 --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ids/SchoolCardSession.kt @@ -0,0 +1,258 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +package io.github.benderblog.traintime_pda.ids + +import android.util.Log +import kotlinx.coroutines.CancellationException +import okhttp3.Request +import org.jsoup.Jsoup +import java.net.URI +import java.io.IOException +import java.util.Base64 +import java.util.concurrent.TimeUnit + +/** + * School-card virtual QR. Only network path allowed on the watch besides Data Layer. + */ +class SchoolCardSession( + cookieJar: PersistentCookieJar, + username: String, + password: String, + browserFingerprint: String, + storedOpenId: String?, + storedOpenIdFetchedAt: Long?, + private val onOpenIdChanged: (String?, Long?) -> Unit, +) : IdsSession(cookieJar, username, password, browserFingerprint) { + init { + restoreOpenId(storedOpenId, storedOpenIdFetchedAt) + } + /** + * Optional SMS re-auth handler. Returns the post-reauth location URI. + * When null and re-auth is required, [WearIDSReAuthExpiredException] is thrown. + */ + suspend fun authenticateWithStoredCredentials( + reAuthHandler: (suspend (WearIDSReAuthClient) -> URI)? = null, + ) { + if (IdsLoginState.state == IdsLoginState.State.SUCCESS && isOpenIdValid) return + + IdsLoginState.state = IdsLoginState.State.REQUESTING + try { + // Reuse the persisted IDS cookie after process restarts. This avoids + // a full password + slider + SMS flow for every payment-code refresh. + if (hasCastgcCookie()) { + try { + ensureOpenId(forceRefresh = true) + IdsLoginState.state = IdsLoginState.State.SUCCESS + return + } catch (cancelled: CancellationException) { + throw cancelled + } catch (network: IOException) { + throw network + } catch (_: Exception) { + clearOpenId() + } + } + val idsService = discoverIdsService() + var location = checkAndLogin( + target = idsService, + sliderCaptcha = { cookie -> + SliderCaptchaClient(client, cookie).solveAutomatically() + }, + ) + val redirectUri = URI(resolveIdsLocation(location)) + if (isReAuthChallenge(location)) { + val handler = reAuthHandler + ?: throw WearIDSReAuthExpiredException("需要短信二次认证") + val reAuthClient = WearIDSReAuthClient( + client = client, + challengeUri = redirectUri, + username = username, + service = idsService, + browserFingerprint = browserFingerprint, + ) + location = handler(reAuthClient).toString() + } + var response = executeGet(resolveIdsLocation(location)) + var current = resolveIdsLocation(location) + while (!response.location.isNullOrEmpty()) { + current = URI(current).resolve(response.location!!).toString() + response = executeGet(current) + } + captureOpenId(response.body) + IdsLoginState.state = IdsLoginState.State.SUCCESS + } catch (e: PasswordWrongException) { + IdsLoginState.state = IdsLoginState.State.PASSWORD_WRONG + throw e + } catch (e: Exception) { + IdsLoginState.state = IdsLoginState.State.FAIL + throw e + } + } + + fun getQRCode(): ByteArray = withOpenIdRetry { + val homeUrl = "https://v8scan.xidian.edu.cn/home/openHomePage?openid=$openid" + val homeBody = executeGetFollow(homeUrl) + val homeDoc = Jsoup.parse(homeBody) + var id: String? = null + for (a in homeDoc.select("a")) { + val href = a.attr("href") + if (href.contains("/virtualcard/openVirtualcard") && href.contains("id=")) { + val uri = URI(href.replace("&", "&")) + // href may be relative + val query = uri.rawQuery ?: URI("https://v8scan.xidian.edu.cn$href") + .rawQuery + id = query?.split('&') + ?.map { it.split('=', limit = 2) } + ?.firstOrNull { it.size == 2 && it[0] == "id" } + ?.get(1) + if (!id.isNullOrEmpty()) break + } + } + if (id.isNullOrEmpty()) throw Exception("aTag id not found.") + + val qrUrl = + "https://v8scan.xidian.edu.cn/virtualcard/openVirtualcard?" + + "openid=$openid&displayflag=1&id=$id" + val qrBody = executeGetFollow(qrUrl) + val qrDoc = Jsoup.parse(qrBody) + val img = qrDoc.getElementById("qrcode") + ?: throw Exception("QR image not found.") + var src = img.attr("src") + val base64Data = src + .replace("data:image/png;base64,", "") + .replace("\n", "") + if (base64Data.isEmpty()) throw Exception("QR data is empty.") + Base64.getDecoder().decode(base64Data) + } + + private fun withOpenIdRetry(action: () -> ByteArray): ByteArray { + ensureOpenId() + return try { + action() + } catch (e: Exception) { + Log.w(TAG, "Request failed, retry with refreshed openid.", e) + ensureOpenId(forceRefresh = true) + action() + } + } + + private fun ensureOpenId(forceRefresh: Boolean = false) { + if (!forceRefresh && isOpenIdValid) return + clearOpenId() + var response = executeGetFollowResult(OPEN_OAUTH_URL) + // follow already done; capture from final HTML + captureOpenId(response) + } + + private fun captureOpenId(html: String) { + val inputs = Jsoup.parse(html).select("input") + for (input in inputs) { + if (input.id() == "openid" && input.attr("type") == "hidden") { + openid = input.attr("value") + break + } + } + if (openid.isEmpty()) throw Exception("School card openid not found.") + openidFetchedAt = System.currentTimeMillis() + onOpenIdChanged(openid, openidFetchedAt) + } + + private fun clearOpenId() { + resetOpenId() + onOpenIdChanged(null, null) + } + + private fun discoverIdsService(): String { + // Use a no-cookie client to discover the service URL without polluting session. + val probe = OkHttpNoCookie() + var currentUrl = OPEN_OAUTH_URL + var response = probe.get(currentUrl) + repeat(10) { + val nextHeader = response.location ?: return@repeat + val nextUrl = URI(currentUrl).resolve(nextHeader).toString() + val nextUri = URI(nextUrl) + if (nextUri.host == "ids.xidian.edu.cn" && + nextUri.path.endsWith("/authserver/login") + ) { + val service = nextUri.query + ?.split('&') + ?.map { it.split('=', limit = 2) } + ?.firstOrNull { it.size == 2 && it[0] == "service" } + ?.get(1) + ?.let { java.net.URLDecoder.decode(it, Charsets.UTF_8.name()) } + if (!service.isNullOrEmpty()) return service + } + currentUrl = nextUrl + response = probe.get(currentUrl) + } + throw Exception("School card IDS service not found.") + } + + private fun executeGetFollow(url: String): String { + var current = url + var response = executeGet(current) + var hops = 0 + while (!response.location.isNullOrEmpty() && hops < 15) { + current = URI(current).resolve(response.location!!).toString() + response = executeGet(current) + hops++ + } + return response.body + } + + private fun executeGetFollowResult(url: String): String = executeGetFollow(url) + + private class OkHttpNoCookie { + private val client = okhttp3.OkHttpClient.Builder() + .followRedirects(false) + .followSslRedirects(false) + .connectTimeout(20, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + fun get(url: String): HttpResult { + val request = Request.Builder().url(url).get().build() + client.newCall(request).execute().use { response -> + return HttpResult( + code = response.code, + body = response.body?.string().orEmpty(), + location = response.header("Location"), + ) + } + } + } + + companion object { + private const val TAG = "SchoolCardSession" + private const val OPEN_OAUTH_URL = + "https://v8scan.xidian.edu.cn/home/openXDOAuth2Page" + private const val OPENID_VALID_MS = 5 * 60 * 1000L + + @Volatile + var openid: String = "" + private set + + @Volatile + private var openidFetchedAt: Long? = null + + val isOpenIdValid: Boolean + get() = openid.isNotEmpty() && + openidFetchedAt != null && + System.currentTimeMillis() - openidFetchedAt!! < OPENID_VALID_MS + + fun resetOpenId() { + openid = "" + openidFetchedAt = null + } + + fun restoreOpenId(value: String?, fetchedAtEpochMs: Long?) { + if (value.isNullOrEmpty() || fetchedAtEpochMs == null) return + if (System.currentTimeMillis() - fetchedAtEpochMs !in 0 until OPENID_VALID_MS) return + if (openidFetchedAt == null || fetchedAtEpochMs > openidFetchedAt!!) { + openid = value + openidFetchedAt = fetchedAtEpochMs + } + } + } +} diff --git a/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ids/SliderCaptcha.kt b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ids/SliderCaptcha.kt new file mode 100644 index 00000000..ea1ee07e --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ids/SliderCaptcha.kt @@ -0,0 +1,307 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +package io.github.benderblog.traintime_pda.ids + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.util.Log +import okhttp3.FormBody +import okhttp3.OkHttpClient +import okhttp3.Request +import org.json.JSONObject +import java.util.Base64 +import kotlin.math.exp +import kotlin.math.max +import kotlin.math.min +import kotlin.random.Random + +class CaptchaSolveFailedException : Exception("验证码校验失败") + +data class TrackPoint(val a: Int, val b: Int, val c: Int) + +/** + * Automatic slider captcha solver for ids.xidian.edu.cn. + * Ports the Flutter Wear [SliderCaptchaClientProvider] NCC matcher + track generator. + */ +class SliderCaptchaClient( + private val client: OkHttpClient, + private val cookieHeader: String, +) { + private var puzzleData: ByteArray? = null + private var pieceData: ByteArray? = null + private val puzzleWidth = 280 + + suspend fun solveAutomatically() { + Log.i(TAG, "Trying automatic slider captcha solve.") + var lastError: Exception? = null + for (attempt in 0 until 5) { + try { + if (solveOnce()) return + } catch (e: Exception) { + lastError = e + Log.w(TAG, "Automatic slider captcha solve failed.", e) + if (attempt < 4) { + kotlinx.coroutines.delay((attempt + 1) * 1000L) + } + } + } + throw lastError ?: CaptchaSolveFailedException() + } + + private suspend fun solveOnce(): Boolean { + updatePuzzle() + val puzzleBytes = puzzleData ?: return false + val pieceBytes = pieceData ?: return false + val puzzle = BitmapFactory.decodeByteArray(puzzleBytes, 0, puzzleBytes.size) + ?: return false + val piece = BitmapFactory.decodeByteArray(pieceBytes, 0, pieceBytes.size) + ?: return false + try { + val solvedOffset = solveSlideOffset(puzzle, piece, border = 24) + val baseMove = solvedOffset * puzzleWidth / puzzle.width + for (delta in intArrayOf(1, -1, 2, -2, 3, -3, 4)) { + val move = baseMove + delta + if (move < 0 || move > puzzleWidth) continue + val tracks = generateAutoTracks(move) + val waitMs = max(0, tracks.last().c - 100) + kotlinx.coroutines.delay(waitMs.toLong()) + if (verifyWithTracks(tracks)) return true + } + return false + } finally { + puzzle.recycle() + piece.recycle() + } + } + + private fun updatePuzzle() { + val url = + "https://ids.xidian.edu.cn/authserver/common/openSliderCaptcha.htl" + + "?_=${System.currentTimeMillis()}" + val request = Request.Builder() + .url(url) + .header("Cookie", cookieHeader) + .get() + .build() + client.newCall(request).execute().use { response -> + val body = response.body?.string() + ?: throw CaptchaSolveFailedException() + val json = JSONObject(body) + puzzleData = Base64.getDecoder().decode(json.getString("bigImage")) + pieceData = Base64.getDecoder().decode(json.getString("smallImage")) + } + } + + private fun verifyWithTracks(tracks: List): Boolean { + val moveLength = tracks.lastOrNull()?.a ?: 0 + val tracksJson = tracks.joinToString( + prefix = "[", + postfix = "]", + ) { """{"a":${it.a},"b":${it.b},"c":${it.c}}""" } + val payload = + """{"canvasLength":$puzzleWidth,"moveLength":$moveLength,"tracks":$tracksJson}""" + val piece = pieceData ?: throw CaptchaSolveFailedException() + val sign = IdsCrypto.encryptCaptchaPayload(payload, piece) + val form = FormBody.Builder() + .add("sign", sign) + .build() + val request = Request.Builder() + .url("https://ids.xidian.edu.cn/authserver/common/verifySliderCaptcha.htl") + .header("Cookie", cookieHeader) + .header("Accept", "application/json, text/javascript, */*; q=0.01") + .header("Origin", "https://ids.xidian.edu.cn") + .header("X-Requested-With", "XMLHttpRequest") + .post(form) + .build() + client.newCall(request).execute().use { response -> + val body = response.body?.string() ?: return false + val json = JSONObject(body) + return json.optString("errorMsg") == "success" || json.optInt("errorCode") == 1 + } + } + + companion object { + private const val TAG = "SliderCaptcha" + + fun solveSlideOffset(puzzle: Bitmap, piece: Bitmap, border: Int = 24): Int { + val bbox = nrgbaBbox(piece) + var xL = bbox[0] + border + var yT = bbox[1] + border + var xR = bbox[2] - border + var yB = bbox[3] - border + if (xL < 0 || yT < 0 || xR < xL || yB < yT) { + throw CaptchaSolveFailedException() + } + val windowWidth = xR - xL + 1 + val windowHeight = yB - yT + 1 + val bigWidth = puzzle.width - piece.width + windowWidth + if (windowWidth <= 0 || windowHeight <= 0 || bigWidth < windowWidth || + xL + windowWidth > piece.width || yT + windowHeight > piece.height || + xL + bigWidth > puzzle.width || yT + windowHeight > puzzle.height + ) { + throw CaptchaSolveFailedException() + } + + val templateGray = grayFromImage(piece, xL, yT, windowWidth, windowHeight) + val templateMean = + graySum(templateGray, 0, 0, windowWidth, windowHeight) / + (windowWidth * windowHeight).toDouble() + val template = grayNorm( + templateGray, 0, 0, windowWidth, windowHeight, templateMean, + ) + val puzzleGray = grayFromImage(puzzle, xL, yT, bigWidth, windowHeight) + val columnSums = DoubleArray(bigWidth) { x -> + graySum(puzzleGray, x, 0, 1, windowHeight) + } + + var windowSum = 0.0 + for (x in 0 until windowWidth) windowSum += columnSums[x] + val area = (windowWidth * windowHeight).toDouble() + var maxScore = grayNccFast( + puzzleGray, 0, 0, windowWidth, windowHeight, windowSum / area, template, + ) + var bestX = 0 + for (x in 1 until bigWidth - windowWidth) { + windowSum += columnSums[x + windowWidth - 1] - columnSums[x - 1] + val score = grayNccFast( + puzzleGray, x, 0, windowWidth, windowHeight, windowSum / area, template, + ) + if (score > maxScore) { + maxScore = score + bestX = x + } + } + return bestX + } + + private fun nrgbaBbox(image: Bitmap): IntArray { + var xL = image.width + var yT = image.height + var xR = 0 + var yB = 0 + var found = false + val pixels = IntArray(image.width * image.height) + image.getPixels(pixels, 0, image.width, 0, 0, image.width, image.height) + for (y in 0 until image.height) { + for (x in 0 until image.width) { + val a = (pixels[y * image.width + x] ushr 24) and 0xFF + if (a == 255) { + found = true + if (x < xL) xL = x + if (y < yT) yT = y + if (x > xR) xR = x + if (y > yB) yB = y + } + } + } + if (!found) throw CaptchaSolveFailedException() + return intArrayOf(xL, yT, xR, yB) + } + + private data class GrayImage(val pixels: IntArray, val stride: Int) + + private fun grayFromImage( + image: Bitmap, + xL: Int, + yT: Int, + width: Int, + height: Int, + ): GrayImage { + val pixels = IntArray(width * height) + var index = 0 + for (y in yT until yT + height) { + for (x in xL until xL + width) { + val color = image.getPixel(x, y) + val r = (color shr 16) and 0xFF + val g = (color shr 8) and 0xFF + val b = color and 0xFF + pixels[index++] = (77 * r + 150 * g + 29 * b) shr 8 + } + } + return GrayImage(pixels, width) + } + + private fun graySum( + gray: GrayImage, + xL: Int, + yT: Int, + width: Int, + height: Int, + ): Double { + var sum = 0.0 + for (y in yT until yT + height) { + val row = y * gray.stride + for (x in xL until xL + width) { + sum += gray.pixels[row + x] + } + } + return sum + } + + private fun grayNorm( + gray: GrayImage, + xL: Int, + yT: Int, + width: Int, + height: Int, + mean: Double, + ): DoubleArray { + val normalized = DoubleArray(width * height) + var index = 0 + for (y in yT until yT + height) { + val row = y * gray.stride + for (x in xL until xL + width) { + normalized[index++] = gray.pixels[row + x] - mean + } + } + return normalized + } + + private fun grayNccFast( + windowImage: GrayImage, + xL: Int, + yT: Int, + width: Int, + height: Int, + mean: Double, + template: DoubleArray, + ): Double { + var sumWindowTemplate = 0.0 + var sumWindowWindow = 0.0 + var index = 0 + for (y in yT until yT + height) { + val row = y * windowImage.stride + for (x in xL until xL + width) { + val window = windowImage.pixels[row + x] - mean + sumWindowWindow += window * window + sumWindowTemplate += window * template[index++] + } + } + if (sumWindowWindow == 0.0) return Double.NEGATIVE_INFINITY + return sumWindowTemplate / sumWindowWindow + } + + fun generateAutoTracks(targetX: Int, random: Random = Random.Default): List { + if (targetX <= 0) return listOf(TrackPoint(0, 0, 0), TrackPoint(0, 0, 0)) + val norm = 1.0 / (1.0 + 0.017248380016648118) + val tracks = mutableListOf(TrackPoint(0, 0, 0)) + val pointCount = random.nextInt(5) + 10 + var y = 0 + for (i in 0 until pointCount) { + val z = (1.0 / (1.0 + exp(-7.0 * (i / pointCount.toDouble() - 0.42)))) / norm + val previousX = tracks.last().a + val x = min(targetX - 1, max(previousX + 1, (targetX * z).toInt())) + val drift = random.nextDouble() + when { + drift < 0.65 -> y-- + drift < 0.80 -> y++ + } + y = max(-10, min(10, y)) + tracks += TrackPoint(x, y, random.nextInt(701) + 900) + } + tracks += TrackPoint(targetX, y, random.nextInt(701) + 900) + return tracks + } + } +} 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 00000000..376152e3 --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/payment/PaymentQrRepository.kt @@ -0,0 +1,134 @@ +// 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.data.WearPreferences +import io.github.benderblog.traintime_pda.ids.PersistentCookieJar +import io.github.benderblog.traintime_pda.ids.SchoolCardSession +import io.github.benderblog.traintime_pda.ids.WearIDSReAuthClient +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 java.net.URI +import java.util.concurrent.atomic.AtomicReference + +data class PaymentQrResult( + val bytes: ByteArray, + val fromCache: Boolean, + val fetchedAtEpochMs: Long, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is PaymentQrResult) return false + return fromCache == other.fromCache && + 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() + return result + } +} + +/** + * Payment QR: prefer watch IDS auth, then phone proxy, then offline cache. + */ +class PaymentQrRepository( + private val preferences: WearPreferences, + private val cache: WearCacheStore, + private val companionClient: WearCompanionClient, +) { + private val activeSession = AtomicReference(null) + + fun cancelActiveRequests() { + activeSession.getAndSet(null)?.cancelRequests() + } + + suspend fun load( + forceRefresh: Boolean = false, + reAuthHandler: (suspend (WearIDSReAuthClient) -> URI)? = null, + ): PaymentQrResult { + if (!forceRefresh) { + cache.readPaymentQr()?.let { (bytes, fetchedAt) -> + return PaymentQrResult(bytes, fromCache = true, fetchedAtEpochMs = fetchedAt) + } + } + return try { + requestDirectly(reAuthHandler) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + currentCoroutineContext().ensureActive() + try { + requestFromPhone() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (phone: Exception) { + currentCoroutineContext().ensureActive() + cache.readPaymentQr()?.let { (bytes, fetchedAt) -> + PaymentQrResult(bytes, fromCache = true, fetchedAtEpochMs = fetchedAt) + } ?: throw phone + } + } + } + + private suspend fun requestFromPhone(): PaymentQrResult { + 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) + return PaymentQrResult( + bytes = response.pngBytes, + fromCache = false, + fetchedAtEpochMs = response.fetchedAtEpochMs, + ) + } + + private suspend fun requestDirectly( + reAuthHandler: (suspend (WearIDSReAuthClient) -> URI)?, + ): PaymentQrResult = withContext(Dispatchers.IO) { + val account = preferences.idsAccount + val password = preferences.idsPassword + if (account.isEmpty() || password.isEmpty()) { + throw IllegalStateException("missing_ids_credentials") + } + val session = SchoolCardSession( + cookieJar = PersistentCookieJar(cache.cookieDir), + username = account, + password = password, + browserFingerprint = preferences.getOrCreateIdsBrowserFingerprint(), + storedOpenId = preferences.schoolCardOpenId, + storedOpenIdFetchedAt = preferences.schoolCardOpenIdFetchedAt, + onOpenIdChanged = { value, fetchedAt -> + if (value == null || fetchedAt == null) { + preferences.clearSchoolCardOpenId() + } else { + preferences.storeSchoolCardOpenId(value, fetchedAt) + } + }, + ) + activeSession.set(session) + try { + session.authenticateWithStoredCredentials(reAuthHandler = reAuthHandler) + val bytes = session.getQRCode() + val fetchedAt = System.currentTimeMillis() + cache.writePaymentQr(bytes, fetchedAt) + PaymentQrResult( + bytes = bytes, + fromCache = false, + fetchedAtEpochMs = fetchedAt, + ) + } finally { + activeSession.compareAndSet(session, null) + } + } +} 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 00000000..ad1edf3e --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/protocol/WearCompanionSync.kt @@ -0,0 +1,187 @@ +// 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 WearCredentialSyncPayload( + val idsAccount: String, + val idsPassword: String, + val isPostGraduate: Boolean? = null, + val currentSemester: String? = null, +) + +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 credentials: WearCredentialSyncPayload, + 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, + credentials = parseCredentials(json.optJSONObject("credentials")), + 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 parseCredentials(json: JSONObject?): WearCredentialSyncPayload { + if (json == null) { + throw WearSyncFormatException("Wear sync credentials are required.") + } + val account = json.optString("idsAccount", "") + val password = json.optString("idsPassword", "") + if (account.isEmpty() || password.isEmpty()) { + throw WearSyncFormatException("Wear sync credentials are invalid.") + } + val isPostGraduate = if (json.has("isPostGraduate") && !json.isNull("isPostGraduate")) { + json.getBoolean("isPostGraduate") + } else { + null + } + val semester = json.optString("currentSemester", "").ifEmpty { null } + return WearCredentialSyncPayload( + idsAccount = account, + idsPassword = password, + isPostGraduate = isPostGraduate, + currentSemester = semester, + ) + } + + 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 00000000..941d0b6d --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/sync/WearCompanionClient.kt @@ -0,0 +1,182 @@ +// 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.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() { + if (!listening.compareAndSet(false, true)) return + messageClient.addListener(this) + pendingSyncPayload?.let { payload -> + pendingSyncPayload = null + handleSyncPayload(payload, pairedPhoneNodeId()) + } + } + + fun stopListening() { + if (!listening.compareAndSet(true, false)) return + messageClient.removeListener(this) + } + + suspend fun requestCompanionSync() { + val nodeId = pairedPhoneNodeId() + ?: throw IllegalStateException("No companion phone is paired") + messageClient.sendMessage(nodeId, WearCompanionPaths.REQUEST, ByteArray(0)).await() + } + + suspend fun requestPaymentQrFromPhone(timeoutMs: Long = 20_000L): WearPaymentQrResponse { + val nodeId = pairedPhoneNodeId() + ?: throw IllegalStateException("No companion phone is paired") + return withTimeout(timeoutMs) { + coroutineScope { + // Subscribe before send to avoid missing a fast response. + val deferred = async { paymentResponses.first() } + messageClient + .sendMessage(nodeId, WearCompanionPaths.PAYMENT_REQUEST, ByteArray(0)) + .await() + deferred.await() + } + } + } + + override fun onMessageReceived(event: MessageEvent) { + 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?.let { nodeId -> + rememberPairedPhone(nodeId) + messageClient.sendMessage( + 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/ui/WearApp.kt b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ui/WearApp.kt new file mode 100644 index 00000000..d0ee5e3d --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ui/WearApp.kt @@ -0,0 +1,95 @@ +// 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.PairingScreen +import io.github.benderblog.traintime_pda.ui.screens.QrScreen +import io.github.benderblog.traintime_pda.ui.screens.ReAuthScreen + +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.REAUTH) { + viewModel.closeQr() + } + + 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, + onLogout = viewModel::logout, + onOpenQr = viewModel::openQr, + onRetry = viewModel::requestManualSync, + ) + WearScreen.QR -> QrScreen( + loading = state.qrLoading, + result = state.qrResult, + error = state.qrError, + onBack = viewModel::closeQr, + onRetry = viewModel::retryQr, + ) + WearScreen.REAUTH -> ReAuthScreen( + notice = state.reAuthNotice, + error = state.reAuthError, + code = state.reAuthCode, + sending = state.reAuthSending, + submitting = state.reAuthSubmitting, + secondsRemaining = state.reAuthSecondsRemaining, + trustDevice = state.reAuthTrustDevice, + onCodeChange = viewModel::updateReAuthCode, + onTrustChange = viewModel::updateReAuthTrustDevice, + onSend = viewModel::sendReAuthSms, + onSubmit = viewModel::submitReAuth, + onCancel = viewModel::closeQr, + ) + } + } + } +} 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 00000000..c32bc53c --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ui/WearViewModel.kt @@ -0,0 +1,484 @@ +// 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.ids.IdsLoginState +import io.github.benderblog.traintime_pda.ids.WearIDSReAuthClient +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.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext +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 kotlinx.coroutines.withContext +import java.net.URI +import kotlin.coroutines.resume + +enum class WearScreen { + PAIRING, + HOME, + QR, + REAUTH, +} + +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 pairingStarting: Boolean = true, + val pairingStatus: String = "请在手机端打开“设置 > XDYou Wear”,选择这块手表", + val qrLoading: Boolean = false, + val qrResult: PaymentQrResult? = null, + val qrError: String? = null, + val reAuthClient: WearIDSReAuthClient? = null, + val reAuthNotice: String? = null, + val reAuthError: String? = null, + val reAuthSending: Boolean = false, + val reAuthSubmitting: Boolean = false, + val reAuthSecondsRemaining: Int = 0, + val reAuthTrustDevice: Boolean = true, + val reAuthCode: String = "", +) + +class WearViewModel( + private val container: WearAppContainer, +) : ViewModel() { + private val paymentRepo = PaymentQrRepository( + preferences = container.preferences, + 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 reAuthContinuation: kotlinx.coroutines.CancellableContinuation? = null + private var countdownJob: Job? = null + private var reAuthSendJob: Job? = null + private var reAuthSubmitJob: Job? = null + private var pendingSyncJob: Job? = null + private var qrJob: Job? = null + private var qrFlowGeneration = 0L + private var activeReAuthGeneration: Long? = null + private var bootstrapSyncRequested = false + + init { + if (!container.needsPairing()) { + IdsLoginState.state = IdsLoginState.State.NONE + refreshHome() + } else { + IdsLoginState.state = IdsLoginState.State.MANUAL + } + viewModelScope.launch { + container.companionClient.imports.collect { + onImportReceived() + } + } + } + + fun onForeground() { + container.companionClient.startListening() + if (!container.needsPairing() && + !container.preferences.hasPaymentCredentials() && + !bootstrapSyncRequested + ) { + bootstrapSyncRequested = true + requestManualSync() + } + } + + fun onBackground() { + container.companionClient.stopListening() + if ((_state.value.screen == WearScreen.QR || + _state.value.screen == WearScreen.REAUTH) && + _state.value.qrResult == null + ) { + qrFlowGeneration++ + cancelActiveQrWork() + _state.update { + it.copy( + screen = WearScreen.QR, + qrLoading = false, + qrError = "操作已暂停,请下拉刷新后重试", + reAuthClient = null, + reAuthNotice = null, + reAuthError = null, + reAuthSending = false, + reAuthSubmitting = false, + reAuthSecondsRemaining = 0, + reAuthCode = "", + ) + } + } + } + + 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 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 { container.companionClient.imports.first() } + container.companionClient.requestCompanionSync() + wait.await() + } + } + } 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) } + refreshHome() + } + } + } + + 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 retryQr() { + val flowId = startNewQrFlow() + _state.update { + it.copy( + qrLoading = true, + qrResult = null, + qrError = null, + ) + } + loadQr(flowId, forceRefresh = true) + } + + private fun loadQr(flowId: Long, forceRefresh: Boolean) { + qrJob = viewModelScope.launch { + try { + val result = paymentRepo.load( + forceRefresh = forceRefresh, + reAuthHandler = { client -> + currentCoroutineContext().ensureActive() + if (!isQrFlowActive(flowId)) { + throw CancellationException("付款码流程已失效") + } + awaitReAuth(client, flowId) + }, + ) + 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 suspend fun awaitReAuth(client: WearIDSReAuthClient, flowId: Long): URI { + currentCoroutineContext().ensureActive() + if (!isQrFlowActive(flowId)) throw CancellationException("付款码流程已失效") + return kotlinx.coroutines.suspendCancellableCoroutine { cont -> + if (!isQrFlowActive(flowId)) { + cont.cancel(CancellationException("付款码流程已失效")) + return@suspendCancellableCoroutine + } + reAuthContinuation?.cancel(CancellationException("短信认证已被新请求替代")) + reAuthContinuation = cont + activeReAuthGeneration = flowId + _state.update { + it.copy( + screen = WearScreen.REAUTH, + reAuthClient = client, + reAuthNotice = null, + reAuthError = null, + reAuthCode = "", + reAuthSending = false, + reAuthSubmitting = false, + reAuthSecondsRemaining = 0, + reAuthTrustDevice = true, + ) + } + sendReAuthSms() + cont.invokeOnCancellation { + if (reAuthContinuation === cont) reAuthContinuation = null + } + } + } + + fun sendReAuthSms() { + val flowId = activeReAuthGeneration ?: return + if (!isQrFlowActive(flowId)) return + val client = _state.value.reAuthClient ?: return + if (_state.value.reAuthSending || _state.value.reAuthSecondsRemaining > 0) return + reAuthSendJob?.cancel() + reAuthSendJob = viewModelScope.launch { + _state.update { it.copy(reAuthSending = true, reAuthError = null) } + try { + val delivery = withContext(Dispatchers.IO) { client.sendSms() } + ensureActive() + if (!isQrFlowActive(flowId)) return@launch + val notice = if (delivery.recipient == null) { + delivery.message + } else { + "${delivery.message}\n${delivery.recipient}" + } + _state.update { it.copy(reAuthNotice = notice) } + startCountdown(delivery.retryAfterSeconds, flowId) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (e: Exception) { + if (!isQrFlowActive(flowId)) return@launch + _state.update { + it.copy(reAuthError = e.message ?: "短信验证码发送失败") + } + } finally { + if (isQrFlowActive(flowId) && _state.value.screen == WearScreen.REAUTH) { + _state.update { it.copy(reAuthSending = false) } + } + } + } + } + + private fun startCountdown(seconds: Int, flowId: Long) { + if (!isQrFlowActive(flowId)) return + countdownJob?.cancel() + _state.update { it.copy(reAuthSecondsRemaining = seconds.coerceAtLeast(0)) } + if (seconds <= 0) return + countdownJob = viewModelScope.launch { + var remaining = seconds + while (remaining > 0) { + kotlinx.coroutines.delay(1_000L) + if (!isQrFlowActive(flowId)) return@launch + remaining-- + _state.update { it.copy(reAuthSecondsRemaining = remaining) } + } + } + } + + fun updateReAuthCode(code: String) { + _state.update { it.copy(reAuthCode = code.filter { ch -> ch.isDigit() }.take(8)) } + } + + fun updateReAuthTrustDevice(trust: Boolean) { + _state.update { it.copy(reAuthTrustDevice = trust) } + } + + fun submitReAuth() { + val flowId = activeReAuthGeneration ?: return + if (!isQrFlowActive(flowId)) return + val client = _state.value.reAuthClient ?: return + val code = _state.value.reAuthCode.trim() + if (code.isEmpty()) { + _state.update { it.copy(reAuthError = "请输入短信验证码") } + return + } + if (_state.value.reAuthSubmitting) return + reAuthSubmitJob?.cancel() + reAuthSubmitJob = viewModelScope.launch { + _state.update { it.copy(reAuthSubmitting = true, reAuthError = null) } + try { + val uri = withContext(Dispatchers.IO) { + client.submitSms(code, _state.value.reAuthTrustDevice) + } + ensureActive() + if (!isQrFlowActive(flowId)) return@launch + val cont = reAuthContinuation + reAuthContinuation = null + activeReAuthGeneration = null + _state.update { + it.copy( + screen = WearScreen.QR, + reAuthClient = null, + reAuthSubmitting = false, + ) + } + cont?.resume(uri) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (e: Exception) { + if (!isQrFlowActive(flowId)) return@launch + _state.update { + it.copy( + reAuthSubmitting = false, + reAuthError = e.message ?: "验证失败", + reAuthCode = "", + ) + } + } + } + } + + private fun startNewQrFlow(): Long { + cancelActiveQrWork() + qrFlowGeneration++ + return qrFlowGeneration + } + + private fun isQrFlowActive(flowId: Long): Boolean = + qrFlowGeneration == flowId && + (_state.value.screen == WearScreen.QR || _state.value.screen == WearScreen.REAUTH) + + private fun cancelActiveQrWork() { + val cancellation = CancellationException("付款码页面已关闭") + qrJob?.cancel(cancellation) + qrJob = null + paymentRepo.cancelActiveRequests() + reAuthSendJob?.cancel(cancellation) + reAuthSendJob = null + reAuthSubmitJob?.cancel(cancellation) + reAuthSubmitJob = null + countdownJob?.cancel(cancellation) + countdownJob = null + val cont = reAuthContinuation + reAuthContinuation = null + activeReAuthGeneration = null + cont?.cancel(cancellation) + } + + 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/HomeScreen.kt b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ui/screens/HomeScreen.kt new file mode 100644 index 00000000..2ea4885d --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ui/screens/HomeScreen.kt @@ -0,0 +1,249 @@ +// 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.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.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +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.Button +import androidx.wear.compose.material.ButtonDefaults +import androidx.wear.compose.material.Chip +import androidx.wear.compose.material.ChipDefaults +import androidx.wear.compose.material.CircularProgressIndicator +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 java.time.format.DateTimeFormatter + +private val TimeFmt: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm") + +@Composable +fun HomeScreen( + data: WearHomeData, + loading: Boolean, + error: String?, + onRefresh: () -> Unit, + onLogout: () -> Unit, + onOpenQr: () -> Unit, + onRetry: () -> Unit, +) { + if (loading && data.todayItems.isEmpty() && data.tomorrowItems.isEmpty() && error == null) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + return + } + + if (error != null && data.todayItems.isEmpty() && data.tomorrowItems.isEmpty()) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = error.take(120), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.error, + ) + Spacer(Modifier.height(12.dp)) + Button(onClick = onRetry) { Text("重试") } + Spacer(Modifier.height(8.dp)) + Button( + onClick = onLogout, + colors = ButtonDefaults.secondaryButtonColors(), + ) { Text("重新登录") } + } + return + } + + // Content is biased slightly upward for round screens (extra top padding is modest). + 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, + ) { + AgendaSection(title = "今天", items = data.todayItems) + AgendaSection(title = "明天", items = data.tomorrowItems) + Spacer(Modifier.height(8.dp)) + CampusCard(onOpenQr = onOpenQr) + Spacer(Modifier.height(8.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Button( + onClick = onRefresh, + modifier = Modifier.weight(1f, fill = false), + ) { + Text(if (loading) "…" else "刷新") + } + Button( + onClick = onLogout, + colors = ButtonDefaults.secondaryButtonColors(), + modifier = Modifier.weight(1f, fill = false), + ) { + Text("退出") + } + } + 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 +private 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) { + 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, + label = { Text("付款码") }, + colors = ChipDefaults.primaryChipColors(), + modifier = Modifier.fillMaxWidth(), + ) + } +} 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 00000000..9642593f --- /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 00000000..1fa8d8ec --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ui/screens/QrScreen.kt @@ -0,0 +1,180 @@ +// 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.detectVerticalDragGestures +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.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.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.style.TextAlign +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 java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +private val CacheFmt: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm") + +@Composable +fun QrScreen( + loading: Boolean, + result: PaymentQrResult?, + error: String?, + onBack: () -> Unit, + onRetry: () -> Unit, +) { + when { + loading -> { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 34.dp, vertical = 28.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator() + Spacer(Modifier.height(12.dp)) + 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)) { + Button(onClick = onRetry) { Text("重试") } + Button( + onClick = onBack, + colors = ButtonDefaults.secondaryButtonColors(), + ) { Text("返回") } + } + } + } + result != null -> { + Column( + modifier = Modifier + .fillMaxSize() + .pointerInput(onRetry) { + var downwardDrag = 0f + val refreshThreshold = 48.dp.toPx() + detectVerticalDragGestures( + onDragStart = { downwardDrag = 0f }, + onVerticalDrag = { change, dragAmount -> + downwardDrag = if (dragAmount > 0f) { + downwardDrag + dragAmount + } else { + (downwardDrag + dragAmount).coerceAtLeast(0f) + } + change.consume() + }, + onDragEnd = { + if (downwardDrag >= refreshThreshold) onRetry() + downwardDrag = 0f + }, + onDragCancel = { downwardDrag = 0f }, + ) + }, + ) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + ) { + val bitmap = remember(result.bytes) { + BitmapFactory.decodeByteArray(result.bytes, 0, result.bytes.size) + } + if (bitmap != null) { + Box( + modifier = Modifier + .align(Alignment.Center) + .padding(start = 16.dp, top = 20.dp, end = 16.dp, bottom = 0.dp) + .aspectRatio(1f) + .clip(RoundedCornerShape(18.dp)) + .background(Color.White) + .padding(12.dp), + ) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = "付款码", + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + if (result.fromCache) { + val label = remember(result.fetchedAtEpochMs) { + val time = Instant.ofEpochMilli(result.fetchedAtEpochMs) + .atZone(ZoneId.systemDefault()) + .toLocalDateTime() + "缓存 ${CacheFmt.format(time)} · 可能失效" + } + Text( + text = label, + fontSize = 10.sp, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 48.dp, vertical = 6.dp) + .clip(RoundedCornerShape(12.dp)) + .background(Color(0xFF7A3E00)) + .padding(horizontal = 8.dp, vertical = 6.dp), + ) + } + } + } + else -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + } +} diff --git a/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ui/screens/ReAuthScreen.kt b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ui/screens/ReAuthScreen.kt new file mode 100644 index 00000000..e153583c --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ui/screens/ReAuthScreen.kt @@ -0,0 +1,146 @@ +// 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.rememberScrollState +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +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.Button +import androidx.wear.compose.material.ButtonDefaults +import androidx.wear.compose.material.MaterialTheme +import androidx.wear.compose.material.Text +import androidx.wear.compose.material.ToggleChip +import androidx.wear.compose.material.ToggleChipDefaults + +@Composable +fun ReAuthScreen( + notice: String?, + error: String?, + code: String, + sending: Boolean, + submitting: Boolean, + secondsRemaining: Int, + trustDevice: Boolean, + onCodeChange: (String) -> Unit, + onTrustChange: (Boolean) -> Unit, + onSend: () -> Unit, + onSubmit: () -> Unit, + onCancel: () -> Unit, +) { + val busy = sending || submitting + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 28.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top, + ) { + Text( + text = "短信验证", + style = MaterialTheme.typography.title3, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.height(8.dp)) + notice?.let { + Text( + text = it, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.caption1, + ) + Spacer(Modifier.height(6.dp)) + } + BasicTextField( + value = code, + onValueChange = onCodeChange, + singleLine = true, + textStyle = TextStyle( + color = MaterialTheme.colors.onBackground, + fontSize = 18.sp, + textAlign = TextAlign.Center, + ), + cursorBrush = SolidColor(MaterialTheme.colors.primary), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + decorationBox = { inner -> + Column(horizontalAlignment = Alignment.CenterHorizontally) { + if (code.isEmpty()) { + Text( + text = "输入验证码", + color = MaterialTheme.colors.onBackground.copy(alpha = 0.5f), + fontSize = 16.sp, + ) + } + inner() + } + }, + ) + ToggleChip( + checked = trustDevice, + onCheckedChange = onTrustChange, + label = { Text("信任此设备", style = MaterialTheme.typography.caption2) }, + toggleControl = { + androidx.wear.compose.material.Checkbox(checked = trustDevice) + }, + colors = ToggleChipDefaults.toggleChipColors(), + modifier = Modifier.fillMaxWidth(), + ) + error?.let { + Text( + text = it, + color = MaterialTheme.colors.error, + style = MaterialTheme.typography.caption2, + textAlign = TextAlign.Center, + ) + } + Spacer(Modifier.height(8.dp)) + Button( + onClick = onSubmit, + enabled = !busy && code.isNotEmpty(), + modifier = Modifier.fillMaxWidth(), + ) { + Text(if (submitting) "提交中…" else "确认") + } + Spacer(Modifier.height(6.dp)) + Button( + onClick = onSend, + enabled = !busy && secondsRemaining <= 0, + colors = ButtonDefaults.secondaryButtonColors(), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + when { + sending -> "发送中…" + secondsRemaining > 0 -> "${secondsRemaining}s 后重发" + else -> "重新发送" + }, + ) + } + Spacer(Modifier.height(4.dp)) + Button( + onClick = onCancel, + colors = ButtonDefaults.secondaryButtonColors(), + modifier = Modifier.fillMaxWidth(), + ) { + Text("取消") + } + } +} 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 00000000..907c1f23 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 00000000..bde9d099 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-v21/background.png b/wearos/android/app/src/main/res/drawable-v21/background.png new file mode 100644 index 00000000..3107d37f 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 00000000..3cc4948a --- /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 00000000..8fbabe8d 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 00000000..3a96ecb0 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 00000000..dc11926a 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 00000000..3107d37f 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 00000000..d7c0716c --- /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 00000000..3cc4948a --- /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 00000000..7353dbd1 --- /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 00000000..7353dbd1 --- /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 00000000..90c78dba 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 00000000..1dd93f39 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 00000000..e5456743 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 00000000..d3fc77cc 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 00000000..bd0e745b 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 00000000..d9260741 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 00000000..41ab9846 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 00000000..8bf55e5d 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 00000000..e04db79e 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 00000000..f466fe58 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 00000000..95f3e559 --- /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 00000000..34cc96bf --- /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 00000000..95f3e559 --- /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 00000000..d11d9ca2 --- /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/styles.xml b/wearos/android/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..627b4493 --- /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 00000000..4437632d --- /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 00000000..e79c8d03 --- /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/app/src/profile/AndroidManifest.xml b/wearos/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 00000000..fbb2ef83 --- /dev/null +++ b/wearos/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/wearos/android/app/src/test/java/io/github/benderblog/traintime_pda/domain/WearAgendaBuilderTest.kt b/wearos/android/app/src/test/java/io/github/benderblog/traintime_pda/domain/WearAgendaBuilderTest.kt new file mode 100644 index 00000000..a37c8eb5 --- /dev/null +++ b/wearos/android/app/src/test/java/io/github/benderblog/traintime_pda/domain/WearAgendaBuilderTest.kt @@ -0,0 +1,122 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +package io.github.benderblog.traintime_pda.domain + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId + +class WearAgendaBuilderTest { + @Test + fun courseItemsUseTargetDateWeekAndClassPeriodTimes() { + val table = ClassTableData( + semesterLength = 2, + semesterCode = "2026-1", + termStartDay = "2026-05-18 00:00:00", + classDetail = listOf(ClassDetail(name = "编译原理", code = "CS301", number = "01")), + timeArrangement = listOf( + TimeArrangement( + index = 0, + weekList = listOf(true, false), + teacher = "张老师", + day = 2, // Tuesday + start = 1, + stop = 2, + source = Source.SCHOOL, + classroom = "B-101", + ), + ), + ) + + val firstWeek = WearAgendaBuilder.courseItemsForDay(table, LocalDate.of(2026, 5, 19)) + val secondWeek = WearAgendaBuilder.courseItemsForDay(table, LocalDate.of(2026, 5, 26)) + + assertThat(firstWeek).hasSize(1) + assertThat(firstWeek.single().kind).isEqualTo(WearAgendaKind.COURSE) + assertThat(firstWeek.single().title).isEqualTo("编译原理") + assertThat(firstWeek.single().subtitle).isEqualTo("张老师") + assertThat(firstWeek.single().location).isEqualTo("B-101") + assertThat(firstWeek.single().start).isEqualTo(LocalDateTime.of(2026, 5, 19, 8, 30)) + assertThat(firstWeek.single().end).isEqualTo(LocalDateTime.of(2026, 5, 19, 10, 5)) + assertThat(secondWeek).isEmpty() + } + + @Test + fun experimentItemsKeepTargetDayRanges() { + val zone = ZoneId.of("Asia/Shanghai") + val experiments = listOf( + ExperimentData( + name = "电工实习", + classroom = "工程坊", + timeRanges = listOf( + LocalDateTime.of(2026, 5, 19, 14, 0) + .atZone(zone).toInstant().toEpochMilli() to + LocalDateTime.of(2026, 5, 19, 16, 0) + .atZone(zone).toInstant().toEpochMilli(), + ), + teacher = "王老师", + ), + ExperimentData( + name = "工程训练", + classroom = "工程坊", + timeRanges = listOf( + LocalDateTime.of(2026, 5, 20, 14, 0) + .atZone(zone).toInstant().toEpochMilli() to + LocalDateTime.of(2026, 5, 20, 16, 0) + .atZone(zone).toInstant().toEpochMilli(), + ), + teacher = "刘老师", + ), + ) + + val items = WearAgendaBuilder.experimentItemsForDay( + experiments, + LocalDate.of(2026, 5, 19), + zone, + ) + + assertThat(items).hasSize(1) + assertThat(items.single().kind).isEqualTo(WearAgendaKind.OTHER_EXPERIMENT) + assertThat(items.single().title).isEqualTo("电工实习") + assertThat(items.single().subtitle).isEqualTo("王老师") + assertThat(items.single().location).isEqualTo("工程坊") + assertThat(items.single().start).isEqualTo(LocalDateTime.of(2026, 5, 19, 14, 0)) + assertThat(items.single().end).isEqualTo(LocalDateTime.of(2026, 5, 19, 16, 0)) + } + + @Test + fun cacheOnlyLoadBuildsAgendaWithoutNetwork() { + val table = singleCourseTable("离线课程") + val data = WearAgendaBuilder.loadHomeData( + semesterCode = "2026-1", + classTable = table, + experiments = null, + now = LocalDateTime.of(2026, 5, 19, 8, 0), + ) + assertThat(data.todayItems.map { it.title }).containsExactly("离线课程") + } + + companion object { + fun singleCourseTable(name: String): ClassTableData = ClassTableData( + semesterLength = 1, + semesterCode = "2026-1", + termStartDay = "2026-05-18 00:00:00", + classDetail = listOf(ClassDetail(name = name)), + timeArrangement = listOf( + TimeArrangement( + index = 0, + weekList = listOf(true), + teacher = "赵老师", + day = 2, + start = 3, + stop = 4, + source = Source.SCHOOL, + classroom = "A-301", + ), + ), + ) + } +} diff --git a/wearos/android/app/src/test/java/io/github/benderblog/traintime_pda/ids/IdsCryptoTest.kt b/wearos/android/app/src/test/java/io/github/benderblog/traintime_pda/ids/IdsCryptoTest.kt new file mode 100644 index 00000000..7ff457d0 --- /dev/null +++ b/wearos/android/app/src/test/java/io/github/benderblog/traintime_pda/ids/IdsCryptoTest.kt @@ -0,0 +1,51 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +package io.github.benderblog.traintime_pda.ids + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class IdsCryptoTest { + @Test + fun passwordEncryptionMatchesGoIdsLoginPayload() { + assertThat(IdsCrypto.aesEncrypt("secret", "1234567890abcdef")) + .isEqualTo( + "Y2fkMlmY/KyUHnWiA9lVrnC8HHWUFePOo/JLpbpV/XfZ/zE6Tk2WrZMyCYY1f9ael+nb8OZB4B2EmFM6G18SWNpKTmuSEP0PjuxgVXBdI90=", + ) + } + + @Test + fun usernameLoginPayloadMatchesGoIdsFields() { + val payload = IdsCrypto.buildUsernameLoginPayload( + username = "2200000000", + password = "secret", + salt = "1234567890abcdef", + execution = "exec-token", + ) + assertThat(payload).containsExactlyEntriesIn( + mapOf( + "username" to "2200000000", + "password" to + "Y2fkMlmY/KyUHnWiA9lVrnC8HHWUFePOo/JLpbpV/XfZ/zE6Tk2WrZMyCYY1f9ael+nb8OZB4B2EmFM6G18SWNpKTmuSEP0PjuxgVXBdI90=", + "rememberMe" to "true", + "cllt" to "userNameLogin", + "dllt" to "generalLogin", + "_eventId" to "submit", + "captcha" to "", + "lt" to "", + "execution" to "exec-token", + ), + ) + } + + @Test + fun captchaPayloadEncryptionMatchesGoShape() { + val key = "1234567890abcdef".toByteArray(Charsets.UTF_8) + val payload = + """{"canvasLength":280,"moveLength":42,"tracks":[{"a":0,"b":0,"c":0},{"a":42,"b":0,"c":900}]}""" + assertThat(IdsCrypto.encryptCaptchaPayload(payload, key)).isEqualTo( + "Y2fkMlmY/KyUHnWiA9lVrnC8HHWUFePOo/JLpbpV/XfZ/zE6Tk2WrZMyCYY1f9ael+nb8OZB4B2EmFM6G18SWMo6nGxXZr4TTOiHUUTFXkeQQVaF2RoG1CsaDxyrQkchEx7YVCH+3fSUlX8CKpybb7jJnIbccr2rP1538MId2OLPck1g1XaCwAOtLK+LyyKILKYdFAT061XHTpBZZfvJOg==", + ) + } +} diff --git a/wearos/android/app/src/test/java/io/github/benderblog/traintime_pda/ids/SliderCaptchaTracksTest.kt b/wearos/android/app/src/test/java/io/github/benderblog/traintime_pda/ids/SliderCaptchaTracksTest.kt new file mode 100644 index 00000000..10fed34e --- /dev/null +++ b/wearos/android/app/src/test/java/io/github/benderblog/traintime_pda/ids/SliderCaptchaTracksTest.kt @@ -0,0 +1,26 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +package io.github.benderblog.traintime_pda.ids + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import kotlin.random.Random + +class SliderCaptchaTracksTest { + @Test + fun generateAutoTracksEndsAtTarget() { + val tracks = SliderCaptchaClient.generateAutoTracks(42, Random(0)) + assertThat(tracks).isNotEmpty() + assertThat(tracks.first().a).isEqualTo(0) + assertThat(tracks.last().a).isEqualTo(42) + assertThat(tracks.zipWithNext().all { (a, b) -> b.a >= a.a }).isTrue() + } + + @Test + fun maskPhoneNumberKeepsPrefixAndSuffix() { + assertThat(WearIDSReAuthClient.maskPhoneNumber("13812345678")) + .isEqualTo("138****5678") + assertThat(WearIDSReAuthClient.maskPhoneNumber("12")).isEqualTo("****") + } +} diff --git a/wearos/android/app/src/test/java/io/github/benderblog/traintime_pda/protocol/WearCompanionSyncTest.kt b/wearos/android/app/src/test/java/io/github/benderblog/traintime_pda/protocol/WearCompanionSyncTest.kt new file mode 100644 index 00000000..6a6a0c62 --- /dev/null +++ b/wearos/android/app/src/test/java/io/github/benderblog/traintime_pda/protocol/WearCompanionSyncTest.kt @@ -0,0 +1,228 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +package io.github.benderblog.traintime_pda.protocol + +import com.google.common.truth.Truth.assertThat +import io.github.benderblog.traintime_pda.data.WearCacheStore +import io.github.benderblog.traintime_pda.domain.ClassDetail +import io.github.benderblog.traintime_pda.domain.ClassTableData +import io.github.benderblog.traintime_pda.domain.Source +import io.github.benderblog.traintime_pda.domain.TimeArrangement +import io.github.benderblog.traintime_pda.domain.WearAgendaBuilder +import io.github.benderblog.traintime_pda.ids.SchoolCardSession +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.time.LocalDateTime +import java.util.Base64 + +class WearCompanionSyncTest { + @get:Rule + val tempFolder = TemporaryFolder() + + @Test + fun rejectsMalformedNativeSyncPayloads() { + val missingCredentials = JSONObject() + .put("schemaVersion", 1) + .put("sessionId", "session-123") + .put( + "schedule", + JSONObject().put("classTable", classTableJson("缺少凭据")), + ) + try { + WearCompanionSyncEnvelope.fromJson(missingCredentials) + throw AssertionError("expected WearSyncFormatException") + } catch (_: WearSyncFormatException) { + // expected + } + + val missingSchedule = JSONObject() + .put("schemaVersion", 1) + .put("sessionId", "session-123") + .put( + "credentials", + JSONObject() + .put("idsAccount", "2200000002") + .put("idsPassword", "secret"), + ) + try { + WearCompanionSyncEnvelope.fromJson(missingSchedule) + throw AssertionError("expected WearSyncFormatException") + } catch (_: WearSyncFormatException) { + // expected + } + } + + @Test + fun decodesBundledNativeSyncPayload() { + val table = classTableJson("扫码同步课程") + val envelope = WearCompanionSyncEnvelope.fromJson( + JSONObject() + .put("schemaVersion", 1) + .put("sessionId", "session-123") + .put( + "credentials", + JSONObject() + .put("idsAccount", "2200000002") + .put("idsPassword", "synced-secret") + .put("isPostGraduate", false) + .put("currentSemester", "fallback-term"), + ) + .put("schedule", JSONObject().put("classTable", table)) + .put( + "paymentQr", + JSONObject() + .put("pngBase64", Base64.getEncoder().encodeToString(byteArrayOf(1, 2, 3))) + .put("fetchedAtEpochMs", 1785816000000L), + ), + ) + + assertThat(envelope.credentials.idsAccount).isEqualTo("2200000002") + assertThat(envelope.credentials.idsPassword).isEqualTo("synced-secret") + assertThat(envelope.schedule.classTable.classDetail.single().name).isEqualTo("扫码同步课程") + assertThat(envelope.paymentQr!!.bytes.toList()).containsExactly(1.toByte(), 2.toByte(), 3.toByte()) + } + + @Test + fun cacheImportAndHomeLoadRoundTrip() { + val root = tempFolder.newFolder("files") + val cache = WearCacheStore(root) + SchoolCardSession.resetOpenId() + + val table = ClassTableData( + semesterLength = 1, + semesterCode = "2026-1", + termStartDay = "2026-05-18 00:00:00", + classDetail = listOf(ClassDetail(name = "同步课程")), + timeArrangement = listOf( + TimeArrangement( + index = 0, + weekList = listOf(true), + teacher = "赵老师", + day = 2, + start = 3, + stop = 4, + source = Source.SCHOOL, + classroom = "A-301", + ), + ), + ) + val classTableRaw = classTableJson("同步课程").toString() + cache.writeClassTableRaw(classTableRaw) + assertThat(cache.readClassTable()!!.classDetail.single().name).isEqualTo("同步课程") + + val home = WearAgendaBuilder.loadHomeData( + semesterCode = "2026-1", + classTable = cache.readClassTable(), + experiments = null, + now = LocalDateTime.of(2026, 5, 19, 8, 0), + ) + assertThat(home.todayItems.map { it.title }).contains("同步课程") + + cache.writePaymentQr(byteArrayOf(9, 8, 7), 1_700_000_000_000L) + val qr = cache.readPaymentQr()!! + assertThat(qr.first.toList()).containsExactly(9.toByte(), 8.toByte(), 7.toByte()) + assertThat(qr.second).isEqualTo(1_700_000_000_000L) + + cache.clearCampusCaches() + cache.clearPaymentQr() + SchoolCardSession.resetOpenId() + assertThat(cache.classTableExists()).isFalse() + assertThat(cache.readPaymentQr()).isNull() + assertThat(SchoolCardSession.openid).isEmpty() + } + + @Test + fun importerWritesCredentialsScheduleAndPayment() { + // Exercise protocol + cache side of import without Android SharedPreferences. + val root = tempFolder.newFolder("import") + val cache = WearCacheStore(root) + val raw = JSONObject() + .put("schemaVersion", 1) + .put("sessionId", "session-import") + .put( + "credentials", + JSONObject() + .put("idsAccount", "2200000001") + .put("idsPassword", "new-secret") + .put("isPostGraduate", true) + .put("currentSemester", "2026-1"), + ) + .put( + "schedule", + JSONObject() + .put("classTable", classTableJson("导入课程")) + .put( + "otherExperiments", + JSONArray().put( + JSONObject() + .put("type", "others") + .put("name", "导入实验") + .put("classroom", "实验楼") + .put( + "timeRanges", + JSONArray().put( + JSONObject() + .put("\$1", "2026-05-19T10:00:00") + .put("\$2", "2026-05-19T11:00:00"), + ), + ) + .put("teacher", "同步老师"), + ), + ), + ) + .put( + "paymentQr", + JSONObject() + .put("pngBase64", Base64.getEncoder().encodeToString(byteArrayOf(1, 2, 3))) + .put("fetchedAtEpochMs", 1785816000000L), + ) + .toString() + + val envelope = WearCompanionSyncEnvelope.decode(raw) + // Prefer raw schedule JSON path used by WearSyncImporter. + cache.writeClassTableRaw( + JSONObject(raw).getJSONObject("schedule").getJSONObject("classTable").toString(), + ) + cache.writeExperimentsRaw( + JSONObject(raw).getJSONObject("schedule").getJSONArray("otherExperiments").toString(), + ) + envelope.paymentQr?.let { cache.writePaymentQr(it.bytes, it.fetchedAtEpochMs) } + + assertThat(envelope.credentials.idsAccount).isEqualTo("2200000001") + assertThat(cache.readClassTable()!!.classDetail.single().name).isEqualTo("导入课程") + assertThat(cache.readExperiments()!!.single().name).isEqualTo("导入实验") + assertThat(cache.readPaymentQr()!!.first.toList()) + .containsExactly(1.toByte(), 2.toByte(), 3.toByte()) + } + + private fun classTableJson(name: String): JSONObject = + JSONObject() + .put("semesterLength", 1) + .put("semesterCode", "2026-1") + .put("termStartDay", "2026-05-18 00:00:00") + .put( + "classDetail", + JSONArray().put(JSONObject().put("name", name)), + ) + .put("userDefinedDetail", JSONArray()) + .put("notArranged", JSONArray()) + .put( + "timeArrangement", + JSONArray().put( + JSONObject() + .put("index", 0) + .put("week_list", JSONArray().put(true)) + .put("teacher", "赵老师") + .put("day", 2) + .put("start", 3) + .put("stop", 4) + .put("source", "school") + .put("classroom", "A-301"), + ), + ) + .put("classChanges", JSONArray()) +} diff --git a/wearos/android/build.gradle b/wearos/android/build.gradle new file mode 100644 index 00000000..f60f590d --- /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 00000000..86d4a2a4 --- /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 00000000..13372aef 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 00000000..088b874c --- /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 00000000..9d82f789 --- /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 00000000..8a0b282a --- /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 00000000..4af8f432 --- /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"