From 3694df75cc0ce25a832dacae766834eaf72670da Mon Sep 17 00:00:00 2001 From: brill594 Date: Tue, 19 May 2026 10:59:53 +0800 Subject: [PATCH 01/16] Show new logs at bottom --- lib/page/login/bottom_buttons.dart | 5 ++++- lib/page/setting/setting.dart | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/page/login/bottom_buttons.dart b/lib/page/login/bottom_buttons.dart index 3c005b07..fc1d8f8f 100644 --- a/lib/page/login/bottom_buttons.dart +++ b/lib/page/login/bottom_buttons.dart @@ -49,7 +49,10 @@ class ButtomButtons extends StatelessWidget { onPressed: () { Navigator.of(context).push( MaterialPageRoute( - builder: (context) => TalkerScreen(talker: log), + builder: (context) => TalkerScreen( + talker: log, + isLogOrderReversed: false, + ), ), ); }, diff --git a/lib/page/setting/setting.dart b/lib/page/setting/setting.dart index a64d166c..407b7538 100644 --- a/lib/page/setting/setting.dart +++ b/lib/page/setting/setting.dart @@ -772,7 +772,9 @@ class _SettingWindowState extends State { FlutterI18n.translate(context, "setting.check_logger"), ), trailing: const Icon(Icons.navigate_next), - onTap: () => context.push(TalkerScreen(talker: log)), + onTap: () => context.push( + TalkerScreen(talker: log, isLogOrderReversed: false), + ), ), const Divider(), if (Platform.isAndroid || Platform.isIOS) ...[ From 93927be0fc1c5b8208ecf1fb2594e4dd709de988 Mon Sep 17 00:00:00 2001 From: brill594 Date: Tue, 19 May 2026 11:10:01 +0800 Subject: [PATCH 02/16] Revert "Show new logs at bottom" This reverts commit 3694df75cc0ce25a832dacae766834eaf72670da. --- lib/page/login/bottom_buttons.dart | 5 +---- lib/page/setting/setting.dart | 4 +--- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/lib/page/login/bottom_buttons.dart b/lib/page/login/bottom_buttons.dart index fc1d8f8f..3c005b07 100644 --- a/lib/page/login/bottom_buttons.dart +++ b/lib/page/login/bottom_buttons.dart @@ -49,10 +49,7 @@ class ButtomButtons extends StatelessWidget { onPressed: () { Navigator.of(context).push( MaterialPageRoute( - builder: (context) => TalkerScreen( - talker: log, - isLogOrderReversed: false, - ), + builder: (context) => TalkerScreen(talker: log), ), ); }, diff --git a/lib/page/setting/setting.dart b/lib/page/setting/setting.dart index 407b7538..a64d166c 100644 --- a/lib/page/setting/setting.dart +++ b/lib/page/setting/setting.dart @@ -772,9 +772,7 @@ class _SettingWindowState extends State { FlutterI18n.translate(context, "setting.check_logger"), ), trailing: const Icon(Icons.navigate_next), - onTap: () => context.push( - TalkerScreen(talker: log, isLogOrderReversed: false), - ), + onTap: () => context.push(TalkerScreen(talker: log)), ), const Divider(), if (Platform.isAndroid || Platform.isIOS) ...[ From 5243e2e59daaf2574520032f0645a7b4a8ea5309 Mon Sep 17 00:00:00 2001 From: brill594 Date: Mon, 3 Aug 2026 19:44:28 +0900 Subject: [PATCH 03/16] feat: add direct Wear companion sync --- android/app/build.gradle | 3 +- android/app/src/main/AndroidManifest.xml | 12 ++ .../benderblog/traintime_pda/MainActivity.kt | 58 +++++++ .../traintime_pda/WearCompanionTransport.kt | 36 +++++ lib/page/homepage/refresh.dart | 14 +- lib/page/login/login_window.dart | 86 +++++++---- lib/page/setting/setting.dart | 14 ++ .../setting/wear_companion_sync_page.dart | 145 ++++++++++++++++++ lib/repository/wear_companion_sync.dart | 99 ++++++++++++ 9 files changed, 433 insertions(+), 34 deletions(-) create mode 100644 android/app/src/main/kotlin/io/github/benderblog/traintime_pda/WearCompanionTransport.kt create mode 100644 lib/page/setting/wear_companion_sync_page.dart create mode 100644 lib/repository/wear_companion_sync.dart 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"> + + + + + + + + when (call.method) { + "getConnectedWearNodes" -> { + Wearable.getNodeClient(this).connectedNodes + .addOnSuccessListener { nodes -> + result.success(nodes.map { node -> + mapOf( + "id" to node.id, + "name" to node.displayName, + "isNearby" to node.isNearby, + ) + }) + } + .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) + Wearable.getMessageClient(this) + .sendMessage(nodeId, path, payload.toByteArray(Charsets.UTF_8)) + .addOnSuccessListener { result.success(null) } + .addOnFailureListener { error -> + 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) + } + } + else -> result.notImplemented() + } + } + } } 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..6a3a39cf --- /dev/null +++ b/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/WearCompanionTransport.kt @@ -0,0 +1,36 @@ +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 REQUEST_PATH = "/traintime_pda_wear_os/request/v1" + private const val PREFS = "wear_companion_transport" + private const val PAYLOAD = "latest_payload" + + 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) +} + +/** Answers a paired watch with the last phone-generated snapshot. */ +class WearCompanionListenerService : WearableListenerService() { + override fun onMessageReceived(event: MessageEvent) { + if (event.path != WearCompanionTransport.REQUEST_PATH) return + val payload = WearCompanionTransport.cachedPayload(this) ?: return + Wearable.getMessageClient(this).sendMessage( + event.sourceNodeId, + WearCompanionTransport.SYNC_PATH, + payload.toByteArray(Charsets.UTF_8), + ) + } +} 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..9a999506 --- /dev/null +++ b/lib/page/setting/wear_companion_sync_page.dart @@ -0,0 +1,145 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +import 'package:flutter/material.dart'; +import 'package:watermeter/repository/wear_companion_sync.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? _status; + + void _reload() { + setState(() { + _status = null; + _nodesFuture = _service.connectedNodes(); + }); + } + + Future _pair(WearNode node) async { + if (_sendingNodeId != null) return; + setState(() { + _sendingNodeId = node.id; + _status = '正在向 ${node.name} 同步…'; + }); + try { + await _service.pairAndSync(node); + if (!mounted) return; + setState(() => _status = '配对与同步已发送,请查看手表'); + } catch (error) { + if (!mounted) return; + setState(() { + _sendingNodeId = null; + _status = error.toString(); + }); + } + } + + @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), + ) + : 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..5f0618cd --- /dev/null +++ b/lib/repository/wear_companion_sync.dart @@ -0,0 +1,99 @@ +// 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/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(); + + Map buildSnapshot({required String sessionId}) { + 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(), + }, + 'generatedAtEpochMs': DateTime.now().millisecondsSinceEpoch, + }; + } + + 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, + ); + }) + .toList(growable: false); + } + + Future pairAndSync(WearNode node) async { + final snapshot = buildSnapshot(sessionId: 'direct-pairing'); + snapshot['directPairing'] = true; + final payload = jsonEncode(snapshot); + await _channel.invokeMethod('sendSyncPayload', { + 'nodeId': node.id, + 'messagePath': syncPath, + 'payload': payload, + }); + } + + /// Updates the native cache used to answer a bound watch in the background. + Future cacheLatestSnapshot() async { + final payload = jsonEncode(buildSnapshot(sessionId: 'background-sync')); + await _channel.invokeMethod('cacheSyncPayload', {'payload': payload}); + } +} + +class WearNode { + final String id; + final String name; + final bool isNearby; + + const WearNode({ + required this.id, + required this.name, + required this.isNearby, + }); +} From e792cc6cd20d6d1d7850c0136ca3c65b884ad827 Mon Sep 17 00:00:00 2001 From: brill594 Date: Tue, 4 Aug 2026 11:49:51 +0900 Subject: [PATCH 04/16] fix: finish Wear pairing progress state --- lib/page/setting/wear_companion_sync_page.dart | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/page/setting/wear_companion_sync_page.dart b/lib/page/setting/wear_companion_sync_page.dart index 9a999506..75771e61 100644 --- a/lib/page/setting/wear_companion_sync_page.dart +++ b/lib/page/setting/wear_companion_sync_page.dart @@ -15,11 +15,13 @@ class _WearCompanionSyncPageState extends State { final _service = const WearCompanionSyncService(); late Future> _nodesFuture = _service.connectedNodes(); String? _sendingNodeId; + String? _completedNodeId; String? _status; void _reload() { setState(() { _status = null; + _completedNodeId = null; _nodesFuture = _service.connectedNodes(); }); } @@ -28,12 +30,17 @@ class _WearCompanionSyncPageState extends State { if (_sendingNodeId != null) return; setState(() { _sendingNodeId = node.id; + _completedNodeId = null; _status = '正在向 ${node.name} 同步…'; }); try { await _service.pairAndSync(node); if (!mounted) return; - setState(() => _status = '配对与同步已发送,请查看手表'); + setState(() { + _sendingNodeId = null; + _completedNodeId = node.id; + _status = '配对与同步完成'; + }); } catch (error) { if (!mounted) return; setState(() { @@ -89,6 +96,8 @@ class _WearCompanionSyncPageState extends State { dimension: 22, child: CircularProgressIndicator(strokeWidth: 2), ) + : _completedNodeId == node.id + ? const Icon(Icons.check_circle, color: Colors.green) : FilledButton( onPressed: _sendingNodeId == null ? () => _pair(node) From c81571b48a024e856b18fed2cf503c3ab01b44b1 Mon Sep 17 00:00:00 2001 From: brill594 Date: Tue, 4 Aug 2026 13:20:46 +0900 Subject: [PATCH 05/16] fix(wear): persist pairing and sync payment cache --- .../benderblog/traintime_pda/MainActivity.kt | 7 ++- .../traintime_pda/WearCompanionTransport.kt | 10 +++++ .../setting/wear_companion_sync_page.dart | 12 +++-- lib/repository/wear_companion_sync.dart | 45 +++++++++++++++++-- 4 files changed, 66 insertions(+), 8 deletions(-) diff --git a/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/MainActivity.kt b/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/MainActivity.kt index 9c898a7b..f94b418c 100644 --- a/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/MainActivity.kt +++ b/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/MainActivity.kt @@ -25,11 +25,13 @@ class MainActivity : FlutterActivity() { "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), ) }) } @@ -51,7 +53,10 @@ class MainActivity : FlutterActivity() { WearCompanionTransport.cachePayload(this, payload) Wearable.getMessageClient(this) .sendMessage(nodeId, path, payload.toByteArray(Charsets.UTF_8)) - .addOnSuccessListener { result.success(null) } + .addOnSuccessListener { + WearCompanionTransport.rememberPairedWatch(this, nodeId) + result.success(null) + } .addOnFailureListener { error -> result.error("send_failed", error.message, null) } 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 index 6a3a39cf..4af8b23d 100644 --- 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 @@ -11,6 +11,7 @@ internal object WearCompanionTransport { const val REQUEST_PATH = "/traintime_pda_wear_os/request/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" fun cachePayload(context: Context, payload: String) { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) @@ -20,6 +21,15 @@ internal object WearCompanionTransport { 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) } /** Answers a paired watch with the last phone-generated snapshot. */ diff --git a/lib/page/setting/wear_companion_sync_page.dart b/lib/page/setting/wear_companion_sync_page.dart index 75771e61..60713506 100644 --- a/lib/page/setting/wear_companion_sync_page.dart +++ b/lib/page/setting/wear_companion_sync_page.dart @@ -21,7 +21,6 @@ class _WearCompanionSyncPageState extends State { void _reload() { setState(() { _status = null; - _completedNodeId = null; _nodesFuture = _service.connectedNodes(); }); } @@ -96,8 +95,15 @@ class _WearCompanionSyncPageState extends State { dimension: 22, child: CircularProgressIndicator(strokeWidth: 2), ) - : _completedNodeId == node.id - ? const Icon(Icons.check_circle, color: Colors.green) + : node.isPaired || _completedNodeId == node.id + ? const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check_circle, color: Colors.green), + SizedBox(width: 6), + Text('已配对'), + ], + ) : FilledButton( onPressed: _sendingNodeId == null ? () => _pair(node) diff --git a/lib/repository/wear_companion_sync.dart b/lib/repository/wear_companion_sync.dart index 5f0618cd..c3ef22fe 100644 --- a/lib/repository/wear_companion_sync.dart +++ b/lib/repository/wear_companion_sync.dart @@ -6,6 +6,7 @@ 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/sysj_session.dart'; /// Phone-side endpoint of the XDYou Wear companion protocol. @@ -17,7 +18,11 @@ class WearCompanionSyncService { const WearCompanionSyncService(); - Map buildSnapshot({required String sessionId}) { + 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( @@ -48,28 +53,55 @@ class WearCompanionSyncService { 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); + 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 = buildSnapshot(sessionId: 'direct-pairing'); + final snapshot = await _buildSnapshotWithPaymentQr( + sessionId: 'direct-pairing', + ); snapshot['directPairing'] = true; final payload = jsonEncode(snapshot); await _channel.invokeMethod('sendSyncPayload', { @@ -81,7 +113,10 @@ class WearCompanionSyncService { /// Updates the native cache used to answer a bound watch in the background. Future cacheLatestSnapshot() async { - final payload = jsonEncode(buildSnapshot(sessionId: 'background-sync')); + final snapshot = await _buildSnapshotWithPaymentQr( + sessionId: 'background-sync', + ); + final payload = jsonEncode(snapshot); await _channel.invokeMethod('cacheSyncPayload', {'payload': payload}); } } @@ -90,10 +125,12 @@ 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, }); } From e5705b6163abf5a5f12dd15da9ef0c3e8f887e59 Mon Sep 17 00:00:00 2001 From: brill594 Date: Tue, 4 Aug 2026 13:44:39 +0900 Subject: [PATCH 06/16] feat(wear): proxy payment authentication through phone --- .../benderblog/traintime_pda/MainActivity.kt | 63 +++++++++++++++++-- .../traintime_pda/WearCompanionTransport.kt | 37 ++++++++--- lib/main.dart | 2 + .../setting/wear_companion_sync_page.dart | 19 +++++- lib/repository/wear_companion_sync.dart | 33 +++++++++- 5 files changed, 139 insertions(+), 15 deletions(-) diff --git a/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/MainActivity.kt b/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/MainActivity.kt index f94b418c..b8ff81f1 100644 --- a/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/MainActivity.kt +++ b/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/MainActivity.kt @@ -2,13 +2,17 @@ package io.github.benderblog.traintime_pda import android.os.Bundle import androidx.core.view.WindowCompat +import com.google.android.gms.wearable.MessageClient +import com.google.android.gms.wearable.MessageEvent import com.google.android.gms.wearable.Wearable import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel -class MainActivity : FlutterActivity() { +class MainActivity : FlutterActivity(), MessageClient.OnMessageReceivedListener { + private var companionChannel: MethodChannel? = null + override fun onCreate(savedInstanceState: Bundle?) { // Enable edge-to-edge display WindowCompat.enableEdgeToEdge(window) @@ -17,11 +21,12 @@ class MainActivity : FlutterActivity() { override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) - MethodChannel( + companionChannel = MethodChannel( flutterEngine.dartExecutor.binaryMessenger, WearCompanionTransport.CHANNEL, - ).setMethodCallHandler { call, result -> - when (call.method) { + ).also { channel -> + channel.setMethodCallHandler { call, result -> + when (call.method) { "getConnectedWearNodes" -> { Wearable.getNodeClient(this).connectedNodes .addOnSuccessListener { nodes -> @@ -70,8 +75,56 @@ class MainActivity : FlutterActivity() { result.success(null) } } - else -> result.notImplemented() + "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.PAYMENT_REQUEST_PATH || + event.sourceNodeId != WearCompanionTransport.pairedWatchNodeId(this) + ) return + runOnUiThread { + companionChannel?.invokeMethod("receivePaymentQrRequest", event.sourceNodeId) + } + } } 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 index 4af8b23d..8378a878 100644 --- 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 @@ -9,9 +9,13 @@ 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 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) @@ -30,17 +34,36 @@ internal object WearCompanionTransport { 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.path != WearCompanionTransport.REQUEST_PATH) return - val payload = WearCompanionTransport.cachedPayload(this) ?: return - Wearable.getMessageClient(this).sendMessage( - event.sourceNodeId, - WearCompanionTransport.SYNC_PATH, - payload.toByteArray(Charsets.UTF_8), - ) + 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/setting/wear_companion_sync_page.dart b/lib/page/setting/wear_companion_sync_page.dart index 60713506..28050730 100644 --- a/lib/page/setting/wear_companion_sync_page.dart +++ b/lib/page/setting/wear_companion_sync_page.dart @@ -2,7 +2,9 @@ // 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}); @@ -32,13 +34,22 @@ class _WearCompanionSyncPageState extends State { _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 { - await _service.pairAndSync(node); + final paymentQrSynced = await _service.pairAndSync(node); if (!mounted) return; setState(() { _sendingNodeId = null; _completedNodeId = node.id; - _status = '配对与同步完成'; + _status = paymentQrSynced + ? '配对、数据与付款码同步完成' + : '配对与数据同步完成,付款码未同步;请完成短信认证后重试'; }); } catch (error) { if (!mounted) return; @@ -46,6 +57,10 @@ class _WearCompanionSyncPageState extends State { _sendingNodeId = null; _status = error.toString(); }); + } finally { + if (identical(activeIDSReAuthHandler, pairingReAuthHandler)) { + activeIDSReAuthHandler = previousReAuthHandler; + } } } diff --git a/lib/repository/wear_companion_sync.dart b/lib/repository/wear_companion_sync.dart index c3ef22fe..90f3625b 100644 --- a/lib/repository/wear_companion_sync.dart +++ b/lib/repository/wear_companion_sync.dart @@ -7,6 +7,7 @@ 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. @@ -18,6 +19,35 @@ class WearCompanionSyncService { 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, @@ -98,7 +128,7 @@ class WearCompanionSyncService { .toList(growable: false); } - Future pairAndSync(WearNode node) async { + Future pairAndSync(WearNode node) async { final snapshot = await _buildSnapshotWithPaymentQr( sessionId: 'direct-pairing', ); @@ -109,6 +139,7 @@ class WearCompanionSyncService { 'messagePath': syncPath, 'payload': payload, }); + return snapshot.containsKey('paymentQr'); } /// Updates the native cache used to answer a bound watch in the background. From 2416c3996195ebcbbe1e5d32393a9cb0cfebc9df Mon Sep 17 00:00:00 2001 From: brill594 Date: Tue, 4 Aug 2026 14:13:20 +0900 Subject: [PATCH 07/16] Squashed 'wearos/' content from commit 081019cb git-subtree-dir: wearos git-subtree-split: 081019cbe3cdc2786197e05bd88d1fb0162acd43 --- .flutter | 1 + .gitignore | 56 + .gitmodules | 3 + .metadata | 30 + LICENSE | 373 +++++++ WEAR_SYNC_INTEGRATION.md | 47 + analysis_options.yaml | 29 + android/.gitignore | 13 + android/app/build.gradle | 98 ++ android/app/proguard-rules.pro | 28 + android/app/src/debug/AndroidManifest.xml | 8 + android/app/src/main/AndroidManifest.xml | 37 + .../app/src/main/ic_launcher-playstore.png | Bin 0 -> 25933 bytes .../benderblog/traintime_pda/MainActivity.kt | 210 ++++ .../app/src/main/res/drawable-hdpi/splash.png | Bin 0 -> 5774 bytes .../app/src/main/res/drawable-mdpi/splash.png | Bin 0 -> 2439 bytes .../src/main/res/drawable-v21/background.png | Bin 0 -> 69 bytes .../res/drawable-v21/launch_background.xml | 9 + .../src/main/res/drawable-xhdpi/splash.png | Bin 0 -> 6648 bytes .../src/main/res/drawable-xxhdpi/splash.png | Bin 0 -> 16352 bytes .../src/main/res/drawable-xxxhdpi/splash.png | Bin 0 -> 18591 bytes .../app/src/main/res/drawable/background.png | Bin 0 -> 69 bytes .../res/drawable/ic_launcher_foreground.xml | 35 + .../main/res/drawable/launch_background.xml | 9 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + .../src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 2002 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 3916 bytes .../src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 998 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 2364 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 2662 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 5054 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 3822 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 7742 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 5238 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 10808 bytes .../src/main/res/values-night-v31/styles.xml | 19 + .../app/src/main/res/values-night/styles.xml | 22 + .../app/src/main/res/values-v31/styles.xml | 19 + .../res/values/ic_launcher_background.xml | 4 + android/app/src/main/res/values/styles.xml | 22 + .../main/res/xml/network_security_config.xml | 6 + android/app/src/profile/AndroidManifest.xml | 8 + android/build.gradle | 38 + android/gradle.properties | 3 + .../gradle/wrapper/gradle-wrapper.properties | 6 + android/settings.gradle | 27 + lib/main.dart | 49 + lib/model/fetch_result.dart | 30 + lib/model/not_school_network_exception.dart | 6 + lib/model/session_state.dart | 4 + lib/model/time_list.dart | 26 + lib/model/xidian_ids/classtable.dart | 327 ++++++ lib/model/xidian_ids/classtable.g.dart | 179 ++++ lib/model/xidian_ids/experiment.dart | 54 + lib/model/xidian_ids/experiment.g.dart | 49 + lib/model/xidian_ids/paid_record.dart | 10 + lib/repository/logger.dart | 33 + lib/repository/network_session.dart | 111 ++ lib/repository/preference.dart | 64 ++ .../xidian_ids/classtable_session.dart | 790 ++++++++++++++ lib/repository/xidian_ids/ehall_session.dart | 138 +++ lib/repository/xidian_ids/ids_session.dart | 399 +++++++ .../xidian_ids/personal_info_session.dart | 149 +++ .../xidian_ids/school_card_session.dart | 271 +++++ lib/repository/xidian_ids/sysj_session.dart | 390 +++++++ lib/wearos/slider_captcha.dart | 864 +++++++++++++++ lib/wearos/wear_app.dart | 46 + lib/wearos/wear_companion_sync.dart | 353 +++++++ lib/wearos/wear_home_page.dart | 395 +++++++ lib/wearos/wear_ids_reauth.dart | 391 +++++++ lib/wearos/wear_qr_page.dart | 299 ++++++ lib/wearos/wear_schedule_service.dart | 341 ++++++ lib/wearos/wear_sync_login_page.dart | 107 ++ pubspec.lock | 986 ++++++++++++++++++ pubspec.yaml | 36 + test/ids_session_test.dart | 33 + test/slider_captcha_test.dart | 41 + test/wear_app_test.dart | 85 ++ test/wear_schedule_service_test.dart | 403 +++++++ 80 files changed, 8629 insertions(+) create mode 160000 .flutter create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 .metadata create mode 100644 LICENSE create mode 100644 WEAR_SYNC_INTEGRATION.md create mode 100644 analysis_options.yaml create mode 100644 android/.gitignore create mode 100644 android/app/build.gradle create mode 100644 android/app/proguard-rules.pro create mode 100644 android/app/src/debug/AndroidManifest.xml create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/ic_launcher-playstore.png create mode 100644 android/app/src/main/kotlin/io/github/benderblog/traintime_pda/MainActivity.kt create mode 100644 android/app/src/main/res/drawable-hdpi/splash.png create mode 100644 android/app/src/main/res/drawable-mdpi/splash.png create mode 100644 android/app/src/main/res/drawable-v21/background.png create mode 100644 android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 android/app/src/main/res/drawable-xhdpi/splash.png create mode 100644 android/app/src/main/res/drawable-xxhdpi/splash.png create mode 100644 android/app/src/main/res/drawable-xxxhdpi/splash.png create mode 100644 android/app/src/main/res/drawable/background.png create mode 100644 android/app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 android/app/src/main/res/drawable/launch_background.xml create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 android/app/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 android/app/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 android/app/src/main/res/values-night-v31/styles.xml create mode 100644 android/app/src/main/res/values-night/styles.xml create mode 100644 android/app/src/main/res/values-v31/styles.xml create mode 100644 android/app/src/main/res/values/ic_launcher_background.xml create mode 100644 android/app/src/main/res/values/styles.xml create mode 100644 android/app/src/main/res/xml/network_security_config.xml create mode 100644 android/app/src/profile/AndroidManifest.xml create mode 100644 android/build.gradle create mode 100644 android/gradle.properties create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100644 android/settings.gradle create mode 100644 lib/main.dart create mode 100644 lib/model/fetch_result.dart create mode 100644 lib/model/not_school_network_exception.dart create mode 100644 lib/model/session_state.dart create mode 100644 lib/model/time_list.dart create mode 100644 lib/model/xidian_ids/classtable.dart create mode 100644 lib/model/xidian_ids/classtable.g.dart create mode 100644 lib/model/xidian_ids/experiment.dart create mode 100644 lib/model/xidian_ids/experiment.g.dart create mode 100644 lib/model/xidian_ids/paid_record.dart create mode 100644 lib/repository/logger.dart create mode 100644 lib/repository/network_session.dart create mode 100644 lib/repository/preference.dart create mode 100644 lib/repository/xidian_ids/classtable_session.dart create mode 100644 lib/repository/xidian_ids/ehall_session.dart create mode 100644 lib/repository/xidian_ids/ids_session.dart create mode 100644 lib/repository/xidian_ids/personal_info_session.dart create mode 100644 lib/repository/xidian_ids/school_card_session.dart create mode 100644 lib/repository/xidian_ids/sysj_session.dart create mode 100644 lib/wearos/slider_captcha.dart create mode 100644 lib/wearos/wear_app.dart create mode 100644 lib/wearos/wear_companion_sync.dart create mode 100644 lib/wearos/wear_home_page.dart create mode 100644 lib/wearos/wear_ids_reauth.dart create mode 100644 lib/wearos/wear_qr_page.dart create mode 100644 lib/wearos/wear_schedule_service.dart create mode 100644 lib/wearos/wear_sync_login_page.dart create mode 100644 pubspec.lock create mode 100644 pubspec.yaml create mode 100644 test/ids_session_test.dart create mode 100644 test/slider_captcha_test.dart create mode 100644 test/wear_app_test.dart create mode 100644 test/wear_schedule_service_test.dart diff --git a/.flutter b/.flutter new file mode 160000 index 00000000..00b0c91f --- /dev/null +++ b/.flutter @@ -0,0 +1 @@ +Subproject commit 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..3a4a1dc3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,56 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ +pubspec.lock +android/app/build/ +android/app/.cxx + +# Web related +lib/generated_plugin_registrant.dart + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# fvm flutter sdk +.fvm/ +*.APK diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..36dfdc0f --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule ".flutter"] + path = .flutter + url = https://github.com/flutter/flutter.git diff --git a/.metadata b/.metadata new file mode 100644 index 00000000..854c55db --- /dev/null +++ b/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "7482962148e8d758338d8a28f589f317e1e42ba4" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 7482962148e8d758338d8a28f589f317e1e42ba4 + base_revision: 7482962148e8d758338d8a28f589f317e1e42ba4 + - platform: windows + create_revision: 7482962148e8d758338d8a28f589f317e1e42ba4 + base_revision: 7482962148e8d758338d8a28f589f317e1e42ba4 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..a612ad98 --- /dev/null +++ b/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/WEAR_SYNC_INTEGRATION.md b/WEAR_SYNC_INTEGRATION.md new file mode 100644 index 00000000..5a3f2653 --- /dev/null +++ b/WEAR_SYNC_INTEGRATION.md @@ -0,0 +1,47 @@ +# WearOS companion sync integration + +XDYou Wear is a companion-only app. Pairing and subsequent synchronization use +the Wear OS Data Layer; camera/QR pairing is intentionally not used. + +## Direct pairing + +1. Open `配对手机` on the watch. The watch accepts a first pairing for five + minutes. +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. + +Wear OS Data Layer only transports messages between applications with the same +package name and signing identity. The explicit five-minute window prevents an +unexpected first import even from another matching development installation. + +## 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. + +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 clearly marked 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. +- `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. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 00000000..ae08714c --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,29 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at + # https://dart-lang.github.io/linter/lints/index.html. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 00000000..6f568019 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,13 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/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/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 00000000..d1325c90 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,98 @@ +plugins { + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" +} + +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '1' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '1.0' +} + +def keystoreProperties = new Properties() +def keystorePropertiesFile = rootProject.file('key.properties') +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) +} + +android { + compileSdk = 36 + ndkVersion = "28.2.13676358" + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = '17' + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + namespace = "io.github.benderblog.traintime_pda" + + defaultConfig { + applicationId "io.github.benderblog.traintime_pda" + minSdk 28 + targetSdkVersion 34 + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + } + + 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 { + signingConfig keystoreProperties['storeFile'] ? signingConfigs.release : signingConfigs.debug + } + } +} + +flutter { + source '../..' +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.2.20" + implementation 'com.google.android.material:material:1.8.0' + implementation 'com.google.android.gms:play-services-wearable:19.0.0' +} + +ext.abiCodes = ["x86_64": 1, "armeabi-v7a": 2, "arm64-v8a": 3] +import com.android.build.OutputFile +android.applicationVariants.all { variant -> + variant.outputs.each { output -> + def abiVersionCode = project.ext.abiCodes.get(output.getFilter(OutputFile.ABI)) + if (abiVersionCode != null) { + output.versionCodeOverride = variant.versionCode * 10 + abiVersionCode + } + } +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 00000000..3ef75429 --- /dev/null +++ b/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/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..31f97076 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..b6f96cb8 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/ic_launcher-playstore.png b/android/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000000000000000000000000000000000000..99b347f505e99a813a60f5d52e415999263409f0 GIT binary patch literal 25933 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 + channel.setMethodCallHandler { call, result -> + when (call.method) { + "beginDirectPairing" -> beginDirectPairing(result) + "isCompanionPaired" -> result.success(pairedPhoneNodeId() != null) + "setKeepScreenOn" -> { + val enabled = call.arguments as? Boolean ?: false + if (enabled) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + result.success(null) + } + "readPendingSyncPayload" -> readPendingSyncPayload(result) + "requestCompanionSync" -> requestCompanionSync(result) + else -> result.notImplemented() + } + } + } + paymentChannel = MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + WEAR_PAYMENT_CHANNEL, + ).also { channel -> + channel.setMethodCallHandler { call, result -> + when (call.method) { + "requestPaymentQr" -> requestPaymentQr(result) + else -> result.notImplemented() + } + } + } + } + + override fun onResume() { + super.onResume() + Wearable.getMessageClient(this).addListener(this) + } + + override fun onPause() { + Wearable.getMessageClient(this).removeListener(this) + super.onPause() + } + + override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) { + syncChannel?.setMethodCallHandler(null) + syncChannel = null + paymentChannel?.setMethodCallHandler(null) + paymentChannel = null + super.cleanUpFlutterEngine(flutterEngine) + } + + private fun beginDirectPairing(result: MethodChannel.Result) { + directPairingExpiresAtEpochMs = System.currentTimeMillis() + SYNC_SESSION_TTL_MS + result.success(directPairingExpiresAtEpochMs) + } + + private fun readPendingSyncPayload(result: MethodChannel.Result) { + val payload = pendingSyncPayload + pendingSyncPayload = null + result.success(payload) + } + + override fun onMessageReceived(event: MessageEvent) { + if (event.path == WEAR_PAYMENT_RESPONSE_PATH) { + if (event.sourceNodeId != pairedPhoneNodeId()) return + val payload = event.data.toString(Charsets.UTF_8) + mainHandler.post { + paymentChannel?.invokeMethod("receivePaymentQrResponse", payload) + } + return + } + if (event.path != WEAR_COMPANION_SYNC_MESSAGE_PATH) return + val payload = event.data.toString(Charsets.UTF_8) + if (!isActiveSyncPayload(payload, event.sourceNodeId)) return + val channel = syncChannel + if (channel == null) { + rememberPairedPhone(event.sourceNodeId) + pendingSyncPayload = payload + return + } + mainHandler.post { + val currentChannel = syncChannel + if (currentChannel == null) { + rememberPairedPhone(event.sourceNodeId) + pendingSyncPayload = payload + } else { + currentChannel.invokeMethod( + "receiveSyncPayload", + payload, + object : MethodChannel.Result { + override fun success(result: Any?) { + rememberPairedPhone(event.sourceNodeId) + clearActiveSyncSession() + } + + override fun error( + errorCode: String, + errorMessage: String?, + errorDetails: Any?, + ) { + // Keep the active session until expiry so the phone can retry. + } + + override fun notImplemented() { + pendingSyncPayload = payload + } + }, + ) + } + } + } + + private fun isActiveSyncPayload(payload: String, sourceNodeId: String): Boolean { + return try { + val json = JSONObject(payload) + if (json.optInt("schemaVersion") != 1) return false + val pairedNodeId = pairedPhoneNodeId() + if (pairedNodeId != null) return pairedNodeId == sourceNodeId + System.currentTimeMillis() <= directPairingExpiresAtEpochMs && + json.optBoolean("directPairing", false) + } catch (_: Exception) { + false + } + } + + private fun requestCompanionSync(result: MethodChannel.Result) { + val nodeId = pairedPhoneNodeId() + if (nodeId == null) { + result.error("not_paired", "No companion phone is paired", null) + return + } + Wearable.getMessageClient(this) + .sendMessage(nodeId, WEAR_COMPANION_REQUEST_PATH, ByteArray(0)) + .addOnSuccessListener { result.success(null) } + .addOnFailureListener { error -> + result.error("request_failed", error.message, null) + } + } + + private fun requestPaymentQr(result: MethodChannel.Result) { + val nodeId = pairedPhoneNodeId() + if (nodeId == null) { + result.error("not_paired", "No companion phone is paired", null) + return + } + Wearable.getMessageClient(this) + .sendMessage(nodeId, WEAR_PAYMENT_REQUEST_PATH, ByteArray(0)) + .addOnSuccessListener { result.success(null) } + .addOnFailureListener { error -> + result.error("request_failed", error.message, null) + } + } + + private fun rememberPairedPhone(nodeId: String) { + getSharedPreferences(WEAR_COMPANION_PREFS, MODE_PRIVATE) + .edit().putString(PAIRED_PHONE_NODE_ID, nodeId).apply() + } + + private fun pairedPhoneNodeId(): String? = + getSharedPreferences(WEAR_COMPANION_PREFS, MODE_PRIVATE) + .getString(PAIRED_PHONE_NODE_ID, null) + + private fun clearActiveSyncSession() { + directPairingExpiresAtEpochMs = 0 + pendingSyncPayload = null + } + + companion object { + private const val WEAR_COMPANION_SYNC_CHANNEL = + "io.github.benderblog.traintime_pda/wear_companion_sync" + private const val WEAR_PAYMENT_CHANNEL = + "io.github.benderblog.traintime_pda/wear_payment" + private const val WEAR_COMPANION_SYNC_MESSAGE_PATH = + "/traintime_pda_wear_os/sync/v1" + private const val WEAR_COMPANION_REQUEST_PATH = + "/traintime_pda_wear_os/request/v1" + private const val WEAR_PAYMENT_REQUEST_PATH = + "/traintime_pda_wear_os/payment/request/v1" + private const val WEAR_PAYMENT_RESPONSE_PATH = + "/traintime_pda_wear_os/payment/response/v1" + private const val WEAR_COMPANION_PREFS = "wear_companion_transport" + private const val PAIRED_PHONE_NODE_ID = "paired_phone_node_id" + private const val SYNC_SESSION_TTL_MS = 5 * 60 * 1000L + } +} diff --git a/android/app/src/main/res/drawable-hdpi/splash.png b/android/app/src/main/res/drawable-hdpi/splash.png new file mode 100644 index 0000000000000000000000000000000000000000..907c1f23be175221058a330c1fe236d662779a71 GIT binary patch literal 5774 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 a>;X-qJ9_K*-kyDf1>meMSXP-668{Cp&}jVt literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-v21/background.png b/android/app/src/main/res/drawable-v21/background.png new file mode 100644 index 0000000000000000000000000000000000000000..3107d37fa533216ce211fdcdd7c9b8633fab4cc4 GIT binary patch literal 69 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1SBVv2j2ryJf1F&Ar*|tKmY%?XJF%FW@0Ma R`v54;;OXk;vd$@?2>`rk4}t&y literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 00000000..3cc4948a --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/android/app/src/main/res/drawable-xhdpi/splash.png b/android/app/src/main/res/drawable-xhdpi/splash.png new file mode 100644 index 0000000000000000000000000000000000000000..8fbabe8db855c0c5703ad1512683db3bd256cdab GIT binary patch literal 6648 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

%-RZXl{YGb3fP)db-C?@~k-q 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 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable/background.png b/android/app/src/main/res/drawable/background.png new file mode 100644 index 0000000000000000000000000000000000000000..3107d37fa533216ce211fdcdd7c9b8633fab4cc4 GIT binary patch literal 69 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1SBVv2j2ryJf1F&Ar*|tKmY%?XJF%FW@0Ma R`v54;;OXk;vd$@?2>`rk4}t&y literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..d7c0716c --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 00000000..3cc4948a --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..7353dbd1 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..7353dbd1 --- /dev/null +++ b/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/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..90c78dba5c5c429e4ed23d1ff5f0b5c7eddeb663 GIT binary patch literal 2002 zcmV;@2QBzgNk&G>2LJ$9MM6+kP&iDz2LJ#sN5Byf=1|bKjTG~Teg7edhzV$ERmiW+ zH9|qn_Ro>>@8WWBr%*IN2dM1gDss+T&OMXozs#Q`|1D74SRNEEbuvwmYqONoJp~|>=Ap1FV+7pRwryIwmxs`iQJ6Y-F@I#+3~j5H=d{|kW%=D*u}bm{ zEOLlsIxA2~F3DXIKQq_>x{f^z+qR8F^}PwMD^Rijf4vrMA9r_mcXxMpcZbIf=i2Xg zUaALnM)&YU#HC+?`1` zjeD9&(>(c&8F1th^?0DxeV&bIB3E~S{LiYmXO=wHhFHS+$wf?ucL-<8_l z-TnhUDqQwbKK5BhGs7KUQEvWLy}24-vB7qBIlg-ESn>Tq`dDZ z?S>Jf3ZDxBkV%&&m7LEf>xE&X;j>sZ?lf|U9Ob+*V!W)10WkBnV=6alP;XU3EZ9!y z4C${i^EOjDgL<0{af}Mx(UJhtGFCuLk=Ig}HWf=*(4x5qA6(oe5$4A;n5C;e%s*DC z|IJdVzg_&Ghcj`GQ6MLN#iYgeCgw7?T@C?&3$Jiy_P&U+c3okOoGW7N5D}=HXAq0w z6N~NrL>Ze-N63%^rf_e~Vp-aA2uV95ahxT7KYW?xc=P&XX-;uj@vZ zvSE#FSVJ39%e+-pVdK29b0Czi<;TC2G`t<=pG!82?^W_=Yqb_%%i?Ei6<28W+rx+G zPX-r5z@NA@d78wKy;~}RP}+tEM~Fa%jdxD&Us9^x^Ijkf2&rxOZJ29W(Fmz&h@Rx| zNCo1qV-yIqb%upTj?_8DLT^YwTb)A$060N|cgM;>NDae}RsQ)eL0pCu(?6Ct?p|SR z+_cd-2JTpa@lAv?b;Xam7bt^Gs-l*E{&j)@<-ik=Ms*EuxhBP&w9UWpzwo%Me=!7*w(=;t`@5qiQ5?P2 zzl21wgMReH8;KH&wrXYL`UwN$F*~rj% z0$!j@9XC87_t=x6{;YK&c}|5}YVt^-wLeqy>7A3~M2h?tKuV$8qHY{cWO(P|Mz=H9 z$Wz~+ghVaFn~^N50`6!?i@Df*A(EYc|94&1?#tj}Wa+pb8mjw|!kalnwDBc>w>Ngi zgWg#efKv?EfsR(j|Fa|8d%ztn(bmB@ljo^-+Lwzp+Hl5JiWQVBzW0*>q-4L~ss=H< zqh=3>6B&|qslOf0!~(V&KOz$F#7Ze)LcwH%t8p!Ww5*(}xb++fTk^@v<45Ou9+@e& z{Njcjvp*usGmw=y>gs5o0hmIyfq=4#qIm?gEq>T@4nKF_d?`U}2Ev=Wa*lU#ih;%s zeNo4LlCJu!KuVHUN9SL73od^^cjL!78R&c9WsJF&x&6W{%@iPtlUMJ})_WIT{~=6Y zW|Fi5P8+XqrhhSF2dAVN)#!GU+S0LaSfxJdYWX%Ao99JNNs~(o^pr?&|V!oC;_%5|F%zFj@mQ@+j};w{oRN7og!E{ z4$@W~<#jW-8p*QjSWhfe6VpF#s-Laz4xw%~n?BF=&T$bC(b}uMu`#%(r#;hD9<}El z@FOC;bMdX#dlyktc9qjs9<)s)%dF2V#P&QBZ?V1Z2}sR9SIM8<;+NDVhkH5#AYNqN zIvb9Vz=vvs%VGAuV*16lHP>tE6{RyBkXKm*8MOQ6I2Y*)=(KLypf@X6IK_Xee=N5v;y0 zxbnTdLvWavvi00l@^6PXb2dyQ%Y3shXF{Z_izmFSp1+9 zrL8`A%x{2yN!gE>%hGxAns2Sr*Pkue6g%h0C z1B53n@5;R)$W{HCFM!yoW>HPbG=NdZ$_?qg;tt4qLP_sG;fOwscZ^>AZQwC1F3J=D zA^_ROcD+O9U@4q>WRI47u#zPgtlU<=EBFDb{+Pdyug}N(!yrjy#Iv6$0fYeJ011Gk k^xn?^d=Ex`X0&=D8^sxZwBtc|(tA3%Wm`QH#Ji{%05z55Bme*a literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..1dd93f39e8bbfeca060b3abc1ee9ad864b419313 GIT binary patch literal 3916 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 a>pG+GLkH)G^&yDpg&(=~2^an3xQH`Rn|ApC literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..e5456743eebc592ff3d74bd2edf1752d3815a1c1 GIT binary patch literal 998 zcmV@XTF00jg?@zF2}2oOMlq61oB$6lZjK-mEe zvd4}VXfzZE0UDq*3V;H-6aY{_00p#A>43ci2n|aC9R)xFP&AB|(0~A>04P8U@>fwm z@;Z_nE&gipU8Nd-Qq+Pfoox$8?Qbdn*Fsq{{OK8KIXYS-YX;T7{H_tybhT>h_I&SG zJ6kS4yT>)6H3H4?(K`I;_0seDTdnmB{t}*z|FwHQbxa>S4gUN1ZoB&JI#j_|pTnBr zLx;64*?ar9LjJaG$E19DZ9BKNm0R1kZD-rI?OJ@Z?_}FG*~YYgK_A|Cj-%hdXEvh$ z6F~hhdnCl=+@u1?pdfG%0IuJ-MOe5FAY~EY@9j$$iC?+{l$7nR7|G(jK1@CE>^wSm z<}6{kAin>O>iI`PJ->i@fq1X@!3Rph^yadReB^LJMm}Q@ZJU*!$up~IuY z744SLxad4Z!=xhqkt*_~xVGvJ-i~liQ*l0lDOaf+F|JJJJRP|yZ^Y!Ky@YAB0%t48 z>0N@!LvJADP;{suUu<8ykN6*_EKmaQGM&~maD07izY@veEaaN1Y#|GPKPAII<4-xR{O$>(D)SZ>bds& z@f|UqJ939peMi_BRN&k0UvtvE(r{0aKaFi|bU=scGTX3#VVh9;1W5L)7`0oc&S4zF zM|PUmllPi1vyfd}MojK}J3?^}v@a;&W%t z5|#@$AAe;6X}=fOZi<1h9In3XFQu6J{(klJ;Uj@42v1)B>B|B_|3N>$eJ3pcHAs+B U?81)$1Brtmr8rC86PHl`OHC-@y8r+H literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..d3fc77cc426e2f72d73bdef465cb0e3b80aff444 GIT binary patch literal 2364 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 UR!Ty?=k@J7L{;Cup68nv1IkL!eEGeD%`m) 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 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..8bf55e5d3bdda0230e2ca191bf8ba2bb56c8df8b GIT binary patch literal 7742 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 E88#oKzyJUM literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..e04db79e88216ed1f712e94e3ad965d80a734b0b GIT binary patch literal 5238 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 w@PoCY65b$v$=02UKjxxduCij2?$>)q5uE@ literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..f466fe588d139e252c8b85c416fb734c9290899b GIT binary patch literal 10808 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 CZj%!L literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/values-night-v31/styles.xml b/android/app/src/main/res/values-night-v31/styles.xml new file mode 100644 index 00000000..a3653cb1 --- /dev/null +++ b/android/app/src/main/res/values-night-v31/styles.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 00000000..e66efecc --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/android/app/src/main/res/values-v31/styles.xml b/android/app/src/main/res/values-v31/styles.xml new file mode 100644 index 00000000..d0a68e92 --- /dev/null +++ b/android/app/src/main/res/values-v31/styles.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/android/app/src/main/res/values/ic_launcher_background.xml b/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 00000000..d11d9ca2 --- /dev/null +++ b/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #0089DC + \ No newline at end of file diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..564790a4 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 00000000..e79c8d03 --- /dev/null +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,6 @@ + + + + xidian.edu.cn + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 00000000..31f97076 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 00000000..eb2ccd7f --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,38 @@ + +allprojects { + repositories { + google() + mavenCentral() + } +} + +// https://github.com/flutter/flutter/issues/153281#issuecomment-2292201697 +rootProject.buildDir = '../build' +subprojects { + afterEvaluate { project -> + if (project.extensions.findByName("android") != null) { + Integer pluginCompileSdk = project.android.compileSdk + if (pluginCompileSdk != null && pluginCompileSdk < 31) { + project.logger.error( + "Warning: Overriding compileSdk version in Flutter plugin: " + + project.name + + " from " + + pluginCompileSdk + + " to 31 (to work around https://issuetracker.google.com/issues/199180389)." + + "\nIf there is not a new version of " + project.name + ", consider filing an issue against " + + project.name + + " to increase their compileSdk to the latest (otherwise try updating to the latest version)." + ) + project.android { + compileSdk 31 + } + } + } + } + + project.buildDir = "${rootProject.buildDir}/${project.name}" + project.evaluationDependsOn(":app") +} +tasks.register("clean", Delete) { + delete rootProject.buildDir +} \ No newline at end of file diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 00000000..40412928 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,3 @@ +android.enableJetifier=true +android.useAndroidX=true +org.gradle.jvmargs=-Xmx4096M -Dkotlin.daemon.jvm.options\="-Xmx4096M" -XX:+HeapDumpOnOutOfMemoryError diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..50f53e39 --- /dev/null +++ b/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.11.1-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 00000000..f3bad359 --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,27 @@ +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version '8.10.0' apply false + id "org.jetbrains.kotlin.android" version "2.2.20" apply false +} + +include ":app" + + diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 00000000..557b89a8 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:shared_preferences/util/legacy_to_async_migration_util.dart'; +import 'package:watermeter/repository/logger.dart'; +import 'package:watermeter/repository/network_session.dart' as network; +import 'package:watermeter/repository/preference.dart' as preference; +import 'package:watermeter/repository/xidian_ids/ids_session.dart'; +import 'package:watermeter/repository/xidian_ids/classtable_session.dart'; +import 'package:watermeter/wearos/wear_app.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + + log.info('Starting XDYou Wear.'); + network.supportPath = await getApplicationSupportDirectory(); + + const options = SharedPreferencesOptions(); + final legacyPrefs = await SharedPreferences.getInstance(); + if (legacyPrefs.getKeys().isNotEmpty) { + await migrateLegacySharedPreferencesToSharedPreferencesAsyncIfNecessary( + legacySharedPreferencesInstance: legacyPrefs, + sharedPreferencesAsyncOptions: options, + migrationCompletedKey: 'pdaMigrationCompleted', + ); + } + + preference.prefs = await SharedPreferencesWithCache.create( + cacheOptions: const SharedPreferencesWithCacheOptions(), + ); + + final semester = preference.getString(preference.Preference.currentSemester); + var isCompanionPaired = false; + try { + isCompanionPaired = + await const MethodChannel( + 'io.github.benderblog.traintime_pda/wear_companion_sync', + ).invokeMethod('isCompanionPaired') ?? + false; + } on PlatformException { + // Treat a missing/unavailable native pairing record as unpaired. + } + final isFirst = + !isCompanionPaired || semester.isEmpty || !ClassTableSession.isCacheExist; + loginState = isFirst ? IDSLoginState.manual : IDSLoginState.none; + + runApp(WearApp(isFirst: isFirst)); +} diff --git a/lib/model/fetch_result.dart b/lib/model/fetch_result.dart new file mode 100644 index 00000000..5408fe7b --- /dev/null +++ b/lib/model/fetch_result.dart @@ -0,0 +1,30 @@ +// Copyright 2026 Traintime PDA Authours, originally by BenderBlog Rodriguez. +// SPDX-License-Identifier: MPL-2.0 + +class FetchResult { + final bool isCache; + final DateTime fetchTime; + final T data; + final String? hintKey; + + const FetchResult._({ + required this.isCache, + required this.fetchTime, + required this.data, + this.hintKey, + }); + + factory FetchResult.fresh({required DateTime fetchTime, required T data}) => + FetchResult._(isCache: false, fetchTime: fetchTime, data: data); + + factory FetchResult.cache({ + required DateTime fetchTime, + required T data, + String? hintKey, + }) => FetchResult._( + isCache: true, + fetchTime: fetchTime, + data: data, + hintKey: hintKey, + ); +} diff --git a/lib/model/not_school_network_exception.dart b/lib/model/not_school_network_exception.dart new file mode 100644 index 00000000..e77c7bee --- /dev/null +++ b/lib/model/not_school_network_exception.dart @@ -0,0 +1,6 @@ +// Copyright 2026 Traintime PDA Authours, originally by BenderBlog Rodriguez. +// SPDX-License-Identifier: MPL-2.0 + +class NotSchoolNetworkException implements Exception { + final String msg = "not_school_network"; +} diff --git a/lib/model/session_state.dart b/lib/model/session_state.dart new file mode 100644 index 00000000..52354bba --- /dev/null +++ b/lib/model/session_state.dart @@ -0,0 +1,4 @@ +// Copyright 2026 Traintime PDA Authours, originally by BenderBlog Rodriguez. +// SPDX-License-Identifier: MPL-2.0 + +enum SessionState { fetching, fetched, error, none } diff --git a/lib/model/time_list.dart b/lib/model/time_list.dart new file mode 100644 index 00000000..db209fbc --- /dev/null +++ b/lib/model/time_list.dart @@ -0,0 +1,26 @@ +/// Time arrangements. +/// Even means start, odd means end. +List timeList = [ + "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/lib/model/xidian_ids/classtable.dart b/lib/model/xidian_ids/classtable.dart new file mode 100644 index 00000000..1685b7be --- /dev/null +++ b/lib/model/xidian_ids/classtable.dart @@ -0,0 +1,327 @@ +// Copyright 2023-2025 BenderBlog Rodriguez and contributors +// Copyright 2025 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 OR Apache-2.0 + +import 'package:flutter/foundation.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'classtable.g.dart'; + +enum Source { empty, school, user } + +@JsonSerializable(explicitToJson: true) +class NotArrangementClassDetail { + String name; // 名称 + String? code; // 课程序号 + String? number; // 班级序号 + String? teacher; // 老师 + + NotArrangementClassDetail({ + required this.name, + this.code, + this.number, + this.teacher, + }); + + factory NotArrangementClassDetail.from(NotArrangementClassDetail e) => + NotArrangementClassDetail( + name: e.name, + code: e.code, + number: e.number, + teacher: e.teacher, + ); + + factory NotArrangementClassDetail.fromJson(Map json) => + _$NotArrangementClassDetailFromJson(json); + + Map toJson() => _$NotArrangementClassDetailToJson(this); + + @override + int get hashCode => name.hashCode; + + @override + bool operator ==(Object other) => + other is ClassDetail && + other.runtimeType == runtimeType && + name == other.name; +} + +@JsonSerializable(explicitToJson: true) +class ClassDetail { + String name; // 名称 + String? code; // 课程序号 + String? number; // 班级序号 + + ClassDetail({required this.name, this.code, this.number}); + + factory ClassDetail.from(ClassDetail e) => + ClassDetail(name: e.name, code: e.code, number: e.number); + + factory ClassDetail.fromJson(Map json) => + _$ClassDetailFromJson(json); + + Map toJson() => _$ClassDetailToJson(this); + + @override + int get hashCode => name.hashCode; + + @override + bool operator ==(Object other) => + other is ClassDetail && + other.runtimeType == runtimeType && + name == other.name; + + @override + String toString() { + return "$name $code $number"; + } +} + +@JsonSerializable(explicitToJson: true) +class TimeArrangement { + /// 课程索引(注:是 `ClassDetail` 的索引,不是 `TimeArrangement` 的索引) + int index; + + /// 返回的是布尔类型列表,true 表示该周有课,false 表示该周无课 + /// 绕过 Swift 字符串不好处理的代价就是 json 要大很多了...... + @JsonKey(name: 'week_list') + List weekList; // 上课周次 + String? teacher; // 老师 + int day; // 星期几上课 + int start; // 上课开始 + int stop; // 上课结束 + Source source; // 数据来源 + @JsonKey(includeIfNull: false) + String? classroom; // 上课教室 + + int get step => stop - start; // 上课长度 + + factory TimeArrangement.fromJson(Map json) => + _$TimeArrangementFromJson(json); + + Map toJson() => _$TimeArrangementToJson(this); + + TimeArrangement({ + required this.source, + required this.index, + required this.weekList, + this.classroom, + this.teacher, + required this.day, + required this.start, + required this.stop, + }); + + @override + String toString() => "$source $index $classroom $teacher"; +} + +@JsonSerializable(explicitToJson: true) +class ClassTableData { + int semesterLength; + String semesterCode; + String termStartDay; + List classDetail; + List userDefinedDetail; + List notArranged; + List timeArrangement; + List classChanges; + + /// Only allowed to be used with classDetail + ClassDetail getClassDetail(TimeArrangement t) { + switch (t.source) { + case Source.school: + return classDetail[t.index]; + case Source.user: + return userDefinedDetail[t.index]; + case Source.empty: + throw NotImplementedException(); + } + } + + ClassTableData.from(ClassTableData c) + : this( + semesterLength: c.semesterLength, + semesterCode: c.semesterCode, + termStartDay: c.termStartDay, + classDetail: c.classDetail, + notArranged: c.notArranged, + timeArrangement: c.timeArrangement, + classChanges: c.classChanges, + ); + + ClassTableData({ + this.semesterLength = 1, + this.semesterCode = "", + this.termStartDay = "", + List? classDetail, + List? userDefinedDetail, + List? notArranged, + List? timeArrangement, + List? classChanges, + }) : classDetail = classDetail ?? [], + userDefinedDetail = userDefinedDetail ?? [], + notArranged = notArranged ?? [], + timeArrangement = timeArrangement ?? [], + classChanges = classChanges ?? [], + assert( + timeArrangement == null || + timeArrangement.isEmpty || + termStartDay.isNotEmpty, + "termStartDay is required when timeArrangement is not empty.", + ); + + factory ClassTableData.fromJson(Map json) => + _$ClassTableDataFromJson(json); + + Map toJson() => _$ClassTableDataToJson(this); +} + +class NotImplementedException implements Exception {} + +enum ChangeType { + change, // 调课 + stop, // 停课 + patch, // 补课 +} + +@JsonSerializable(explicitToJson: true) +class ClassChange { + final ChangeType type; + + /// KCH 课程号 + final String classCode; + + /// KXH 班级号 + final String classNumber; + + /// KCM 课程名 + final String className; + + /// 来自 SKZC 原周次信息,可能是空 + final List? originalAffectedWeeks; + + /// 来自 XSKZC 新周次信息,可能是空 + final List? newAffectedWeeks; + + /// YSKJS 原先的老师 + final String? originalTeacherData; + + /// XSKJS 新换的老师 + final String? newTeacherData; + + /// KSJS-JSJC 原先的课次信息 + final List originalClassRange; + + /// XKSJS-XJSJC 新的课次信息 + final List newClassRange; + + /// SKXQ 原先的星期 + final int? originalWeek; + + /// XSKXQ 现在的星期 + final int? newWeek; + + /// JASMC 旧教室 + final String? originalClassroom; + + /// XJASMC 新教室 + final String? newClassroom; + + ClassChange({ + required this.type, + required this.classCode, + required this.classNumber, + required this.className, + required this.originalAffectedWeeks, + required this.newAffectedWeeks, + required this.originalTeacherData, + required this.newTeacherData, + required this.originalClassRange, + required this.newClassRange, + required this.originalWeek, + required this.newWeek, + required this.originalClassroom, + required this.newClassroom, + }); + + /// 必须假设后台有问题,返回长度不一样的数组 + /// 亏他们想得出来用 01 表示布尔信息,日子不是这么省的啊 + List get originalAffectedWeeksList { + if (originalAffectedWeeks == null) return []; + List toReturn = []; + for (int i = 0; i < originalAffectedWeeks!.length; ++i) { + if (originalAffectedWeeks![i]) toReturn.add(i); + } + return toReturn; + } + + List get newAffectedWeeksList { + List toReturn = []; + for (int i = 0; i < (newAffectedWeeks?.length ?? 0); ++i) { + if (newAffectedWeeks![i]) toReturn.add(i); + } + return toReturn; + } + + String? get originalTeacher => + originalTeacherData?.replaceAll(RegExp(r'(/|[0-9a-zA-z])'), ''); + + String? get newTeacher => + newTeacherData?.replaceAll(RegExp(r'(/|[0-9a-zA-z])'), ''); + + String? get originalNewTeacher => newTeacherData; + + bool get isTeacherChanged { + List originalTeacherCode = + originalTeacherData?.replaceAll(' ', '').split(RegExp(r',|/')) ?? []; + + originalTeacherCode.retainWhere( + (element) => element.contains(RegExp(r'([0-9])')), + ); + + List newTeacherCode = + newTeacherData?.replaceAll(' ', '').split(RegExp(r',|/')) ?? []; + + newTeacherCode.retainWhere( + (element) => element.contains(RegExp(r'([0-9])')), + ); + + return !listEquals(originalTeacherCode, newTeacherCode); + } + + String get changeTypeString { + switch (type) { + case ChangeType.change: + return "调课"; + case ChangeType.patch: + return "补课"; + case ChangeType.stop: + return "停课"; + } + } + + factory ClassChange.fromJson(Map json) => + _$ClassChangeFromJson(json); + + Map toJson() => _$ClassChangeToJson(this); +} + +@JsonSerializable(explicitToJson: true) +class UserDefinedClassData { + List userDefinedDetail; + List timeArrangement; + + UserDefinedClassData({ + required this.userDefinedDetail, + required this.timeArrangement, + }); + + factory UserDefinedClassData.fromJson(Map json) => + _$UserDefinedClassDataFromJson(json); + + factory UserDefinedClassData.empty() => + UserDefinedClassData(userDefinedDetail: [], timeArrangement: []); + + Map toJson() => _$UserDefinedClassDataToJson(this); +} diff --git a/lib/model/xidian_ids/classtable.g.dart b/lib/model/xidian_ids/classtable.g.dart new file mode 100644 index 00000000..fed21c88 --- /dev/null +++ b/lib/model/xidian_ids/classtable.g.dart @@ -0,0 +1,179 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'classtable.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +NotArrangementClassDetail _$NotArrangementClassDetailFromJson( + Map json, +) => NotArrangementClassDetail( + name: json['name'] as String, + code: json['code'] as String?, + number: json['number'] as String?, + teacher: json['teacher'] as String?, +); + +Map _$NotArrangementClassDetailToJson( + NotArrangementClassDetail instance, +) => { + 'name': instance.name, + 'code': instance.code, + 'number': instance.number, + 'teacher': instance.teacher, +}; + +ClassDetail _$ClassDetailFromJson(Map json) => ClassDetail( + name: json['name'] as String, + code: json['code'] as String?, + number: json['number'] as String?, +); + +Map _$ClassDetailToJson(ClassDetail instance) => + { + 'name': instance.name, + 'code': instance.code, + 'number': instance.number, + }; + +TimeArrangement _$TimeArrangementFromJson(Map json) => + TimeArrangement( + source: $enumDecode(_$SourceEnumMap, json['source']), + index: (json['index'] as num).toInt(), + weekList: (json['week_list'] as List) + .map((e) => e as bool) + .toList(), + classroom: json['classroom'] as String?, + teacher: json['teacher'] as String?, + day: (json['day'] as num).toInt(), + start: (json['start'] as num).toInt(), + stop: (json['stop'] as num).toInt(), + ); + +Map _$TimeArrangementToJson(TimeArrangement instance) => + { + 'index': instance.index, + 'week_list': instance.weekList, + 'teacher': instance.teacher, + 'day': instance.day, + 'start': instance.start, + 'stop': instance.stop, + 'source': _$SourceEnumMap[instance.source]!, + 'classroom': ?instance.classroom, + }; + +const _$SourceEnumMap = { + Source.empty: 'empty', + Source.school: 'school', + Source.user: 'user', +}; + +ClassTableData _$ClassTableDataFromJson(Map json) => + ClassTableData( + semesterLength: (json['semesterLength'] as num?)?.toInt() ?? 1, + semesterCode: json['semesterCode'] as String? ?? "", + termStartDay: json['termStartDay'] as String? ?? "", + classDetail: (json['classDetail'] as List?) + ?.map((e) => ClassDetail.fromJson(e as Map)) + .toList(), + userDefinedDetail: (json['userDefinedDetail'] as List?) + ?.map((e) => ClassDetail.fromJson(e as Map)) + .toList(), + notArranged: (json['notArranged'] as List?) + ?.map( + (e) => + NotArrangementClassDetail.fromJson(e as Map), + ) + .toList(), + timeArrangement: (json['timeArrangement'] as List?) + ?.map((e) => TimeArrangement.fromJson(e as Map)) + .toList(), + classChanges: (json['classChanges'] as List?) + ?.map((e) => ClassChange.fromJson(e as Map)) + .toList(), + ); + +Map _$ClassTableDataToJson( + ClassTableData instance, +) => { + 'semesterLength': instance.semesterLength, + 'semesterCode': instance.semesterCode, + 'termStartDay': instance.termStartDay, + 'classDetail': instance.classDetail.map((e) => e.toJson()).toList(), + 'userDefinedDetail': instance.userDefinedDetail + .map((e) => e.toJson()) + .toList(), + 'notArranged': instance.notArranged.map((e) => e.toJson()).toList(), + 'timeArrangement': instance.timeArrangement.map((e) => e.toJson()).toList(), + 'classChanges': instance.classChanges.map((e) => e.toJson()).toList(), +}; + +ClassChange _$ClassChangeFromJson(Map json) => ClassChange( + type: $enumDecode(_$ChangeTypeEnumMap, json['type']), + classCode: json['classCode'] as String, + classNumber: json['classNumber'] as String, + className: json['className'] as String, + originalAffectedWeeks: (json['originalAffectedWeeks'] as List?) + ?.map((e) => e as bool) + .toList(), + newAffectedWeeks: (json['newAffectedWeeks'] as List?) + ?.map((e) => e as bool) + .toList(), + originalTeacherData: json['originalTeacherData'] as String?, + newTeacherData: json['newTeacherData'] as String?, + originalClassRange: (json['originalClassRange'] as List) + .map((e) => (e as num).toInt()) + .toList(), + newClassRange: (json['newClassRange'] as List) + .map((e) => (e as num).toInt()) + .toList(), + originalWeek: (json['originalWeek'] as num?)?.toInt(), + newWeek: (json['newWeek'] as num?)?.toInt(), + originalClassroom: json['originalClassroom'] as String?, + newClassroom: json['newClassroom'] as String?, +); + +Map _$ClassChangeToJson(ClassChange instance) => + { + 'type': _$ChangeTypeEnumMap[instance.type]!, + 'classCode': instance.classCode, + 'classNumber': instance.classNumber, + 'className': instance.className, + 'originalAffectedWeeks': instance.originalAffectedWeeks, + 'newAffectedWeeks': instance.newAffectedWeeks, + 'originalTeacherData': instance.originalTeacherData, + 'newTeacherData': instance.newTeacherData, + 'originalClassRange': instance.originalClassRange, + 'newClassRange': instance.newClassRange, + 'originalWeek': instance.originalWeek, + 'newWeek': instance.newWeek, + 'originalClassroom': instance.originalClassroom, + 'newClassroom': instance.newClassroom, + }; + +const _$ChangeTypeEnumMap = { + ChangeType.change: 'change', + ChangeType.stop: 'stop', + ChangeType.patch: 'patch', +}; + +UserDefinedClassData _$UserDefinedClassDataFromJson( + Map json, +) => UserDefinedClassData( + userDefinedDetail: (json['userDefinedDetail'] as List) + .map((e) => ClassDetail.fromJson(e as Map)) + .toList(), + timeArrangement: (json['timeArrangement'] as List) + .map((e) => TimeArrangement.fromJson(e as Map)) + .toList(), +); + +Map _$UserDefinedClassDataToJson( + UserDefinedClassData instance, +) => { + 'userDefinedDetail': instance.userDefinedDetail + .map((e) => e.toJson()) + .toList(), + 'timeArrangement': instance.timeArrangement.map((e) => e.toJson()).toList(), +}; diff --git a/lib/model/xidian_ids/experiment.dart b/lib/model/xidian_ids/experiment.dart new file mode 100644 index 00000000..2d73deb1 --- /dev/null +++ b/lib/model/xidian_ids/experiment.dart @@ -0,0 +1,54 @@ +// Copyright 2023-2025 BenderBlog Rodriguez and contributors +// Copyright 2025 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +import 'package:json_annotation/json_annotation.dart'; + +part 'experiment.g.dart'; + +enum ExperimentType { others } + +@JsonSerializable(explicitToJson: true) +class ExperimentData { + final ExperimentType type; + final String name; + final String classroom; + final List<(DateTime, DateTime)> timeRanges; + final String teacher; + final String? reference; + + const ExperimentData({ + required this.type, + required this.name, + required this.classroom, + required this.timeRanges, + required this.teacher, + this.reference, + }); + + factory ExperimentData.fromJson(Map json) => + _$ExperimentDataFromJson(json); + + Map toJson() => _$ExperimentDataToJson(this); + + @override + String toString() { + return 'ExperimentData(' + 'type: $type, ' + 'name: $name, ' + 'classroom: $classroom, ' + 'timeRanges: ${timeRanges.map((range) => "[${range.$1.toIso8601String()} - ${range.$2.toIso8601String()}]").join(", ")}, ' + 'teacher: $teacher, ' + 'reference: ${reference ?? "N/A"}' + ')'; + } + + factory ExperimentData.from(ExperimentData src) => ExperimentData( + type: src.type, + name: src.name, + classroom: src.classroom, + timeRanges: src.timeRanges.toList(), + teacher: src.teacher, + reference: src.reference, + ); +} diff --git a/lib/model/xidian_ids/experiment.g.dart b/lib/model/xidian_ids/experiment.g.dart new file mode 100644 index 00000000..5ac6663b --- /dev/null +++ b/lib/model/xidian_ids/experiment.g.dart @@ -0,0 +1,49 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'experiment.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ExperimentData _$ExperimentDataFromJson(Map json) => + ExperimentData( + type: $enumDecode(_$ExperimentTypeEnumMap, json['type']), + name: json['name'] as String, + classroom: json['classroom'] as String, + timeRanges: (json['timeRanges'] as List) + .map( + (e) => _$recordConvert( + e, + ($jsonValue) => ( + DateTime.parse($jsonValue[r'$1'] as String), + DateTime.parse($jsonValue[r'$2'] as String), + ), + ), + ) + .toList(), + teacher: json['teacher'] as String, + reference: json['reference'] as String?, + ); + +Map _$ExperimentDataToJson(ExperimentData instance) => + { + 'type': _$ExperimentTypeEnumMap[instance.type]!, + 'name': instance.name, + 'classroom': instance.classroom, + 'timeRanges': instance.timeRanges + .map( + (e) => { + r'$1': e.$1.toIso8601String(), + r'$2': e.$2.toIso8601String(), + }, + ) + .toList(), + 'teacher': instance.teacher, + 'reference': instance.reference, + }; + +const _$ExperimentTypeEnumMap = {ExperimentType.others: 'others'}; + +$Rec _$recordConvert<$Rec>(Object? value, $Rec Function(Map) convert) => + convert(value as Map); diff --git a/lib/model/xidian_ids/paid_record.dart b/lib/model/xidian_ids/paid_record.dart new file mode 100644 index 00000000..5903aa35 --- /dev/null +++ b/lib/model/xidian_ids/paid_record.dart @@ -0,0 +1,10 @@ +// Copyright 2023-2025 BenderBlog Rodriguez and contributors +// Copyright 2025 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +class PaidRecord { + String place; + String date; + String money; + PaidRecord({required this.place, required this.date, required this.money}); +} diff --git a/lib/repository/logger.dart b/lib/repository/logger.dart new file mode 100644 index 00000000..9e47c4bc --- /dev/null +++ b/lib/repository/logger.dart @@ -0,0 +1,33 @@ +// Copyright 2023-2025 BenderBlog Rodriguez and contributors +// Copyright 2025 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +import 'dart:typed_data'; + +import 'package:talker_dio_logger/talker_dio_logger.dart'; +import 'package:talker_flutter/talker_flutter.dart'; + +final log = TalkerFlutter.init(); +final logDioAdapter = TalkerDioLogger( + talker: log, + settings: TalkerDioLoggerSettings( + printRequestHeaders: true, + printResponseHeaders: true, + printResponseMessage: true, + responseFilter: (response) { + // 1. 忽略特定 URL + final url = response.requestOptions.uri.toString(); + if (url.contains('openSliderCaptcha.htl')) { + return false; + } + + // 2. 忽略二进制文件 (Uint8List) + // 通常通过检查 response.data 的类型或 Content-Type 头部 + if (response.data is List || response.data is Uint8List) { + return false; + } + + return true; + }, + ), +); diff --git a/lib/repository/network_session.dart b/lib/repository/network_session.dart new file mode 100644 index 00000000..51387af5 --- /dev/null +++ b/lib/repository/network_session.dart @@ -0,0 +1,111 @@ +// Copyright 2023-2025 BenderBlog Rodriguez and contributors +// Copyright 2025 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +// General network class. + +import 'dart:io'; +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:cookie_jar/cookie_jar.dart'; +import 'package:dio_cookie_manager/dio_cookie_manager.dart'; +import 'package:flutter/widgets.dart'; +import 'package:watermeter/model/session_state.dart'; +import 'package:watermeter/repository/logger.dart'; + +late Directory supportPath; + +class NetworkSession { + static SessionState _isInit = SessionState.none; + + //@protected + final PersistCookieJar cookieJar = PersistCookieJar( + persistSession: true, + storage: FileStorage("${supportPath.path}/cookie/general"), + ); + + Future clearCookieJar() => cookieJar.deleteAll(); + + @protected + Dio get dio => + Dio( + BaseOptions( + contentType: Headers.formUrlEncodedContentType, + headers: { + HttpHeaders.userAgentHeader: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/130.0.0.0 Safari/537.36", + }, + ), + ) + ..interceptors.add(CookieManager(cookieJar, ignoreInvalidCookies: true)) + ..interceptors.add(logDioAdapter) + ..options.connectTimeout = const Duration(seconds: 10) + ..options.receiveTimeout = const Duration(seconds: 30) + ..options.followRedirects = false + ..options.validateStatus = (status) => + status != null && status >= 200 && status < 400; + + static Future isInSchool() async { + bool isInSchool = false; + Dio dio = Dio() + ..interceptors.add(logDioAdapter) + ..options.connectTimeout = const Duration(seconds: 30); + isInSchool = await dio + .get("https://rs.xidian.edu.cn/cas/login.php") + .then((value) => true) + .onError((error, stackTrace) { + log.warning( + "[isSchoolNet] Current net is not schoolnet.", + error, + stackTrace, + ); + return false; + }); + return isInSchool; + } + + NetworkSession() { + if (_isInit == SessionState.none) { + initSession(); + } + } + + Future initSession() async { + log.info( + "[NetworkSession][initSession] " + "Current State: $_isInit", + ); + if (_isInit == SessionState.fetching) { + return; + } + try { + _isInit = SessionState.fetching; + log.info( + "[NetworkSession][initSession] " + "Fetching...", + ); + var response = await dio.get("http://linux.xidian.edu.cn"); + if (response.statusCode == 200) { + _isInit = SessionState.fetched; + log.info( + "[NetworkSession][initSession] " + "Fetched", + ); + } else { + _isInit = SessionState.error; + log.error( + "[NetworkSession][initSession] " + "Error", + ); + } + } catch (e) { + _isInit = SessionState.error; + log.error( + "[NetworkSession][initSession] " + "Error: $e", + ); + } + } +} diff --git a/lib/repository/preference.dart b/lib/repository/preference.dart new file mode 100644 index 00000000..13069ba3 --- /dev/null +++ b/lib/repository/preference.dart @@ -0,0 +1,64 @@ +// Copyright 2023-2025 BenderBlog Rodriguez and contributors +// Copyright 2025 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +// General user setting preference. + +import 'package:shared_preferences/shared_preferences.dart'; + +late SharedPreferencesWithCache prefs; + +enum Preference { + idsAccount(key: "idsAccount"), + idsPassword(key: "idsPassword"), + currentSemester(key: "currentSemester"), + isUserDefinedSemester(key: "isUserDefinedSemester", type: "bool"), + role(key: "role", type: "bool"); + + const Preference({required this.key, this.type = "String"}); + + final String key; + final String type; +} + +String getString(Preference key) { + if (key.type != 'String') { + throw WrongTypeException; + } + return prefs.getString(key.key) ?? ""; +} + +bool getBool(Preference key) { + if (key.type != 'bool') { + throw WrongTypeException; + } + + return prefs.getBool(key.key) ?? false; +} + +bool contains(Preference key) { + return prefs.containsKey(key.key); +} + +Future setString(Preference key, String value) async { + if (key.type != 'String') { + throw WrongTypeException; + } + await prefs.setString(key.key, value); + await prefs.reloadCache(); +} + +Future setBool(Preference key, bool value) async { + if (key.type != 'bool') { + throw WrongTypeException; + } + await prefs.setBool(key.key, value); + await prefs.reloadCache(); +} + +Future remove(Preference key) async { + await prefs.remove(key.key); + await prefs.reloadCache(); +} + +class WrongTypeException implements Exception {} diff --git a/lib/repository/xidian_ids/classtable_session.dart b/lib/repository/xidian_ids/classtable_session.dart new file mode 100644 index 00000000..15f7e21f --- /dev/null +++ b/lib/repository/xidian_ids/classtable_session.dart @@ -0,0 +1,790 @@ +// Copyright 2023-2025 BenderBlog Rodriguez and contributors +// Copyright 2025 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +// The class table window source. +// Thanks xidian-script and libxdauth! + +import 'dart:convert'; +import 'dart:io'; +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:intl/intl.dart'; +import 'package:time/time.dart'; +import 'package:watermeter/model/fetch_result.dart'; +import 'package:watermeter/wearos/slider_captcha.dart'; +import 'package:watermeter/repository/logger.dart'; +import 'package:watermeter/repository/network_session.dart'; +import 'package:watermeter/repository/preference.dart' as pref; +import 'package:watermeter/model/xidian_ids/classtable.dart'; +import 'package:watermeter/repository/xidian_ids/ehall_session.dart'; +import 'package:watermeter/repository/xidian_ids/ids_session.dart'; + +String _cacheHintFromError(Object error) { + if (error is PasswordWrongException) { + return "classtable.cache_hint_password_wrong"; + } + if (error is LoginFailedException) { + return "classtable.cache_hint_login_failed"; + } + if (error is DioException) { + return "classtable.cache_hint_network_failed"; + } + return "classtable.cache_hint_unknown_error"; +} + +Future> getClassTable(String semesterCode) async { + try { + ClassTableData data = pref.getBool(pref.Preference.role) + ? await ClassTableSession().getYjspt(semesterCode) + : await ClassTableSession().getEhall(semesterCode); + DateTime fetchTime = DateTime.now(); + await ClassTableSession.updateCacheAndGroup(data); + return FetchResult.fresh(fetchTime: fetchTime, data: data); + } catch (e, s) { + log.handle(e, s, "[getClassTable] Have issue"); + (DateTime, ClassTableData)? cache = ClassTableSession.getCache(); + if (cache != null) { + return FetchResult.cache( + fetchTime: cache.$1, + data: cache.$2, + hintKey: _cacheHintFromError(e), + ); + } + rethrow; + } +} + +/// 课程表 4770397878132218 +class ClassTableSession extends EhallSession { + static const schoolClassName = "ClassTable.json"; + + static File schoolClassDataCache = File( + "${supportPath.path}/$schoolClassName", + ); + static bool get isCacheExist => schoolClassDataCache.existsSync(); + + static void deleteCache() { + if (schoolClassDataCache.existsSync()) { + schoolClassDataCache.deleteSync(); + } + } + + static Future updateCacheAndGroup(ClassTableData data) async { + await schoolClassDataCache.writeAsString(jsonEncode(data.toJson())); + } + + static (DateTime, ClassTableData)? getCache() { + try { + ClassTableData toReturn = ClassTableData.fromJson( + jsonDecode(schoolClassDataCache.readAsStringSync()), + ); + DateTime fetchTime = schoolClassDataCache.lastModifiedSync(); + return (fetchTime, toReturn); + } catch (e, s) { + log.handle(e, s); + return null; + } + } + + Future getYjspt(String semesterCode) async { + Map qResult = {}; + + // const semesterCodeURL = + // "https://yjspt.xidian.edu.cn/gsapp/sys/wdkbapp/modules/xskcb/kfdxnxqcx.do"; + const classInfoURL = + "https://yjspt.xidian.edu.cn/gsapp/sys/wdkbapp/modules/xskcb/xspkjgcx.do"; + const notArrangedInfoURL = + "https://yjspt.xidian.edu.cn/gsapp/sys/wdkbapp/modules/xskcb/xswsckbkc.do"; + + log.info("[getClasstable][getYjspt] Login the system."); + String? location = await checkAndLogin( + target: + "https://yjspt.xidian.edu.cn/gsapp/" + "sys/wdkbapp/*default/index.do#/xskcb", + sliderCaptcha: (String cookieStr) => + SliderCaptchaClientProvider(cookie: cookieStr).solve(null), + ); + + while (location != null) { + var response = await dio.get(location); + log.info("[getClasstable][getYjspt] Received location: $location."); + location = response.headers[HttpHeaders.locationHeader]?[0]; + } + + DateTime now = DateTime.now(); + var currentWeek = await dio + .post( + 'https://yjspt.xidian.edu.cn/gsapp/sys/yjsemaphome/portal/queryRcap.do', + data: {'day': DateFormat("yyyyMMdd").format(now)}, + ) + .then((value) => value.data); + if (!currentWeek.toString().contains("xnxq")) { + return ClassTableData(semesterCode: semesterCode); + } + currentWeek = + RegExp(r'[0-9]+').firstMatch(currentWeek["xnxq"])?[0] ?? "null"; + + log.info( + "[getClasstable][getYjspt] Current week is $currentWeek, fetching...", + ); + int weekDay = now.weekday - 1; + String termStartDay = DateFormat("yyyy-MM-dd HH:mm:ss").format( + now.add(Duration(days: (1 - int.parse(currentWeek)) * 7 - weekDay)).date, + ); + + Map data = await dio + .post(classInfoURL, data: {"XNXQDM": semesterCode}) + .then((response) => response.data); + + if (data['code'] != "0") { + log.warning( + "[getClasstable][getYjspt] " + "extParams: ${data['extParams']['msg']} isNotPublish: " + "${data['extParams']['msg'].toString().contains("查询学年学期的课程未发布")}", + ); + if (data['extParams']['msg'].toString().contains("查询学年学期的课程未发布")) { + log.warning( + "[getClasstable][getYjspt] " + "extParams: ${data['extParams']['msg']} isNotPublish: " + "Classtable not released.", + ); + return ClassTableData( + semesterCode: semesterCode, + termStartDay: termStartDay, + ); + } else { + throw Exception("${data['extParams']['msg']}"); + } + } + + qResult["rows"] = data["datas"]["xspkjgcx"]["rows"]; + + var notOnTable = await dio + .post( + notArrangedInfoURL, + data: { + 'XNXQDM': semesterCode, + 'XH': pref.getString(pref.Preference.idsAccount), + }, + ) + .then((value) => value.data['datas']['xswsckbkc']); + qResult["notArranged"] = notOnTable["rows"]; + + ClassTableData toReturn = ClassTableData(); + toReturn.semesterCode = semesterCode; + toReturn.termStartDay = termStartDay; + + log.info( + "[getClasstable][getYjspt] " + "${toReturn.semesterCode} ${toReturn.termStartDay}", + ); + + for (var i in qResult["rows"]) { + var toDeal = ClassDetail(name: i["KCMC"], code: i["KCDM"]); + if (!toReturn.classDetail.contains(toDeal)) { + toReturn.classDetail.add(toDeal); + } + + toReturn.timeArrangement.add( + TimeArrangement( + source: Source.school, + index: toReturn.classDetail.indexOf(toDeal), + start: i["KSJCDM"], + teacher: i["JSXM"], + stop: i["JSJCDM"], + day: int.parse(i["XQ"].toString()), + weekList: List.generate( + i["ZCBH"].toString().length, + (index) => i["ZCBH"].toString()[index] == "1", + ), + classroom: i["JASMC"], + ), + ); + + if (i["ZCBH"].toString().length > toReturn.semesterLength) { + toReturn.semesterLength = i["ZCBH"].toString().length; + } + } + + // Post deal here + List newStuff = []; + int getCourseId(TimeArrangement i) => + "${i.weekList}-${i.day}-${i.classroom}".hashCode; + + for (var i = 0; i < toReturn.classDetail.length; ++i) { + List data = List.from( + toReturn.timeArrangement, + )..removeWhere((item) => item.index != i); + List entries = []; + //Map> toAdd = {}; + + for (var j in data) { + int id = getCourseId(j); + if (!entries.any((k) => k == id)) entries.add(id); + } + for (var j in entries) { + List result = List.from(data) + ..removeWhere((item) => getCourseId(item) != j) + ..sort((a, b) => a.start - b.start); + + List arrangementsProto = { + for (var i in result) ...[i.start, i.stop], + }.toList()..sort(); + + log.info(arrangementsProto); + + List> arrangements = [[]]; + for (var j in arrangementsProto) { + if (arrangements.last.isEmpty || arrangements.last.last == j - 1) { + arrangements.last.add(j); + } else { + arrangements.add([j]); + } + } + + log.info(arrangements); + + for (var j in arrangements) { + newStuff.add( + TimeArrangement( + source: Source.school, + index: i, + classroom: result.first.classroom, + teacher: result.first.teacher, + weekList: result.first.weekList, + day: result.first.day, + start: j.first, + stop: j.last, + ), + ); + } + } + } + + toReturn.timeArrangement = newStuff; + + for (var i in qResult["notArranged"]) { + toReturn.notArranged.add( + NotArrangementClassDetail(name: i["KCMC"], code: i["KCDM"]), + ); + } + + return toReturn; + } + + Future getEhall(String semesterCode) async { + Map qResult = {}; + log.info("[getClasstable][getEhall] Login the system."); + String get = await useApp("4770397878132218"); + log.info("[getClasstable][getEhall] Location: $get"); + await dioEhall.post(get); + + log.info( + "[getClasstable][getEhall] " + "Fetch the semester information.", + ); + + log.info( + "[getClasstable][getEhall] " + "Fetch the day the semester begin.", + ); + String termStartDay = await dioEhall + .post( + 'https://ehall.xidian.edu.cn/jwapp/sys/wdkb/modules/jshkcb/cxjcs.do', + data: { + 'XN': '${semesterCode.split('-')[0]}-${semesterCode.split('-')[1]}', + 'XQ': semesterCode.split('-')[2], + }, + ) + .then((value) => value.data['datas']['cxjcs']['rows'][0]["XQKSRQ"]); + log.info( + "[getClasstable][getEhall] " + "Will get $semesterCode which start at $termStartDay.", + ); + + qResult = await dioEhall + .post( + 'https://ehall.xidian.edu.cn/jwapp/sys/wdkb/modules/xskcb/xskcb.do', + data: { + 'XNXQDM': semesterCode, + 'XH': pref.getString(pref.Preference.idsAccount), + }, + ) + .then((value) => value.data['datas']['xskcb']); + if (qResult['extParams']['code'] != 1) { + log.warning( + "[getClasstable][getEhall] " + "extParams: ${qResult['extParams']['msg']} isNotPublish: " + "${qResult['extParams']['msg'].toString().contains("查询学年学期的课程未发布")}", + ); + if (qResult['extParams']['msg'].toString().contains("查询学年学期的课程未发布")) { + log.warning( + "[getClasstable][getEhall] " + "extParams: ${qResult['extParams']['msg']} isNotPublish: " + "Classtable not released.", + ); + return ClassTableData( + semesterCode: semesterCode, + termStartDay: termStartDay, + ); + } else { + throw Exception("${qResult['extParams']['msg']}"); + } + } + + log.info( + "[getClasstable][getEhall] " + "Preliminary storage...", + ); + qResult["semesterCode"] = semesterCode; + qResult["termStartDay"] = termStartDay; + + var notOnTable = await dioEhall + .post( + "https://ehall.xidian.edu.cn/jwapp/sys/wdkb/modules/xskcb/cxxsllsywpk.do", + data: { + 'XNXQDM': semesterCode, + 'XH': pref.getString(pref.Preference.idsAccount), + }, + ) + .then((value) => value.data['datas']['cxxsllsywpk']); + + log.info("[getClasstable][getEhall] $notOnTable"); + qResult["notArranged"] = notOnTable["rows"]; + + ClassTableData preliminaryData = ClassTableData(); + + preliminaryData.semesterCode = qResult["semesterCode"]; + preliminaryData.termStartDay = qResult["termStartDay"]; + + log.info( + "[getClasstable][getEhall] " + "${preliminaryData.semesterCode} ${preliminaryData.termStartDay}", + ); + + for (var i in qResult["rows"]) { + var toDeal = ClassDetail( + name: i["KCM"], + code: i["KCH"], + number: i["KXH"], + ); + if (!preliminaryData.classDetail.contains(toDeal)) { + preliminaryData.classDetail.add(toDeal); + } + preliminaryData.timeArrangement.add( + TimeArrangement( + source: Source.school, + index: preliminaryData.classDetail.indexOf(toDeal), + start: int.parse(i["KSJC"]), + teacher: i["SKJS"], + stop: int.parse(i["JSJC"]), + day: int.parse(i["SKXQ"]), + weekList: List.generate( + i["SKZC"].toString().length, + (index) => i["SKZC"].toString()[index] == "1", + ), + classroom: i["JASMC"], + ), + ); + if (i["SKZC"].toString().length > preliminaryData.semesterLength) { + preliminaryData.semesterLength = i["SKZC"].toString().length; + } + } + + // Deal with the not arranged data. + for (var i in qResult["notArranged"]) { + preliminaryData.notArranged.add( + NotArrangementClassDetail( + name: i["KCM"], + code: i["KCH"], + number: i["KXH"], + teacher: i["SKJS"], + ), + ); + } + + /// Deal with the class change. + log.info( + "[getClasstable][getEhall] " + "Deal with the class change...", + ); + + qResult = await dioEhall + .post( + 'https://ehall.xidian.edu.cn/jwapp/sys/wdkb/modules/xskcb/xsdkkc.do', + data: { + 'XNXQDM': semesterCode, + //'SKZC': "6", + '*order': "-SQSJ", + }, + ) + .then((value) => value.data['datas']['xsdkkc']); + if (qResult['extParams']['code'] != 1) { + log.warning("[getClasstable][getEhall] ${qResult['extParams']['msg']}"); + } + + // ignore: non_constant_identifier_names + ChangeType type(String TKLXDM) { + if (TKLXDM == '01') { + return ChangeType.change; //调课 + } else if (TKLXDM == '02') { + return ChangeType.stop; //停课 + } else { + return ChangeType.patch; //补课 + } + } + + // Merge change info + if (int.parse(qResult["totalSize"].toString()) > 0) { + for (var i in qResult["rows"]) { + preliminaryData.classChanges.add( + ClassChange( + type: type(i["TKLXDM"]), + classCode: i["KCH"], + classNumber: i["KXH"], + className: i["KCM"], + originalAffectedWeeks: i["SKZC"] == null + ? null + : List.generate( + i["SKZC"].toString().length, + (index) => i["SKZC"].toString()[index] == "1", + ), + newAffectedWeeks: i["XSKZC"] == null + ? null + : List.generate( + i["XSKZC"].toString().length, + (index) => i["XSKZC"].toString()[index] == "1", + ), + originalTeacherData: i["YSKJS"], + newTeacherData: i["XSKJS"], + originalClassRange: [ + int.parse(i["KSJC"]?.toString() ?? "-1"), + int.parse(i["JSJC"]?.toString() ?? "-1"), + ], + newClassRange: [ + int.parse(i["XKSJC"]?.toString() ?? "-1"), + int.parse(i["XJSJC"]?.toString() ?? "-1"), + ], + originalWeek: i["SKXQ"], + newWeek: i["XSKXQ"], + originalClassroom: i["JASMC"], + newClassroom: i["XJASMC"], + ), + ); + } + } + + log.info( + "[getClasstable][getEhall] " + "Dealing class change with ${preliminaryData.classChanges.length} info(s).", + ); + + List cache = []; + List toDeal = List.from( + preliminaryData.classChanges, + ); + + while (toDeal.isNotEmpty) { + int previousLength = toDeal.length; + List toBeRemovedIndex = []; + for (var e in toDeal) { + /// First, search for the classes. + /// Due to the unstability of the api, a list is introduced. + /// This must have an answer, otherwise there's a potato in the school's server. + List indexClassDetailList = []; + for (int i = 0; i < preliminaryData.classDetail.length; ++i) { + if (preliminaryData.classDetail[i].code == e.classCode) { + indexClassDetailList.add(i); + } + } + log.info( + "[getClasstable][getEhall] " + "Class change related to class index $indexClassDetailList.", + ); + + /// If the class is not in the main schedule, create a new entry. + if (indexClassDetailList.isEmpty) { + if (e.type == ChangeType.patch) { + log.info( + "[getClasstable][getEhall] " + "Class ${e.className} (${e.classCode}) not in main schedule, " + "creating new ClassDetail for patch.", + ); + var newDetail = ClassDetail( + name: e.className, + code: e.classCode, + number: e.classNumber, + ); + preliminaryData.classDetail.add(newDetail); + int newIndex = preliminaryData.classDetail.length - 1; + preliminaryData.timeArrangement.add( + TimeArrangement( + source: Source.school, + index: newIndex, + weekList: e.newAffectedWeeks ?? e.originalAffectedWeeks ?? [], + day: e.newWeek ?? e.originalWeek ?? 0, + start: e.newClassRange[0], + stop: e.newClassRange[1], + classroom: e.newClassroom ?? e.originalClassroom, + teacher: e.isTeacherChanged ? e.newTeacher : e.originalTeacher, + ), + ); + } else { + log.warning( + "[getClasstable][getEhall] " + "Class ${e.className} (${e.classCode}) not found in main schedule, " + "skipping class change entry (type: ${e.type}).", + ); + } + toBeRemovedIndex.add(toDeal.indexOf(e)); + continue; + } + + /// Then, if patch, find the class and add one + if (e.type == ChangeType.patch) { + log.info( + "[getClasstable][getEhall] " + "Class patch.", + ); + + /// Add classes. + preliminaryData.timeArrangement.add( + TimeArrangement( + source: Source.school, + index: indexClassDetailList.first, + weekList: e.newAffectedWeeks ?? e.originalAffectedWeeks ?? [], + day: e.newWeek ?? e.originalWeek ?? 0, + start: e.newClassRange[0], + stop: e.newClassRange[1], + classroom: e.newClassroom ?? e.originalClassroom, + teacher: e.isTeacherChanged ? e.newTeacher : e.originalTeacher, + ), + ); + continue; + } + + /// Otherwise, find the all time arrangement related to the class. + log.info( + "[getClasstable][getEhall] " + "Class change related to class detail index $indexClassDetailList.", + ); + List indexOriginalTimeArrangementList = []; + for (var currentClassIndex in indexClassDetailList) { + for (int i = 0; i < preliminaryData.timeArrangement.length; ++i) { + if (preliminaryData.timeArrangement[i].index == currentClassIndex && + preliminaryData.timeArrangement[i].day == e.originalWeek && + preliminaryData.timeArrangement[i].start == + e.originalClassRange[0] && + preliminaryData.timeArrangement[i].stop == + e.originalClassRange[1]) { + indexOriginalTimeArrangementList.add(i); + } + } + } + + /// Third, search for the time arrangements, seek for the truth. + log.info( + "[getClasstable][getEhall] " + "Class change related to time arrangement index $indexOriginalTimeArrangementList.", + ); + + /// If empty, remove from toDeal to avoid infinite loop. + if (indexOriginalTimeArrangementList.isEmpty) { + toBeRemovedIndex.add(toDeal.indexOf(e)); + continue; + } + + if (e.type == ChangeType.change) { + int timeArrangementIndex = indexOriginalTimeArrangementList.first; + + log.info( + "[getClasstable][getEhall] " + "Class change. Teacher changed? ${e.isTeacherChanged}. timeArrangementIndex is $timeArrangementIndex", + ); + for (int indexOriginalTimeArrangement + in indexOriginalTimeArrangementList) { + /// Seek for the change entry. Delete the classes moved waay. + log.info( + "[getClasstable][getEhall] " + "Original weeklist ${preliminaryData.timeArrangement[indexOriginalTimeArrangement].weekList} " + "with originalAffectedWeeksList ${e.originalAffectedWeeksList}.", + ); + for (int i in e.originalAffectedWeeksList) { + var weekList = preliminaryData + .timeArrangement[indexOriginalTimeArrangement] + .weekList; + if (i >= weekList.length) { + int oldLength = weekList.length; + weekList.addAll(List.filled(i + 1 - oldLength, false)); + if (weekList.length > preliminaryData.semesterLength) { + preliminaryData.semesterLength = weekList.length; + } + } + log.info( + "[getClasstable][getEhall] " + "Week $i, status ${preliminaryData.timeArrangement[indexOriginalTimeArrangement].weekList[i]}.", + ); + if (preliminaryData + .timeArrangement[indexOriginalTimeArrangement] + .weekList[i]) { + preliminaryData + .timeArrangement[indexOriginalTimeArrangement] + .weekList[i] = + false; + timeArrangementIndex = preliminaryData + .timeArrangement[indexOriginalTimeArrangement] + .index; + } + } + + log.info( + "[getClasstable][getEhall] " + "New weeklist ${preliminaryData.timeArrangement[indexOriginalTimeArrangement].weekList}.", + ); + } + + if (timeArrangementIndex == indexOriginalTimeArrangementList.first) { + cache.add(e); + timeArrangementIndex = preliminaryData + .timeArrangement[indexOriginalTimeArrangementList.first] + .index; + } + + log.info( + "[getClasstable][getEhall] " + "New week: ${e.newAffectedWeeks}, " + "day: ${e.newWeek}, " + "startToStop: ${e.newClassRange}, " + "timeArrangementIndex: $timeArrangementIndex.", + ); + + bool flag = false; + ClassChange? toRemove; + log.info("[getClasstable][getEhall] cache length = ${cache.length}"); + for (var f in cache) { + //log.info("[getClasstable][getFromWeb]" + // "${f.className} ${f.classCode} ${f.originalClassRange} ${f.originalAffectedWeeksList} ${f.originalWeek}"); + //log.info("[getClasstable][getFromWeb]" + // "${e.className} ${e.classCode} ${e.newClassRange} ${e.newAffectedWeeksList} ${e.newWeek}"); + //log.info("[getClasstable][getFromWeb]" + // "${f.className == e.className} ${f.classCode == e.classCode} ${listEquals(f.originalClassRange, e.newClassRange)} ${listEquals(f.originalAffectedWeeksList, e.newAffectedWeeksList)} ${f.originalWeek == e.newWeek}"); + if (f.className == e.className && + f.classCode == e.classCode && + listEquals(f.originalClassRange, e.newClassRange) && + listEquals( + f.originalAffectedWeeksList, + e.newAffectedWeeksList, + ) && + f.originalWeek == e.newWeek && + f.originalClassroom == e.newClassroom && + f.originalTeacherData == e.newTeacherData) { + flag = true; + toRemove = f; + break; + } + } + + if (flag) { + cache.remove(toRemove); + log.info( + "[getClasstable][getEhall] " + "Cannot be added", + ); + continue; + } + + log.info( + "[getClasstable][getEhall] " + "Can be added", + ); + + /// Add classes. + preliminaryData.timeArrangement.add( + TimeArrangement( + source: Source.school, + index: timeArrangementIndex, + weekList: e.newAffectedWeeks ?? e.originalAffectedWeeks ?? [], + day: e.newWeek ?? e.originalWeek ?? 0, + start: e.newClassRange[0], + stop: e.newClassRange[1], + classroom: e.newClassroom ?? e.originalClassroom, + teacher: e.isTeacherChanged ? e.newTeacher : e.originalTeacher, + ), + ); + } else { + log.info( + "[getClasstable][getEhall] " + "Class stop.", + ); + + for (int indexOriginalTimeArrangement + in indexOriginalTimeArrangementList) { + log.info( + "[getClasstable][getEhall] " + "Original weeklist " + "${preliminaryData.timeArrangement[indexOriginalTimeArrangement].weekList} " + "with originalAffectedWeeksList ${e.originalAffectedWeeksList}.", + ); + for (int i in e.originalAffectedWeeksList) { + var weekList = preliminaryData + .timeArrangement[indexOriginalTimeArrangement] + .weekList; + if (i >= weekList.length) { + int oldLength = weekList.length; + weekList.addAll(List.filled(i + 1 - oldLength, false)); + if (weekList.length > preliminaryData.semesterLength) { + preliminaryData.semesterLength = weekList.length; + } + } + log.info( + "[getClasstable][getEhall] " + "$i ${preliminaryData.timeArrangement[indexOriginalTimeArrangement].weekList[i]}", + ); + if (preliminaryData + .timeArrangement[indexOriginalTimeArrangement] + .weekList[i]) { + preliminaryData + .timeArrangement[indexOriginalTimeArrangement] + .weekList[i] = + false; + } + } + log.info( + "[getClasstable][getEhall] " + "New weeklist " + "${preliminaryData.timeArrangement[indexOriginalTimeArrangement].weekList}.", + ); + } + } + toBeRemovedIndex.add(toDeal.indexOf(e)); + } + toDeal = [ + for (var i = 0; i < toDeal.length; ++i) + if (!toBeRemovedIndex.contains(i)) toDeal[i], + ]; + log.info( + "[getClasstable][getEhall] " + "After this turn, ${toDeal.length} left, removed $toBeRemovedIndex.", + ); + + /// Safety: if no progress was made in this pass, break to avoid infinite loop. + if (toDeal.length == previousLength) { + log.warning( + "[getClasstable][getEhall] " + "No progress made in class change processing. " + "Remaining ${toDeal.length} change(s) could not be resolved. " + "Breaking to avoid infinite loop.", + ); + break; + } + } + + return preliminaryData; + } +} + +class NotSameSemesterException implements Exception { + final String msg; + NotSameSemesterException({required this.msg}); +} diff --git a/lib/repository/xidian_ids/ehall_session.dart b/lib/repository/xidian_ids/ehall_session.dart new file mode 100644 index 00000000..c3cdd52e --- /dev/null +++ b/lib/repository/xidian_ids/ehall_session.dart @@ -0,0 +1,138 @@ +// Copyright 2023-2025 BenderBlog Rodriguez and contributors +// Copyright 2025 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +// E-hall class, which get lots of useful data here. +// Thanks xidian-script and libxdauth! + +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:synchronized/synchronized.dart'; +import 'package:watermeter/wearos/slider_captcha.dart'; +import 'package:watermeter/repository/logger.dart'; +import 'package:watermeter/repository/xidian_ids/ids_session.dart'; + +class EhallSession extends IDSSession { + static final _ehallLock = Lock(); + + /// This header shall only be used in the ehall related stuff... + Map refererHeader = { + HttpHeaders.refererHeader: "http://ehall.xidian.edu.cn/new/index_xd.html", + HttpHeaders.hostHeader: "ehall.xidian.edu.cn", + HttpHeaders.acceptHeader: + "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9", + HttpHeaders.acceptLanguageHeader: + 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6', + HttpHeaders.acceptEncodingHeader: 'identity', + HttpHeaders.connectionHeader: 'Keep-Alive', + HttpHeaders.contentTypeHeader: + "application/x-www-form-urlencoded; charset=UTF-8", + }; + + Dio get dioEhall => super.dio..options = BaseOptions(headers: refererHeader); + Dio get dioEhallNoOfflineCheck => + dioNoOfflineCheck..options = BaseOptions(headers: refererHeader); + + Future completeLoginRedirect( + String location, { + bool ignoreOffline = false, + }) async { + final initialDio = ignoreOffline ? dioNoOfflineCheck : dio; + final redirectDio = ignoreOffline ? dioEhallNoOfflineCheck : dioEhall; + var response = await initialDio.get(location); + while (response.headers[HttpHeaders.locationHeader] != null) { + location = response.headers[HttpHeaders.locationHeader]![0]; + log.info( + "[ehall_session][completeLoginRedirect] " + "Received location: $location", + ); + response = await redirectDio.get(location); + } + } + + Future isLoggedIn() async { + var response = await dioEhall.get( + "https://ehall.xidian.edu.cn/jsonp/getAppUsageMonitor.json?type=uv", + ); + log.info( + "[ehall_session][isLoggedIn] " + "Ehall isLoggedin: ${response.data["hasLogin"]}", + ); + return response.data["hasLogin"]; + } + + Future loginEhall({ + required String username, + required String password, + required Future Function(String) sliderCaptcha, + required void Function(int, String) onResponse, + }) async { + String location = await super.login( + target: + "https://ehall.xidian.edu.cn/login?service=https://ehall.xidian.edu.cn/new/index.html", + username: username, + password: password, + sliderCaptcha: sliderCaptcha, + onResponse: onResponse, + ); + await completeLoginRedirect(location); + } + + Future useApp(String appID) async { + return await _ehallLock.synchronized(() async { + log.info( + "[ehall_session][useApp] " + "Ready to use the app $appID. Try to Login.", + ); + if (!await isLoggedIn()) { + String location = await super.checkAndLogin( + target: + "https://ehall.xidian.edu.cn/login?" + "service=https://ehall.xidian.edu.cn/new/index.html", + sliderCaptcha: (String cookieStr) => + SliderCaptchaClientProvider(cookie: cookieStr).solve(null), + ); + var response = await dio.get(location); + while (response.headers[HttpHeaders.locationHeader] != null) { + location = response.headers[HttpHeaders.locationHeader]![0]; + log.info( + "[ehall_session][useApp] " + "Received location: $location.", + ); + response = await dioEhall.get(location); + } + } + log.info( + "[ehall_session][useApp] " + "Try to use the $appID.", + ); + var value = await dioEhall.get( + "https://ehall.xidian.edu.cn/appShow?appId=$appID", + options: Options( + followRedirects: false, + validateStatus: (status) { + return status! < 500; + }, + ), + ); + log.info( + "[ehall_session][useApp] " + "Transfer address: ${value.headers['location']![0]}.", + ); + + return value.headers['location']![0].replaceAll( + RegExp(r';jsessionid=(.*)\?'), + "?", + ); + }); + } +} + +class GetInformationFailedException implements Exception { + final String msg; + const GetInformationFailedException(this.msg); + + @override + String toString() => msg; +} diff --git a/lib/repository/xidian_ids/ids_session.dart b/lib/repository/xidian_ids/ids_session.dart new file mode 100644 index 00000000..7b06763a --- /dev/null +++ b/lib/repository/xidian_ids/ids_session.dart @@ -0,0 +1,399 @@ +// Copyright 2023-2025 BenderBlog Rodriguez and contributors +// Copyright 2025 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +// IDS (统一认证服务) login class. +// Thanks xidian-script and libxdauth! + +import 'dart:io'; +import 'package:dio/dio.dart'; +import 'package:html/parser.dart'; +import 'package:encrypter_plus/encrypter_plus.dart' as encrypt; +import 'package:synchronized/synchronized.dart'; +import 'package:watermeter/wearos/slider_captcha.dart'; +import 'package:watermeter/repository/logger.dart'; +import 'package:watermeter/repository/network_session.dart'; +import 'package:watermeter/repository/preference.dart' as preference; + +enum IDSLoginState { + none, + requesting, + success, + fail, + passwordWrong, + + /// Indicate that the user will login via LoginWindow + manual, +} + +IDSLoginState loginState = IDSLoginState.none; + +bool get offline => + loginState != IDSLoginState.success && loginState != IDSLoginState.manual; + +class IDSSession extends NetworkSession { + static final _idslock = Lock(); + static const _goLoginPasswordPrefix = + '................................................................'; + static const _goLoginPasswordIv = '................'; + + @override + Dio get dio => super.dio + ..interceptors.add( + InterceptorsWrapper( + onRequest: (options, handler) { + log.info( + "[IDSSession][OfflineCheckInspector]" + "Offline status: $offline", + ); + if (offline) { + handler.reject( + DioException.requestCancelled( + reason: "Offline mode, all ids function unuseable.", + requestOptions: options, + ), + ); + } else { + handler.next(options); + } + }, + ), + ); + + Dio get dioNoOfflineCheck => super.dio; + + Future _hasCastgcCookie() async { + final cookies = await cookieJar.loadForRequest( + Uri.parse('https://ids.xidian.edu.cn/authserver/login'), + ); + return cookies.any((cookie) => cookie.name == 'CASTGC'); + } + + /// Get base64 encoded data. Which is aes encrypted [toEnc] encoded string using [key]. + /// Matches the Go ids login payload: 64 fixed prefix bytes, AES-CBC, + /// PKCS7 padding, and the fixed `................` IV. + static String aesEncrypt(String toEnc, String key) { + final crypt = encrypt.AES( + encrypt.Key.fromUtf8(key), + mode: encrypt.AESMode.cbc, + ); + return encrypt.Encrypter(crypt) + .encrypt( + '$_goLoginPasswordPrefix$toEnc', + iv: encrypt.IV.fromUtf8(_goLoginPasswordIv), + ) + .base64; + } + + static Map buildUsernameLoginPayloadForTesting({ + required String username, + required String password, + required String salt, + required String execution, + }) => _buildUsernameLoginPayload( + username: username, + password: password, + salt: salt, + execution: execution, + ); + + static Map _buildUsernameLoginPayload({ + required String username, + required String password, + required String salt, + required String execution, + }) { + return { + 'username': username, + 'password': aesEncrypt(password, salt), + 'rememberMe': 'true', + 'cllt': 'userNameLogin', + 'dllt': 'generalLogin', + '_eventId': 'submit', + 'captcha': '', + 'lt': '', + 'execution': execution, + }; + } + + String _parsePasswordWrongMsg(String html) { + var form = parse(html).getElementById("showErrorTip"); + var msg = form?.text ?? "登录遇到问题"; + + // Simplify the error message because there is no '找回密码' button here XD. + // "用户名或密码有误,用户名为工号/学号,如果确认用户名无误,请点‘找回密码’自助重置密码。" + if (msg.contains(RegExp(r"(用户名|密码).*误", unicode: true, dotAll: true))) { + msg = "用户名或密码有误"; + } + return msg; + } + + Future checkAndLogin({ + required String target, + required Future Function(String) sliderCaptcha, + }) async { + return await _idslock.synchronized(() async { + log.info( + "[IDSSession][checkAndLogin] " + "Ready to get $target.", + ); + var data = await dioNoOfflineCheck.get( + "https://ids.xidian.edu.cn/authserver/login", + queryParameters: {'service': target, 'type': 'userNameLogin'}, + ); + log.info( + "[IDSSession][checkAndLogin] " + "Received: $data.", + ); + if (data.statusCode == 401) { + throw PasswordWrongException(msg: _parsePasswordWrongMsg(data.data)); + } else if (data.statusCode == 301 || data.statusCode == 302) { + /// Post login progress, due to something wrong, return the location here... + return data.headers[HttpHeaders.locationHeader]![0]; + } else { + var page = parse(data.data ?? ""); + var form = page.getElementsByTagName("form") + ..removeWhere((element) => element.id != "continue"); + log.info( + "[IDSSession][login] " + "form: $form.", + ); + if (form.isNotEmpty) { + var inputSearch = form[0].getElementsByTagName("input"); + Map toPostAgain = {}; + for (var i in inputSearch) { + toPostAgain[i.attributes["name"]!] = i.attributes["value"]!; + } + var data = await dioNoOfflineCheck.post( + "https://ids.xidian.edu.cn/authserver/login", + data: toPostAgain, + options: Options( + validateStatus: (status) => + status != null && status >= 200 && status < 400, + ), + ); + if (data.statusCode == 301 || data.statusCode == 302) { + return data.headers[HttpHeaders.locationHeader]![0]; + } + } + return await login( + username: preference.getString(preference.Preference.idsAccount), + password: preference.getString(preference.Preference.idsPassword), + sliderCaptcha: sliderCaptcha, + target: target, + ); + } + }); + } + + Future login({ + required String username, + required String password, + required Future Function(String) sliderCaptcha, + bool forceReLogin = false, + void Function(int, String)? onResponse, + String? target, + }) async { + /// Get the login webpage. + if (onResponse != null) { + onResponse(10, "login_process.ready_page"); + log.info( + "[IDSSession][login] " + "Ready to get the login webpage.", + ); + } + final queryParameters = {'type': 'userNameLogin'}; + if (target != null) { + queryParameters['service'] = target; + } + + var response = await dioNoOfflineCheck + .get( + "https://ids.xidian.edu.cn/authserver/login", + queryParameters: queryParameters, + ) + .then((value) => value.data); + + /// Start getting data from webpage. + var page = parse(response); + var form = page.getElementsByTagName("input") + ..removeWhere((element) => element.attributes["type"] != "hidden"); + + /// Check whether it need CAPTCHA or not:-P + /// Used in two captcha. + String cookieStr = ""; + var cookie = await cookieJar.loadForRequest( + Uri.parse("https://ids.xidian.edu.cn/authserver"), + ); + for (var i in cookie) { + cookieStr += "${i.name}=${i.value}; "; + } + log.info( + "[IDSSession][login] " + "cookie: $cookieStr.", + ); + + /// Get AES encrypt key. There must be. + if (onResponse != null) { + onResponse(30, "login_process.get_encrypt"); + } + String keys = form + .firstWhere((element) => element.id == "pwdEncryptSalt") + .attributes["value"]!; + log.info( + "[IDSSession][login] " + "encrypt key: $keys.", + ); + + /// Prepare for login. + if (onResponse != null) { + onResponse(40, "login_process.ready_login"); + } + final execution = form + .firstWhere( + (element) => + element.attributes["name"] == "execution" || + element.id == "execution", + ) + .attributes["value"]!; + final head = _buildUsernameLoginPayload( + username: username, + password: password, + salt: keys, + execution: execution, + ); + + if (onResponse != null) { + onResponse(45, "login_process.slider"); + } + + try { + await sliderCaptcha(cookieStr); + } on CaptchaSolveFailedException { + throw const LoginFailedException(msg: "验证码校验失败"); + } + + /// Post login request. + if (onResponse != null) { + onResponse(50, "login_process.ready_login"); + } + try { + var data = await dioNoOfflineCheck.post( + "https://ids.xidian.edu.cn/authserver/login", + queryParameters: target != null ? {'service': target} : null, + data: head, + options: Options( + validateStatus: (status) => + status != null && status >= 200 && status < 400, + ), + ); + final location = data.headers[HttpHeaders.locationHeader]?.first; + if (location != null && + (data.statusCode == 301 || + data.statusCode == 302 || + await _hasCastgcCookie())) { + /// Post login progress. + if (onResponse != null) { + onResponse(80, "login_process.after_process"); + } + return location; + } else { + /// Check whether need continue. + log.info( + "[IDSSession][login] " + "data: ${(data.data as String).length}.", + ); + + var page = parse(data.data ?? ""); + var form = page.getElementsByTagName("form") + ..removeWhere((element) => element.id != "continue"); + log.info( + "[IDSSession][login] " + "form: $form.", + ); + if (form.isNotEmpty) { + var inputSearch = form[0].getElementsByTagName("input"); + Map toPostAgain = {}; + for (var i in inputSearch) { + toPostAgain[i.attributes["name"]!] = i.attributes["value"]!; + } + var data = await dioNoOfflineCheck.post( + "https://ids.xidian.edu.cn/authserver/login", + data: toPostAgain, + options: Options( + validateStatus: (status) => + status != null && status >= 200 && status < 400, + ), + ); + final location = data.headers[HttpHeaders.locationHeader]?.first; + if (location != null && + (data.statusCode == 301 || + data.statusCode == 302 || + await _hasCastgcCookie())) { + /// Post login progress. + if (onResponse != null) { + onResponse(80, "login_process.after_process"); + } + return location; + } + } + throw LoginFailedException(msg: "登录失败,响应状态码:${data.statusCode}。"); + } + } on DioException catch (e) { + if (e.response?.statusCode == 401) { + throw PasswordWrongException( + msg: _parsePasswordWrongMsg(e.response!.data), + ); + } else { + rethrow; + } + } + } + + Future checkWhetherPostgraduate({ + Future Function(String)? sliderCaptcha, + }) async { + String location = await checkAndLogin( + target: + "https://yjspt.xidian.edu.cn/gsapp" + "/sys/yjsemaphome/portal/index.do", + sliderCaptcha: + sliderCaptcha ?? + (cookieStr) => + SliderCaptchaClientProvider(cookie: cookieStr).solve(null), + ); + var response = await dio.get(location); + while (response.headers[HttpHeaders.locationHeader] != null) { + location = response.headers[HttpHeaders.locationHeader]![0]; + log.info("[checkWhetherPostgraduate] Received location: $location"); + response = await dio.get(location); + } + + bool toReturn = await dio + .post( + "https://yjspt.xidian.edu.cn/gsapp" + "/sys/yjsemaphome/modules/pubWork/getCanVisitAppList.do", + ) + .then((value) => value.data["res"] != null); + + await preference.setBool(preference.Preference.role, toReturn); + + return toReturn; + } +} + +class NeedCaptchaException implements Exception {} + +class PasswordWrongException implements Exception { + final String msg; + const PasswordWrongException({required this.msg}); + @override + String toString() => msg; +} + +class LoginFailedException implements Exception { + final String msg; + const LoginFailedException({required this.msg}); + @override + String toString() => msg; +} diff --git a/lib/repository/xidian_ids/personal_info_session.dart b/lib/repository/xidian_ids/personal_info_session.dart new file mode 100644 index 00000000..4b76a33c --- /dev/null +++ b/lib/repository/xidian_ids/personal_info_session.dart @@ -0,0 +1,149 @@ +// Copyright 2023-2025 BenderBlog Rodriguez and contributors +// Copyright 2025 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:watermeter/wearos/slider_captcha.dart'; +import 'package:watermeter/repository/logger.dart'; +import 'package:watermeter/repository/preference.dart' as preference; +import 'package:watermeter/repository/xidian_ids/ehall_session.dart'; + +class PersonalInfoSession extends EhallSession { + Future getSemesterInfoYjspt() async { + String location = await checkAndLogin( + target: "https://yjspt.xidian.edu.cn/", + sliderCaptcha: (String cookieStr) => + SliderCaptchaClientProvider(cookie: cookieStr).solve(null), + ); + + log.info( + "[PersonalInfoSession][getSemesterInfoYjspt] " + "Location is $location", + ); + var response = await dio.get(location); + while (response.headers[HttpHeaders.locationHeader] != null) { + location = response.headers[HttpHeaders.locationHeader]![0]; + log.info( + "[PersonalInfoSession][getSemesterInfoYjspt] " + "Received location: $location.", + ); + response = await dio.get(location); + } + + log.info( + "[PersonalInfoSession][getSemesterInfoYjspt] " + "Getting the current semester info.", + ); + var detailed = await dio + .post( + "https://yjspt.xidian.edu.cn/gsapp/sys/yjsemaphome/modules/pubWork/getUserInfo.do", + ) + .then((value) => value.data); + if (detailed["code"] != "0") { + throw GetInformationFailedException(detailed["msg"].toString()); + } + return detailed["data"]["xnxqdm"]; + } + + Future getDormInfoEhall() async { + log.info( + "[ehall_session][getDormInfoEhall] " + "Ready to get the user information.", + ); + + String location = await super.checkAndLogin( + target: + "https://xgxt.xidian.edu.cn/xsfw/sys/jbxxapp/*default/index.do#/wdxx", + sliderCaptcha: (String cookieStr) => + SliderCaptchaClientProvider(cookie: cookieStr).solve(null), + ); + log.info( + "[ehall_session][getDormInfoEhall] " + "Location is $location", + ); + var response = await dio.get( + location, + options: Options( + headers: { + HttpHeaders.refererHeader: + "https://xgxt.xidian.edu.cn/xsfw/sys/jbxxapp/*default/index.do", + HttpHeaders.hostHeader: "xgxt.xidian.edu.cn", + }, + ), + ); + while (response.headers[HttpHeaders.locationHeader] != null) { + location = response.headers[HttpHeaders.locationHeader]![0]; + log.info( + "[ehall_session][useApp] " + "Received location: $location.", + ); + response = await dioEhall.get( + location, + options: Options( + headers: { + HttpHeaders.refererHeader: + "https://xgxt.xidian.edu.cn/xsfw/sys/jbxxapp/*default/index.do", + HttpHeaders.hostHeader: "xgxt.xidian.edu.cn", + }, + ), + ); + } + await dioEhall.post( + "https://xgxt.xidian.edu.cn/xsfw/sys/swpubapp/indexmenu/getAppConfig.do?appId=4585275700341858&appName=jbxxapp", + options: Options( + headers: { + HttpHeaders.refererHeader: + "https://xgxt.xidian.edu.cn/xsfw/sys/jbxxapp/*default/index.do", + HttpHeaders.hostHeader: "xgxt.xidian.edu.cn", + }, + ), + ); + + /// Get information here. resultCode==00000 is successful. + log.info( + "[ehall_session][getDormInfoEhall] " + "Getting the dorm information.", + ); + var detailed = await dioEhall + .post( + "https://xgxt.xidian.edu.cn/xsfw/sys/jbxxapp/modules/infoStudent/getStuBaseInfo.do", + data: + "requestParamStr=" + "{\"XSBH\":\"${preference.getString(preference.Preference.idsAccount)}\"}", + options: Options( + headers: { + HttpHeaders.refererHeader: + "https://xgxt.xidian.edu.cn/xsfw/sys/jbxxapp/*default/index.do", + HttpHeaders.hostHeader: "xgxt.xidian.edu.cn", + }, + ), + ) + .then((value) => value.data); + log.info( + "[ehall_session][getDormInfoEhall] " + "Storing the user information.", + ); + if (detailed["returnCode"] != "#E000000000000") { + throw GetInformationFailedException(detailed["description"]); + } + + return detailed["data"]["ZSDZ"].toString(); + } + + Future getSemesterInfoEhall() async { + log.info( + "[ehall_session][getSemesterInfoEhall] " + "Get the semester information.", + ); + String get = await useApp("4770397878132218"); + await dioEhall.post(get); + String semesterCode = await dioEhall + .post( + "https://ehall.xidian.edu.cn/jwapp/sys/wdkb/modules/jshkcb/dqxnxq.do", + ) + .then((value) => value.data['datas']['dqxnxq']['rows'][0]['DM']); + return semesterCode; + } +} diff --git a/lib/repository/xidian_ids/school_card_session.dart b/lib/repository/xidian_ids/school_card_session.dart new file mode 100644 index 00000000..1f071333 --- /dev/null +++ b/lib/repository/xidian_ids/school_card_session.dart @@ -0,0 +1,271 @@ +// Copyright 2023-2025 BenderBlog Rodriguez and contributors +// Copyright 2025 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +// Get your school card money's info, unless you use wechat or alipay... + +import 'dart:io'; +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:html/parser.dart'; +import 'package:dio/dio.dart'; +import 'package:watermeter/repository/logger.dart'; +import 'package:watermeter/model/xidian_ids/paid_record.dart'; +import 'package:watermeter/repository/preference.dart' as preference; +import 'package:watermeter/repository/xidian_ids/ids_session.dart'; +import 'package:watermeter/wearos/wear_ids_reauth.dart'; +import 'package:watermeter/wearos/slider_captcha.dart'; + +class SchoolCardSession extends IDSSession { + static const _openOauthUrl = + "https://v8scan.xidian.edu.cn/home/openXDOAuth2Page"; + static String openid = ""; + static DateTime? _openidFetchedAt; + static const Duration _openidValidDuration = Duration(minutes: 5); + static const _failedOverviewKey = "school_card_status.failed_to_query"; + + static void resetOpenId() { + openid = ""; + _openidFetchedAt = null; + } + + bool get _isOpenIdValid => + openid.isNotEmpty && + _openidFetchedAt != null && + DateTime.now().difference(_openidFetchedAt!) < _openidValidDuration; + + Future _ensureOpenId({bool forceRefresh = false}) async { + if (!forceRefresh && _isOpenIdValid) return; + + resetOpenId(); + + var response = await dio.get(_openOauthUrl); + while (response.headers[HttpHeaders.locationHeader] != null) { + String location = response.headers[HttpHeaders.locationHeader]![0]; + log.info( + "[SchoolCardSession][_ensureOpenId] " + "Received location: $location.", + ); + response = await dio.get(location); + } + _captureOpenId(response.data); + } + + void _captureOpenId(dynamic html) { + final inputs = parse(html?.toString() ?? '').getElementsByTagName('input'); + for (final input in inputs) { + if (input.id == 'openid' && input.attributes['type'] == 'hidden') { + openid = input.attributes['value'] ?? ''; + break; + } + } + if (openid.isEmpty) throw Exception('School card openid not found.'); + _openidFetchedAt = DateTime.now(); + } + + Future _discoverIdsService() async { + var currentUrl = _openOauthUrl; + var response = await dioNoOfflineCheck.get(currentUrl); + for (var redirect = 0; redirect < 10; redirect++) { + final nextHeader = + response.headers[HttpHeaders.locationHeader]?.firstOrNull; + if (nextHeader == null) break; + final nextUrl = Uri.parse(currentUrl).resolve(nextHeader).toString(); + final nextUri = Uri.parse(nextUrl); + if (nextUri.host == 'ids.xidian.edu.cn' && + nextUri.path.endsWith('/authserver/login')) { + final service = nextUri.queryParameters['service']; + if (service != null && service.isNotEmpty) return service; + } + currentUrl = nextUrl; + response = await dioNoOfflineCheck.get(currentUrl); + } + throw Exception('School card IDS service not found.'); + } + + /// Authenticates only the payment-card flow with credentials synced from the + /// companion phone. Other Wear OS data remains cache-only. + Future authenticateWithStoredCredentials({ + WearIDSReAuthHandler? reAuthHandler, + }) async { + if (loginState == IDSLoginState.success) return; + + loginState = IDSLoginState.requesting; + try { + await clearCookieJar(); + final idsService = await _discoverIdsService(); + var location = await checkAndLogin( + target: idsService, + sliderCaptcha: (cookie) => + SliderCaptchaClientProvider(cookie: cookie).solveAutomatically(), + ); + final redirectUri = Uri.parse( + 'https://ids.xidian.edu.cn', + ).resolve(location); + if (redirectUri.host == 'ids.xidian.edu.cn' && + redirectUri.path == '/authserver/reAuthCheck/reAuthLoginView.do') { + final handler = reAuthHandler; + if (handler == null) { + throw const WearIDSReAuthExpiredException('需要短信二次认证'); + } + location = (await handler( + WearIDSReAuthClient( + dio: dioNoOfflineCheck, + challengeUri: redirectUri, + username: preference.getString(preference.Preference.idsAccount), + service: idsService, + ), + )).toString(); + } + var response = await dioNoOfflineCheck.get(location); + while (response.headers[HttpHeaders.locationHeader]?.isNotEmpty == true) { + location = Uri.parse(location) + .resolve(response.headers[HttpHeaders.locationHeader]!.first) + .toString(); + response = await dioNoOfflineCheck.get(location); + } + _captureOpenId(response.data); + loginState = IDSLoginState.success; + } on PasswordWrongException { + loginState = IDSLoginState.passwordWrong; + rethrow; + } catch (_) { + loginState = IDSLoginState.fail; + rethrow; + } + } + + Future _withOpenIdRetry(Future Function() action) async { + await _ensureOpenId(); + try { + return await action(); + } catch (e, s) { + log.warning( + "[SchoolCardSession][_withOpenIdRetry] " + "Request failed, retry with refreshed openid.", + e, + s, + ); + await _ensureOpenId(forceRefresh: true); + return await action(); + } + } + + Future _fetchOverview() async { + final responseData = await dio + .get( + "https://v8scan.xidian.edu.cn/myaccount/openMyAccount?openid=$openid", + ) + .then((value) => value.data); + return parse(responseData) + .getElementsByTagName("li") + .firstOrNull + ?.children + .elementAtOrNull(1) + ?.children + .elementAtOrNull(1) + ?.innerHtml ?? + _failedOverviewKey; + } + + Future getOverview() async { + log.info( + "[SchoolCardSession][getOverview] " + "Try to fetch school card overview.", + ); + String money = await _withOpenIdRetry(_fetchOverview); + if (money == _failedOverviewKey) { + await _ensureOpenId(forceRefresh: true); + money = await _fetchOverview(); + } + if (money == _failedOverviewKey) { + throw const SchoolCardQueryFailedException( + "School card balance not found.", + ); + } + return money; + } + + Future getQRCode() async { + log.info( + "[SchoolCardSession][initSession] " + "Try to get QR Code", + ); + return _withOpenIdRetry(() async { + final homeUrl = + "https://v8scan.xidian.edu.cn/home/openHomePage?openid=$openid"; + final homeResp = await dio.get(homeUrl); + final homeDoc = parse(homeResp.data); + + final aTags = homeDoc.getElementsByTagName('a'); + String? id; + for (var a in aTags) { + final href = a.attributes['href'] ?? ''; + if (href.contains('/virtualcard/openVirtualcard') && + href.contains('id=')) { + final uri = Uri.parse(href.replaceAll('&', '&')); + id = uri.queryParameters['id']; + if (id != null && id.isNotEmpty) break; + } + } + if (id == null) { + throw Exception("aTag id not found."); + } + + final qrUrl = + "https://v8scan.xidian.edu.cn" + "/virtualcard/openVirtualcard?" + "openid=$openid&" + "displayflag=1&" + "id=$id"; + final qrResp = await dio.get(qrUrl); + final qrDoc = parse(qrResp.data); + final img = qrDoc.getElementById("qrcode"); + if (img == null) { + throw Exception("QR image not found."); + } + var src = img.attributes["src"] ?? ""; + // 提取 base64 数据 + var base64Data = src + .replaceAll("data:image/png;base64,", "") + .replaceAll("\n", ""); + if (base64Data.isEmpty) { + throw Exception("QR data is empty."); + } + return base64Decode(base64Data); + }); + } + + // 获取支付记录 + Future> getPaidStatus(String begin, String end) async { + return _withOpenIdRetry(() async { + List toReturn = []; + var response = await dio + .post( + "https://v8scan.xidian.edu.cn/selftrade/queryCardSelfTradeList?openid=$openid", + options: Options(contentType: "application/json; charset=utf-8"), + data: { + "beginDate": begin, + "endDate": end, + "tradeType": "-1", + "openid": openid, + }, + ) + .then((value) => jsonDecode(value.data)); + for (var i in response["resultData"]) { + toReturn.add( + PaidRecord(place: i["mername"], date: i["txdate"], money: i["txamt"]), + ); + } + return toReturn; + }); + } +} + +class SchoolCardQueryFailedException implements Exception { + final String message; + const SchoolCardQueryFailedException(this.message); + + @override + String toString() => message; +} diff --git a/lib/repository/xidian_ids/sysj_session.dart b/lib/repository/xidian_ids/sysj_session.dart new file mode 100644 index 00000000..65d1a597 --- /dev/null +++ b/lib/repository/xidian_ids/sysj_session.dart @@ -0,0 +1,390 @@ +// Copyright 2023-2025 BenderBlog Rodriguez and contributors +// Copyright 2025 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +import 'dart:convert'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:html/dom.dart'; +import 'package:html/parser.dart'; +import 'package:watermeter/model/fetch_result.dart'; +import 'package:watermeter/model/not_school_network_exception.dart'; +import 'package:watermeter/model/xidian_ids/experiment.dart'; +import 'package:watermeter/model/time_list.dart'; +import 'package:watermeter/wearos/slider_captcha.dart'; +import 'package:watermeter/repository/logger.dart'; +import 'package:watermeter/repository/network_session.dart'; +import 'package:watermeter/repository/preference.dart' as prefs; +import 'package:watermeter/repository/xidian_ids/ids_session.dart'; + +String _cacheHintFromError(Object error) { + if (error is LoginFailedException) { + return "experiment.other_cache_hint_login_failed"; + } + if (error is NotSchoolNetworkException) { + return "experiment.other_cache_hint_not_school_network"; + } + if (error is DioException) { + return "experiment.other_cache_hint_network_failed"; + } + return "experiment.other_cache_hint_unknown_error"; +} + +Future>> getOtherExperimentData() async { + try { + List data = await SysjSession().getDataFromSysj(); + DateTime fetchTime = DateTime.now(); + await SysjSession.writeCache(data); + return FetchResult.fresh(fetchTime: fetchTime, data: data); + } on PasswordWrongException { + log.error( + "[SysjSession][getExperimentData] " + "Password changed, remove cache", + ); + await SysjSession.deleteCache(); + rethrow; + } catch (e, s) { + log.handle(e, s, "[SysjSession][getOtherExperimentData] Have issue"); + var cache = SysjSession.getCache(); + if (cache != null) { + return FetchResult.cache( + fetchTime: cache.$1, + data: cache.$2, + hintKey: _cacheHintFromError(e), + ); + } + rethrow; + } +} + +class SysjSession extends IDSSession { + static const otherExperimentCacheName = "OtherExperiment.json"; + static File otherExperimentCacheFile = File( + "${supportPath.path}/$otherExperimentCacheName", + ); + static bool get isCacheExist => otherExperimentCacheFile.existsSync(); + + static Future deleteCache() async { + if (await otherExperimentCacheFile.exists()) { + await otherExperimentCacheFile.delete(); + } + } + + static Future writeCache(List data) async { + log.info( + "[SysjSession][writeCache] " + "Store to cache.", + ); + otherExperimentCacheFile.writeAsStringSync(jsonEncode(data)); + } + + static (DateTime, List)? getCache() { + if (!isCacheExist) return null; + try { + List toDecode = jsonDecode( + otherExperimentCacheFile.readAsStringSync(), + ); + List otherData = List.generate( + toDecode.length, + (index) => ExperimentData.fromJson(toDecode[index]), + ); + + DateTime lastUpdateTime = otherExperimentCacheFile.lastModifiedSync(); + return (lastUpdateTime, otherData); + } catch (e, s) { + log.handle(e, s); + log.warning( + "[SysjSession][getCache] " + "Failed to parse other experiment cache, will refresh.", + ); + return null; + } + } + + /// These are from sysj.xidian.edu.cn's js file + Future> getDataFromSysj() async { + if (!(await NetworkSession.isInSchool())) { + throw NotSchoolNetworkException(); + } + + Response firstRequest = await dio.get( + "https://sysj.xidian.edu.cn/xidian/test", + ); + + if (firstRequest.isRedirect) { + String redirectUrl = firstRequest.headers[HttpHeaders.locationHeader]![0]; + firstRequest = await dio.get(redirectUrl); + + redirectUrl = firstRequest.headers[HttpHeaders.locationHeader]![0]; + + Uri toParseParameter = Uri.parse(redirectUrl); + String state = toParseParameter.queryParameters["state"]!; + + firstRequest = await dio.get(redirectUrl); + + firstRequest = await dio.getUri( + Uri.https("sysj.xidian.edu.cn", "/uaa/xidian/login", { + "redirect_uri": "https://sysj.xidian.edu.cn/xidian/webapp/callback", + "state": state, + "client_id": "GvsunLims", + "response_type": "code", + "authorize_uri": "https://sysj.xidian.edu.cn/uaa/oauth/authorize", + }), + ); + + // String clientId = RegExp( + // "let\\sclient_id\\s=\\s\"(?\\d+)\";", + // ).firstMatch(firstRequest.data!.toString())!.namedGroup("clientId")!; + + Uri hrefIds = + Uri.https("ids.xidian.edu.cn", "authserver/oauth2.0/authorize", { + "redirect_uri": "https://sysj.xidian.edu.cn/uaa/xidian/callback", + "response_type": "code", + "state": state, + "client_id": "1387116615722893312", + }); + + firstRequest = await dio.getUri(hrefIds); + + hrefIds = Uri.parse(firstRequest.headers[HttpHeaders.locationHeader]![0]); + + log.info(hrefIds); + + String? location; + + if (!hrefIds.authority.contains("sysj")) { + log.info( + "[SysjSession][getDataFromSysj] Jump not have sysj, treat as new login.", + ); + location = await checkAndLogin( + target: hrefIds.queryParameters["service"]!, + sliderCaptcha: (String cookieStr) => + SliderCaptchaClientProvider(cookie: cookieStr).solve(null), + ); + } else { + location = hrefIds.toString(); + } + + while (location != null) { + var response = await dio.get(location); + log.info( + "[SysjSession][getDataFromSysj] Received location: $location.", + ); + location = response.headers[HttpHeaders.locationHeader]?[0]; + } + + location = await dio + .getUri( + Uri.https("sysj.xidian.edu.cn", "/uaa/oauth/authorize", { + "redirect_uri": + "https://sysj.xidian.edu.cn/xidian/webapp/callback", + "state": state, + "client_id": "GvsunLims", + "response_type": "code", + }), + ) + .then((value) => value.data.toString()); + final match = RegExp(r'\?code=(?.*)\\u0026').firstMatch(location!); + String code = match!.namedGroup("code")!; + + String loginLastTime = await dio + .getUri( + Uri.https("sysj.xidian.edu.cn", "/xidian/webapp/callback", { + "code": code, + "state": state, + }), + ) + .then((value) => value.data.toString()); + final matchPwd = RegExp( + r"var password = \'(?.*)\';", + ).firstMatch(loginLastTime); + String pwd = matchPwd!.namedGroup("pwd")!; + Response data = await dio.post( + "https://sysj.xidian.edu.cn/xidian/webapp/login", + data: { + "username": prefs.getString(prefs.Preference.idsAccount), + "password": pwd, + }, + ); + if (data.statusCode == 302) { + log.info( + "[SysjSession][getDataFromSysj] Login post returns a redirect " + "${data.headers[HttpHeaders.locationHeader]![0]}.", + ); + + data = await dio.get(data.headers[HttpHeaders.locationHeader]![0]); + } + } + + List experimentData = []; + + const experimentNameMark = '???student.timetable.course???:'; + const experimentClassroomMark = '???schedule.course.lab???:'; + const experimentTeacherMark = '???student.timetable.teacher???:'; + + for (int i = 1; i <= 25; ++i) { + Document classTableHtml = await dio + .post( + "https://sysj.xidian.edu.cn/xidian/StudentCurrWeekTimetable", + data: "weeks=$i", + ) + .then((value) { + return parse(value.data.toString().trim()); + }); + + List tables = classTableHtml.getElementsByTagName("table"); + if (tables.length < 2) { + log.info("[SysjSession][getDataFromSysj] No tables at week $i"); + continue; + } + + Element table = tables[1]; + + /// Fetch the weekdays + List weekdays = []; + table.querySelectorAll('thead th').forEach((e) { + if (e.innerHtml.isEmpty) return; + String dateStr = e.innerHtml.split('
')[1].trim(); + weekdays.add(dateStr); + }); + + for (int weekDay = 1; weekDay <= 7; ++weekDay) { + for (int classIndex = 1; classIndex <= 13; ++classIndex) { + String cellContent = + table + .querySelector("td[do-labReservation='$weekDay,$classIndex']") + ?.innerHtml + .trim() ?? + ""; + + if (cellContent.isEmpty || + !cellContent.contains(experimentNameMark) || + !cellContent.contains(experimentClassroomMark) || + !cellContent.contains(experimentTeacherMark)) { + continue; + } + + log.info( + "[SysjSession][getDataFromSysj] cellContent of week $weekDay class $classIndex is $cellContent", + ); + + List contentList = cellContent.split('\n') + ..removeWhere((e) => e.isEmpty) + ..map((e) => e.trim()); + String name = contentList[0] + .replaceAll(experimentNameMark, "") + .trim() + .replaceAll("
", ""); + String classroom = contentList[1] + .replaceAll(experimentClassroomMark, "") + .trim() + .replaceAll("
", ""); + String teacher = contentList[2] + .replaceAll(experimentTeacherMark, "") + .trim() + .replaceAll("
", ""); + + List dateNums = weekdays[weekDay - 1] + .split('-') + .map((e) => int.parse(e)) + .toList(); + List startTimeList = timeList[(classIndex - 1) * 2] + .split(":") + .map((e) => int.parse(e)) + .toList(); + List endTimeList = timeList[(classIndex - 1) * 2 + 1] + .split(":") + .map((e) => int.parse(e)) + .toList(); + DateTime startTime = DateTime( + dateNums[0], + dateNums[1], + dateNums[2], + startTimeList[0], + startTimeList[1], + ); + DateTime endTime = DateTime( + dateNums[0], + dateNums[1], + dateNums[2], + endTimeList[0], + endTimeList[1], + ); + + while (classIndex < 13) { + String nextCellContent = + table + .querySelector( + "td[do-labReservation='$weekDay,${classIndex + 1}']", + ) + ?.innerHtml + .trim() ?? + ""; + log.info( + "[SysjSession][getDataFromSysj] fetching next class, " + "nextCellContent of week $weekDay class $classIndex is $cellContent", + ); + + if (cellContent != nextCellContent) { + log.info( + "[SysjSession][getDataFromSysj] fetching next class, " + "not match the last one, break looping", + ); + break; + } + + // Actually +1 for next day, then -1 to match the index of the array + List newEndTimeList = timeList[classIndex * 2 + 1] + .split(":") + .map((e) => int.parse(e)) + .toList(); + endTime = DateTime( + dateNums[0], + dateNums[1], + dateNums[2], + newEndTimeList[0], + newEndTimeList[1], + ); + log.info( + "[SysjSession][getDataFromSysj] fetching next class, " + "new endTime $endTime", + ); + + classIndex++; + } + + // If the list have no data related to this name, lab or teacher, just add it. + int dataWithSameInfoIndex = experimentData.indexWhere( + (e) => + e.name == name && + e.classroom == classroom && + e.teacher == teacher, + ); + if (experimentData.isEmpty || dataWithSameInfoIndex == -1) { + final newData = ExperimentData( + type: ExperimentType.others, + name: name, + classroom: classroom, + timeRanges: [(startTime, endTime)], + teacher: teacher, + ); + log.info("[SysjSession][getDataFromSysj] Added: $newData"); + experimentData.add(newData); + continue; + } + + experimentData[dataWithSameInfoIndex].timeRanges.add(( + startTime, + endTime, + )); + log.info( + "[SysjSession][getDataFromSysj] Updated: ${experimentData[dataWithSameInfoIndex]}", + ); + } + } + } + + return experimentData; + } +} diff --git a/lib/wearos/slider_captcha.dart b/lib/wearos/slider_captcha.dart new file mode 100644 index 00000000..b8e6b57d --- /dev/null +++ b/lib/wearos/slider_captcha.dart @@ -0,0 +1,864 @@ +// Copyright 2023-2025 BenderBlog Rodriguez and contributors +// Copyright 2025 Traintime PDA authors. +// SPDX-License-Identifier: MIT + +// https://juejin.cn/post/7284608063914622995 + +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:encrypter_plus/encrypter_plus.dart' as encrypt; +import 'package:flutter/material.dart'; +import 'package:image/image.dart' as img; +import 'package:watermeter/repository/logger.dart'; + +class Lazy { + final T Function() _initializer; + + Lazy(this._initializer); + + T? _value; + + T get value => _value ??= _initializer(); +} + +/// 轨迹点模型 +class TrackPoint { + final int a; // x 轴位移 + final int b; // y 轴位移 + final int c; // 时间戳 (毫秒) + + TrackPoint(this.a, this.b, this.c); + + Map toJson() => {'a': a, 'b': b, 'c': c}; +} + +class SliderCaptchaClientProvider { + static const int _blockSize = 16; + static const int _captchaKeySize = 16; + static const int _keySize = 16; + static const String _aesChars = + "ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678"; + static const String _captchaPayloadPrefix = + '................................................................'; + static final Random _random = Random.secure(); + + final String cookie; + Dio dio = Dio()..interceptors.add(logDioAdapter); + + /// 生成指定长度的随机字符串 + static String randomString(int n) { + final random = Random(); + return List.generate( + n, + (index) => _aesChars[random.nextInt(_aesChars.length)], + ).join(); + } + + /// 加密逻辑 + static String encryptData(String plainText, Uint8List keyBytes) { + final ivStr = randomString(_blockSize); + final nonce = randomString(_blockSize * 4); + final plain = nonce + plainText; + + final key = encrypt.Key(keyBytes); + final iv = encrypt.IV.fromUtf8(ivStr); + + final encrypter = encrypt.Encrypter( + encrypt.AES(key, mode: encrypt.AESMode.cbc), + ); + + // encrypt.AES 默认使用 PKCS7 填充,等同于 Python 的 pad(..., 16) + final encrypted = encrypter.encrypt(plain, iv: iv); + + return encrypted.base64; + } + + /// 解密逻辑 + static String decryptData(String cipherText, Uint8List keyBytes) { + final Uint8List fullCipher = base64.decode(cipherText); + + if (fullCipher.length < _blockSize * 4) { + throw Exception("Cipher text is too short to contain nonce."); + } + + // 根据 Python 逻辑:IV 是密文的第 48-64 字节 (Block 4) + // 实际密文从第 64 字节开始 + final ivBytes = fullCipher.sublist(_blockSize * 3, _blockSize * 4); + final encryptedPayload = fullCipher.sublist(_blockSize * 4); + + final key = encrypt.Key(keyBytes); + final iv = encrypt.IV(ivBytes); + + final encrypter = encrypt.Encrypter( + encrypt.AES(key, mode: encrypt.AESMode.cbc), + ); + + // 解密并自动去除 PKCS7 填充 + final decrypted = encrypter.decrypt( + encrypt.Encrypted(encryptedPayload), + iv: iv, + ); + + return decrypted; + } + + /// 从图片字节数组末尾提取 AES Key + static Uint8List extractAesKeyFromImage(Uint8List imageBytes) { + if (imageBytes.length < _keySize) { + throw Exception("Image is too short to contain AES key."); + } + return imageBytes.sublist(imageBytes.length - _keySize); + } + + /// 优化后的轨迹生成函数 + List generateTracks(int targetX) { + List tracks = []; + Random random = Random(); + + int currentX = 0; + int currentY = 0; + + // 1. 起始点 [cite: 89, 90] + tracks.add(TrackPoint(0, 0, 0)); + + // 调整后的参数:更大的步长,更紧凑的时间 + // 参考你提供的样本:位移 32 像素仅用了 9 个点 + while (currentX < targetX) { + int remaining = targetX - currentX; + + // 增大步长随机区间 (5-9 像素),这样点数会明显减少 + int stepX = remaining > 20 + ? random.nextInt(5) + 5 + : random.nextInt(3) + 1; + + currentX += stepX; + if (currentX > targetX) currentX = targetX; + + // 减小垂直抖动频率,使其看起来更平滑 [cite: 120] + if (random.nextDouble() > 0.7) { + currentY += random.nextBool() ? 1 : -1; + } + + // 将时间间隔 c 锁定在 20-25ms 之间,匹配你提供的样本 + int stepTime = 20 + random.nextInt(6); + + tracks.add(TrackPoint(currentX, currentY, stepTime)); + + if (currentX == targetX) break; + } + + // 2. 结束点:最后的停留点 [cite: 106, 107] + tracks.add(TrackPoint(targetX, currentY, 20 + random.nextInt(10))); + + return tracks; + } + + static int solveSlideOffsetForTesting({ + required Uint8List puzzleBytes, + required Uint8List pieceBytes, + int border = 24, + }) { + final puzzle = img.decodeImage(puzzleBytes); + final piece = img.decodeImage(pieceBytes); + if (puzzle == null || piece == null) { + throw CaptchaSolveFailedException(); + } + return _solveSlideOffset(puzzle, piece, border); + } + + static String encryptCaptchaPayloadForTesting( + String payload, + Uint8List keyBytes, + ) => _encryptCaptchaPayload(payload, keyBytes); + + static int _solveSlideOffset(img.Image puzzle, img.Image piece, int border) { + final bbox = _nrgbaBbox(piece); + var xL = bbox.$1 + border; + var yT = bbox.$2 + border; + var xR = bbox.$3 - border; + var yB = bbox.$4 - border; + if (xL < 0 || yT < 0 || xR < xL || yB < yT) { + throw CaptchaSolveFailedException(); + } + + final windowWidth = xR - xL + 1; + final windowHeight = yB - yT + 1; + final 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(); + } + + final templateGray = _grayFromImage( + piece, + xL, + yT, + windowWidth, + windowHeight, + ); + final templateMean = + _graySum(templateGray, 0, 0, windowWidth, windowHeight) / + (windowWidth * windowHeight); + final template = _grayNorm( + templateGray, + 0, + 0, + windowWidth, + windowHeight, + templateMean, + ); + final puzzleGray = _grayFromImage(puzzle, xL, yT, bigWidth, windowHeight); + final columnSums = List.generate( + bigWidth, + (x) => _graySum(puzzleGray, x, 0, 1, windowHeight), + growable: false, + ); + + var windowSum = 0.0; + for (var x = 0; x < windowWidth; x++) { + windowSum += columnSums[x]; + } + final area = windowWidth * windowHeight; + var maxScore = _grayNccFast( + puzzleGray, + 0, + 0, + windowWidth, + windowHeight, + windowSum / area, + template, + ); + var bestX = 0; + for (var x = 1; x < bigWidth - windowWidth; x++) { + windowSum += columnSums[x + windowWidth - 1] - columnSums[x - 1]; + final score = _grayNccFast( + puzzleGray, + x, + 0, + windowWidth, + windowHeight, + windowSum / area, + template, + ); + if (score > maxScore) { + maxScore = score; + bestX = x; + } + } + return bestX; + } + + static (int, int, int, int) _nrgbaBbox(img.Image image) { + var xL = image.width; + var yT = image.height; + var xR = 0; + var yB = 0; + var found = false; + for (var y = 0; y < image.height; y++) { + for (var x = 0; x < image.width; x++) { + if (image.getPixel(x, y).a.toInt() == 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 (xL, yT, xR, yB); + } + + static ({List pixels, int stride}) _grayFromImage( + img.Image image, + int xL, + int yT, + int width, + int height, + ) { + final pixels = List.filled(width * height, 0, growable: false); + var index = 0; + for (var y = yT; y < yT + height; y++) { + for (var x = xL; x < xL + width; x++) { + final pixel = image.getPixel(x, y); + pixels[index++] = + (77 * pixel.r.toInt() + + 150 * pixel.g.toInt() + + 29 * pixel.b.toInt()) >> + 8; + } + } + return (pixels: pixels, stride: width); + } + + static double _graySum( + ({List pixels, int stride}) gray, + int xL, + int yT, + int width, + int height, + ) { + var sum = 0.0; + for (var y = yT; y < yT + height; y++) { + final rowOffset = y * gray.stride; + for (var x = xL; x < xL + width; x++) { + sum += gray.pixels[rowOffset + x]; + } + } + return sum; + } + + static List _grayNorm( + ({List pixels, int stride}) gray, + int xL, + int yT, + int width, + int height, + double mean, + ) { + final normalized = List.filled(width * height, 0, growable: false); + var index = 0; + for (var y = yT; y < yT + height; y++) { + final rowOffset = y * gray.stride; + for (var x = xL; x < xL + width; x++) { + normalized[index++] = gray.pixels[rowOffset + x] - mean; + } + } + return normalized; + } + + static double _grayNccFast( + ({List pixels, int stride}) windowImage, + int xL, + int yT, + int width, + int height, + double mean, + List template, + ) { + var sumWindowTemplate = 0.0; + var sumWindowWindow = 0.0; + var index = 0; + for (var y = yT; y < yT + height; y++) { + final rowOffset = y * windowImage.stride; + for (var x = xL; x < xL + width; x++) { + final window = windowImage.pixels[rowOffset + x] - mean; + sumWindowWindow += window * window; + sumWindowTemplate += window * template[index++]; + } + } + if (sumWindowWindow == 0) return double.negativeInfinity; + return sumWindowTemplate / sumWindowWindow; + } + + static List _generateAutoTracks(int targetX) { + if (targetX <= 0) { + return [TrackPoint(0, 0, 0), TrackPoint(0, 0, 0)]; + } + const norm = 1.0 / (1.0 + 0.017248380016648118); + final tracks = [TrackPoint(0, 0, 0)]; + final pointCount = _random.nextInt(5) + 10; + var y = 0; + for (var i = 0; i < pointCount; i++) { + final z = (1.0 / (1.0 + exp(-7.0 * (i / pointCount - 0.42)))) / norm; + final previousX = tracks.last.a; + final x = min(targetX - 1, max(previousX + 1, (targetX * z).round())); + final drift = _random.nextDouble(); + if (drift < 0.65) { + y--; + } else if (drift < 0.80) { + y++; + } + y = max(-10, min(10, y)); + tracks.add(TrackPoint(x, y, _random.nextInt(701) + 900)); + } + tracks.add(TrackPoint(targetX, y, _random.nextInt(701) + 900)); + return tracks; + } + + static String _encryptCaptchaPayload(String payload, Uint8List keyBytes) { + final key = encrypt.Key(Uint8List.fromList(keyBytes)); + final iv = encrypt.IV.fromUtf8('................'); + final aes = encrypt.Encrypter(encrypt.AES(key, mode: encrypt.AESMode.cbc)); + return aes.encrypt('$_captchaPayloadPrefix$payload', iv: iv).base64; + } + + Future _solveAutomatically() async { + await updatePuzzle(); + final puzzle = img.decodeImage(puzzleData!); + final piece = img.decodeImage(pieceData!); + if (puzzle == null || piece == null) return false; + final solvedOffset = _solveSlideOffset(puzzle, piece, 24); + final baseMove = solvedOffset * puzzleWidth.toInt() ~/ puzzle.width; + for (final delta in const [1, -1, 2, -2, 3, -3, 4]) { + final move = baseMove + delta; + if (move < 0 || move > puzzleWidth) continue; + final tracks = _generateAutoTracks(move); + await Future.delayed( + Duration(milliseconds: max(0, tracks.last.c - 100)), + ); + if (await verifyWithTracks(tracks)) return true; + } + return false; + } + + SliderCaptchaClientProvider({required this.cookie}); + + Uint8List? puzzleData; + Uint8List? pieceData; + Lazy? puzzleImage; + Lazy? pieceImage; + Uint8List? extractedKey; + + final double puzzleWidth = 280; + final double puzzleHeight = 155; + final double pieceWidth = 44; + final double pieceHeight = 155; + + Future updatePuzzle() async { + log.info("Fetching slider captcha..."); + var rsp = await dio.get( + "https://ids.xidian.edu.cn/authserver/common/openSliderCaptcha.htl", + queryParameters: {'_': DateTime.now().millisecondsSinceEpoch.toString()}, + options: Options(headers: {"Cookie": cookie}), + ); + log.info("Captcha fetched, decoding images."); + + String puzzleBase64 = rsp.data["bigImage"]; + String pieceBase64 = rsp.data["smallImage"]; + // double coordinatesY = double.parse(rsp.data["tagWidth"].toString()); + + puzzleData = const Base64Decoder().convert(puzzleBase64); + pieceData = const Base64Decoder().convert(pieceBase64); + + extractedKey = extractAesKeyFromImage(pieceData!); + + puzzleImage = Lazy( + () => Image.memory( + puzzleData!, + width: puzzleWidth, + height: puzzleHeight, + fit: BoxFit.fitWidth, + ), + ); + pieceImage = Lazy( + () => Image.memory( + pieceData!, + width: pieceWidth, + height: pieceHeight, + fit: BoxFit.fitWidth, + ), + ); + } + + Future solveAutomatically() async { + log.info('Trying automatic slider captcha solve.'); + for (var attempt = 0; attempt < 5; attempt++) { + try { + if (await _solveAutomatically()) return; + } catch (error, stackTrace) { + log.warning( + 'Automatic slider captcha solve failed.', + error, + stackTrace, + ); + if (attempt < 4) { + await Future.delayed(Duration(seconds: attempt + 1)); + } + } + } + throw CaptchaSolveFailedException(); + } + + Future solve(BuildContext? context) async { + try { + await solveAutomatically(); + return; + } on CaptchaSolveFailedException { + // Fall through to the manual slider when a UI context is available. + } + + log.info('Automatic slider captcha solve failed, entering manual slider.'); + if (context != null && context.mounted) { + final verified = await Navigator.of(context).push( + MaterialPageRoute(builder: (context) => CaptchaWidget(provider: this)), + ); + if (verified == true) return; + } + throw CaptchaSolveFailedException(); + } + + Future verifyWithTracks(List tracks) async { + final moveLength = tracks.isNotEmpty ? tracks.last.a : 0; + final payload = jsonEncode({ + "canvasLength": puzzleWidth.toInt(), + "moveLength": moveLength, + "tracks": tracks, + }); + log.info( + "Verify captcha with ${tracks.length} track points " + "(moveLength=$moveLength).", + ); + final sign = _encryptPayload(payload); + + dynamic result = await dio.post( + "https://ids.xidian.edu.cn/authserver/common/verifySliderCaptcha.htl", + data: "sign=${Uri.encodeQueryComponent(sign)}", + options: Options( + headers: { + HttpHeaders.acceptHeader: + "application/json, text/javascript, */*; q=0.01", + "Cookie": cookie, + HttpHeaders.contentTypeHeader: + "application/x-www-form-urlencoded;charset=UTF-8", + "Origin": "https://ids.xidian.edu.cn", + HttpHeaders.accessControlAllowOriginHeader: + "https://ids.xidian.edu.cn", + "X-Requested-With": "XMLHttpRequest", + }, + ), + ); + log.info("Verify response: ${result.data}"); + return result.data["errorMsg"] == "success" || + result.data["errorCode"] == 1; + } + + String _encryptPayload(String payload) { + if (pieceData == null || pieceData!.length < _captchaKeySize) { + throw StateError("Captcha image is too short to contain AES key."); + } + + return _encryptCaptchaPayload( + payload, + pieceData!.sublist(pieceData!.length - _captchaKeySize), + ); + } +} + +class CaptchaWidget extends StatefulWidget { + final SliderCaptchaClientProvider provider; + + const CaptchaWidget({super.key, required this.provider}); + + @override + State createState() => _CaptchaWidgetState(); +} + +class _CaptchaWidgetState extends State { + static const double _sliderHandleSize = 42; + static const double _jsSliderRightPadding = 40; + static const int _recordIntervalMs = 20; + static const double _recordDistancePx = 2; + + late Future _providerFuture; + + final List _tracks = []; + DateTime? _lastRecordTime; + Offset? _dragStartGlobal; + int? _activePointer; + int? _lastTrackA; + int? _lastTrackB; + + double _sliderLeftPx = 0; + bool _isSubmitting = false; + String? _statusText; + + @override + void initState() { + super.initState(); + updateProvider(); + } + + void updateProvider({String? statusText}) { + _sliderLeftPx = 0; + _tracks.clear(); + _lastRecordTime = null; + _dragStartGlobal = null; + _activePointer = null; + _lastTrackA = null; + _lastTrackB = null; + _isSubmitting = false; + _statusText = statusText; + _providerFuture = widget.provider.updatePuzzle().then((value) { + return widget.provider; + }); + } + + double _dragLimit(double puzzleWidth) { + return max(0, puzzleWidth - _jsSliderRightPadding).toDouble(); + } + + double _thumbLeft(double puzzleWidth) { + return (_sliderLeftPx - 1) + .clamp(0.0, max(0, puzzleWidth - _sliderHandleSize)) + .toDouble(); + } + + bool _isInsideThumb(Offset localPosition, double puzzleWidth) { + final left = _thumbLeft(puzzleWidth); + return localPosition.dx >= left && + localPosition.dx <= left + _sliderHandleSize && + localPosition.dy >= 0 && + localPosition.dy <= _sliderHandleSize; + } + + void _onPointerDown(PointerDownEvent event, double puzzleWidth) { + if (_isSubmitting || _activePointer != null) return; + if (!_isInsideThumb(event.localPosition, puzzleWidth)) return; + + _activePointer = event.pointer; + _dragStartGlobal = event.position; + _lastRecordTime = DateTime.now(); + _lastTrackA = null; + _lastTrackB = null; + _tracks.clear(); + _tracks.add(TrackPoint(0, 0, 0)); + if (_statusText != null) { + setState(() => _statusText = null); + } + } + + void _onPointerMove(PointerMoveEvent event, double puzzleWidth) { + if (event.pointer != _activePointer) return; + final start = _dragStartGlobal; + final lastTime = _lastRecordTime; + if (start == null || lastTime == null) return; + + final dx = event.position.dx - start.dx; + if (dx < 0 || dx + _jsSliderRightPadding > puzzleWidth) return; + + final now = DateTime.now(); + final dy = event.position.dy - start.dy; + final elapsed = now.difference(lastTime).inMilliseconds; + + setState(() => _sliderLeftPx = dx.clamp(0.0, _dragLimit(puzzleWidth))); + + if (elapsed < _recordIntervalMs) return; + + final a = dx.round(); + final b = dy.round(); + final lastA = _lastTrackA; + final lastB = _lastTrackB; + if (lastA != null && lastB != null) { + final distanceSquared = + (a - lastA) * (a - lastA) + (b - lastB) * (b - lastB); + if (distanceSquared < _recordDistancePx * _recordDistancePx) return; + } + + _tracks.add(TrackPoint(a, b, elapsed)); + _lastTrackA = a; + _lastTrackB = b; + _lastRecordTime = now; + } + + Future _onPointerUp(PointerUpEvent event, double puzzleWidth) async { + if (event.pointer != _activePointer) return; + await _finishDrag(event.position, puzzleWidth); + } + + void _onPointerCancel(PointerCancelEvent event) { + if (event.pointer != _activePointer) return; + _activePointer = null; + _dragStartGlobal = null; + _lastRecordTime = null; + _lastTrackA = null; + _lastTrackB = null; + } + + Future _finishDrag(Offset globalPosition, double puzzleWidth) async { + final start = _dragStartGlobal; + final lastTime = _lastRecordTime; + _activePointer = null; + _dragStartGlobal = null; + + if (start == null || lastTime == null) return; + + final dx = globalPosition.dx - start.dx; + if (dx == 0) return; + + final dy = globalPosition.dy - start.dy; + final elapsed = DateTime.now().difference(lastTime).inMilliseconds; + _tracks.add(TrackPoint(dx.round(), dy.round(), elapsed)); + log.info("Recorded ${_tracks.length} real slider track points."); + + setState(() { + _sliderLeftPx = dx.clamp(0.0, _dragLimit(puzzleWidth)); + _isSubmitting = true; + }); + + try { + final verified = await widget.provider.verifyWithTracks(_tracks); + if (!mounted) return; + if (verified) { + Navigator.of(context).pop(true); + return; + } + + setState(() { + updateProvider(statusText: "再试一次"); + }); + } catch (e, s) { + log.warning("Slider captcha verify failed: $e\n$s"); + if (!mounted) return; + setState(() { + updateProvider(statusText: "再试一次"); + }); + } + } + + Widget _buildSlider(double puzzleWidth) { + return Listener( + behavior: HitTestBehavior.opaque, + onPointerDown: (event) => _onPointerDown(event, puzzleWidth), + onPointerMove: (event) => _onPointerMove(event, puzzleWidth), + onPointerUp: (event) => _onPointerUp(event, puzzleWidth), + onPointerCancel: _onPointerCancel, + child: SizedBox( + width: puzzleWidth, + height: 44, + child: Stack( + children: [ + Positioned( + top: 17, + left: 0, + right: 0, + child: Container( + height: 10, + decoration: BoxDecoration( + color: Colors.green[900], + borderRadius: BorderRadius.circular(5), + ), + ), + ), + Positioned( + top: 17, + left: 0, + width: (_sliderLeftPx + 4).clamp(0.0, puzzleWidth).toDouble(), + child: Container( + height: 10, + decoration: BoxDecoration( + color: Colors.green[700], + borderRadius: BorderRadius.circular(5), + ), + ), + ), + Positioned( + left: _thumbLeft(puzzleWidth), + top: 1, + child: Container( + width: _sliderHandleSize, + height: _sliderHandleSize, + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black26, + blurRadius: 4, + offset: Offset(0, 2), + ), + ], + ), + child: _isSubmitting + ? const Padding( + padding: EdgeInsets.all(11), + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Icon( + Icons.arrow_forward, + size: 20, + color: Colors.green[900], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildCaptcha(SliderCaptchaClientProvider provider) { + final pw = provider.puzzleWidth; + final ph = provider.puzzleHeight; + return LayoutBuilder( + builder: (context, _) { + return Center( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: pw, + height: ph, + child: Stack( + alignment: Alignment.center, + children: [ + provider.puzzleImage!.value, + Positioned( + left: _sliderLeftPx, + child: provider.pieceImage!.value, + ), + ], + ), + ), + _buildSlider(pw), + if (_statusText != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + _statusText!, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + ), + ], + ), + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('滑块验证')), + body: FutureBuilder( + future: _providerFuture, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Center( + child: IconButton( + onPressed: () { + setState(() { + updateProvider(statusText: "Try Again"); + }); + }, + icon: const Icon(Icons.refresh), + ), + ); + } + + if (!snapshot.hasData) { + return const Center(child: CircularProgressIndicator()); + } + + return _buildCaptcha(snapshot.data!); + }, + ), + ); + } +} + +class CaptchaSolveFailedException implements Exception {} diff --git a/lib/wearos/wear_app.dart b/lib/wearos/wear_app.dart new file mode 100644 index 00000000..6144dab3 --- /dev/null +++ b/lib/wearos/wear_app.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:watermeter/wearos/wear_home_page.dart'; +import 'package:watermeter/wearos/wear_sync_login_page.dart'; + +class WearApp extends StatelessWidget { + final bool isFirst; + + const WearApp({super.key, required this.isFirst}); + + @override + Widget build(BuildContext context) { + final colorScheme = ColorScheme.fromSeed( + seedColor: const Color(0xFF00A3FF), + brightness: Brightness.dark, + ); + + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'XDYou Wear', + theme: ThemeData( + useMaterial3: true, + colorScheme: colorScheme, + scaffoldBackgroundColor: Colors.black, + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(48), + textStyle: const TextStyle(fontWeight: FontWeight.w700), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + border: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(18)), + ), + isDense: true, + ), + cardTheme: const CardThemeData( + margin: EdgeInsets.symmetric(vertical: 4), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(18)), + ), + ), + ), + home: isFirst ? const WearSyncLoginPage() : const WearHomePage(), + ); + } +} diff --git a/lib/wearos/wear_companion_sync.dart b/lib/wearos/wear_companion_sync.dart new file mode 100644 index 00000000..a83e2f9b --- /dev/null +++ b/lib/wearos/wear_companion_sync.dart @@ -0,0 +1,353 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:watermeter/model/xidian_ids/classtable.dart'; +import 'package:watermeter/model/xidian_ids/experiment.dart'; +import 'package:watermeter/repository/preference.dart' as preference; +import 'package:watermeter/repository/network_session.dart' as network; +import 'package:watermeter/repository/xidian_ids/classtable_session.dart'; +import 'package:watermeter/repository/xidian_ids/sysj_session.dart'; +import 'package:watermeter/repository/xidian_ids/ids_session.dart'; +import 'package:watermeter/repository/xidian_ids/school_card_session.dart'; +import 'package:watermeter/wearos/wear_schedule_service.dart'; +import 'package:watermeter/wearos/wear_qr_page.dart'; + +const wearCompanionSyncEnvelopeExampleJson = ''' +{ + "schemaVersion": 1, + "sessionId": "", + "credentials": { + "idsAccount": "2200000000", + "idsPassword": "saved-password", + "isPostGraduate": false, + "currentSemester": "2026-1" + }, + "schedule": { + "classTable": { + "semesterLength": 16, + "semesterCode": "2026-1", + "termStartDay": "2026-05-18 00:00:00", + "classDetail": [{"name": "数据库系统"}], + "userDefinedDetail": [], + "notArranged": [], + "timeArrangement": [], + "classChanges": [] + } + } +} +'''; + +class WearCredentialSyncPayload { + final String idsAccount; + final String idsPassword; + final bool? isPostGraduate; + final String? currentSemester; + + const WearCredentialSyncPayload({ + required this.idsAccount, + required this.idsPassword, + this.isPostGraduate, + this.currentSemester, + }); +} + +class WearScheduleSyncPayload { + final ClassTableData? classTable; + final List? otherExperiments; + + const WearScheduleSyncPayload({this.classTable, this.otherExperiments}); +} + +class WearPaymentQrSyncPayload { + final Uint8List bytes; + final DateTime fetchedAt; + + const WearPaymentQrSyncPayload({ + required this.bytes, + required this.fetchedAt, + }); +} + +class WearCompanionSyncEnvelope { + final String sessionId; + final WearCredentialSyncPayload credentials; + final WearScheduleSyncPayload schedule; + final WearPaymentQrSyncPayload? paymentQr; + + const WearCompanionSyncEnvelope({ + required this.sessionId, + required this.credentials, + required this.schedule, + this.paymentQr, + }); + + factory WearCompanionSyncEnvelope.fromJson(Map json) { + final version = json['schemaVersion']; + if (version != 1) { + throw const FormatException('Unsupported Wear sync schema version.'); + } + final sessionId = json['sessionId']; + if (sessionId is! String || sessionId.isEmpty) { + throw const FormatException('Wear sync session is missing.'); + } + final credentials = _credentialPayloadFromJson(json['credentials']); + final schedule = _schedulePayloadFromJson(json['schedule']); + final paymentQr = _paymentQrPayloadFromJson(json['paymentQr']); + return WearCompanionSyncEnvelope( + sessionId: sessionId, + credentials: credentials, + schedule: schedule, + paymentQr: paymentQr, + ); + } + + static WearCompanionSyncEnvelope decode(Object? payload) { + if (payload is String) { + final decoded = jsonDecode(payload); + if (decoded is Map) { + return WearCompanionSyncEnvelope.fromJson(decoded); + } + throw const FormatException('Wear sync payload must be a JSON object.'); + } + if (payload is Map) { + return WearCompanionSyncEnvelope.fromJson(_stringKeyedMap(payload)); + } + throw const FormatException('Wear sync payload must be a string or map.'); + } + + Future importInto(WearCompanionSyncPort port) async { + await port.importCredentials(credentials); + await port.importSchedule(schedule); + final qr = paymentQr; + if (qr != null) await port.importPaymentQr(qr); + } +} + +class WearCompanionSyncBridge { + static const channelName = + 'io.github.benderblog.traintime_pda/wear_companion_sync'; + static const syncMessagePath = '/traintime_pda_wear_os/sync/v1'; + static const _channel = MethodChannel(channelName); + + final WearCompanionSyncPort _port; + final MethodChannel _methodChannel; + final _imports = StreamController.broadcast(); + + WearCompanionSyncBridge({ + WearCompanionSyncPort port = const WearLocalCompanionSyncPort(), + MethodChannel methodChannel = _channel, + }) : _port = port, + _methodChannel = methodChannel; + + Stream get imports => _imports.stream; + + Future beginDirectPairing() => + _methodChannel.invokeMethod('beginDirectPairing'); + + Future start() async { + _methodChannel.setMethodCallHandler(_handleNativeCall); + final pending = await _methodChannel.invokeMethod( + 'readPendingSyncPayload', + ); + if (pending != null) { + await _importNativePayload(pending); + } + } + + Future requestSync() => + _methodChannel.invokeMethod('requestCompanionSync'); + + Future stop() async { + _methodChannel.setMethodCallHandler(null); + } + + Future dispose() async { + await stop(); + await _imports.close(); + } + + Future _handleNativeCall(MethodCall call) async { + switch (call.method) { + case 'receiveSyncPayload': + try { + await _importNativePayload(call.arguments); + } catch (error, stackTrace) { + _imports.addError(error, stackTrace); + rethrow; + } + return; + default: + throw MissingPluginException('Unknown Wear sync method ${call.method}'); + } + } + + Future _importNativePayload(Object? payload) async { + final envelope = WearCompanionSyncEnvelope.decode(payload); + await envelope.importInto(_port); + _imports.add(envelope); + } +} + +WearCredentialSyncPayload _credentialPayloadFromJson(Object? value) { + if (value is! Map) { + throw const FormatException('Wear sync credentials are required.'); + } + final json = _stringKeyedMap(value); + final idsAccount = json['idsAccount']; + final idsPassword = json['idsPassword']; + if (idsAccount is! String || + idsAccount.isEmpty || + idsPassword is! String || + idsPassword.isEmpty) { + throw const FormatException('Wear sync credentials are invalid.'); + } + return WearCredentialSyncPayload( + idsAccount: idsAccount, + idsPassword: idsPassword, + isPostGraduate: json['isPostGraduate'] as bool?, + currentSemester: json['currentSemester'] as String?, + ); +} + +WearScheduleSyncPayload _schedulePayloadFromJson(Object? value) { + if (value is! Map) { + throw const FormatException('Wear sync schedule is required.'); + } + final json = _stringKeyedMap(value); + final classTableJson = json['classTable']; + if (classTableJson is! Map) { + throw const FormatException('Wear sync class table is required.'); + } + final experimentsJson = json['otherExperiments']; + if (experimentsJson != null && experimentsJson is! List) { + throw const FormatException('Wear sync experiments must be a list.'); + } + return WearScheduleSyncPayload( + classTable: ClassTableData.fromJson(_stringKeyedMap(classTableJson)), + otherExperiments: experimentsJson + ?.map((item) { + if (item is! Map) { + throw const FormatException('Wear sync experiment is invalid.'); + } + return ExperimentData.fromJson(_stringKeyedMap(item)); + }) + .toList(growable: false), + ); +} + +WearPaymentQrSyncPayload? _paymentQrPayloadFromJson(Object? value) { + if (value == null) return null; + if (value is! Map) { + throw const FormatException('Wear sync payment QR must be an object.'); + } + final json = _stringKeyedMap(value); + final encoded = json['pngBase64']; + final fetchedAt = json['fetchedAtEpochMs']; + if (encoded is! String || encoded.isEmpty || fetchedAt is! int) { + throw const FormatException('Wear sync payment QR is invalid.'); + } + try { + return WearPaymentQrSyncPayload( + bytes: base64Decode(encoded), + fetchedAt: DateTime.fromMillisecondsSinceEpoch(fetchedAt), + ); + } on FormatException { + throw const FormatException('Wear sync payment QR is invalid.'); + } +} + +Map _stringKeyedMap(Map value) => + value.map((key, value) => MapEntry(key as String, value)); + +abstract interface class WearCompanionSyncPort { + Future importCredentials(WearCredentialSyncPayload payload); + + Future importSchedule(WearScheduleSyncPayload payload); + + Future importPaymentQr(WearPaymentQrSyncPayload payload); +} + +class WearLocalCompanionSyncPort implements WearCompanionSyncPort { + const WearLocalCompanionSyncPort(); + + @override + Future importCredentials(WearCredentialSyncPayload payload) async { + final accountChanged = + preference.getString(preference.Preference.idsAccount) != + payload.idsAccount; + await _clearUserScopedState(clearPaymentQr: accountChanged); + await preference.setString( + preference.Preference.idsAccount, + payload.idsAccount, + ); + await preference.setString( + preference.Preference.idsPassword, + payload.idsPassword, + ); + final isPostGraduate = payload.isPostGraduate; + if (isPostGraduate != null) { + await preference.setBool(preference.Preference.role, isPostGraduate); + } + final currentSemester = payload.currentSemester; + if (currentSemester != null && currentSemester.isNotEmpty) { + await preference.setString( + preference.Preference.currentSemester, + currentSemester, + ); + await preference.setBool( + preference.Preference.isUserDefinedSemester, + false, + ); + } + } + + @override + Future importSchedule(WearScheduleSyncPayload payload) async { + final classTable = payload.classTable; + if (classTable != null) { + await ClassTableSession.updateCacheAndGroup(classTable); + if (classTable.semesterCode.isNotEmpty) { + await preference.setString( + preference.Preference.currentSemester, + classTable.semesterCode, + ); + await preference.setBool( + preference.Preference.isUserDefinedSemester, + false, + ); + } + } + + final otherExperiments = payload.otherExperiments; + if (otherExperiments != null) { + await SysjSession.writeCache(otherExperiments); + } + } + + @override + Future importPaymentQr(WearPaymentQrSyncPayload payload) => + storeCachedWearPaymentQr(payload.bytes, fetchedAt: payload.fetchedAt); +} + +Future _clearUserScopedState({required bool clearPaymentQr}) async { + await _deleteIdsCookieStore(); + SchoolCardSession.resetOpenId(); + await clearWearCampusCaches(); + if (clearPaymentQr) await clearCachedWearPaymentQr(); + loginState = IDSLoginState.none; + await preference.remove(preference.Preference.currentSemester); + await preference.remove(preference.Preference.role); + await preference.remove(preference.Preference.isUserDefinedSemester); +} + +Future _deleteIdsCookieStore() async { + final cookieStore = Directory('${network.supportPath.path}/cookie/general'); + if (await cookieStore.exists()) { + await cookieStore.delete(recursive: true); + } +} diff --git a/lib/wearos/wear_home_page.dart b/lib/wearos/wear_home_page.dart new file mode 100644 index 00000000..af0012b7 --- /dev/null +++ b/lib/wearos/wear_home_page.dart @@ -0,0 +1,395 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:watermeter/repository/preference.dart' as preference; +import 'package:watermeter/repository/xidian_ids/ids_session.dart'; +import 'package:watermeter/repository/xidian_ids/school_card_session.dart'; +import 'package:watermeter/wearos/wear_companion_sync.dart'; +import 'package:watermeter/wearos/wear_qr_page.dart'; +import 'package:watermeter/wearos/wear_schedule_service.dart'; +import 'package:watermeter/wearos/wear_sync_login_page.dart'; + +const _wearHomeDashboardPadding = EdgeInsets.fromLTRB(28, 40, 28, 28); +const double _wearHomeDashboardMaxWidth = 280; + +class WearHomePage extends StatefulWidget { + const WearHomePage({super.key}); + + @override + State createState() => _WearHomePageState(); +} + +class _WearHomePageState extends State { + late Future _loadFuture; + late final WearCompanionSyncBridge _companionBridge; + StreamSubscription? _syncSubscription; + Completer? _pendingSync; + + @override + void initState() { + super.initState(); + _loadFuture = _loadCached(); + _companionBridge = WearCompanionSyncBridge(); + _syncSubscription = _companionBridge.imports.listen( + (_) { + if (!mounted) return; + setState(() => _loadFuture = _loadCached()); + _pendingSync?.complete(); + _pendingSync = null; + }, + onError: (Object error, StackTrace stackTrace) { + _pendingSync?.completeError(error, stackTrace); + _pendingSync = null; + }, + ); + unawaited(_companionBridge.start()); + } + + Future _loadCached() async { + final semester = preference.getString( + preference.Preference.currentSemester, + ); + return loadCachedWearHomeData(semesterCode: semester); + } + + Future _manualSync() async { + if (_pendingSync != null) return _pendingSync!.future; + final completer = Completer(); + _pendingSync = completer; + try { + await _companionBridge.requestSync(); + await completer.future.timeout(const Duration(seconds: 15)); + } finally { + if (identical(_pendingSync, completer)) _pendingSync = null; + } + } + + Future _logout() async { + await preference.remove(preference.Preference.idsAccount); + await preference.remove(preference.Preference.idsPassword); + await preference.remove(preference.Preference.currentSemester); + await preference.remove(preference.Preference.role); + await preference.remove(preference.Preference.isUserDefinedSemester); + await IDSSession().clearCookieJar(); + SchoolCardSession.resetOpenId(); + await clearWearCampusCaches(); + await clearCachedWearPaymentQr(); + loginState = IDSLoginState.manual; + if (!mounted) return; + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => const WearSyncLoginPage()), + ); + } + + @override + void dispose() { + _syncSubscription?.cancel(); + unawaited(_companionBridge.dispose()); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: FutureBuilder( + future: _loadFuture, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return _ErrorView( + error: snapshot.error!, + onRetry: _manualSync, + onLogout: _logout, + ); + } + return WearHomeDashboard( + result: snapshot.requireData, + onRefresh: _manualSync, + onLogout: _logout, + ); + }, + ), + ), + ); + } +} + +class WearHomeDashboard extends StatelessWidget { + static final _timeFormat = DateFormat('HH:mm'); + final WearHomeLoadResult result; + final Future Function() onRefresh; + final VoidCallback onLogout; + + const WearHomeDashboard({ + super.key, + required this.result, + required this.onRefresh, + required this.onLogout, + }); + + @override + Widget build(BuildContext context) { + return RefreshIndicator( + onRefresh: onRefresh, + child: ListView( + padding: _wearHomeDashboardPadding, + physics: const AlwaysScrollableScrollPhysics(), + children: [ + Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: _wearHomeDashboardMaxWidth, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _AgendaSection( + title: '今天', + items: result.data.todayItems, + timeFormat: _timeFormat, + ), + _AgendaSection( + title: '明天', + items: result.data.tomorrowItems, + timeFormat: _timeFormat, + ), + const SizedBox(height: 8), + _BalanceCard( + balanceText: result.data.balanceText, + hasBalanceFailure: result.failures.any( + (failure) => + failure.source == WearDataSource.schoolCardBalance, + ), + ), + if (result.failures.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text( + '部分数据刷新失败:${result.failures.map((e) => _sourceName(e.source)).join('、')}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton.filledTonal( + tooltip: '刷新', + onPressed: () => onRefresh(), + icon: const Icon(Icons.refresh), + ), + const SizedBox(width: 12), + IconButton.filledTonal( + tooltip: '退出', + onPressed: onLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ); + } + + static String _sourceName(WearDataSource source) { + switch (source) { + case WearDataSource.classTable: + return '课表'; + case WearDataSource.otherExperiment: + return '实验'; + case WearDataSource.schoolCardBalance: + return '一卡通'; + } + } +} + +class _BalanceCard extends StatelessWidget { + final String? balanceText; + final bool hasBalanceFailure; + + const _BalanceCard({ + required this.balanceText, + required this.hasBalanceFailure, + }); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('一卡通余额', style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text( + balanceText ?? (hasBalanceFailure ? '查询失败' : '暂无数据'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800), + ), + const SizedBox(height: 10), + FilledButton.icon( + onPressed: () => Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const WearQrPage())), + icon: const Icon(Icons.qr_code_2), + label: const Text('付款码'), + ), + ], + ), + ), + ); + } +} + +class _AgendaSection extends StatelessWidget { + final String title; + final List items; + final DateFormat timeFormat; + + const _AgendaSection({ + required this.title, + required this.items, + required this.timeFormat, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(title, style: Theme.of(context).textTheme.titleSmall), + if (items.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: Text('没有安排'), + ) + else + for (final item in items) + Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _KindPill(kind: item.kind), + const SizedBox(width: 6), + Expanded( + child: Text( + item.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + '${timeFormat.format(item.start)}-${timeFormat.format(item.end)}', + ), + if (item.location != null) + Text( + item.location!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (item.subtitle != null) + Text( + item.subtitle!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +class _KindPill extends StatelessWidget { + final WearAgendaKind kind; + + const _KindPill({required this.kind}); + + @override + Widget build(BuildContext context) { + final (label, color) = switch (kind) { + WearAgendaKind.course => ('课', Theme.of(context).colorScheme.primary), + WearAgendaKind.otherExperiment => ('实', Colors.green), + }; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.22), + borderRadius: BorderRadius.circular(999), + ), + child: Text(label, style: TextStyle(color: color, fontSize: 11)), + ); + } +} + +class _ErrorView extends StatelessWidget { + final Object error; + final VoidCallback onRetry; + final VoidCallback onLogout; + + const _ErrorView({ + required this.error, + required this.onRetry, + required this.onLogout, + }); + + @override + Widget build(BuildContext context) { + final text = error.toString(); + return Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(height: 8), + Text( + text.substring(0, min(text.length, 120)), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton(onPressed: onRetry, child: const Text('重试')), + TextButton(onPressed: onLogout, child: const Text('重新登录')), + ], + ), + ), + ); + } +} diff --git a/lib/wearos/wear_ids_reauth.dart b/lib/wearos/wear_ids_reauth.dart new file mode 100644 index 00000000..1d567130 --- /dev/null +++ b/lib/wearos/wear_ids_reauth.dart @@ -0,0 +1,391 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; + +typedef WearIDSReAuthHandler = Future Function(WearIDSReAuthClient client); + +class WearIDSReAuthClient { + WearIDSReAuthClient({ + required Dio dio, + required this.challengeUri, + required this.username, + required this.service, + }) : _dio = dio; + + final Dio _dio; + final Uri challengeUri; + final String username; + final String service; + + String? recipientDescription; + bool _prepared = false; + + String get _isMultifactor => + challengeUri.queryParameters['isMultifactor'] ?? 'true'; + + Future prepare() async { + if (_prepared) return; + final challengeResponse = await _dio.getUri(challengeUri); + if (challengeResponse.statusCode != HttpStatus.ok) { + throw const WearIDSReAuthExpiredException('二次认证已失效,请重新登录'); + } + final response = await _dio.post( + 'https://ids.xidian.edu.cn/authserver/reAuthCheck/changeReAuthType.do', + data: { + 'isMultifactor': _isMultifactor, + 'reAuthType': '3', + 'service': service, + }, + ); + final json = _responseJson(response.data); + if (json['code']?.toString() != '1') { + throw WearIDSProtocolException( + json['message']?.toString() ?? '无法切换到短信二次认证', + ); + } + final data = json['data']; + if (data is Map) { + recipientDescription = data['reAuthUserNameInput']?.toString(); + } + _prepared = true; + } + + Future sendSms() async { + await prepare(); + final response = await _dio.post( + 'https://ids.xidian.edu.cn/authserver/dynamicCode/' + 'getDynamicCodeByReauth.do', + data: {'userName': username, 'authCodeTypeName': 'reAuthDynamicCodeType'}, + ); + final json = _responseJson(response.data); + final result = json['res']?.toString(); + if (result != 'success' && result != 'code_time_fail') { + throw WearIDSProtocolException( + json['returnMessage']?.toString() ?? '短信验证码发送失败', + ); + } + final rawSeconds = int.tryParse(json['codeTime']?.toString() ?? ''); + final seconds = rawSeconds == null || rawSeconds < 0 ? 0 : rawSeconds; + final mobile = json['mobile']?.toString(); + return WearIDSSmsDelivery( + message: json['returnMessage']?.toString() ?? '验证码已发送', + recipient: mobile == null || mobile.isEmpty + ? recipientDescription + : _maskPhoneNumber(mobile), + retryAfter: Duration(seconds: seconds), + ); + } + + Future submitSms({ + required String code, + required bool trustDevice, + }) async { + await prepare(); + final normalizedCode = code.trim(); + if (normalizedCode.isEmpty) { + throw const WearIDSReAuthCodeRejectedException('请输入短信验证码'); + } + final response = await _dio.post( + 'https://ids.xidian.edu.cn/authserver/reAuthCheck/reAuthSubmit.do', + data: { + 'service': service, + 'reAuthType': '3', + 'isMultifactor': _isMultifactor, + 'password': '', + 'dynamicCode': normalizedCode, + 'uuid': '', + 'answer1': '', + 'answer2': '', + 'otpCode': '', + 'skipTmpReAuth': trustDevice.toString(), + }, + ); + final json = _responseJson(response.data); + final result = json['code']?.toString(); + if (result == 'reAuth_failed') { + throw WearIDSReAuthCodeRejectedException( + json['msg']?.toString() ?? '验证码错误', + ); + } + if (result == 'reAuth_unauthorized') { + throw WearIDSReAuthExpiredException(json['msg']?.toString() ?? '二次认证已失效'); + } + if (result != 'reAuth_success') { + throw const WearIDSProtocolException('统一认证返回了未知的二次认证状态'); + } + + final loginResponse = await _dio.get( + 'https://ids.xidian.edu.cn/authserver/login', + queryParameters: {'service': service}, + ); + final location = loginResponse.headers.value(HttpHeaders.locationHeader); + if ((loginResponse.statusCode != HttpStatus.movedPermanently && + loginResponse.statusCode != HttpStatus.found) || + location == null) { + throw const WearIDSProtocolException('二次认证成功,但没有收到业务系统登录票据'); + } + final uri = Uri.parse('https://ids.xidian.edu.cn').resolve(location); + if (uri.host == 'ids.xidian.edu.cn' && + uri.path == '/authserver/reAuthCheck/reAuthLoginView.do') { + throw const WearIDSReAuthExpiredException('二次认证未完成,请重新登录'); + } + return uri; + } +} + +class WearIDSSmsDelivery { + const WearIDSSmsDelivery({ + required this.message, + required this.recipient, + required this.retryAfter, + }); + + final String message; + final String? recipient; + final Duration retryAfter; +} + +Future showWearIDSReAuthPage( + BuildContext context, + WearIDSReAuthClient client, +) async { + final result = await Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => _WearIDSReAuthPage(client: client), + ), + ); + if (result == null) throw const WearIDSReAuthCancelledException(); + return result; +} + +class _WearIDSReAuthPage extends StatefulWidget { + const _WearIDSReAuthPage({required this.client}); + + final WearIDSReAuthClient client; + + @override + State<_WearIDSReAuthPage> createState() => _WearIDSReAuthPageState(); +} + +class _WearIDSReAuthPageState extends State<_WearIDSReAuthPage> { + final _codeController = TextEditingController(); + Timer? _timer; + int _secondsRemaining = 0; + bool _trustDevice = true; + bool _sending = false; + bool _submitting = false; + String? _notice; + String? _error; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _sendCode()); + } + + Future _sendCode() async { + if (_sending || _secondsRemaining > 0) return; + setState(() { + _sending = true; + _error = null; + }); + try { + final delivery = await widget.client.sendSms(); + if (!mounted) return; + setState(() { + _notice = delivery.recipient == null + ? delivery.message + : '${delivery.message}\n${delivery.recipient}'; + }); + _startCountdown(delivery.retryAfter.inSeconds); + } on DioException { + if (mounted) setState(() => _error = '网络连接失败'); + } on WearIDSReAuthExpiredException catch (error) { + if (mounted) setState(() => _error = error.message); + } on WearIDSProtocolException catch (error) { + if (mounted) setState(() => _error = error.message); + } finally { + if (mounted) setState(() => _sending = false); + } + } + + void _startCountdown(int seconds) { + _timer?.cancel(); + setState(() => _secondsRemaining = seconds); + if (seconds <= 0) return; + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (!mounted || _secondsRemaining <= 1) { + timer.cancel(); + if (mounted) setState(() => _secondsRemaining = 0); + } else { + setState(() => _secondsRemaining--); + } + }); + } + + Future _submit() async { + if (_submitting || _codeController.text.trim().isEmpty) { + if (_codeController.text.trim().isEmpty) { + setState(() => _error = '请输入短信验证码'); + } + return; + } + setState(() { + _submitting = true; + _error = null; + }); + try { + final uri = await widget.client.submitSms( + code: _codeController.text, + trustDevice: _trustDevice, + ); + if (mounted) Navigator.of(context).pop(uri); + } on WearIDSReAuthCodeRejectedException catch (error) { + _codeController.clear(); + if (mounted) setState(() => _error = error.message); + } on DioException { + if (mounted) setState(() => _error = '网络连接失败'); + } on WearIDSReAuthExpiredException catch (error) { + if (mounted) setState(() => _error = error.message); + } on WearIDSProtocolException catch (error) { + if (mounted) setState(() => _error = error.message); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + @override + void dispose() { + _timer?.cancel(); + _codeController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final busy = _sending || _submitting; + return Scaffold( + body: SafeArea( + child: ListView( + padding: const EdgeInsets.fromLTRB(34, 30, 34, 40), + children: [ + Text( + '短信认证', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 10), + Text( + _notice ?? '正在准备验证码…', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), + if (_error != null) ...[ + const SizedBox(height: 8), + Text( + _error!, + textAlign: TextAlign.center, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ], + const SizedBox(height: 10), + TextField( + controller: _codeController, + enabled: !busy, + keyboardType: TextInputType.number, + autofillHints: const [AutofillHints.oneTimeCode], + textAlign: TextAlign.center, + maxLength: 8, + decoration: const InputDecoration( + hintText: '验证码', + counterText: '', + ), + onSubmitted: (_) => _submit(), + ), + const SizedBox(height: 8), + OutlinedButton( + onPressed: busy || _secondsRemaining > 0 ? null : _sendCode, + child: Text( + _secondsRemaining > 0 ? '${_secondsRemaining}s 后重发' : '发送验证码', + ), + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + dense: true, + value: _trustDevice, + onChanged: busy + ? null + : (value) => setState(() => _trustDevice = value), + title: const Text('信任此手表'), + ), + FilledButton( + onPressed: busy ? null : _submit, + child: _submitting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('确认'), + ), + TextButton( + onPressed: busy ? null : () => Navigator.of(context).pop(), + child: const Text('取消'), + ), + ], + ), + ), + ); + } +} + +Map _responseJson(dynamic data) { + if (data is Map) return data; + if (data is String) { + try { + final decoded = jsonDecode(data); + if (decoded is Map) return decoded; + } on FormatException { + // Fall through to the protocol exception below. + } + } + throw const WearIDSProtocolException('统一认证返回了非 JSON 响应'); +} + +String _maskPhoneNumber(String value) { + if (value.length < 7) return '****'; + return '${value.substring(0, 3)}****${value.substring(value.length - 4)}'; +} + +class WearIDSProtocolException implements Exception { + const WearIDSProtocolException(this.message); + final String message; + @override + String toString() => message; +} + +class WearIDSReAuthCodeRejectedException implements Exception { + const WearIDSReAuthCodeRejectedException(this.message); + final String message; + @override + String toString() => message; +} + +class WearIDSReAuthExpiredException implements Exception { + const WearIDSReAuthExpiredException(this.message); + final String message; + @override + String toString() => message; +} + +class WearIDSReAuthCancelledException implements Exception { + const WearIDSReAuthCancelledException(); + @override + String toString() => '已取消短信认证'; +} diff --git a/lib/wearos/wear_qr_page.dart b/lib/wearos/wear_qr_page.dart new file mode 100644 index 00000000..da720cdf --- /dev/null +++ b/lib/wearos/wear_qr_page.dart @@ -0,0 +1,299 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:intl/intl.dart'; +import 'package:watermeter/repository/network_session.dart' as network; +import 'package:watermeter/repository/xidian_ids/school_card_session.dart'; +import 'package:watermeter/wearos/wear_ids_reauth.dart'; + +typedef _PaymentQrResult = ({ + Uint8List bytes, + bool fromCache, + DateTime fetchedAt, +}); + +File get _paymentQrCache => + File('${network.supportPath.path}/WearPaymentQr.png'); + +Future clearCachedWearPaymentQr() async { + if (await _paymentQrCache.exists()) await _paymentQrCache.delete(); +} + +Future storeCachedWearPaymentQr( + Uint8List bytes, { + required DateTime fetchedAt, +}) async { + await _paymentQrCache.writeAsBytes(bytes, flush: true); + await _paymentQrCache.setLastModified(fetchedAt); +} + +class WearQrPage extends StatefulWidget { + const WearQrPage({super.key}); + + @override + State createState() => _WearQrPageState(); +} + +class _WearQrPageState extends State { + static const _nativeChannel = MethodChannel( + 'io.github.benderblog.traintime_pda/wear_companion_sync', + ); + static const _paymentChannel = MethodChannel( + 'io.github.benderblog.traintime_pda/wear_payment', + ); + late Future<_PaymentQrResult> _qrFuture; + bool _usingWatchAuthentication = false; + + @override + void initState() { + super.initState(); + unawaited(_setKeepScreenOn(true)); + _qrFuture = _loadQrWithCache(); + } + + Future _setKeepScreenOn(bool enabled) async { + try { + await _nativeChannel.invokeMethod('setKeepScreenOn', enabled); + } on PlatformException { + // The QR flow still works when the host cannot expose this optimization. + } + } + + @override + void dispose() { + unawaited(_setKeepScreenOn(false)); + super.dispose(); + } + + void _retry() { + setState(() { + _usingWatchAuthentication = false; + _qrFuture = _loadQrWithCache(forceRefresh: true); + }); + } + + void _authenticateOnWatch() { + _paymentChannel.setMethodCallHandler(null); + setState(() { + _usingWatchAuthentication = true; + _qrFuture = _loadQrDirectlyWithCache(); + }); + } + + Future<_PaymentQrResult> _loadQrDirectlyWithCache() async { + try { + return await _requestQrDirectly(); + } catch (_) { + if (!await _paymentQrCache.exists()) rethrow; + return ( + bytes: await _paymentQrCache.readAsBytes(), + fromCache: true, + fetchedAt: await _paymentQrCache.lastModified(), + ); + } + } + + Future<_PaymentQrResult> _loadQrWithCache({bool forceRefresh = false}) async { + if (!forceRefresh && await _paymentQrCache.exists()) { + return ( + bytes: await _paymentQrCache.readAsBytes(), + fromCache: true, + fetchedAt: await _paymentQrCache.lastModified(), + ); + } + try { + return await _requestQrFromPhone(); + } catch (_) { + try { + return await _requestQrDirectly(); + } catch (_) { + if (!await _paymentQrCache.exists()) rethrow; + return ( + bytes: await _paymentQrCache.readAsBytes(), + fromCache: true, + fetchedAt: await _paymentQrCache.lastModified(), + ); + } + } + } + + Future<_PaymentQrResult> _requestQrFromPhone() async { + try { + final completer = Completer(); + _paymentChannel.setMethodCallHandler((call) async { + if (call.method == 'receivePaymentQrResponse' && + call.arguments is String && + !completer.isCompleted) { + completer.complete(call.arguments as String); + } + }); + await _paymentChannel.invokeMethod('requestPaymentQr'); + final raw = await completer.future.timeout(const Duration(minutes: 3)); + final json = jsonDecode(raw); + if (json is! Map || json['ok'] != true) { + throw StateError('Companion phone could not provide a payment QR.'); + } + final encoded = json['pngBase64']; + final fetchedAtEpochMs = json['fetchedAtEpochMs']; + if (encoded is! String || fetchedAtEpochMs is! int) { + throw const FormatException('Invalid companion payment QR response.'); + } + final bytes = base64Decode(encoded); + final fetchedAt = DateTime.fromMillisecondsSinceEpoch(fetchedAtEpochMs); + await storeCachedWearPaymentQr(bytes, fetchedAt: fetchedAt); + return (bytes: bytes, fromCache: false, fetchedAt: fetchedAt); + } finally { + _paymentChannel.setMethodCallHandler(null); + } + } + + Future<_PaymentQrResult> _requestQrDirectly() async { + final session = SchoolCardSession(); + await session.authenticateWithStoredCredentials( + reAuthHandler: (client) { + if (!mounted) throw const WearIDSReAuthCancelledException(); + return showWearIDSReAuthPage(context, client); + }, + ); + final bytes = await session.getQRCode(); + final fetchedAt = DateTime.now(); + await storeCachedWearPaymentQr(bytes, fetchedAt: fetchedAt); + return (bytes: bytes, fromCache: false, fetchedAt: fetchedAt); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: SafeArea( + child: FutureBuilder<_PaymentQrResult>( + future: _qrFuture, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return Center( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(34, 28, 34, 28), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 12), + Text( + _usingWatchAuthentication ? '正在由手表认证' : '正在向手机请求付款码', + textAlign: TextAlign.center, + ), + if (!_usingWatchAuthentication) ...[ + const SizedBox(height: 10), + OutlinedButton( + onPressed: _authenticateOnWatch, + child: const Text('改用手表认证'), + ), + ], + ], + ), + ), + ); + } + if (snapshot.hasError) { + return Center( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(28, 24, 28, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.qr_code_2), + const SizedBox(height: 6), + const Text('付款码获取失败', textAlign: TextAlign.center), + const SizedBox(height: 8), + Wrap( + alignment: WrapAlignment.center, + spacing: 8, + children: [ + FilledButton( + onPressed: _retry, + child: const Text('重试'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('返回'), + ), + ], + ), + ], + ), + ), + ); + } + + final result = snapshot.requireData; + return Column( + children: [ + Expanded( + child: Stack( + children: [ + Center( + child: Container( + margin: const EdgeInsets.fromLTRB(22, 28, 22, 4), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + ), + child: Image.memory( + result.bytes, + fit: BoxFit.contain, + filterQuality: FilterQuality.none, + ), + ), + ), + Positioned( + top: 4, + left: 4, + child: IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.arrow_back), + ), + ), + Positioned( + top: 4, + right: 4, + child: IconButton( + onPressed: _retry, + icon: const Icon(Icons.refresh), + ), + ), + ], + ), + ), + if (result.fromCache) + Padding( + padding: const EdgeInsets.fromLTRB(34, 2, 34, 12), + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.orange.shade900, + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 6, + ), + child: Text( + '缓存 ${DateFormat('MM-dd HH:mm').format(result.fetchedAt)},可能失效', + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 10), + ), + ), + ), + ), + ], + ); + }, + ), + ), + ); + } +} diff --git a/lib/wearos/wear_schedule_service.dart b/lib/wearos/wear_schedule_service.dart new file mode 100644 index 00000000..43e433c4 --- /dev/null +++ b/lib/wearos/wear_schedule_service.dart @@ -0,0 +1,341 @@ +import 'package:watermeter/model/fetch_result.dart'; +import 'package:watermeter/model/time_list.dart'; +import 'package:watermeter/model/xidian_ids/classtable.dart'; +import 'package:watermeter/model/xidian_ids/experiment.dart'; +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/sysj_session.dart'; + +typedef ClassTableFetcher = + Future> Function(String semesterCode); +typedef ClassTableCacheLoader = + FetchResult? Function(String semesterCode); +typedef ExperimentFetcher = + Future>> Function(); +typedef ExperimentCacheLoader = FetchResult>? Function(); +typedef BalanceFetcher = Future Function(); + +Future clearWearCampusCaches() async { + ClassTableSession.deleteCache(); + await SysjSession.deleteCache(); +} + +enum WearAgendaKind { course, otherExperiment } + +enum WearDataSource { classTable, otherExperiment, schoolCardBalance } + +class WearAgendaItem { + final WearAgendaKind kind; + final String title; + final String? subtitle; + final String? location; + final DateTime start; + final DateTime end; + + const WearAgendaItem({ + required this.kind, + required this.title, + required this.start, + required this.end, + this.subtitle, + this.location, + }); +} + +class WearSourceFailure { + final WearDataSource source; + final Object error; + final StackTrace stackTrace; + + const WearSourceFailure({ + required this.source, + required this.error, + required this.stackTrace, + }); +} + +class WearCachedDataException implements Exception { + final String hintKey; + + const WearCachedDataException(this.hintKey); + + @override + String toString() => hintKey; +} + +class WearHomeData { + final String? balanceText; + final List todayItems; + final List tomorrowItems; + final DateTime fetchedAt; + + const WearHomeData({ + required this.todayItems, + required this.tomorrowItems, + required this.fetchedAt, + this.balanceText, + }); +} + +class WearHomeLoadResult { + final WearHomeData data; + final List failures; + + const WearHomeLoadResult({required this.data, required this.failures}); + + bool get hasUsableData => + data.balanceText != null || + data.todayItems.isNotEmpty || + data.tomorrowItems.isNotEmpty; +} + +class WearAgendaBuilder { + const WearAgendaBuilder._(); + + static List courseItemsForDay( + ClassTableData table, + DateTime day, + ) { + final weekIndex = weekIndexForDay(table, day); + if (weekIndex < 0 || weekIndex >= table.semesterLength) { + return const []; + } + + final items = []; + for (final arrangement in table.timeArrangement) { + if (arrangement.source == Source.empty || + arrangement.day != day.weekday || + arrangement.weekList.length <= weekIndex || + !arrangement.weekList[weekIndex]) { + continue; + } + + final startIndex = (arrangement.start - 1) * 2; + final endIndex = (arrangement.stop - 1) * 2 + 1; + if (startIndex < 0 || endIndex >= timeList.length) { + continue; + } + + final ClassDetail detail; + try { + detail = table.getClassDetail(arrangement); + } on Object { + continue; + } + + items.add( + WearAgendaItem( + kind: WearAgendaKind.course, + title: detail.name, + subtitle: _blankToNull(arrangement.teacher), + location: _blankToNull(arrangement.classroom), + start: _dateAtClassTime(day, timeList[startIndex]), + end: _dateAtClassTime(day, timeList[endIndex]), + ), + ); + } + items.sort(_compareAgendaItems); + return items; + } + + static List experimentItemsForDay( + List experiments, + DateTime day, + ) { + final items = []; + for (final experiment in experiments) { + for (final range in experiment.timeRanges) { + if (!_isSameDate(range.$1, day)) continue; + items.add( + WearAgendaItem( + kind: WearAgendaKind.otherExperiment, + title: experiment.name, + subtitle: _blankToNull(experiment.teacher), + location: _blankToNull(experiment.classroom), + start: range.$1, + end: range.$2, + ), + ); + } + } + items.sort(_compareAgendaItems); + return items; + } + + static int weekIndexForDay(ClassTableData table, DateTime day) { + if (table.termStartDay.isEmpty) return -1; + final start = DateTime.parse(table.termStartDay); + final delta = _dateOnly(day).difference(_dateOnly(start)).inDays; + return delta < 0 ? -1 : delta ~/ DateTime.daysPerWeek; + } +} + +Future loadCachedWearHomeData({ + required String semesterCode, + DateTime? now, + ClassTableCacheLoader? classTableCacheLoader, + ExperimentCacheLoader? otherExperimentCacheLoader, + ClassTableFetcher? classTableFetcher, + ExperimentFetcher? otherExperimentFetcher, + BalanceFetcher? balanceFetcher, +}) async { + final effectiveNow = now ?? DateTime.now(); + final classCache = classTableCacheLoader ?? _loadClassTableCache; + final experimentCache = + otherExperimentCacheLoader ?? _loadOtherExperimentCache; + return _buildWearHomeData( + now: effectiveNow, + classTableResult: classCache(semesterCode), + otherExperimentResult: experimentCache(), + balanceText: null, + ); +} + +Future loadWearHomeData({ + required String semesterCode, + DateTime? now, + ClassTableFetcher? classTableFetcher, + ExperimentFetcher? otherExperimentFetcher, + BalanceFetcher? balanceFetcher, +}) async { + final effectiveNow = now ?? DateTime.now(); + final failures = []; + + FetchResult? classTableResult; + try { + classTableResult = await (classTableFetcher ?? getClassTable)(semesterCode); + } catch (error, stackTrace) { + failures.add( + WearSourceFailure( + source: WearDataSource.classTable, + error: error, + stackTrace: stackTrace, + ), + ); + } + + String? balanceText; + try { + balanceText = await (balanceFetcher ?? _fetchSchoolCardBalance)(); + } catch (error, stackTrace) { + failures.add( + WearSourceFailure( + source: WearDataSource.schoolCardBalance, + error: error, + stackTrace: stackTrace, + ), + ); + } + + final result = _buildWearHomeData( + now: effectiveNow, + classTableResult: classTableResult, + otherExperimentResult: null, + balanceText: balanceText, + initialFailures: failures, + ); + return result; +} + +WearHomeLoadResult _buildWearHomeData({ + required DateTime now, + required FetchResult? classTableResult, + required FetchResult>? otherExperimentResult, + required String? balanceText, + List initialFailures = const [], +}) { + final today = _dateOnly(now); + final tomorrow = today.add(const Duration(days: 1)); + final failures = [...initialFailures]; + final todayItems = []; + final tomorrowItems = []; + + if (classTableResult != null) { + _recordCacheFailure(failures, WearDataSource.classTable, classTableResult); + final table = classTableResult.data; + todayItems.addAll(WearAgendaBuilder.courseItemsForDay(table, today)); + tomorrowItems.addAll(WearAgendaBuilder.courseItemsForDay(table, tomorrow)); + } + + if (otherExperimentResult != null) { + _recordCacheFailure( + failures, + WearDataSource.otherExperiment, + otherExperimentResult, + ); + final experiments = otherExperimentResult.data; + todayItems.addAll( + WearAgendaBuilder.experimentItemsForDay(experiments, today), + ); + tomorrowItems.addAll( + WearAgendaBuilder.experimentItemsForDay(experiments, tomorrow), + ); + } + + todayItems.sort(_compareAgendaItems); + tomorrowItems.sort(_compareAgendaItems); + return WearHomeLoadResult( + data: WearHomeData( + balanceText: balanceText, + todayItems: List.unmodifiable(todayItems), + tomorrowItems: List.unmodifiable(tomorrowItems), + fetchedAt: DateTime.now(), + ), + failures: List.unmodifiable(failures), + ); +} + +Future _fetchSchoolCardBalance() => SchoolCardSession().getOverview(); + +FetchResult? _loadClassTableCache(String semesterCode) { + final cache = ClassTableSession.getCache(); + if (cache == null || cache.$2.semesterCode != semesterCode) return null; + return FetchResult.cache(fetchTime: cache.$1, data: cache.$2, hintKey: null); +} + +FetchResult>? _loadOtherExperimentCache() { + final cache = SysjSession.getCache(); + if (cache == null) return null; + return FetchResult.cache(fetchTime: cache.$1, data: cache.$2, hintKey: null); +} + +void _recordCacheFailure( + List failures, + WearDataSource source, + FetchResult result, +) { + final hintKey = result.hintKey; + if (!result.isCache || hintKey == null) return; + failures.add( + WearSourceFailure( + source: source, + error: WearCachedDataException(hintKey), + stackTrace: StackTrace.current, + ), + ); +} + +DateTime _dateOnly(DateTime value) => + DateTime(value.year, value.month, value.day); + +DateTime _dateAtClassTime(DateTime day, String hhmm) { + final hour = (hhmm.codeUnitAt(0) - 48) * 10 + hhmm.codeUnitAt(1) - 48; + final minute = (hhmm.codeUnitAt(3) - 48) * 10 + hhmm.codeUnitAt(4) - 48; + return DateTime(day.year, day.month, day.day, hour, minute); +} + +bool _isSameDate(DateTime left, DateTime right) => + left.year == right.year && + left.month == right.month && + left.day == right.day; + +String? _blankToNull(String? value) { + if (value == null || value.isEmpty) return null; + return value; +} + +int _compareAgendaItems(WearAgendaItem left, WearAgendaItem right) { + final start = left.start.compareTo(right.start); + if (start != 0) return start; + return left.end.compareTo(right.end); +} diff --git a/lib/wearos/wear_sync_login_page.dart b/lib/wearos/wear_sync_login_page.dart new file mode 100644 index 00000000..58870cba --- /dev/null +++ b/lib/wearos/wear_sync_login_page.dart @@ -0,0 +1,107 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:watermeter/repository/logger.dart'; +import 'package:watermeter/wearos/wear_companion_sync.dart'; +import 'package:watermeter/wearos/wear_home_page.dart'; + +class WearSyncLoginPage extends StatefulWidget { + const WearSyncLoginPage({super.key}); + + @override + State createState() => _WearSyncLoginPageState(); +} + +class _WearSyncLoginPageState extends State { + late final WearCompanionSyncBridge _bridge; + StreamSubscription? _subscription; + String _status = '请在手机端打开“设置 > XDYou Wear”,选择这块手表'; + bool _starting = true; + + @override + void initState() { + super.initState(); + _bridge = WearCompanionSyncBridge(); + _subscription = _bridge.imports.listen( + (_) { + if (!mounted) return; + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => const WearHomePage()), + ); + }, + onError: (Object error, StackTrace stackTrace) { + log.warning( + '[WearSyncLoginPage] Direct pairing failed', + error, + stackTrace, + ); + if (mounted) setState(() => _status = '同步失败:$error'); + }, + ); + unawaited(_start()); + } + + Future _start() async { + try { + await _bridge.start(); + await _bridge.beginDirectPairing(); + if (mounted) setState(() => _starting = false); + } catch (error, stackTrace) { + log.warning( + '[WearSyncLoginPage] Cannot start pairing', + error, + stackTrace, + ); + if (mounted) { + setState(() { + _starting = false; + _status = '无法开始配对:$error'; + }); + } + } + } + + @override + void dispose() { + _subscription?.cancel(); + unawaited(_bridge.dispose()); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 260), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(_starting ? Icons.sync : Icons.watch_outlined, size: 54), + const SizedBox(height: 12), + Text( + '等待手机配对', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 10), + Text(_status, textAlign: TextAlign.center), + const SizedBox(height: 12), + if (_starting) const CircularProgressIndicator(), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('返回'), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 00000000..649633b1 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,986 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d" + url: "https://pub.dev" + source: hosted + version: "93.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b + url: "https://pub.dev" + source: hosted + version: "10.0.1" + ansicolor: + dependency: transitive + description: + name: ansicolor + sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f" + url: "https://pub.dev" + source: hosted + version: "2.0.3" + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + asn1lib: + dependency: transitive + description: + name: asn1lib + sha256: "9a8f69025044eb466b9b60ef3bc3ac99b4dc6c158ae9c56d25eeccf5bc56d024" + url: "https://pub.dev" + source: hosted + version: "1.6.5" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10 + url: "https://pub.dev" + source: hosted + version: "4.0.6" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + url: "https://pub.dev" + source: hosted + version: "4.1.1" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6" + url: "https://pub.dev" + source: hosted + version: "2.15.0" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56" + url: "https://pub.dev" + source: hosted + version: "8.12.6" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + cookie_jar: + dependency: "direct main" + description: + name: cookie_jar + sha256: "963da02c1ef64cb5ac20de948c9e5940aa351f1e34a12b1d327c83d85b7e8fff" + url: "https://pub.dev" + source: hosted + version: "4.0.9" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2" + url: "https://pub.dev" + source: hosted + version: "3.1.7" + dio: + dependency: "direct main" + description: + name: dio + sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c + url: "https://pub.dev" + source: hosted + version: "5.9.2" + dio_cookie_manager: + dependency: "direct main" + description: + name: dio_cookie_manager + sha256: "0db1a7b997a0455e488ac35744c68eed3f2a4280d3ab531835a65641b0a08744" + url: "https://pub.dev" + source: hosted + version: "3.4.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + encrypter_plus: + dependency: "direct main" + description: + name: encrypter_plus + sha256: "6f6f3c73e26058af4fd138369a928ccae667e45d254cf6ded6301a2d99551a67" + url: "https://pub.dev" + source: hosted + version: "5.1.0" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + group_button: + dependency: transitive + description: + name: group_button + sha256: "0610fcf28ed122bfb4b410fce161a390f7f2531d55d1d65c5375982001415940" + url: "https://pub.dev" + source: hosted + version: "5.3.4" + hooks: + dependency: transitive + description: + name: hooks + sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e" + url: "https://pub.dev" + source: hosted + version: "1.0.3" + html: + dependency: "direct main" + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: "direct main" + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.dev" + source: hosted + version: "4.8.0" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + json_annotation: + dependency: "direct main" + description: + name: json_annotation + sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 + url: "https://pub.dev" + source: hosted + version: "4.11.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: "2c15e78e1cc6e62aadecf59f81566fd56829713d96a8c4177699e2b2e17f20db" + url: "https://pub.dev" + source: hosted + version: "6.13.2" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" + url: "https://pub.dev" + source: hosted + version: "0.17.6" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + posix: + dependency: transitive + description: + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.dev" + source: hosted + version: "6.5.0" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + share_plus: + dependency: transitive + description: + name: share_plus + sha256: a857d8b1479250aff6b57a51b2c02d31ca05848d441817c43f1640c885c286c0 + url: "https://pub.dev" + source: hosted + version: "13.1.0" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "7f7ae28cf400d13f811e297ff37742dba83b79e0a6f5dce14eec0248274e6ce9" + url: "https://pub.dev" + source: hosted + version: "7.1.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 + url: "https://pub.dev" + source: hosted + version: "2.4.23" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: "direct dev" + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02 + url: "https://pub.dev" + source: hosted + version: "4.2.3" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: "4227d54ceefd0bb8ca4c8fcb96e1719dc53f1ee1b6e2ca9d7a6069da160e4eae" + url: "https://pub.dev" + source: hosted + version: "1.3.12" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + synchronized: + dependency: "direct main" + description: + name: synchronized + sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5" + url: "https://pub.dev" + source: hosted + version: "3.4.0+1" + talker: + dependency: transitive + description: + name: talker + sha256: f1a14d623f1d1bec42bb3bb77674eb766ffe8d26e5f79af652d85cb097c3e757 + url: "https://pub.dev" + source: hosted + version: "5.1.17" + talker_dio_logger: + dependency: "direct main" + description: + name: talker_dio_logger + sha256: "6dba5c29afb566c6efe1a2c1b676488ea7c727b486bda0654d965a1cfad6ea9b" + url: "https://pub.dev" + source: hosted + version: "5.1.17" + talker_flutter: + dependency: "direct main" + description: + name: talker_flutter + sha256: "7e4b5fb520b4dadfc8db97e73a2a76ea5d6eda471a51489f3c0bd58b96a1ed43" + url: "https://pub.dev" + source: hosted + version: "5.1.17" + talker_logger: + dependency: transitive + description: + name: talker_logger + sha256: "459205c3e571f97ecc6be6e1b1b7e6b97b853e78ea458894650be407596e3216" + url: "https://pub.dev" + source: hosted + version: "5.1.17" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + url: "https://pub.dev" + source: hosted + version: "0.7.10" + time: + dependency: "direct main" + description: + name: time + sha256: "46187cf30bffdab28c56be9a63861b36e4ab7347bf403297595d6a97e10c789f" + url: "https://pub.dev" + source: hosted + version: "2.1.6" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + universal_io: + dependency: transitive + description: + name: universal_io + sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 + url: "https://pub.dev" + source: hosted + version: "2.3.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + uuid: + dependency: transitive + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: a1fc9eb9248baa05dfc12ed5b66e377b3e23f095eec078e0371622b9033810d9 + url: "https://pub.dev" + source: hosted + version: "6.2.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.11.0 <4.0.0" + flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 00000000..67af1f80 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,36 @@ +name: watermeter +description: Another personal data assistant for XDU. +publish_to: "none" +version: 1.5.13+43 + +environment: + sdk: ^3.8.0 + +dependencies: + dio: ^5.0.0 + encrypter_plus: ^5.1.0 + talker_flutter: ^5.0.0 + talker_dio_logger: ^5.0.0 + synchronized: ^3.1.0+1 + shared_preferences: ^2.5.3 + dio_cookie_manager: ^3.0.0 + cookie_jar: ^4.0.3 + path_provider: ^2.0.11 + json_annotation: ^4.9.0 + html: ^0.15.4 + time: ^2.1.5 + image: ^4.5.4 + flutter: + sdk: flutter + intl: + +dev_dependencies: + flutter_test: + sdk: flutter + build_runner: ^2.6.0 + json_serializable: ^6.10.0 + flutter_lints: ^6.0.0 + shared_preferences_platform_interface: ^2.4.2 + +flutter: + uses-material-design: true diff --git a/test/ids_session_test.dart b/test/ids_session_test.dart new file mode 100644 index 00000000..5c3b1915 --- /dev/null +++ b/test/ids_session_test.dart @@ -0,0 +1,33 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:watermeter/repository/xidian_ids/ids_session.dart'; + +void main() { + test('password encryption matches the Go ids login payload', () { + expect( + IDSSession.aesEncrypt('secret', '1234567890abcdef'), + 'Y2fkMlmY/KyUHnWiA9lVrnC8HHWUFePOo/JLpbpV/XfZ/zE6Tk2WrZMyCYY1f9ael+nb8OZB4B2EmFM6G18SWNpKTmuSEP0PjuxgVXBdI90=', + ); + }); + + test('username login payload matches Go ids fields', () { + final payload = IDSSession.buildUsernameLoginPayloadForTesting( + username: '2200000000', + password: 'secret', + salt: '1234567890abcdef', + execution: 'exec-token', + ); + + expect(payload, { + 'username': '2200000000', + 'password': + 'Y2fkMlmY/KyUHnWiA9lVrnC8HHWUFePOo/JLpbpV/XfZ/zE6Tk2WrZMyCYY1f9ael+nb8OZB4B2EmFM6G18SWNpKTmuSEP0PjuxgVXBdI90=', + 'rememberMe': 'true', + 'cllt': 'userNameLogin', + 'dllt': 'generalLogin', + '_eventId': 'submit', + 'captcha': '', + 'lt': '', + 'execution': 'exec-token', + }); + }); +} diff --git a/test/slider_captcha_test.dart b/test/slider_captcha_test.dart new file mode 100644 index 00000000..18ee1078 --- /dev/null +++ b/test/slider_captcha_test.dart @@ -0,0 +1,41 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:watermeter/wearos/slider_captcha.dart'; + +void main() { + group('Go-compatible IDS slider captcha', () { + test('solves slide offset using the Go cross-correlation algorithm', () { + final puzzleBytes = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAECAYAAACHtL/sAAAA70lEQVR4nB3OQQfCAACA0RHRrFtEh93GWIwOMRo7LRazbh1GdNsYo+MYo+PotGN02rFrx64dO8WOO40dY8QOX/QHnieMx2NkWUbXdSzLYrvdcjgcOB6PnE4niqKgLEvu9zvP55Oqqmjblr7vkSQJYT6fY5omruuy3++J45gsyxBFkclk8sdVVeX9ftM0Dd/vl9FoxGw2Q9M0hM1mg+/7RFFEmqacz2eu1yuLxYLVaoVt23ieR9d1DIdDptPpHzQMA8dxEMIwJEkS8jzncrlwu914PB68Xi/quubz+TAYDP4bRVFYLpes12t2ux1BEPADQTaOsaGO5RAAAAAASUVORK5CYII=', + ); + final pieceBytes = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAYAAAAECAYAAACtBE5DAAAAM0lEQVR4nGNgwAe4uLj+i4iI/JeTk/uvoaHxHy5hZGT038bG5r+bm9v/gICA/3jMYGAAADvqDDHTarfFAAAAAElFTkSuQmCC', + ); + + expect( + SliderCaptchaClientProvider.solveSlideOffsetForTesting( + puzzleBytes: puzzleBytes, + pieceBytes: pieceBytes, + border: 0, + ), + 5, + ); + }); + + test('encrypts captcha payload with the Go fixed-prefix AES-CBC shape', () { + final key = Uint8List.fromList('1234567890abcdef'.codeUnits); + const payload = + '{"canvasLength":280,"moveLength":42,"tracks":[{"a":0,"b":0,"c":0},{"a":42,"b":0,"c":900}]}'; + + expect( + SliderCaptchaClientProvider.encryptCaptchaPayloadForTesting( + payload, + key, + ), + 'Y2fkMlmY/KyUHnWiA9lVrnC8HHWUFePOo/JLpbpV/XfZ/zE6Tk2WrZMyCYY1f9ael+nb8OZB4B2EmFM6G18SWMo6nGxXZr4TTOiHUUTFXkeQQVaF2RoG1CsaDxyrQkchEx7YVCH+3fSUlX8CKpybb7jJnIbccr2rP1538MId2OLPck1g1XaCwAOtLK+LyyKILKYdFAT061XHTpBZZfvJOg==', + ); + }); + }); +} diff --git a/test/wear_app_test.dart b/test/wear_app_test.dart new file mode 100644 index 00000000..81360c2b --- /dev/null +++ b/test/wear_app_test.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:watermeter/wearos/wear_app.dart'; +import 'package:watermeter/wearos/wear_home_page.dart'; +import 'package:watermeter/wearos/wear_schedule_service.dart'; + +void main() { + testWidgets('first launch is companion-only and has no IDS login form', ( + tester, + ) async { + const channel = MethodChannel( + 'io.github.benderblog.traintime_pda/wear_companion_sync', + ); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (call) async => null, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ), + ); + await tester.pumpWidget(const WearApp(isFirst: true)); + await tester.pump(); + + expect(find.text('等待手机配对'), findsOneWidget); + expect(find.byType(TextField), findsNothing); + expect(find.text('登录'), findsNothing); + expect(find.textContaining('设置 > XDYou Wear'), findsOneWidget); + }); + + testWidgets('home dashboard bounds long text on a round watch', ( + tester, + ) async { + tester.view.physicalSize = const Size(384, 384); + tester.view.devicePixelRatio = 1; + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + const longBalance = '¥123456789012345678901234567890'; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: WearHomeDashboard( + result: WearHomeLoadResult( + data: WearHomeData( + balanceText: longBalance, + todayItems: [ + WearAgendaItem( + kind: WearAgendaKind.course, + title: '很长很长很长很长很长的课程名称', + start: DateTime(2026, 5, 19, 8, 30), + end: DateTime(2026, 5, 19, 10, 5), + location: '很长很长很长很长很长的教室名称', + subtitle: '很长很长很长很长很长的教师名称', + ), + ], + tomorrowItems: const [], + fetchedAt: DateTime(2026, 5, 19), + ), + failures: [ + WearSourceFailure( + source: WearDataSource.schoolCardBalance, + error: StateError('balance failed'), + stackTrace: StackTrace.current, + ), + ], + ), + onRefresh: () async {}, + onLogout: () {}, + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + final balanceText = tester.widget(find.text(longBalance)); + expect(balanceText.maxLines, 1); + expect(balanceText.overflow, TextOverflow.ellipsis); + }); +} diff --git a/test/wear_schedule_service_test.dart b/test/wear_schedule_service_test.dart new file mode 100644 index 00000000..4d3e587b --- /dev/null +++ b/test/wear_schedule_service_test.dart @@ -0,0 +1,403 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; +import 'package:watermeter/model/fetch_result.dart'; +import 'package:watermeter/model/xidian_ids/classtable.dart'; +import 'package:watermeter/model/xidian_ids/experiment.dart'; +import 'package:watermeter/repository/network_session.dart' as network; +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/sysj_session.dart'; +import 'package:watermeter/wearos/wear_companion_sync.dart'; +import 'package:watermeter/wearos/wear_schedule_service.dart'; + +void main() { + group('Wear agenda conversion', () { + test('course items use target date week and class-period times', () { + final table = ClassTableData( + semesterLength: 2, + semesterCode: '2026-1', + termStartDay: '2026-05-18 00:00:00', + classDetail: [ClassDetail(name: '编译原理', code: 'CS301', number: '01')], + timeArrangement: [ + TimeArrangement( + source: Source.school, + index: 0, + weekList: [true, false], + teacher: '张老师', + classroom: 'B-101', + day: DateTime.tuesday, + start: 1, + stop: 2, + ), + ], + ); + + final firstWeekItems = WearAgendaBuilder.courseItemsForDay( + table, + DateTime(2026, 5, 19), + ); + final secondWeekItems = WearAgendaBuilder.courseItemsForDay( + table, + DateTime(2026, 5, 26), + ); + + expect(firstWeekItems, hasLength(1)); + expect(firstWeekItems.single.kind, WearAgendaKind.course); + expect(firstWeekItems.single.title, '编译原理'); + expect(firstWeekItems.single.subtitle, '张老师'); + expect(firstWeekItems.single.location, 'B-101'); + expect(firstWeekItems.single.start, DateTime(2026, 5, 19, 8, 30)); + expect(firstWeekItems.single.end, DateTime(2026, 5, 19, 10, 5)); + expect(secondWeekItems, isEmpty); + }); + + test('other experiment items keep target-day ranges', () { + final experiments = [ + ExperimentData( + type: ExperimentType.others, + name: '电工实习', + classroom: '工程坊', + timeRanges: [(DateTime(2026, 5, 19, 14), DateTime(2026, 5, 19, 16))], + teacher: '王老师', + ), + ExperimentData( + type: ExperimentType.others, + name: '工程训练', + classroom: '工程坊', + timeRanges: [(DateTime(2026, 5, 20, 14), DateTime(2026, 5, 20, 16))], + teacher: '刘老师', + ), + ]; + + final items = WearAgendaBuilder.experimentItemsForDay( + experiments, + DateTime(2026, 5, 19), + ); + + expect(items, hasLength(1)); + expect(items.single.kind, WearAgendaKind.otherExperiment); + expect(items.single.title, '电工实习'); + expect(items.single.subtitle, '王老师'); + expect(items.single.location, '工程坊'); + expect(items.single.start, DateTime(2026, 5, 19, 14)); + expect(items.single.end, DateTime(2026, 5, 19, 16)); + }); + + test('school card reset clears cached openid between users', () { + SchoolCardSession.openid = 'previous-user-openid'; + + SchoolCardSession.resetOpenId(); + + expect(SchoolCardSession.openid, isEmpty); + }); + }); + + group('Wear home loading', () { + test('network sync preserves successful agenda and balance data', () async { + final now = DateTime(2026, 5, 19, 8); + final table = _singleCourseTable('数据库系统'); + var experimentNetworkCalled = false; + + final result = await loadWearHomeData( + semesterCode: '2026-1', + now: now, + classTableFetcher: (_) async => + FetchResult.fresh(fetchTime: now, data: table), + otherExperimentFetcher: () async { + experimentNetworkCalled = true; + throw StateError('experiment fetch should not run'); + }, + balanceFetcher: () async => '¥12.34', + ); + + expect(result.data.balanceText, '¥12.34'); + expect(result.data.todayItems.map((item) => item.title), ['数据库系统']); + expect(result.failures, isEmpty); + expect(result.hasUsableData, isTrue); + expect(experimentNetworkCalled, isFalse); + }); + + test( + 'cached fetch result records source warning while keeping data', + () async { + final now = DateTime(2026, 5, 19, 8); + final table = _singleCourseTable('操作系统'); + + final result = await loadWearHomeData( + semesterCode: '2026-1', + now: now, + classTableFetcher: (_) async => FetchResult.cache( + fetchTime: now, + data: table, + hintKey: 'classtable.cache_hint_network_failed', + ), + otherExperimentFetcher: () async => + FetchResult.fresh(fetchTime: now, data: const []), + balanceFetcher: () async => '¥12.34', + ); + + expect(result.data.todayItems.map((item) => item.title), ['操作系统']); + expect(result.failures.map((failure) => failure.source), [ + WearDataSource.classTable, + ]); + expect(result.failures.single.error, isA()); + }, + ); + + test('cache-only load does not call network fetchers', () async { + final now = DateTime(2026, 5, 19, 8); + final table = _singleCourseTable('离线课程'); + var classNetworkCalled = false; + var experimentNetworkCalled = false; + var balanceNetworkCalled = false; + + final result = await loadCachedWearHomeData( + semesterCode: '2026-1', + now: now, + classTableCacheLoader: (_) => + FetchResult.cache(fetchTime: now, data: table, hintKey: null), + otherExperimentCacheLoader: () => null, + classTableFetcher: (_) async { + classNetworkCalled = true; + throw StateError('network class fetch should not run'); + }, + otherExperimentFetcher: () async { + experimentNetworkCalled = true; + throw StateError('network experiment fetch should not run'); + }, + balanceFetcher: () async { + balanceNetworkCalled = true; + return '¥0.00'; + }, + ); + + expect(result.data.todayItems.map((item) => item.title), ['离线课程']); + expect(result.data.balanceText, isNull); + expect(result.failures, isEmpty); + expect(classNetworkCalled, isFalse); + expect(experimentNetworkCalled, isFalse); + expect(balanceNetworkCalled, isFalse); + }); + }); + + group('Wear companion sync interface', () { + late Directory tempDir; + + setUp(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + preference.prefs = await SharedPreferencesWithCache.create( + cacheOptions: const SharedPreferencesWithCacheOptions(), + ); + tempDir = await Directory.systemTemp.createTemp('wear-sync-test-'); + network.supportPath = tempDir; + ClassTableSession.schoolClassDataCache = File( + '${tempDir.path}/${ClassTableSession.schoolClassName}', + ); + SysjSession.otherExperimentCacheFile = File( + '${tempDir.path}/${SysjSession.otherExperimentCacheName}', + ); + ClassTableSession.deleteCache(); + await SysjSession.deleteCache(); + }); + + tearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + test('credential import clears previous user-scoped state', () async { + SchoolCardSession.openid = 'old-openid'; + await ClassTableSession.updateCacheAndGroup(_singleCourseTable('旧课程')); + await SysjSession.writeCache([ + ExperimentData( + type: ExperimentType.others, + name: '旧实验', + classroom: '实验楼', + timeRanges: [(DateTime(2026, 5, 19, 10), DateTime(2026, 5, 19, 11))], + teacher: '旧老师', + ), + ]); + await preference.setString( + preference.Preference.currentSemester, + 'old-term', + ); + await preference.setBool(preference.Preference.role, true); + await preference.setBool( + preference.Preference.isUserDefinedSemester, + true, + ); + + await WearLocalCompanionSyncPort().importCredentials( + const WearCredentialSyncPayload( + idsAccount: '2200000001', + idsPassword: 'new-secret', + ), + ); + + expect(SchoolCardSession.openid, isEmpty); + expect(ClassTableSession.schoolClassDataCache.existsSync(), isFalse); + expect(SysjSession.otherExperimentCacheFile.existsSync(), isFalse); + expect( + preference.getString(preference.Preference.currentSemester), + isEmpty, + ); + expect(preference.getBool(preference.Preference.role), isFalse); + expect( + preference.getBool(preference.Preference.isUserDefinedSemester), + isFalse, + ); + expect( + preference.getString(preference.Preference.idsAccount), + '2200000001', + ); + }); + + test('imports credentials for future mobile-device transport', () async { + await WearLocalCompanionSyncPort().importCredentials( + const WearCredentialSyncPayload( + idsAccount: '2200000000', + idsPassword: 'secret', + isPostGraduate: true, + currentSemester: '2026-1', + ), + ); + + expect( + preference.getString(preference.Preference.idsAccount), + '2200000000', + ); + expect(preference.getString(preference.Preference.idsPassword), 'secret'); + expect(preference.getBool(preference.Preference.role), isTrue); + expect( + preference.getString(preference.Preference.currentSemester), + '2026-1', + ); + }); + + test( + 'imports class table and other experiments into local caches', + () async { + final table = _singleCourseTable('同步课程'); + final experiment = ExperimentData( + type: ExperimentType.others, + name: '同步实验', + classroom: '实验楼', + timeRanges: [(DateTime(2026, 5, 19, 10), DateTime(2026, 5, 19, 11))], + teacher: '同步老师', + ); + + await WearLocalCompanionSyncPort().importSchedule( + WearScheduleSyncPayload( + classTable: table, + otherExperiments: [experiment], + ), + ); + + expect( + ClassTableSession.getCache()?.$2.classDetail.single.name, + '同步课程', + ); + expect(SysjSession.getCache()?.$2.single.name, '同步实验'); + final cachedHome = await loadCachedWearHomeData( + semesterCode: '2026-1', + now: DateTime(2026, 5, 19, 8), + ); + expect( + preference.getString(preference.Preference.currentSemester), + '2026-1', + ); + expect( + cachedHome.data.todayItems.map((item) => item.title), + contains('同步课程'), + ); + }, + ); + + test('imports bundled native sync payload from companion phone', () async { + final table = _singleCourseTable('扫码同步课程'); + final envelope = WearCompanionSyncEnvelope.fromJson({ + 'schemaVersion': 1, + 'sessionId': 'session-123', + 'credentials': { + 'idsAccount': '2200000002', + 'idsPassword': 'synced-secret', + 'isPostGraduate': false, + 'currentSemester': 'fallback-term', + }, + 'schedule': {'classTable': table.toJson()}, + 'paymentQr': {'pngBase64': 'AQID', 'fetchedAtEpochMs': 1785816000000}, + }); + + await envelope.importInto(const WearLocalCompanionSyncPort()); + + expect( + preference.getString(preference.Preference.idsAccount), + '2200000002', + ); + expect( + preference.getString(preference.Preference.idsPassword), + 'synced-secret', + ); + expect( + preference.getString(preference.Preference.currentSemester), + '2026-1', + ); + expect( + ClassTableSession.getCache()?.$2.classDetail.single.name, + '扫码同步课程', + ); + expect( + File('${network.supportPath.path}/WearPaymentQr.png').readAsBytesSync(), + [1, 2, 3], + ); + }); + + test('rejects malformed native sync payloads', () { + expect( + () => WearCompanionSyncEnvelope.fromJson({ + 'schemaVersion': 1, + 'sessionId': 'session-123', + 'schedule': {'classTable': _singleCourseTable('缺少凭据').toJson()}, + }), + throwsFormatException, + ); + expect( + () => WearCompanionSyncEnvelope.fromJson({ + 'schemaVersion': 1, + 'sessionId': 'session-123', + 'credentials': {'idsAccount': '2200000002', 'idsPassword': 'secret'}, + }), + throwsFormatException, + ); + }); + }); +} + +ClassTableData _singleCourseTable(String name) { + return ClassTableData( + semesterLength: 1, + semesterCode: '2026-1', + termStartDay: '2026-05-18 00:00:00', + classDetail: [ClassDetail(name: name)], + timeArrangement: [ + TimeArrangement( + source: Source.school, + index: 0, + weekList: [true], + teacher: '赵老师', + classroom: 'A-301', + day: DateTime.tuesday, + start: 3, + stop: 4, + ), + ], + ); +} From 349e670d7459f17348ba22659b8bcef9e4d2d7b8 Mon Sep 17 00:00:00 2001 From: brill594 Date: Tue, 4 Aug 2026 14:19:53 +0900 Subject: [PATCH 08/16] build: wire Wear OS into monorepo --- .github/workflows/check_wearos.yaml | 44 +++++++++++++++++++++++++++++ README.md | 21 ++++++++++++++ wearos/.flutter | 1 - wearos/.gitmodules | 3 -- wearos/WEAR_SYNC_INTEGRATION.md | 14 +++++++-- wearos/pubspec.lock | 8 +++--- 6 files changed, 81 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/check_wearos.yaml delete mode 160000 wearos/.flutter delete mode 100644 wearos/.gitmodules diff --git a/.github/workflows/check_wearos.yaml b/.github/workflows/check_wearos.yaml new file mode 100644 index 00000000..75b5784b --- /dev/null +++ b/.github/workflows/check_wearos.yaml @@ -0,0 +1,44 @@ +name: Check Wear OS + +on: + pull_request: + paths: + - "wearos/**" + - ".github/workflows/check_wearos.yaml" + push: + branches: + - main + paths: + - "wearos/**" + - ".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: Resolve dependencies + working-directory: wearos + run: ../.flutter/bin/flutter pub get + + - name: Analyze + working-directory: wearos + run: ../.flutter/bin/flutter analyze + + - name: Test + working-directory: wearos + run: ../.flutter/bin/flutter test + + - name: Build Wear OS APK + working-directory: wearos + run: ../.flutter/bin/flutter build apk --debug --target-platform android-arm diff --git a/README.md b/README.md index ebd71ae8..7ef76986 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ XDYou,代码名称为 Traintime PDA,是为西电学生设计的开源信息 13. 上课前提醒。 14. 完备的国际化支持:支持繁体中文和英语。 15. 宿舍水机支持。 +16. 提供配套 Wear OS 应用,可同步课程、一卡通余额与付款码,并在断开手机时使用缓存数据。 ## 其他特性 @@ -66,6 +67,26 @@ Engine • hash fcf463a2242790d1fdcd9d044f533080f5022e18 (revision 4c525dac5e) ( Tools • Dart 3.12.0 • DevTools 2.57.0 ``` +### 仓库结构与 Wear OS 构建 + +主应用位于仓库根目录,配套的 Wear OS Flutter 应用位于 [`wearos/`](./wearos)。两端通信协议需要同步演进,因此 Wear OS 源码直接维护在同一仓库中,不使用额外 submodule。 + +首次拉取后初始化仓库共用的 Flutter SDK: + +```bash +git submodule update --init --recursive +``` + +构建和测试 Wear OS 应用: + +```bash +cd wearos +../.flutter/bin/flutter pub get +../.flutter/bin/flutter analyze +../.flutter/bin/flutter test +../.flutter/bin/flutter build apk --release --target-platform android-arm +``` + ## 授权信息 本程序源代码按照 MPLv2 授权,部分文件有 MIT / Apache-2.0 授权。 diff --git a/wearos/.flutter b/wearos/.flutter deleted file mode 160000 index 00b0c91f..00000000 --- a/wearos/.flutter +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 diff --git a/wearos/.gitmodules b/wearos/.gitmodules deleted file mode 100644 index 36dfdc0f..00000000 --- a/wearos/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule ".flutter"] - path = .flutter - url = https://github.com/flutter/flutter.git diff --git a/wearos/WEAR_SYNC_INTEGRATION.md b/wearos/WEAR_SYNC_INTEGRATION.md index 5a3f2653..0c0d97e8 100644 --- a/wearos/WEAR_SYNC_INTEGRATION.md +++ b/wearos/WEAR_SYNC_INTEGRATION.md @@ -3,6 +3,9 @@ XDYou Wear is a companion-only app. 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 and the standalone Wear OS Flutter +target lives in `wearos/`. Both targets use the root `.flutter` submodule. + ## Direct pairing 1. Open `配对手机` on the watch. The watch accepts a first pairing for five @@ -25,10 +28,16 @@ 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, the watch first asks the foreground phone app to use the +phone's current IDS session. The user can immediately choose `改用手表认证`; +the watch then uses the synchronized account/password and its own persistent +cookie store. Automatic slider verification and an on-watch SMS MFA page are +supported. + 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 clearly marked with its fetch time because it may -have expired. +watch; an offline copy is marked below the QR with its fetch time because it +may have expired. ## Envelope @@ -40,6 +49,7 @@ The JSON envelope uses schema version `1` and contains: 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 diff --git a/wearos/pubspec.lock b/wearos/pubspec.lock index 649633b1..0a320ba9 100644 --- a/wearos/pubspec.lock +++ b/wearos/pubspec.lock @@ -468,10 +468,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mime: dependency: transitive description: @@ -833,10 +833,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" time: dependency: "direct main" description: From d895501548b5f7ea7d3366b318b2d856ffd5f2c5 Mon Sep 17 00:00:00 2001 From: brill594 Date: Tue, 4 Aug 2026 18:16:14 +0900 Subject: [PATCH 09/16] refactor(wear): remove unused standalone data fetchers --- wearos/lib/main.dart | 4 +- wearos/lib/model/fetch_result.dart | 30 - .../model/not_school_network_exception.dart | 6 - wearos/lib/model/session_state.dart | 4 - wearos/lib/model/xidian_ids/paid_record.dart | 10 - wearos/lib/repository/network_session.dart | 66 -- .../xidian_ids/classtable_session.dart | 790 ------------------ .../repository/xidian_ids/ehall_session.dart | 138 --- .../repository/xidian_ids/ids_session.dart | 31 - .../xidian_ids/personal_info_session.dart | 149 ---- .../xidian_ids/school_card_session.dart | 71 -- .../repository/xidian_ids/sysj_session.dart | 390 --------- wearos/lib/wearos/slider_captcha.dart | 483 ----------- wearos/lib/wearos/wear_cache_store.dart | 71 ++ wearos/lib/wearos/wear_companion_sync.dart | 7 +- wearos/lib/wearos/wear_home_page.dart | 70 +- wearos/lib/wearos/wear_schedule_service.dart | 205 +---- wearos/pubspec.lock | 8 - wearos/pubspec.yaml | 1 - wearos/test/wear_app_test.dart | 40 +- wearos/test/wear_schedule_service_test.dart | 121 +-- 21 files changed, 154 insertions(+), 2541 deletions(-) delete mode 100644 wearos/lib/model/fetch_result.dart delete mode 100644 wearos/lib/model/not_school_network_exception.dart delete mode 100644 wearos/lib/model/session_state.dart delete mode 100644 wearos/lib/model/xidian_ids/paid_record.dart delete mode 100644 wearos/lib/repository/xidian_ids/classtable_session.dart delete mode 100644 wearos/lib/repository/xidian_ids/ehall_session.dart delete mode 100644 wearos/lib/repository/xidian_ids/personal_info_session.dart delete mode 100644 wearos/lib/repository/xidian_ids/sysj_session.dart create mode 100644 wearos/lib/wearos/wear_cache_store.dart diff --git a/wearos/lib/main.dart b/wearos/lib/main.dart index 557b89a8..dfb56d09 100644 --- a/wearos/lib/main.dart +++ b/wearos/lib/main.dart @@ -7,8 +7,8 @@ import 'package:watermeter/repository/logger.dart'; import 'package:watermeter/repository/network_session.dart' as network; import 'package:watermeter/repository/preference.dart' as preference; import 'package:watermeter/repository/xidian_ids/ids_session.dart'; -import 'package:watermeter/repository/xidian_ids/classtable_session.dart'; import 'package:watermeter/wearos/wear_app.dart'; +import 'package:watermeter/wearos/wear_cache_store.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -42,7 +42,7 @@ Future main() async { // Treat a missing/unavailable native pairing record as unpaired. } final isFirst = - !isCompanionPaired || semester.isEmpty || !ClassTableSession.isCacheExist; + !isCompanionPaired || semester.isEmpty || !WearClassTableCache.exists; loginState = isFirst ? IDSLoginState.manual : IDSLoginState.none; runApp(WearApp(isFirst: isFirst)); diff --git a/wearos/lib/model/fetch_result.dart b/wearos/lib/model/fetch_result.dart deleted file mode 100644 index 5408fe7b..00000000 --- a/wearos/lib/model/fetch_result.dart +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright 2026 Traintime PDA Authours, originally by BenderBlog Rodriguez. -// SPDX-License-Identifier: MPL-2.0 - -class FetchResult { - final bool isCache; - final DateTime fetchTime; - final T data; - final String? hintKey; - - const FetchResult._({ - required this.isCache, - required this.fetchTime, - required this.data, - this.hintKey, - }); - - factory FetchResult.fresh({required DateTime fetchTime, required T data}) => - FetchResult._(isCache: false, fetchTime: fetchTime, data: data); - - factory FetchResult.cache({ - required DateTime fetchTime, - required T data, - String? hintKey, - }) => FetchResult._( - isCache: true, - fetchTime: fetchTime, - data: data, - hintKey: hintKey, - ); -} diff --git a/wearos/lib/model/not_school_network_exception.dart b/wearos/lib/model/not_school_network_exception.dart deleted file mode 100644 index e77c7bee..00000000 --- a/wearos/lib/model/not_school_network_exception.dart +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright 2026 Traintime PDA Authours, originally by BenderBlog Rodriguez. -// SPDX-License-Identifier: MPL-2.0 - -class NotSchoolNetworkException implements Exception { - final String msg = "not_school_network"; -} diff --git a/wearos/lib/model/session_state.dart b/wearos/lib/model/session_state.dart deleted file mode 100644 index 52354bba..00000000 --- a/wearos/lib/model/session_state.dart +++ /dev/null @@ -1,4 +0,0 @@ -// Copyright 2026 Traintime PDA Authours, originally by BenderBlog Rodriguez. -// SPDX-License-Identifier: MPL-2.0 - -enum SessionState { fetching, fetched, error, none } diff --git a/wearos/lib/model/xidian_ids/paid_record.dart b/wearos/lib/model/xidian_ids/paid_record.dart deleted file mode 100644 index 5903aa35..00000000 --- a/wearos/lib/model/xidian_ids/paid_record.dart +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright 2023-2025 BenderBlog Rodriguez and contributors -// Copyright 2025 Traintime PDA authors. -// SPDX-License-Identifier: MPL-2.0 - -class PaidRecord { - String place; - String date; - String money; - PaidRecord({required this.place, required this.date, required this.money}); -} diff --git a/wearos/lib/repository/network_session.dart b/wearos/lib/repository/network_session.dart index 51387af5..52415f33 100644 --- a/wearos/lib/repository/network_session.dart +++ b/wearos/lib/repository/network_session.dart @@ -9,15 +9,11 @@ import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:cookie_jar/cookie_jar.dart'; import 'package:dio_cookie_manager/dio_cookie_manager.dart'; -import 'package:flutter/widgets.dart'; -import 'package:watermeter/model/session_state.dart'; import 'package:watermeter/repository/logger.dart'; late Directory supportPath; class NetworkSession { - static SessionState _isInit = SessionState.none; - //@protected final PersistCookieJar cookieJar = PersistCookieJar( persistSession: true, @@ -46,66 +42,4 @@ class NetworkSession { ..options.followRedirects = false ..options.validateStatus = (status) => status != null && status >= 200 && status < 400; - - static Future isInSchool() async { - bool isInSchool = false; - Dio dio = Dio() - ..interceptors.add(logDioAdapter) - ..options.connectTimeout = const Duration(seconds: 30); - isInSchool = await dio - .get("https://rs.xidian.edu.cn/cas/login.php") - .then((value) => true) - .onError((error, stackTrace) { - log.warning( - "[isSchoolNet] Current net is not schoolnet.", - error, - stackTrace, - ); - return false; - }); - return isInSchool; - } - - NetworkSession() { - if (_isInit == SessionState.none) { - initSession(); - } - } - - Future initSession() async { - log.info( - "[NetworkSession][initSession] " - "Current State: $_isInit", - ); - if (_isInit == SessionState.fetching) { - return; - } - try { - _isInit = SessionState.fetching; - log.info( - "[NetworkSession][initSession] " - "Fetching...", - ); - var response = await dio.get("http://linux.xidian.edu.cn"); - if (response.statusCode == 200) { - _isInit = SessionState.fetched; - log.info( - "[NetworkSession][initSession] " - "Fetched", - ); - } else { - _isInit = SessionState.error; - log.error( - "[NetworkSession][initSession] " - "Error", - ); - } - } catch (e) { - _isInit = SessionState.error; - log.error( - "[NetworkSession][initSession] " - "Error: $e", - ); - } - } } diff --git a/wearos/lib/repository/xidian_ids/classtable_session.dart b/wearos/lib/repository/xidian_ids/classtable_session.dart deleted file mode 100644 index 15f7e21f..00000000 --- a/wearos/lib/repository/xidian_ids/classtable_session.dart +++ /dev/null @@ -1,790 +0,0 @@ -// Copyright 2023-2025 BenderBlog Rodriguez and contributors -// Copyright 2025 Traintime PDA authors. -// SPDX-License-Identifier: MPL-2.0 - -// The class table window source. -// Thanks xidian-script and libxdauth! - -import 'dart:convert'; -import 'dart:io'; -import 'package:dio/dio.dart'; -import 'package:flutter/foundation.dart'; -import 'package:intl/intl.dart'; -import 'package:time/time.dart'; -import 'package:watermeter/model/fetch_result.dart'; -import 'package:watermeter/wearos/slider_captcha.dart'; -import 'package:watermeter/repository/logger.dart'; -import 'package:watermeter/repository/network_session.dart'; -import 'package:watermeter/repository/preference.dart' as pref; -import 'package:watermeter/model/xidian_ids/classtable.dart'; -import 'package:watermeter/repository/xidian_ids/ehall_session.dart'; -import 'package:watermeter/repository/xidian_ids/ids_session.dart'; - -String _cacheHintFromError(Object error) { - if (error is PasswordWrongException) { - return "classtable.cache_hint_password_wrong"; - } - if (error is LoginFailedException) { - return "classtable.cache_hint_login_failed"; - } - if (error is DioException) { - return "classtable.cache_hint_network_failed"; - } - return "classtable.cache_hint_unknown_error"; -} - -Future> getClassTable(String semesterCode) async { - try { - ClassTableData data = pref.getBool(pref.Preference.role) - ? await ClassTableSession().getYjspt(semesterCode) - : await ClassTableSession().getEhall(semesterCode); - DateTime fetchTime = DateTime.now(); - await ClassTableSession.updateCacheAndGroup(data); - return FetchResult.fresh(fetchTime: fetchTime, data: data); - } catch (e, s) { - log.handle(e, s, "[getClassTable] Have issue"); - (DateTime, ClassTableData)? cache = ClassTableSession.getCache(); - if (cache != null) { - return FetchResult.cache( - fetchTime: cache.$1, - data: cache.$2, - hintKey: _cacheHintFromError(e), - ); - } - rethrow; - } -} - -/// 课程表 4770397878132218 -class ClassTableSession extends EhallSession { - static const schoolClassName = "ClassTable.json"; - - static File schoolClassDataCache = File( - "${supportPath.path}/$schoolClassName", - ); - static bool get isCacheExist => schoolClassDataCache.existsSync(); - - static void deleteCache() { - if (schoolClassDataCache.existsSync()) { - schoolClassDataCache.deleteSync(); - } - } - - static Future updateCacheAndGroup(ClassTableData data) async { - await schoolClassDataCache.writeAsString(jsonEncode(data.toJson())); - } - - static (DateTime, ClassTableData)? getCache() { - try { - ClassTableData toReturn = ClassTableData.fromJson( - jsonDecode(schoolClassDataCache.readAsStringSync()), - ); - DateTime fetchTime = schoolClassDataCache.lastModifiedSync(); - return (fetchTime, toReturn); - } catch (e, s) { - log.handle(e, s); - return null; - } - } - - Future getYjspt(String semesterCode) async { - Map qResult = {}; - - // const semesterCodeURL = - // "https://yjspt.xidian.edu.cn/gsapp/sys/wdkbapp/modules/xskcb/kfdxnxqcx.do"; - const classInfoURL = - "https://yjspt.xidian.edu.cn/gsapp/sys/wdkbapp/modules/xskcb/xspkjgcx.do"; - const notArrangedInfoURL = - "https://yjspt.xidian.edu.cn/gsapp/sys/wdkbapp/modules/xskcb/xswsckbkc.do"; - - log.info("[getClasstable][getYjspt] Login the system."); - String? location = await checkAndLogin( - target: - "https://yjspt.xidian.edu.cn/gsapp/" - "sys/wdkbapp/*default/index.do#/xskcb", - sliderCaptcha: (String cookieStr) => - SliderCaptchaClientProvider(cookie: cookieStr).solve(null), - ); - - while (location != null) { - var response = await dio.get(location); - log.info("[getClasstable][getYjspt] Received location: $location."); - location = response.headers[HttpHeaders.locationHeader]?[0]; - } - - DateTime now = DateTime.now(); - var currentWeek = await dio - .post( - 'https://yjspt.xidian.edu.cn/gsapp/sys/yjsemaphome/portal/queryRcap.do', - data: {'day': DateFormat("yyyyMMdd").format(now)}, - ) - .then((value) => value.data); - if (!currentWeek.toString().contains("xnxq")) { - return ClassTableData(semesterCode: semesterCode); - } - currentWeek = - RegExp(r'[0-9]+').firstMatch(currentWeek["xnxq"])?[0] ?? "null"; - - log.info( - "[getClasstable][getYjspt] Current week is $currentWeek, fetching...", - ); - int weekDay = now.weekday - 1; - String termStartDay = DateFormat("yyyy-MM-dd HH:mm:ss").format( - now.add(Duration(days: (1 - int.parse(currentWeek)) * 7 - weekDay)).date, - ); - - Map data = await dio - .post(classInfoURL, data: {"XNXQDM": semesterCode}) - .then((response) => response.data); - - if (data['code'] != "0") { - log.warning( - "[getClasstable][getYjspt] " - "extParams: ${data['extParams']['msg']} isNotPublish: " - "${data['extParams']['msg'].toString().contains("查询学年学期的课程未发布")}", - ); - if (data['extParams']['msg'].toString().contains("查询学年学期的课程未发布")) { - log.warning( - "[getClasstable][getYjspt] " - "extParams: ${data['extParams']['msg']} isNotPublish: " - "Classtable not released.", - ); - return ClassTableData( - semesterCode: semesterCode, - termStartDay: termStartDay, - ); - } else { - throw Exception("${data['extParams']['msg']}"); - } - } - - qResult["rows"] = data["datas"]["xspkjgcx"]["rows"]; - - var notOnTable = await dio - .post( - notArrangedInfoURL, - data: { - 'XNXQDM': semesterCode, - 'XH': pref.getString(pref.Preference.idsAccount), - }, - ) - .then((value) => value.data['datas']['xswsckbkc']); - qResult["notArranged"] = notOnTable["rows"]; - - ClassTableData toReturn = ClassTableData(); - toReturn.semesterCode = semesterCode; - toReturn.termStartDay = termStartDay; - - log.info( - "[getClasstable][getYjspt] " - "${toReturn.semesterCode} ${toReturn.termStartDay}", - ); - - for (var i in qResult["rows"]) { - var toDeal = ClassDetail(name: i["KCMC"], code: i["KCDM"]); - if (!toReturn.classDetail.contains(toDeal)) { - toReturn.classDetail.add(toDeal); - } - - toReturn.timeArrangement.add( - TimeArrangement( - source: Source.school, - index: toReturn.classDetail.indexOf(toDeal), - start: i["KSJCDM"], - teacher: i["JSXM"], - stop: i["JSJCDM"], - day: int.parse(i["XQ"].toString()), - weekList: List.generate( - i["ZCBH"].toString().length, - (index) => i["ZCBH"].toString()[index] == "1", - ), - classroom: i["JASMC"], - ), - ); - - if (i["ZCBH"].toString().length > toReturn.semesterLength) { - toReturn.semesterLength = i["ZCBH"].toString().length; - } - } - - // Post deal here - List newStuff = []; - int getCourseId(TimeArrangement i) => - "${i.weekList}-${i.day}-${i.classroom}".hashCode; - - for (var i = 0; i < toReturn.classDetail.length; ++i) { - List data = List.from( - toReturn.timeArrangement, - )..removeWhere((item) => item.index != i); - List entries = []; - //Map> toAdd = {}; - - for (var j in data) { - int id = getCourseId(j); - if (!entries.any((k) => k == id)) entries.add(id); - } - for (var j in entries) { - List result = List.from(data) - ..removeWhere((item) => getCourseId(item) != j) - ..sort((a, b) => a.start - b.start); - - List arrangementsProto = { - for (var i in result) ...[i.start, i.stop], - }.toList()..sort(); - - log.info(arrangementsProto); - - List> arrangements = [[]]; - for (var j in arrangementsProto) { - if (arrangements.last.isEmpty || arrangements.last.last == j - 1) { - arrangements.last.add(j); - } else { - arrangements.add([j]); - } - } - - log.info(arrangements); - - for (var j in arrangements) { - newStuff.add( - TimeArrangement( - source: Source.school, - index: i, - classroom: result.first.classroom, - teacher: result.first.teacher, - weekList: result.first.weekList, - day: result.first.day, - start: j.first, - stop: j.last, - ), - ); - } - } - } - - toReturn.timeArrangement = newStuff; - - for (var i in qResult["notArranged"]) { - toReturn.notArranged.add( - NotArrangementClassDetail(name: i["KCMC"], code: i["KCDM"]), - ); - } - - return toReturn; - } - - Future getEhall(String semesterCode) async { - Map qResult = {}; - log.info("[getClasstable][getEhall] Login the system."); - String get = await useApp("4770397878132218"); - log.info("[getClasstable][getEhall] Location: $get"); - await dioEhall.post(get); - - log.info( - "[getClasstable][getEhall] " - "Fetch the semester information.", - ); - - log.info( - "[getClasstable][getEhall] " - "Fetch the day the semester begin.", - ); - String termStartDay = await dioEhall - .post( - 'https://ehall.xidian.edu.cn/jwapp/sys/wdkb/modules/jshkcb/cxjcs.do', - data: { - 'XN': '${semesterCode.split('-')[0]}-${semesterCode.split('-')[1]}', - 'XQ': semesterCode.split('-')[2], - }, - ) - .then((value) => value.data['datas']['cxjcs']['rows'][0]["XQKSRQ"]); - log.info( - "[getClasstable][getEhall] " - "Will get $semesterCode which start at $termStartDay.", - ); - - qResult = await dioEhall - .post( - 'https://ehall.xidian.edu.cn/jwapp/sys/wdkb/modules/xskcb/xskcb.do', - data: { - 'XNXQDM': semesterCode, - 'XH': pref.getString(pref.Preference.idsAccount), - }, - ) - .then((value) => value.data['datas']['xskcb']); - if (qResult['extParams']['code'] != 1) { - log.warning( - "[getClasstable][getEhall] " - "extParams: ${qResult['extParams']['msg']} isNotPublish: " - "${qResult['extParams']['msg'].toString().contains("查询学年学期的课程未发布")}", - ); - if (qResult['extParams']['msg'].toString().contains("查询学年学期的课程未发布")) { - log.warning( - "[getClasstable][getEhall] " - "extParams: ${qResult['extParams']['msg']} isNotPublish: " - "Classtable not released.", - ); - return ClassTableData( - semesterCode: semesterCode, - termStartDay: termStartDay, - ); - } else { - throw Exception("${qResult['extParams']['msg']}"); - } - } - - log.info( - "[getClasstable][getEhall] " - "Preliminary storage...", - ); - qResult["semesterCode"] = semesterCode; - qResult["termStartDay"] = termStartDay; - - var notOnTable = await dioEhall - .post( - "https://ehall.xidian.edu.cn/jwapp/sys/wdkb/modules/xskcb/cxxsllsywpk.do", - data: { - 'XNXQDM': semesterCode, - 'XH': pref.getString(pref.Preference.idsAccount), - }, - ) - .then((value) => value.data['datas']['cxxsllsywpk']); - - log.info("[getClasstable][getEhall] $notOnTable"); - qResult["notArranged"] = notOnTable["rows"]; - - ClassTableData preliminaryData = ClassTableData(); - - preliminaryData.semesterCode = qResult["semesterCode"]; - preliminaryData.termStartDay = qResult["termStartDay"]; - - log.info( - "[getClasstable][getEhall] " - "${preliminaryData.semesterCode} ${preliminaryData.termStartDay}", - ); - - for (var i in qResult["rows"]) { - var toDeal = ClassDetail( - name: i["KCM"], - code: i["KCH"], - number: i["KXH"], - ); - if (!preliminaryData.classDetail.contains(toDeal)) { - preliminaryData.classDetail.add(toDeal); - } - preliminaryData.timeArrangement.add( - TimeArrangement( - source: Source.school, - index: preliminaryData.classDetail.indexOf(toDeal), - start: int.parse(i["KSJC"]), - teacher: i["SKJS"], - stop: int.parse(i["JSJC"]), - day: int.parse(i["SKXQ"]), - weekList: List.generate( - i["SKZC"].toString().length, - (index) => i["SKZC"].toString()[index] == "1", - ), - classroom: i["JASMC"], - ), - ); - if (i["SKZC"].toString().length > preliminaryData.semesterLength) { - preliminaryData.semesterLength = i["SKZC"].toString().length; - } - } - - // Deal with the not arranged data. - for (var i in qResult["notArranged"]) { - preliminaryData.notArranged.add( - NotArrangementClassDetail( - name: i["KCM"], - code: i["KCH"], - number: i["KXH"], - teacher: i["SKJS"], - ), - ); - } - - /// Deal with the class change. - log.info( - "[getClasstable][getEhall] " - "Deal with the class change...", - ); - - qResult = await dioEhall - .post( - 'https://ehall.xidian.edu.cn/jwapp/sys/wdkb/modules/xskcb/xsdkkc.do', - data: { - 'XNXQDM': semesterCode, - //'SKZC': "6", - '*order': "-SQSJ", - }, - ) - .then((value) => value.data['datas']['xsdkkc']); - if (qResult['extParams']['code'] != 1) { - log.warning("[getClasstable][getEhall] ${qResult['extParams']['msg']}"); - } - - // ignore: non_constant_identifier_names - ChangeType type(String TKLXDM) { - if (TKLXDM == '01') { - return ChangeType.change; //调课 - } else if (TKLXDM == '02') { - return ChangeType.stop; //停课 - } else { - return ChangeType.patch; //补课 - } - } - - // Merge change info - if (int.parse(qResult["totalSize"].toString()) > 0) { - for (var i in qResult["rows"]) { - preliminaryData.classChanges.add( - ClassChange( - type: type(i["TKLXDM"]), - classCode: i["KCH"], - classNumber: i["KXH"], - className: i["KCM"], - originalAffectedWeeks: i["SKZC"] == null - ? null - : List.generate( - i["SKZC"].toString().length, - (index) => i["SKZC"].toString()[index] == "1", - ), - newAffectedWeeks: i["XSKZC"] == null - ? null - : List.generate( - i["XSKZC"].toString().length, - (index) => i["XSKZC"].toString()[index] == "1", - ), - originalTeacherData: i["YSKJS"], - newTeacherData: i["XSKJS"], - originalClassRange: [ - int.parse(i["KSJC"]?.toString() ?? "-1"), - int.parse(i["JSJC"]?.toString() ?? "-1"), - ], - newClassRange: [ - int.parse(i["XKSJC"]?.toString() ?? "-1"), - int.parse(i["XJSJC"]?.toString() ?? "-1"), - ], - originalWeek: i["SKXQ"], - newWeek: i["XSKXQ"], - originalClassroom: i["JASMC"], - newClassroom: i["XJASMC"], - ), - ); - } - } - - log.info( - "[getClasstable][getEhall] " - "Dealing class change with ${preliminaryData.classChanges.length} info(s).", - ); - - List cache = []; - List toDeal = List.from( - preliminaryData.classChanges, - ); - - while (toDeal.isNotEmpty) { - int previousLength = toDeal.length; - List toBeRemovedIndex = []; - for (var e in toDeal) { - /// First, search for the classes. - /// Due to the unstability of the api, a list is introduced. - /// This must have an answer, otherwise there's a potato in the school's server. - List indexClassDetailList = []; - for (int i = 0; i < preliminaryData.classDetail.length; ++i) { - if (preliminaryData.classDetail[i].code == e.classCode) { - indexClassDetailList.add(i); - } - } - log.info( - "[getClasstable][getEhall] " - "Class change related to class index $indexClassDetailList.", - ); - - /// If the class is not in the main schedule, create a new entry. - if (indexClassDetailList.isEmpty) { - if (e.type == ChangeType.patch) { - log.info( - "[getClasstable][getEhall] " - "Class ${e.className} (${e.classCode}) not in main schedule, " - "creating new ClassDetail for patch.", - ); - var newDetail = ClassDetail( - name: e.className, - code: e.classCode, - number: e.classNumber, - ); - preliminaryData.classDetail.add(newDetail); - int newIndex = preliminaryData.classDetail.length - 1; - preliminaryData.timeArrangement.add( - TimeArrangement( - source: Source.school, - index: newIndex, - weekList: e.newAffectedWeeks ?? e.originalAffectedWeeks ?? [], - day: e.newWeek ?? e.originalWeek ?? 0, - start: e.newClassRange[0], - stop: e.newClassRange[1], - classroom: e.newClassroom ?? e.originalClassroom, - teacher: e.isTeacherChanged ? e.newTeacher : e.originalTeacher, - ), - ); - } else { - log.warning( - "[getClasstable][getEhall] " - "Class ${e.className} (${e.classCode}) not found in main schedule, " - "skipping class change entry (type: ${e.type}).", - ); - } - toBeRemovedIndex.add(toDeal.indexOf(e)); - continue; - } - - /// Then, if patch, find the class and add one - if (e.type == ChangeType.patch) { - log.info( - "[getClasstable][getEhall] " - "Class patch.", - ); - - /// Add classes. - preliminaryData.timeArrangement.add( - TimeArrangement( - source: Source.school, - index: indexClassDetailList.first, - weekList: e.newAffectedWeeks ?? e.originalAffectedWeeks ?? [], - day: e.newWeek ?? e.originalWeek ?? 0, - start: e.newClassRange[0], - stop: e.newClassRange[1], - classroom: e.newClassroom ?? e.originalClassroom, - teacher: e.isTeacherChanged ? e.newTeacher : e.originalTeacher, - ), - ); - continue; - } - - /// Otherwise, find the all time arrangement related to the class. - log.info( - "[getClasstable][getEhall] " - "Class change related to class detail index $indexClassDetailList.", - ); - List indexOriginalTimeArrangementList = []; - for (var currentClassIndex in indexClassDetailList) { - for (int i = 0; i < preliminaryData.timeArrangement.length; ++i) { - if (preliminaryData.timeArrangement[i].index == currentClassIndex && - preliminaryData.timeArrangement[i].day == e.originalWeek && - preliminaryData.timeArrangement[i].start == - e.originalClassRange[0] && - preliminaryData.timeArrangement[i].stop == - e.originalClassRange[1]) { - indexOriginalTimeArrangementList.add(i); - } - } - } - - /// Third, search for the time arrangements, seek for the truth. - log.info( - "[getClasstable][getEhall] " - "Class change related to time arrangement index $indexOriginalTimeArrangementList.", - ); - - /// If empty, remove from toDeal to avoid infinite loop. - if (indexOriginalTimeArrangementList.isEmpty) { - toBeRemovedIndex.add(toDeal.indexOf(e)); - continue; - } - - if (e.type == ChangeType.change) { - int timeArrangementIndex = indexOriginalTimeArrangementList.first; - - log.info( - "[getClasstable][getEhall] " - "Class change. Teacher changed? ${e.isTeacherChanged}. timeArrangementIndex is $timeArrangementIndex", - ); - for (int indexOriginalTimeArrangement - in indexOriginalTimeArrangementList) { - /// Seek for the change entry. Delete the classes moved waay. - log.info( - "[getClasstable][getEhall] " - "Original weeklist ${preliminaryData.timeArrangement[indexOriginalTimeArrangement].weekList} " - "with originalAffectedWeeksList ${e.originalAffectedWeeksList}.", - ); - for (int i in e.originalAffectedWeeksList) { - var weekList = preliminaryData - .timeArrangement[indexOriginalTimeArrangement] - .weekList; - if (i >= weekList.length) { - int oldLength = weekList.length; - weekList.addAll(List.filled(i + 1 - oldLength, false)); - if (weekList.length > preliminaryData.semesterLength) { - preliminaryData.semesterLength = weekList.length; - } - } - log.info( - "[getClasstable][getEhall] " - "Week $i, status ${preliminaryData.timeArrangement[indexOriginalTimeArrangement].weekList[i]}.", - ); - if (preliminaryData - .timeArrangement[indexOriginalTimeArrangement] - .weekList[i]) { - preliminaryData - .timeArrangement[indexOriginalTimeArrangement] - .weekList[i] = - false; - timeArrangementIndex = preliminaryData - .timeArrangement[indexOriginalTimeArrangement] - .index; - } - } - - log.info( - "[getClasstable][getEhall] " - "New weeklist ${preliminaryData.timeArrangement[indexOriginalTimeArrangement].weekList}.", - ); - } - - if (timeArrangementIndex == indexOriginalTimeArrangementList.first) { - cache.add(e); - timeArrangementIndex = preliminaryData - .timeArrangement[indexOriginalTimeArrangementList.first] - .index; - } - - log.info( - "[getClasstable][getEhall] " - "New week: ${e.newAffectedWeeks}, " - "day: ${e.newWeek}, " - "startToStop: ${e.newClassRange}, " - "timeArrangementIndex: $timeArrangementIndex.", - ); - - bool flag = false; - ClassChange? toRemove; - log.info("[getClasstable][getEhall] cache length = ${cache.length}"); - for (var f in cache) { - //log.info("[getClasstable][getFromWeb]" - // "${f.className} ${f.classCode} ${f.originalClassRange} ${f.originalAffectedWeeksList} ${f.originalWeek}"); - //log.info("[getClasstable][getFromWeb]" - // "${e.className} ${e.classCode} ${e.newClassRange} ${e.newAffectedWeeksList} ${e.newWeek}"); - //log.info("[getClasstable][getFromWeb]" - // "${f.className == e.className} ${f.classCode == e.classCode} ${listEquals(f.originalClassRange, e.newClassRange)} ${listEquals(f.originalAffectedWeeksList, e.newAffectedWeeksList)} ${f.originalWeek == e.newWeek}"); - if (f.className == e.className && - f.classCode == e.classCode && - listEquals(f.originalClassRange, e.newClassRange) && - listEquals( - f.originalAffectedWeeksList, - e.newAffectedWeeksList, - ) && - f.originalWeek == e.newWeek && - f.originalClassroom == e.newClassroom && - f.originalTeacherData == e.newTeacherData) { - flag = true; - toRemove = f; - break; - } - } - - if (flag) { - cache.remove(toRemove); - log.info( - "[getClasstable][getEhall] " - "Cannot be added", - ); - continue; - } - - log.info( - "[getClasstable][getEhall] " - "Can be added", - ); - - /// Add classes. - preliminaryData.timeArrangement.add( - TimeArrangement( - source: Source.school, - index: timeArrangementIndex, - weekList: e.newAffectedWeeks ?? e.originalAffectedWeeks ?? [], - day: e.newWeek ?? e.originalWeek ?? 0, - start: e.newClassRange[0], - stop: e.newClassRange[1], - classroom: e.newClassroom ?? e.originalClassroom, - teacher: e.isTeacherChanged ? e.newTeacher : e.originalTeacher, - ), - ); - } else { - log.info( - "[getClasstable][getEhall] " - "Class stop.", - ); - - for (int indexOriginalTimeArrangement - in indexOriginalTimeArrangementList) { - log.info( - "[getClasstable][getEhall] " - "Original weeklist " - "${preliminaryData.timeArrangement[indexOriginalTimeArrangement].weekList} " - "with originalAffectedWeeksList ${e.originalAffectedWeeksList}.", - ); - for (int i in e.originalAffectedWeeksList) { - var weekList = preliminaryData - .timeArrangement[indexOriginalTimeArrangement] - .weekList; - if (i >= weekList.length) { - int oldLength = weekList.length; - weekList.addAll(List.filled(i + 1 - oldLength, false)); - if (weekList.length > preliminaryData.semesterLength) { - preliminaryData.semesterLength = weekList.length; - } - } - log.info( - "[getClasstable][getEhall] " - "$i ${preliminaryData.timeArrangement[indexOriginalTimeArrangement].weekList[i]}", - ); - if (preliminaryData - .timeArrangement[indexOriginalTimeArrangement] - .weekList[i]) { - preliminaryData - .timeArrangement[indexOriginalTimeArrangement] - .weekList[i] = - false; - } - } - log.info( - "[getClasstable][getEhall] " - "New weeklist " - "${preliminaryData.timeArrangement[indexOriginalTimeArrangement].weekList}.", - ); - } - } - toBeRemovedIndex.add(toDeal.indexOf(e)); - } - toDeal = [ - for (var i = 0; i < toDeal.length; ++i) - if (!toBeRemovedIndex.contains(i)) toDeal[i], - ]; - log.info( - "[getClasstable][getEhall] " - "After this turn, ${toDeal.length} left, removed $toBeRemovedIndex.", - ); - - /// Safety: if no progress was made in this pass, break to avoid infinite loop. - if (toDeal.length == previousLength) { - log.warning( - "[getClasstable][getEhall] " - "No progress made in class change processing. " - "Remaining ${toDeal.length} change(s) could not be resolved. " - "Breaking to avoid infinite loop.", - ); - break; - } - } - - return preliminaryData; - } -} - -class NotSameSemesterException implements Exception { - final String msg; - NotSameSemesterException({required this.msg}); -} diff --git a/wearos/lib/repository/xidian_ids/ehall_session.dart b/wearos/lib/repository/xidian_ids/ehall_session.dart deleted file mode 100644 index c3cdd52e..00000000 --- a/wearos/lib/repository/xidian_ids/ehall_session.dart +++ /dev/null @@ -1,138 +0,0 @@ -// Copyright 2023-2025 BenderBlog Rodriguez and contributors -// Copyright 2025 Traintime PDA authors. -// SPDX-License-Identifier: MPL-2.0 - -// E-hall class, which get lots of useful data here. -// Thanks xidian-script and libxdauth! - -import 'dart:io'; - -import 'package:dio/dio.dart'; -import 'package:synchronized/synchronized.dart'; -import 'package:watermeter/wearos/slider_captcha.dart'; -import 'package:watermeter/repository/logger.dart'; -import 'package:watermeter/repository/xidian_ids/ids_session.dart'; - -class EhallSession extends IDSSession { - static final _ehallLock = Lock(); - - /// This header shall only be used in the ehall related stuff... - Map refererHeader = { - HttpHeaders.refererHeader: "http://ehall.xidian.edu.cn/new/index_xd.html", - HttpHeaders.hostHeader: "ehall.xidian.edu.cn", - HttpHeaders.acceptHeader: - "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9", - HttpHeaders.acceptLanguageHeader: - 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6', - HttpHeaders.acceptEncodingHeader: 'identity', - HttpHeaders.connectionHeader: 'Keep-Alive', - HttpHeaders.contentTypeHeader: - "application/x-www-form-urlencoded; charset=UTF-8", - }; - - Dio get dioEhall => super.dio..options = BaseOptions(headers: refererHeader); - Dio get dioEhallNoOfflineCheck => - dioNoOfflineCheck..options = BaseOptions(headers: refererHeader); - - Future completeLoginRedirect( - String location, { - bool ignoreOffline = false, - }) async { - final initialDio = ignoreOffline ? dioNoOfflineCheck : dio; - final redirectDio = ignoreOffline ? dioEhallNoOfflineCheck : dioEhall; - var response = await initialDio.get(location); - while (response.headers[HttpHeaders.locationHeader] != null) { - location = response.headers[HttpHeaders.locationHeader]![0]; - log.info( - "[ehall_session][completeLoginRedirect] " - "Received location: $location", - ); - response = await redirectDio.get(location); - } - } - - Future isLoggedIn() async { - var response = await dioEhall.get( - "https://ehall.xidian.edu.cn/jsonp/getAppUsageMonitor.json?type=uv", - ); - log.info( - "[ehall_session][isLoggedIn] " - "Ehall isLoggedin: ${response.data["hasLogin"]}", - ); - return response.data["hasLogin"]; - } - - Future loginEhall({ - required String username, - required String password, - required Future Function(String) sliderCaptcha, - required void Function(int, String) onResponse, - }) async { - String location = await super.login( - target: - "https://ehall.xidian.edu.cn/login?service=https://ehall.xidian.edu.cn/new/index.html", - username: username, - password: password, - sliderCaptcha: sliderCaptcha, - onResponse: onResponse, - ); - await completeLoginRedirect(location); - } - - Future useApp(String appID) async { - return await _ehallLock.synchronized(() async { - log.info( - "[ehall_session][useApp] " - "Ready to use the app $appID. Try to Login.", - ); - if (!await isLoggedIn()) { - String location = await super.checkAndLogin( - target: - "https://ehall.xidian.edu.cn/login?" - "service=https://ehall.xidian.edu.cn/new/index.html", - sliderCaptcha: (String cookieStr) => - SliderCaptchaClientProvider(cookie: cookieStr).solve(null), - ); - var response = await dio.get(location); - while (response.headers[HttpHeaders.locationHeader] != null) { - location = response.headers[HttpHeaders.locationHeader]![0]; - log.info( - "[ehall_session][useApp] " - "Received location: $location.", - ); - response = await dioEhall.get(location); - } - } - log.info( - "[ehall_session][useApp] " - "Try to use the $appID.", - ); - var value = await dioEhall.get( - "https://ehall.xidian.edu.cn/appShow?appId=$appID", - options: Options( - followRedirects: false, - validateStatus: (status) { - return status! < 500; - }, - ), - ); - log.info( - "[ehall_session][useApp] " - "Transfer address: ${value.headers['location']![0]}.", - ); - - return value.headers['location']![0].replaceAll( - RegExp(r';jsessionid=(.*)\?'), - "?", - ); - }); - } -} - -class GetInformationFailedException implements Exception { - final String msg; - const GetInformationFailedException(this.msg); - - @override - String toString() => msg; -} diff --git a/wearos/lib/repository/xidian_ids/ids_session.dart b/wearos/lib/repository/xidian_ids/ids_session.dart index 7b06763a..005cea47 100644 --- a/wearos/lib/repository/xidian_ids/ids_session.dart +++ b/wearos/lib/repository/xidian_ids/ids_session.dart @@ -349,37 +349,6 @@ class IDSSession extends NetworkSession { } } } - - Future checkWhetherPostgraduate({ - Future Function(String)? sliderCaptcha, - }) async { - String location = await checkAndLogin( - target: - "https://yjspt.xidian.edu.cn/gsapp" - "/sys/yjsemaphome/portal/index.do", - sliderCaptcha: - sliderCaptcha ?? - (cookieStr) => - SliderCaptchaClientProvider(cookie: cookieStr).solve(null), - ); - var response = await dio.get(location); - while (response.headers[HttpHeaders.locationHeader] != null) { - location = response.headers[HttpHeaders.locationHeader]![0]; - log.info("[checkWhetherPostgraduate] Received location: $location"); - response = await dio.get(location); - } - - bool toReturn = await dio - .post( - "https://yjspt.xidian.edu.cn/gsapp" - "/sys/yjsemaphome/modules/pubWork/getCanVisitAppList.do", - ) - .then((value) => value.data["res"] != null); - - await preference.setBool(preference.Preference.role, toReturn); - - return toReturn; - } } class NeedCaptchaException implements Exception {} diff --git a/wearos/lib/repository/xidian_ids/personal_info_session.dart b/wearos/lib/repository/xidian_ids/personal_info_session.dart deleted file mode 100644 index 4b76a33c..00000000 --- a/wearos/lib/repository/xidian_ids/personal_info_session.dart +++ /dev/null @@ -1,149 +0,0 @@ -// Copyright 2023-2025 BenderBlog Rodriguez and contributors -// Copyright 2025 Traintime PDA authors. -// SPDX-License-Identifier: MPL-2.0 - -import 'dart:io'; - -import 'package:dio/dio.dart'; -import 'package:watermeter/wearos/slider_captcha.dart'; -import 'package:watermeter/repository/logger.dart'; -import 'package:watermeter/repository/preference.dart' as preference; -import 'package:watermeter/repository/xidian_ids/ehall_session.dart'; - -class PersonalInfoSession extends EhallSession { - Future getSemesterInfoYjspt() async { - String location = await checkAndLogin( - target: "https://yjspt.xidian.edu.cn/", - sliderCaptcha: (String cookieStr) => - SliderCaptchaClientProvider(cookie: cookieStr).solve(null), - ); - - log.info( - "[PersonalInfoSession][getSemesterInfoYjspt] " - "Location is $location", - ); - var response = await dio.get(location); - while (response.headers[HttpHeaders.locationHeader] != null) { - location = response.headers[HttpHeaders.locationHeader]![0]; - log.info( - "[PersonalInfoSession][getSemesterInfoYjspt] " - "Received location: $location.", - ); - response = await dio.get(location); - } - - log.info( - "[PersonalInfoSession][getSemesterInfoYjspt] " - "Getting the current semester info.", - ); - var detailed = await dio - .post( - "https://yjspt.xidian.edu.cn/gsapp/sys/yjsemaphome/modules/pubWork/getUserInfo.do", - ) - .then((value) => value.data); - if (detailed["code"] != "0") { - throw GetInformationFailedException(detailed["msg"].toString()); - } - return detailed["data"]["xnxqdm"]; - } - - Future getDormInfoEhall() async { - log.info( - "[ehall_session][getDormInfoEhall] " - "Ready to get the user information.", - ); - - String location = await super.checkAndLogin( - target: - "https://xgxt.xidian.edu.cn/xsfw/sys/jbxxapp/*default/index.do#/wdxx", - sliderCaptcha: (String cookieStr) => - SliderCaptchaClientProvider(cookie: cookieStr).solve(null), - ); - log.info( - "[ehall_session][getDormInfoEhall] " - "Location is $location", - ); - var response = await dio.get( - location, - options: Options( - headers: { - HttpHeaders.refererHeader: - "https://xgxt.xidian.edu.cn/xsfw/sys/jbxxapp/*default/index.do", - HttpHeaders.hostHeader: "xgxt.xidian.edu.cn", - }, - ), - ); - while (response.headers[HttpHeaders.locationHeader] != null) { - location = response.headers[HttpHeaders.locationHeader]![0]; - log.info( - "[ehall_session][useApp] " - "Received location: $location.", - ); - response = await dioEhall.get( - location, - options: Options( - headers: { - HttpHeaders.refererHeader: - "https://xgxt.xidian.edu.cn/xsfw/sys/jbxxapp/*default/index.do", - HttpHeaders.hostHeader: "xgxt.xidian.edu.cn", - }, - ), - ); - } - await dioEhall.post( - "https://xgxt.xidian.edu.cn/xsfw/sys/swpubapp/indexmenu/getAppConfig.do?appId=4585275700341858&appName=jbxxapp", - options: Options( - headers: { - HttpHeaders.refererHeader: - "https://xgxt.xidian.edu.cn/xsfw/sys/jbxxapp/*default/index.do", - HttpHeaders.hostHeader: "xgxt.xidian.edu.cn", - }, - ), - ); - - /// Get information here. resultCode==00000 is successful. - log.info( - "[ehall_session][getDormInfoEhall] " - "Getting the dorm information.", - ); - var detailed = await dioEhall - .post( - "https://xgxt.xidian.edu.cn/xsfw/sys/jbxxapp/modules/infoStudent/getStuBaseInfo.do", - data: - "requestParamStr=" - "{\"XSBH\":\"${preference.getString(preference.Preference.idsAccount)}\"}", - options: Options( - headers: { - HttpHeaders.refererHeader: - "https://xgxt.xidian.edu.cn/xsfw/sys/jbxxapp/*default/index.do", - HttpHeaders.hostHeader: "xgxt.xidian.edu.cn", - }, - ), - ) - .then((value) => value.data); - log.info( - "[ehall_session][getDormInfoEhall] " - "Storing the user information.", - ); - if (detailed["returnCode"] != "#E000000000000") { - throw GetInformationFailedException(detailed["description"]); - } - - return detailed["data"]["ZSDZ"].toString(); - } - - Future getSemesterInfoEhall() async { - log.info( - "[ehall_session][getSemesterInfoEhall] " - "Get the semester information.", - ); - String get = await useApp("4770397878132218"); - await dioEhall.post(get); - String semesterCode = await dioEhall - .post( - "https://ehall.xidian.edu.cn/jwapp/sys/wdkb/modules/jshkcb/dqxnxq.do", - ) - .then((value) => value.data['datas']['dqxnxq']['rows'][0]['DM']); - return semesterCode; - } -} diff --git a/wearos/lib/repository/xidian_ids/school_card_session.dart b/wearos/lib/repository/xidian_ids/school_card_session.dart index 1f071333..74826bd6 100644 --- a/wearos/lib/repository/xidian_ids/school_card_session.dart +++ b/wearos/lib/repository/xidian_ids/school_card_session.dart @@ -8,9 +8,7 @@ import 'dart:io'; import 'dart:convert'; import 'dart:typed_data'; import 'package:html/parser.dart'; -import 'package:dio/dio.dart'; import 'package:watermeter/repository/logger.dart'; -import 'package:watermeter/model/xidian_ids/paid_record.dart'; import 'package:watermeter/repository/preference.dart' as preference; import 'package:watermeter/repository/xidian_ids/ids_session.dart'; import 'package:watermeter/wearos/wear_ids_reauth.dart'; @@ -22,7 +20,6 @@ class SchoolCardSession extends IDSSession { static String openid = ""; static DateTime? _openidFetchedAt; static const Duration _openidValidDuration = Duration(minutes: 5); - static const _failedOverviewKey = "school_card_status.failed_to_query"; static void resetOpenId() { openid = ""; @@ -151,41 +148,6 @@ class SchoolCardSession extends IDSSession { } } - Future _fetchOverview() async { - final responseData = await dio - .get( - "https://v8scan.xidian.edu.cn/myaccount/openMyAccount?openid=$openid", - ) - .then((value) => value.data); - return parse(responseData) - .getElementsByTagName("li") - .firstOrNull - ?.children - .elementAtOrNull(1) - ?.children - .elementAtOrNull(1) - ?.innerHtml ?? - _failedOverviewKey; - } - - Future getOverview() async { - log.info( - "[SchoolCardSession][getOverview] " - "Try to fetch school card overview.", - ); - String money = await _withOpenIdRetry(_fetchOverview); - if (money == _failedOverviewKey) { - await _ensureOpenId(forceRefresh: true); - money = await _fetchOverview(); - } - if (money == _failedOverviewKey) { - throw const SchoolCardQueryFailedException( - "School card balance not found.", - ); - } - return money; - } - Future getQRCode() async { log.info( "[SchoolCardSession][initSession] " @@ -235,37 +197,4 @@ class SchoolCardSession extends IDSSession { return base64Decode(base64Data); }); } - - // 获取支付记录 - Future> getPaidStatus(String begin, String end) async { - return _withOpenIdRetry(() async { - List toReturn = []; - var response = await dio - .post( - "https://v8scan.xidian.edu.cn/selftrade/queryCardSelfTradeList?openid=$openid", - options: Options(contentType: "application/json; charset=utf-8"), - data: { - "beginDate": begin, - "endDate": end, - "tradeType": "-1", - "openid": openid, - }, - ) - .then((value) => jsonDecode(value.data)); - for (var i in response["resultData"]) { - toReturn.add( - PaidRecord(place: i["mername"], date: i["txdate"], money: i["txamt"]), - ); - } - return toReturn; - }); - } -} - -class SchoolCardQueryFailedException implements Exception { - final String message; - const SchoolCardQueryFailedException(this.message); - - @override - String toString() => message; } diff --git a/wearos/lib/repository/xidian_ids/sysj_session.dart b/wearos/lib/repository/xidian_ids/sysj_session.dart deleted file mode 100644 index 65d1a597..00000000 --- a/wearos/lib/repository/xidian_ids/sysj_session.dart +++ /dev/null @@ -1,390 +0,0 @@ -// Copyright 2023-2025 BenderBlog Rodriguez and contributors -// Copyright 2025 Traintime PDA authors. -// SPDX-License-Identifier: MPL-2.0 - -import 'dart:convert'; -import 'dart:io'; - -import 'package:dio/dio.dart'; -import 'package:html/dom.dart'; -import 'package:html/parser.dart'; -import 'package:watermeter/model/fetch_result.dart'; -import 'package:watermeter/model/not_school_network_exception.dart'; -import 'package:watermeter/model/xidian_ids/experiment.dart'; -import 'package:watermeter/model/time_list.dart'; -import 'package:watermeter/wearos/slider_captcha.dart'; -import 'package:watermeter/repository/logger.dart'; -import 'package:watermeter/repository/network_session.dart'; -import 'package:watermeter/repository/preference.dart' as prefs; -import 'package:watermeter/repository/xidian_ids/ids_session.dart'; - -String _cacheHintFromError(Object error) { - if (error is LoginFailedException) { - return "experiment.other_cache_hint_login_failed"; - } - if (error is NotSchoolNetworkException) { - return "experiment.other_cache_hint_not_school_network"; - } - if (error is DioException) { - return "experiment.other_cache_hint_network_failed"; - } - return "experiment.other_cache_hint_unknown_error"; -} - -Future>> getOtherExperimentData() async { - try { - List data = await SysjSession().getDataFromSysj(); - DateTime fetchTime = DateTime.now(); - await SysjSession.writeCache(data); - return FetchResult.fresh(fetchTime: fetchTime, data: data); - } on PasswordWrongException { - log.error( - "[SysjSession][getExperimentData] " - "Password changed, remove cache", - ); - await SysjSession.deleteCache(); - rethrow; - } catch (e, s) { - log.handle(e, s, "[SysjSession][getOtherExperimentData] Have issue"); - var cache = SysjSession.getCache(); - if (cache != null) { - return FetchResult.cache( - fetchTime: cache.$1, - data: cache.$2, - hintKey: _cacheHintFromError(e), - ); - } - rethrow; - } -} - -class SysjSession extends IDSSession { - static const otherExperimentCacheName = "OtherExperiment.json"; - static File otherExperimentCacheFile = File( - "${supportPath.path}/$otherExperimentCacheName", - ); - static bool get isCacheExist => otherExperimentCacheFile.existsSync(); - - static Future deleteCache() async { - if (await otherExperimentCacheFile.exists()) { - await otherExperimentCacheFile.delete(); - } - } - - static Future writeCache(List data) async { - log.info( - "[SysjSession][writeCache] " - "Store to cache.", - ); - otherExperimentCacheFile.writeAsStringSync(jsonEncode(data)); - } - - static (DateTime, List)? getCache() { - if (!isCacheExist) return null; - try { - List toDecode = jsonDecode( - otherExperimentCacheFile.readAsStringSync(), - ); - List otherData = List.generate( - toDecode.length, - (index) => ExperimentData.fromJson(toDecode[index]), - ); - - DateTime lastUpdateTime = otherExperimentCacheFile.lastModifiedSync(); - return (lastUpdateTime, otherData); - } catch (e, s) { - log.handle(e, s); - log.warning( - "[SysjSession][getCache] " - "Failed to parse other experiment cache, will refresh.", - ); - return null; - } - } - - /// These are from sysj.xidian.edu.cn's js file - Future> getDataFromSysj() async { - if (!(await NetworkSession.isInSchool())) { - throw NotSchoolNetworkException(); - } - - Response firstRequest = await dio.get( - "https://sysj.xidian.edu.cn/xidian/test", - ); - - if (firstRequest.isRedirect) { - String redirectUrl = firstRequest.headers[HttpHeaders.locationHeader]![0]; - firstRequest = await dio.get(redirectUrl); - - redirectUrl = firstRequest.headers[HttpHeaders.locationHeader]![0]; - - Uri toParseParameter = Uri.parse(redirectUrl); - String state = toParseParameter.queryParameters["state"]!; - - firstRequest = await dio.get(redirectUrl); - - firstRequest = await dio.getUri( - Uri.https("sysj.xidian.edu.cn", "/uaa/xidian/login", { - "redirect_uri": "https://sysj.xidian.edu.cn/xidian/webapp/callback", - "state": state, - "client_id": "GvsunLims", - "response_type": "code", - "authorize_uri": "https://sysj.xidian.edu.cn/uaa/oauth/authorize", - }), - ); - - // String clientId = RegExp( - // "let\\sclient_id\\s=\\s\"(?\\d+)\";", - // ).firstMatch(firstRequest.data!.toString())!.namedGroup("clientId")!; - - Uri hrefIds = - Uri.https("ids.xidian.edu.cn", "authserver/oauth2.0/authorize", { - "redirect_uri": "https://sysj.xidian.edu.cn/uaa/xidian/callback", - "response_type": "code", - "state": state, - "client_id": "1387116615722893312", - }); - - firstRequest = await dio.getUri(hrefIds); - - hrefIds = Uri.parse(firstRequest.headers[HttpHeaders.locationHeader]![0]); - - log.info(hrefIds); - - String? location; - - if (!hrefIds.authority.contains("sysj")) { - log.info( - "[SysjSession][getDataFromSysj] Jump not have sysj, treat as new login.", - ); - location = await checkAndLogin( - target: hrefIds.queryParameters["service"]!, - sliderCaptcha: (String cookieStr) => - SliderCaptchaClientProvider(cookie: cookieStr).solve(null), - ); - } else { - location = hrefIds.toString(); - } - - while (location != null) { - var response = await dio.get(location); - log.info( - "[SysjSession][getDataFromSysj] Received location: $location.", - ); - location = response.headers[HttpHeaders.locationHeader]?[0]; - } - - location = await dio - .getUri( - Uri.https("sysj.xidian.edu.cn", "/uaa/oauth/authorize", { - "redirect_uri": - "https://sysj.xidian.edu.cn/xidian/webapp/callback", - "state": state, - "client_id": "GvsunLims", - "response_type": "code", - }), - ) - .then((value) => value.data.toString()); - final match = RegExp(r'\?code=(?.*)\\u0026').firstMatch(location!); - String code = match!.namedGroup("code")!; - - String loginLastTime = await dio - .getUri( - Uri.https("sysj.xidian.edu.cn", "/xidian/webapp/callback", { - "code": code, - "state": state, - }), - ) - .then((value) => value.data.toString()); - final matchPwd = RegExp( - r"var password = \'(?.*)\';", - ).firstMatch(loginLastTime); - String pwd = matchPwd!.namedGroup("pwd")!; - Response data = await dio.post( - "https://sysj.xidian.edu.cn/xidian/webapp/login", - data: { - "username": prefs.getString(prefs.Preference.idsAccount), - "password": pwd, - }, - ); - if (data.statusCode == 302) { - log.info( - "[SysjSession][getDataFromSysj] Login post returns a redirect " - "${data.headers[HttpHeaders.locationHeader]![0]}.", - ); - - data = await dio.get(data.headers[HttpHeaders.locationHeader]![0]); - } - } - - List experimentData = []; - - const experimentNameMark = '???student.timetable.course???:'; - const experimentClassroomMark = '???schedule.course.lab???:'; - const experimentTeacherMark = '???student.timetable.teacher???:'; - - for (int i = 1; i <= 25; ++i) { - Document classTableHtml = await dio - .post( - "https://sysj.xidian.edu.cn/xidian/StudentCurrWeekTimetable", - data: "weeks=$i", - ) - .then((value) { - return parse(value.data.toString().trim()); - }); - - List tables = classTableHtml.getElementsByTagName("table"); - if (tables.length < 2) { - log.info("[SysjSession][getDataFromSysj] No tables at week $i"); - continue; - } - - Element table = tables[1]; - - /// Fetch the weekdays - List weekdays = []; - table.querySelectorAll('thead th').forEach((e) { - if (e.innerHtml.isEmpty) return; - String dateStr = e.innerHtml.split('
')[1].trim(); - weekdays.add(dateStr); - }); - - for (int weekDay = 1; weekDay <= 7; ++weekDay) { - for (int classIndex = 1; classIndex <= 13; ++classIndex) { - String cellContent = - table - .querySelector("td[do-labReservation='$weekDay,$classIndex']") - ?.innerHtml - .trim() ?? - ""; - - if (cellContent.isEmpty || - !cellContent.contains(experimentNameMark) || - !cellContent.contains(experimentClassroomMark) || - !cellContent.contains(experimentTeacherMark)) { - continue; - } - - log.info( - "[SysjSession][getDataFromSysj] cellContent of week $weekDay class $classIndex is $cellContent", - ); - - List contentList = cellContent.split('\n') - ..removeWhere((e) => e.isEmpty) - ..map((e) => e.trim()); - String name = contentList[0] - .replaceAll(experimentNameMark, "") - .trim() - .replaceAll("
", ""); - String classroom = contentList[1] - .replaceAll(experimentClassroomMark, "") - .trim() - .replaceAll("
", ""); - String teacher = contentList[2] - .replaceAll(experimentTeacherMark, "") - .trim() - .replaceAll("
", ""); - - List dateNums = weekdays[weekDay - 1] - .split('-') - .map((e) => int.parse(e)) - .toList(); - List startTimeList = timeList[(classIndex - 1) * 2] - .split(":") - .map((e) => int.parse(e)) - .toList(); - List endTimeList = timeList[(classIndex - 1) * 2 + 1] - .split(":") - .map((e) => int.parse(e)) - .toList(); - DateTime startTime = DateTime( - dateNums[0], - dateNums[1], - dateNums[2], - startTimeList[0], - startTimeList[1], - ); - DateTime endTime = DateTime( - dateNums[0], - dateNums[1], - dateNums[2], - endTimeList[0], - endTimeList[1], - ); - - while (classIndex < 13) { - String nextCellContent = - table - .querySelector( - "td[do-labReservation='$weekDay,${classIndex + 1}']", - ) - ?.innerHtml - .trim() ?? - ""; - log.info( - "[SysjSession][getDataFromSysj] fetching next class, " - "nextCellContent of week $weekDay class $classIndex is $cellContent", - ); - - if (cellContent != nextCellContent) { - log.info( - "[SysjSession][getDataFromSysj] fetching next class, " - "not match the last one, break looping", - ); - break; - } - - // Actually +1 for next day, then -1 to match the index of the array - List newEndTimeList = timeList[classIndex * 2 + 1] - .split(":") - .map((e) => int.parse(e)) - .toList(); - endTime = DateTime( - dateNums[0], - dateNums[1], - dateNums[2], - newEndTimeList[0], - newEndTimeList[1], - ); - log.info( - "[SysjSession][getDataFromSysj] fetching next class, " - "new endTime $endTime", - ); - - classIndex++; - } - - // If the list have no data related to this name, lab or teacher, just add it. - int dataWithSameInfoIndex = experimentData.indexWhere( - (e) => - e.name == name && - e.classroom == classroom && - e.teacher == teacher, - ); - if (experimentData.isEmpty || dataWithSameInfoIndex == -1) { - final newData = ExperimentData( - type: ExperimentType.others, - name: name, - classroom: classroom, - timeRanges: [(startTime, endTime)], - teacher: teacher, - ); - log.info("[SysjSession][getDataFromSysj] Added: $newData"); - experimentData.add(newData); - continue; - } - - experimentData[dataWithSameInfoIndex].timeRanges.add(( - startTime, - endTime, - )); - log.info( - "[SysjSession][getDataFromSysj] Updated: ${experimentData[dataWithSameInfoIndex]}", - ); - } - } - } - - return experimentData; - } -} diff --git a/wearos/lib/wearos/slider_captcha.dart b/wearos/lib/wearos/slider_captcha.dart index b8e6b57d..3ea86e6d 100644 --- a/wearos/lib/wearos/slider_captcha.dart +++ b/wearos/lib/wearos/slider_captcha.dart @@ -11,20 +11,9 @@ import 'dart:typed_data'; import 'package:dio/dio.dart'; import 'package:encrypter_plus/encrypter_plus.dart' as encrypt; -import 'package:flutter/material.dart'; import 'package:image/image.dart' as img; import 'package:watermeter/repository/logger.dart'; -class Lazy { - final T Function() _initializer; - - Lazy(this._initializer); - - T? _value; - - T get value => _value ??= _initializer(); -} - /// 轨迹点模型 class TrackPoint { final int a; // x 轴位移 @@ -37,11 +26,7 @@ class TrackPoint { } class SliderCaptchaClientProvider { - static const int _blockSize = 16; static const int _captchaKeySize = 16; - static const int _keySize = 16; - static const String _aesChars = - "ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678"; static const String _captchaPayloadPrefix = '................................................................'; static final Random _random = Random.secure(); @@ -49,114 +34,6 @@ class SliderCaptchaClientProvider { final String cookie; Dio dio = Dio()..interceptors.add(logDioAdapter); - /// 生成指定长度的随机字符串 - static String randomString(int n) { - final random = Random(); - return List.generate( - n, - (index) => _aesChars[random.nextInt(_aesChars.length)], - ).join(); - } - - /// 加密逻辑 - static String encryptData(String plainText, Uint8List keyBytes) { - final ivStr = randomString(_blockSize); - final nonce = randomString(_blockSize * 4); - final plain = nonce + plainText; - - final key = encrypt.Key(keyBytes); - final iv = encrypt.IV.fromUtf8(ivStr); - - final encrypter = encrypt.Encrypter( - encrypt.AES(key, mode: encrypt.AESMode.cbc), - ); - - // encrypt.AES 默认使用 PKCS7 填充,等同于 Python 的 pad(..., 16) - final encrypted = encrypter.encrypt(plain, iv: iv); - - return encrypted.base64; - } - - /// 解密逻辑 - static String decryptData(String cipherText, Uint8List keyBytes) { - final Uint8List fullCipher = base64.decode(cipherText); - - if (fullCipher.length < _blockSize * 4) { - throw Exception("Cipher text is too short to contain nonce."); - } - - // 根据 Python 逻辑:IV 是密文的第 48-64 字节 (Block 4) - // 实际密文从第 64 字节开始 - final ivBytes = fullCipher.sublist(_blockSize * 3, _blockSize * 4); - final encryptedPayload = fullCipher.sublist(_blockSize * 4); - - final key = encrypt.Key(keyBytes); - final iv = encrypt.IV(ivBytes); - - final encrypter = encrypt.Encrypter( - encrypt.AES(key, mode: encrypt.AESMode.cbc), - ); - - // 解密并自动去除 PKCS7 填充 - final decrypted = encrypter.decrypt( - encrypt.Encrypted(encryptedPayload), - iv: iv, - ); - - return decrypted; - } - - /// 从图片字节数组末尾提取 AES Key - static Uint8List extractAesKeyFromImage(Uint8List imageBytes) { - if (imageBytes.length < _keySize) { - throw Exception("Image is too short to contain AES key."); - } - return imageBytes.sublist(imageBytes.length - _keySize); - } - - /// 优化后的轨迹生成函数 - List generateTracks(int targetX) { - List tracks = []; - Random random = Random(); - - int currentX = 0; - int currentY = 0; - - // 1. 起始点 [cite: 89, 90] - tracks.add(TrackPoint(0, 0, 0)); - - // 调整后的参数:更大的步长,更紧凑的时间 - // 参考你提供的样本:位移 32 像素仅用了 9 个点 - while (currentX < targetX) { - int remaining = targetX - currentX; - - // 增大步长随机区间 (5-9 像素),这样点数会明显减少 - int stepX = remaining > 20 - ? random.nextInt(5) + 5 - : random.nextInt(3) + 1; - - currentX += stepX; - if (currentX > targetX) currentX = targetX; - - // 减小垂直抖动频率,使其看起来更平滑 [cite: 120] - if (random.nextDouble() > 0.7) { - currentY += random.nextBool() ? 1 : -1; - } - - // 将时间间隔 c 锁定在 20-25ms 之间,匹配你提供的样本 - int stepTime = 20 + random.nextInt(6); - - tracks.add(TrackPoint(currentX, currentY, stepTime)); - - if (currentX == targetX) break; - } - - // 2. 结束点:最后的停留点 [cite: 106, 107] - tracks.add(TrackPoint(targetX, currentY, 20 + random.nextInt(10))); - - return tracks; - } - static int solveSlideOffsetForTesting({ required Uint8List puzzleBytes, required Uint8List pieceBytes, @@ -415,14 +292,8 @@ class SliderCaptchaClientProvider { Uint8List? puzzleData; Uint8List? pieceData; - Lazy? puzzleImage; - Lazy? pieceImage; - Uint8List? extractedKey; final double puzzleWidth = 280; - final double puzzleHeight = 155; - final double pieceWidth = 44; - final double pieceHeight = 155; Future updatePuzzle() async { log.info("Fetching slider captcha..."); @@ -439,25 +310,6 @@ class SliderCaptchaClientProvider { puzzleData = const Base64Decoder().convert(puzzleBase64); pieceData = const Base64Decoder().convert(pieceBase64); - - extractedKey = extractAesKeyFromImage(pieceData!); - - puzzleImage = Lazy( - () => Image.memory( - puzzleData!, - width: puzzleWidth, - height: puzzleHeight, - fit: BoxFit.fitWidth, - ), - ); - pieceImage = Lazy( - () => Image.memory( - pieceData!, - width: pieceWidth, - height: pieceHeight, - fit: BoxFit.fitWidth, - ), - ); } Future solveAutomatically() async { @@ -479,24 +331,6 @@ class SliderCaptchaClientProvider { throw CaptchaSolveFailedException(); } - Future solve(BuildContext? context) async { - try { - await solveAutomatically(); - return; - } on CaptchaSolveFailedException { - // Fall through to the manual slider when a UI context is available. - } - - log.info('Automatic slider captcha solve failed, entering manual slider.'); - if (context != null && context.mounted) { - final verified = await Navigator.of(context).push( - MaterialPageRoute(builder: (context) => CaptchaWidget(provider: this)), - ); - if (verified == true) return; - } - throw CaptchaSolveFailedException(); - } - Future verifyWithTracks(List tracks) async { final moveLength = tracks.isNotEmpty ? tracks.last.a : 0; final payload = jsonEncode({ @@ -544,321 +378,4 @@ class SliderCaptchaClientProvider { } } -class CaptchaWidget extends StatefulWidget { - final SliderCaptchaClientProvider provider; - - const CaptchaWidget({super.key, required this.provider}); - - @override - State createState() => _CaptchaWidgetState(); -} - -class _CaptchaWidgetState extends State { - static const double _sliderHandleSize = 42; - static const double _jsSliderRightPadding = 40; - static const int _recordIntervalMs = 20; - static const double _recordDistancePx = 2; - - late Future _providerFuture; - - final List _tracks = []; - DateTime? _lastRecordTime; - Offset? _dragStartGlobal; - int? _activePointer; - int? _lastTrackA; - int? _lastTrackB; - - double _sliderLeftPx = 0; - bool _isSubmitting = false; - String? _statusText; - - @override - void initState() { - super.initState(); - updateProvider(); - } - - void updateProvider({String? statusText}) { - _sliderLeftPx = 0; - _tracks.clear(); - _lastRecordTime = null; - _dragStartGlobal = null; - _activePointer = null; - _lastTrackA = null; - _lastTrackB = null; - _isSubmitting = false; - _statusText = statusText; - _providerFuture = widget.provider.updatePuzzle().then((value) { - return widget.provider; - }); - } - - double _dragLimit(double puzzleWidth) { - return max(0, puzzleWidth - _jsSliderRightPadding).toDouble(); - } - - double _thumbLeft(double puzzleWidth) { - return (_sliderLeftPx - 1) - .clamp(0.0, max(0, puzzleWidth - _sliderHandleSize)) - .toDouble(); - } - - bool _isInsideThumb(Offset localPosition, double puzzleWidth) { - final left = _thumbLeft(puzzleWidth); - return localPosition.dx >= left && - localPosition.dx <= left + _sliderHandleSize && - localPosition.dy >= 0 && - localPosition.dy <= _sliderHandleSize; - } - - void _onPointerDown(PointerDownEvent event, double puzzleWidth) { - if (_isSubmitting || _activePointer != null) return; - if (!_isInsideThumb(event.localPosition, puzzleWidth)) return; - - _activePointer = event.pointer; - _dragStartGlobal = event.position; - _lastRecordTime = DateTime.now(); - _lastTrackA = null; - _lastTrackB = null; - _tracks.clear(); - _tracks.add(TrackPoint(0, 0, 0)); - if (_statusText != null) { - setState(() => _statusText = null); - } - } - - void _onPointerMove(PointerMoveEvent event, double puzzleWidth) { - if (event.pointer != _activePointer) return; - final start = _dragStartGlobal; - final lastTime = _lastRecordTime; - if (start == null || lastTime == null) return; - - final dx = event.position.dx - start.dx; - if (dx < 0 || dx + _jsSliderRightPadding > puzzleWidth) return; - - final now = DateTime.now(); - final dy = event.position.dy - start.dy; - final elapsed = now.difference(lastTime).inMilliseconds; - - setState(() => _sliderLeftPx = dx.clamp(0.0, _dragLimit(puzzleWidth))); - - if (elapsed < _recordIntervalMs) return; - - final a = dx.round(); - final b = dy.round(); - final lastA = _lastTrackA; - final lastB = _lastTrackB; - if (lastA != null && lastB != null) { - final distanceSquared = - (a - lastA) * (a - lastA) + (b - lastB) * (b - lastB); - if (distanceSquared < _recordDistancePx * _recordDistancePx) return; - } - - _tracks.add(TrackPoint(a, b, elapsed)); - _lastTrackA = a; - _lastTrackB = b; - _lastRecordTime = now; - } - - Future _onPointerUp(PointerUpEvent event, double puzzleWidth) async { - if (event.pointer != _activePointer) return; - await _finishDrag(event.position, puzzleWidth); - } - - void _onPointerCancel(PointerCancelEvent event) { - if (event.pointer != _activePointer) return; - _activePointer = null; - _dragStartGlobal = null; - _lastRecordTime = null; - _lastTrackA = null; - _lastTrackB = null; - } - - Future _finishDrag(Offset globalPosition, double puzzleWidth) async { - final start = _dragStartGlobal; - final lastTime = _lastRecordTime; - _activePointer = null; - _dragStartGlobal = null; - - if (start == null || lastTime == null) return; - - final dx = globalPosition.dx - start.dx; - if (dx == 0) return; - - final dy = globalPosition.dy - start.dy; - final elapsed = DateTime.now().difference(lastTime).inMilliseconds; - _tracks.add(TrackPoint(dx.round(), dy.round(), elapsed)); - log.info("Recorded ${_tracks.length} real slider track points."); - - setState(() { - _sliderLeftPx = dx.clamp(0.0, _dragLimit(puzzleWidth)); - _isSubmitting = true; - }); - - try { - final verified = await widget.provider.verifyWithTracks(_tracks); - if (!mounted) return; - if (verified) { - Navigator.of(context).pop(true); - return; - } - - setState(() { - updateProvider(statusText: "再试一次"); - }); - } catch (e, s) { - log.warning("Slider captcha verify failed: $e\n$s"); - if (!mounted) return; - setState(() { - updateProvider(statusText: "再试一次"); - }); - } - } - - Widget _buildSlider(double puzzleWidth) { - return Listener( - behavior: HitTestBehavior.opaque, - onPointerDown: (event) => _onPointerDown(event, puzzleWidth), - onPointerMove: (event) => _onPointerMove(event, puzzleWidth), - onPointerUp: (event) => _onPointerUp(event, puzzleWidth), - onPointerCancel: _onPointerCancel, - child: SizedBox( - width: puzzleWidth, - height: 44, - child: Stack( - children: [ - Positioned( - top: 17, - left: 0, - right: 0, - child: Container( - height: 10, - decoration: BoxDecoration( - color: Colors.green[900], - borderRadius: BorderRadius.circular(5), - ), - ), - ), - Positioned( - top: 17, - left: 0, - width: (_sliderLeftPx + 4).clamp(0.0, puzzleWidth).toDouble(), - child: Container( - height: 10, - decoration: BoxDecoration( - color: Colors.green[700], - borderRadius: BorderRadius.circular(5), - ), - ), - ), - Positioned( - left: _thumbLeft(puzzleWidth), - top: 1, - child: Container( - width: _sliderHandleSize, - height: _sliderHandleSize, - decoration: const BoxDecoration( - color: Colors.white, - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: Colors.black26, - blurRadius: 4, - offset: Offset(0, 2), - ), - ], - ), - child: _isSubmitting - ? const Padding( - padding: EdgeInsets.all(11), - child: CircularProgressIndicator(strokeWidth: 2), - ) - : Icon( - Icons.arrow_forward, - size: 20, - color: Colors.green[900], - ), - ), - ), - ], - ), - ), - ); - } - - Widget _buildCaptcha(SliderCaptchaClientProvider provider) { - final pw = provider.puzzleWidth; - final ph = provider.puzzleHeight; - return LayoutBuilder( - builder: (context, _) { - return Center( - child: FittedBox( - fit: BoxFit.scaleDown, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: pw, - height: ph, - child: Stack( - alignment: Alignment.center, - children: [ - provider.puzzleImage!.value, - Positioned( - left: _sliderLeftPx, - child: provider.pieceImage!.value, - ), - ], - ), - ), - _buildSlider(pw), - if (_statusText != null) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - _statusText!, - style: TextStyle( - color: Theme.of(context).colorScheme.error, - ), - ), - ), - ], - ), - ), - ); - }, - ); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('滑块验证')), - body: FutureBuilder( - future: _providerFuture, - builder: (context, snapshot) { - if (snapshot.hasError) { - return Center( - child: IconButton( - onPressed: () { - setState(() { - updateProvider(statusText: "Try Again"); - }); - }, - icon: const Icon(Icons.refresh), - ), - ); - } - - if (!snapshot.hasData) { - return const Center(child: CircularProgressIndicator()); - } - - return _buildCaptcha(snapshot.data!); - }, - ), - ); - } -} - class CaptchaSolveFailedException implements Exception {} diff --git a/wearos/lib/wearos/wear_cache_store.dart b/wearos/lib/wearos/wear_cache_store.dart new file mode 100644 index 00000000..29f4dfc0 --- /dev/null +++ b/wearos/lib/wearos/wear_cache_store.dart @@ -0,0 +1,71 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +import 'dart:convert'; +import 'dart:io'; + +import 'package:watermeter/model/xidian_ids/classtable.dart'; +import 'package:watermeter/model/xidian_ids/experiment.dart'; +import 'package:watermeter/repository/logger.dart'; +import 'package:watermeter/repository/network_session.dart' as network; + +class WearClassTableCache { + WearClassTableCache._(); + + static const fileName = 'ClassTable.json'; + static File file = File('${network.supportPath.path}/$fileName'); + + static bool get exists => file.existsSync(); + + static Future write(ClassTableData data) => + file.writeAsString(jsonEncode(data.toJson())); + + static (DateTime, ClassTableData)? read() { + if (!exists) return null; + try { + return ( + file.lastModifiedSync(), + ClassTableData.fromJson(jsonDecode(file.readAsStringSync())), + ); + } catch (error, stackTrace) { + log.handle(error, stackTrace, '[WearClassTableCache] Invalid cache.'); + return null; + } + } + + static Future clear() async { + if (await file.exists()) await file.delete(); + } +} + +class WearExperimentCache { + WearExperimentCache._(); + + static const fileName = 'OtherExperiment.json'; + static File file = File('${network.supportPath.path}/$fileName'); + + static bool get exists => file.existsSync(); + + static Future write(List data) => + file.writeAsString(jsonEncode(data)); + + static (DateTime, List)? read() { + if (!exists) return null; + try { + final decoded = jsonDecode(file.readAsStringSync()) as List; + return ( + file.lastModifiedSync(), + decoded + .map((item) => ExperimentData.fromJson(item)) + .toList(growable: false), + ); + } catch (error, stackTrace) { + log.handle(error, stackTrace, '[WearExperimentCache] Invalid cache.'); + return null; + } + } + + static Future clear() async { + if (await file.exists()) await file.delete(); + } +} diff --git a/wearos/lib/wearos/wear_companion_sync.dart b/wearos/lib/wearos/wear_companion_sync.dart index a83e2f9b..baedd164 100644 --- a/wearos/lib/wearos/wear_companion_sync.dart +++ b/wearos/lib/wearos/wear_companion_sync.dart @@ -10,10 +10,9 @@ import 'package:watermeter/model/xidian_ids/classtable.dart'; import 'package:watermeter/model/xidian_ids/experiment.dart'; import 'package:watermeter/repository/preference.dart' as preference; import 'package:watermeter/repository/network_session.dart' as network; -import 'package:watermeter/repository/xidian_ids/classtable_session.dart'; -import 'package:watermeter/repository/xidian_ids/sysj_session.dart'; import 'package:watermeter/repository/xidian_ids/ids_session.dart'; import 'package:watermeter/repository/xidian_ids/school_card_session.dart'; +import 'package:watermeter/wearos/wear_cache_store.dart'; import 'package:watermeter/wearos/wear_schedule_service.dart'; import 'package:watermeter/wearos/wear_qr_page.dart'; @@ -310,7 +309,7 @@ class WearLocalCompanionSyncPort implements WearCompanionSyncPort { Future importSchedule(WearScheduleSyncPayload payload) async { final classTable = payload.classTable; if (classTable != null) { - await ClassTableSession.updateCacheAndGroup(classTable); + await WearClassTableCache.write(classTable); if (classTable.semesterCode.isNotEmpty) { await preference.setString( preference.Preference.currentSemester, @@ -325,7 +324,7 @@ class WearLocalCompanionSyncPort implements WearCompanionSyncPort { final otherExperiments = payload.otherExperiments; if (otherExperiments != null) { - await SysjSession.writeCache(otherExperiments); + await WearExperimentCache.write(otherExperiments); } } diff --git a/wearos/lib/wearos/wear_home_page.dart b/wearos/lib/wearos/wear_home_page.dart index af0012b7..46e4cf3f 100644 --- a/wearos/lib/wearos/wear_home_page.dart +++ b/wearos/lib/wearos/wear_home_page.dart @@ -22,7 +22,7 @@ class WearHomePage extends StatefulWidget { } class _WearHomePageState extends State { - late Future _loadFuture; + late Future _loadFuture; late final WearCompanionSyncBridge _companionBridge; StreamSubscription? _syncSubscription; Completer? _pendingSync; @@ -47,7 +47,7 @@ class _WearHomePageState extends State { unawaited(_companionBridge.start()); } - Future _loadCached() async { + Future _loadCached() async { final semester = preference.getString( preference.Preference.currentSemester, ); @@ -94,7 +94,7 @@ class _WearHomePageState extends State { Widget build(BuildContext context) { return Scaffold( body: SafeArea( - child: FutureBuilder( + child: FutureBuilder( future: _loadFuture, builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { @@ -108,7 +108,7 @@ class _WearHomePageState extends State { ); } return WearHomeDashboard( - result: snapshot.requireData, + data: snapshot.requireData, onRefresh: _manualSync, onLogout: _logout, ); @@ -121,13 +121,13 @@ class _WearHomePageState extends State { class WearHomeDashboard extends StatelessWidget { static final _timeFormat = DateFormat('HH:mm'); - final WearHomeLoadResult result; + final WearHomeData data; final Future Function() onRefresh; final VoidCallback onLogout; const WearHomeDashboard({ super.key, - required this.result, + required this.data, required this.onRefresh, required this.onLogout, }); @@ -151,34 +151,16 @@ class WearHomeDashboard extends StatelessWidget { children: [ _AgendaSection( title: '今天', - items: result.data.todayItems, + items: data.todayItems, timeFormat: _timeFormat, ), _AgendaSection( title: '明天', - items: result.data.tomorrowItems, + items: data.tomorrowItems, timeFormat: _timeFormat, ), const SizedBox(height: 8), - _BalanceCard( - balanceText: result.data.balanceText, - hasBalanceFailure: result.failures.any( - (failure) => - failure.source == WearDataSource.schoolCardBalance, - ), - ), - if (result.failures.isNotEmpty) - Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Text( - '部分数据刷新失败:${result.failures.map((e) => _sourceName(e.source)).join('、')}', - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.error, - ), - ), - ), + const _CampusCard(), const SizedBox(height: 8), Row( mainAxisAlignment: MainAxisAlignment.center, @@ -204,27 +186,10 @@ class WearHomeDashboard extends StatelessWidget { ), ); } - - static String _sourceName(WearDataSource source) { - switch (source) { - case WearDataSource.classTable: - return '课表'; - case WearDataSource.otherExperiment: - return '实验'; - case WearDataSource.schoolCardBalance: - return '一卡通'; - } - } } -class _BalanceCard extends StatelessWidget { - final String? balanceText; - final bool hasBalanceFailure; - - const _BalanceCard({ - required this.balanceText, - required this.hasBalanceFailure, - }); +class _CampusCard extends StatelessWidget { + const _CampusCard(); @override Widget build(BuildContext context) { @@ -234,17 +199,8 @@ class _BalanceCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text('一卡通余额', style: Theme.of(context).textTheme.labelMedium), - const SizedBox(height: 4), - Text( - balanceText ?? (hasBalanceFailure ? '查询失败' : '暂无数据'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800), - ), - const SizedBox(height: 10), + Text('校园卡', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), FilledButton.icon( onPressed: () => Navigator.of( context, diff --git a/wearos/lib/wearos/wear_schedule_service.dart b/wearos/lib/wearos/wear_schedule_service.dart index 43e433c4..4a33c576 100644 --- a/wearos/lib/wearos/wear_schedule_service.dart +++ b/wearos/lib/wearos/wear_schedule_service.dart @@ -1,29 +1,18 @@ -import 'package:watermeter/model/fetch_result.dart'; import 'package:watermeter/model/time_list.dart'; import 'package:watermeter/model/xidian_ids/classtable.dart'; import 'package:watermeter/model/xidian_ids/experiment.dart'; -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/sysj_session.dart'; +import 'package:watermeter/wearos/wear_cache_store.dart'; -typedef ClassTableFetcher = - Future> Function(String semesterCode); -typedef ClassTableCacheLoader = - FetchResult? Function(String semesterCode); -typedef ExperimentFetcher = - Future>> Function(); -typedef ExperimentCacheLoader = FetchResult>? Function(); -typedef BalanceFetcher = Future Function(); +typedef ClassTableCacheLoader = ClassTableData? Function(String semesterCode); +typedef ExperimentCacheLoader = List? Function(); Future clearWearCampusCaches() async { - ClassTableSession.deleteCache(); - await SysjSession.deleteCache(); + await WearClassTableCache.clear(); + await WearExperimentCache.clear(); } enum WearAgendaKind { course, otherExperiment } -enum WearDataSource { classTable, otherExperiment, schoolCardBalance } - class WearAgendaItem { final WearAgendaKind kind; final String title; @@ -42,51 +31,11 @@ class WearAgendaItem { }); } -class WearSourceFailure { - final WearDataSource source; - final Object error; - final StackTrace stackTrace; - - const WearSourceFailure({ - required this.source, - required this.error, - required this.stackTrace, - }); -} - -class WearCachedDataException implements Exception { - final String hintKey; - - const WearCachedDataException(this.hintKey); - - @override - String toString() => hintKey; -} - class WearHomeData { - final String? balanceText; final List todayItems; final List tomorrowItems; - final DateTime fetchedAt; - - const WearHomeData({ - required this.todayItems, - required this.tomorrowItems, - required this.fetchedAt, - this.balanceText, - }); -} - -class WearHomeLoadResult { - final WearHomeData data; - final List failures; - - const WearHomeLoadResult({required this.data, required this.failures}); - bool get hasUsableData => - data.balanceText != null || - data.todayItems.isNotEmpty || - data.tomorrowItems.isNotEmpty; + const WearHomeData({required this.todayItems, required this.tomorrowItems}); } class WearAgendaBuilder { @@ -112,9 +61,7 @@ class WearAgendaBuilder { final startIndex = (arrangement.start - 1) * 2; final endIndex = (arrangement.stop - 1) * 2 + 1; - if (startIndex < 0 || endIndex >= timeList.length) { - continue; - } + if (startIndex < 0 || endIndex >= timeList.length) continue; final ClassDetail detail; try { @@ -170,100 +117,30 @@ class WearAgendaBuilder { } } -Future loadCachedWearHomeData({ +Future loadCachedWearHomeData({ required String semesterCode, DateTime? now, ClassTableCacheLoader? classTableCacheLoader, ExperimentCacheLoader? otherExperimentCacheLoader, - ClassTableFetcher? classTableFetcher, - ExperimentFetcher? otherExperimentFetcher, - BalanceFetcher? balanceFetcher, }) async { final effectiveNow = now ?? DateTime.now(); - final classCache = classTableCacheLoader ?? _loadClassTableCache; - final experimentCache = - otherExperimentCacheLoader ?? _loadOtherExperimentCache; - return _buildWearHomeData( - now: effectiveNow, - classTableResult: classCache(semesterCode), - otherExperimentResult: experimentCache(), - balanceText: null, - ); -} - -Future loadWearHomeData({ - required String semesterCode, - DateTime? now, - ClassTableFetcher? classTableFetcher, - ExperimentFetcher? otherExperimentFetcher, - BalanceFetcher? balanceFetcher, -}) async { - final effectiveNow = now ?? DateTime.now(); - final failures = []; - - FetchResult? classTableResult; - try { - classTableResult = await (classTableFetcher ?? getClassTable)(semesterCode); - } catch (error, stackTrace) { - failures.add( - WearSourceFailure( - source: WearDataSource.classTable, - error: error, - stackTrace: stackTrace, - ), - ); - } - - String? balanceText; - try { - balanceText = await (balanceFetcher ?? _fetchSchoolCardBalance)(); - } catch (error, stackTrace) { - failures.add( - WearSourceFailure( - source: WearDataSource.schoolCardBalance, - error: error, - stackTrace: stackTrace, - ), - ); - } - - final result = _buildWearHomeData( - now: effectiveNow, - classTableResult: classTableResult, - otherExperimentResult: null, - balanceText: balanceText, - initialFailures: failures, - ); - return result; -} - -WearHomeLoadResult _buildWearHomeData({ - required DateTime now, - required FetchResult? classTableResult, - required FetchResult>? otherExperimentResult, - required String? balanceText, - List initialFailures = const [], -}) { - final today = _dateOnly(now); + final today = _dateOnly(effectiveNow); final tomorrow = today.add(const Duration(days: 1)); - final failures = [...initialFailures]; + final classTable = (classTableCacheLoader ?? _loadClassTableCache)( + semesterCode, + ); + final experiments = + (otherExperimentCacheLoader ?? _loadOtherExperimentCache)(); final todayItems = []; final tomorrowItems = []; - if (classTableResult != null) { - _recordCacheFailure(failures, WearDataSource.classTable, classTableResult); - final table = classTableResult.data; - todayItems.addAll(WearAgendaBuilder.courseItemsForDay(table, today)); - tomorrowItems.addAll(WearAgendaBuilder.courseItemsForDay(table, tomorrow)); - } - - if (otherExperimentResult != null) { - _recordCacheFailure( - failures, - WearDataSource.otherExperiment, - otherExperimentResult, + if (classTable != null) { + todayItems.addAll(WearAgendaBuilder.courseItemsForDay(classTable, today)); + tomorrowItems.addAll( + WearAgendaBuilder.courseItemsForDay(classTable, tomorrow), ); - final experiments = otherExperimentResult.data; + } + if (experiments != null) { todayItems.addAll( WearAgendaBuilder.experimentItemsForDay(experiments, today), ); @@ -274,46 +151,20 @@ WearHomeLoadResult _buildWearHomeData({ todayItems.sort(_compareAgendaItems); tomorrowItems.sort(_compareAgendaItems); - return WearHomeLoadResult( - data: WearHomeData( - balanceText: balanceText, - todayItems: List.unmodifiable(todayItems), - tomorrowItems: List.unmodifiable(tomorrowItems), - fetchedAt: DateTime.now(), - ), - failures: List.unmodifiable(failures), + return WearHomeData( + todayItems: List.unmodifiable(todayItems), + tomorrowItems: List.unmodifiable(tomorrowItems), ); } -Future _fetchSchoolCardBalance() => SchoolCardSession().getOverview(); - -FetchResult? _loadClassTableCache(String semesterCode) { - final cache = ClassTableSession.getCache(); +ClassTableData? _loadClassTableCache(String semesterCode) { + final cache = WearClassTableCache.read(); if (cache == null || cache.$2.semesterCode != semesterCode) return null; - return FetchResult.cache(fetchTime: cache.$1, data: cache.$2, hintKey: null); -} - -FetchResult>? _loadOtherExperimentCache() { - final cache = SysjSession.getCache(); - if (cache == null) return null; - return FetchResult.cache(fetchTime: cache.$1, data: cache.$2, hintKey: null); + return cache.$2; } -void _recordCacheFailure( - List failures, - WearDataSource source, - FetchResult result, -) { - final hintKey = result.hintKey; - if (!result.isCache || hintKey == null) return; - failures.add( - WearSourceFailure( - source: source, - error: WearCachedDataException(hintKey), - stackTrace: StackTrace.current, - ), - ); -} +List? _loadOtherExperimentCache() => + WearExperimentCache.read()?.$2; DateTime _dateOnly(DateTime value) => DateTime(value.year, value.month, value.day); diff --git a/wearos/pubspec.lock b/wearos/pubspec.lock index 0a320ba9..076eac7f 100644 --- a/wearos/pubspec.lock +++ b/wearos/pubspec.lock @@ -837,14 +837,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.11" - time: - dependency: "direct main" - description: - name: time - sha256: "46187cf30bffdab28c56be9a63861b36e4ab7347bf403297595d6a97e10c789f" - url: "https://pub.dev" - source: hosted - version: "2.1.6" typed_data: dependency: transitive description: diff --git a/wearos/pubspec.yaml b/wearos/pubspec.yaml index 67af1f80..6d23e316 100644 --- a/wearos/pubspec.yaml +++ b/wearos/pubspec.yaml @@ -18,7 +18,6 @@ dependencies: path_provider: ^2.0.11 json_annotation: ^4.9.0 html: ^0.15.4 - time: ^2.1.5 image: ^4.5.4 flutter: sdk: flutter diff --git a/wearos/test/wear_app_test.dart b/wearos/test/wear_app_test.dart index 81360c2b..b0b36e57 100644 --- a/wearos/test/wear_app_test.dart +++ b/wearos/test/wear_app_test.dart @@ -40,35 +40,24 @@ void main() { tester.view.resetPhysicalSize(); tester.view.resetDevicePixelRatio(); }); - const longBalance = '¥123456789012345678901234567890'; + const longTitle = '很长很长很长很长很长的课程名称'; await tester.pumpWidget( MaterialApp( home: Scaffold( body: WearHomeDashboard( - result: WearHomeLoadResult( - data: WearHomeData( - balanceText: longBalance, - todayItems: [ - WearAgendaItem( - kind: WearAgendaKind.course, - title: '很长很长很长很长很长的课程名称', - start: DateTime(2026, 5, 19, 8, 30), - end: DateTime(2026, 5, 19, 10, 5), - location: '很长很长很长很长很长的教室名称', - subtitle: '很长很长很长很长很长的教师名称', - ), - ], - tomorrowItems: const [], - fetchedAt: DateTime(2026, 5, 19), - ), - failures: [ - WearSourceFailure( - source: WearDataSource.schoolCardBalance, - error: StateError('balance failed'), - stackTrace: StackTrace.current, + data: WearHomeData( + todayItems: [ + WearAgendaItem( + kind: WearAgendaKind.course, + title: longTitle, + start: DateTime(2026, 5, 19, 8, 30), + end: DateTime(2026, 5, 19, 10, 5), + location: '很长很长很长很长很长的教室名称', + subtitle: '很长很长很长很长很长的教师名称', ), ], + tomorrowItems: const [], ), onRefresh: () async {}, onLogout: () {}, @@ -78,8 +67,9 @@ void main() { ); expect(tester.takeException(), isNull); - final balanceText = tester.widget(find.text(longBalance)); - expect(balanceText.maxLines, 1); - expect(balanceText.overflow, TextOverflow.ellipsis); + final titleText = tester.widget(find.text(longTitle)); + expect(titleText.maxLines, 1); + expect(titleText.overflow, TextOverflow.ellipsis); + expect(find.text('校园卡'), findsOneWidget); }); } diff --git a/wearos/test/wear_schedule_service_test.dart b/wearos/test/wear_schedule_service_test.dart index 4d3e587b..ac6482bc 100644 --- a/wearos/test/wear_schedule_service_test.dart +++ b/wearos/test/wear_schedule_service_test.dart @@ -4,14 +4,12 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; -import 'package:watermeter/model/fetch_result.dart'; import 'package:watermeter/model/xidian_ids/classtable.dart'; import 'package:watermeter/model/xidian_ids/experiment.dart'; import 'package:watermeter/repository/network_session.dart' as network; 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/sysj_session.dart'; +import 'package:watermeter/wearos/wear_cache_store.dart'; import 'package:watermeter/wearos/wear_companion_sync.dart'; import 'package:watermeter/wearos/wear_schedule_service.dart'; @@ -98,91 +96,22 @@ void main() { }); group('Wear home loading', () { - test('network sync preserves successful agenda and balance data', () async { - final now = DateTime(2026, 5, 19, 8); - final table = _singleCourseTable('数据库系统'); - var experimentNetworkCalled = false; - - final result = await loadWearHomeData( - semesterCode: '2026-1', - now: now, - classTableFetcher: (_) async => - FetchResult.fresh(fetchTime: now, data: table), - otherExperimentFetcher: () async { - experimentNetworkCalled = true; - throw StateError('experiment fetch should not run'); - }, - balanceFetcher: () async => '¥12.34', - ); - - expect(result.data.balanceText, '¥12.34'); - expect(result.data.todayItems.map((item) => item.title), ['数据库系统']); - expect(result.failures, isEmpty); - expect(result.hasUsableData, isTrue); - expect(experimentNetworkCalled, isFalse); - }); - test( - 'cached fetch result records source warning while keeping data', + 'cache-only load builds the agenda without network fetchers', () async { final now = DateTime(2026, 5, 19, 8); - final table = _singleCourseTable('操作系统'); + final table = _singleCourseTable('离线课程'); - final result = await loadWearHomeData( + final data = await loadCachedWearHomeData( semesterCode: '2026-1', now: now, - classTableFetcher: (_) async => FetchResult.cache( - fetchTime: now, - data: table, - hintKey: 'classtable.cache_hint_network_failed', - ), - otherExperimentFetcher: () async => - FetchResult.fresh(fetchTime: now, data: const []), - balanceFetcher: () async => '¥12.34', + classTableCacheLoader: (_) => table, + otherExperimentCacheLoader: () => null, ); - expect(result.data.todayItems.map((item) => item.title), ['操作系统']); - expect(result.failures.map((failure) => failure.source), [ - WearDataSource.classTable, - ]); - expect(result.failures.single.error, isA()); + expect(data.todayItems.map((item) => item.title), ['离线课程']); }, ); - - test('cache-only load does not call network fetchers', () async { - final now = DateTime(2026, 5, 19, 8); - final table = _singleCourseTable('离线课程'); - var classNetworkCalled = false; - var experimentNetworkCalled = false; - var balanceNetworkCalled = false; - - final result = await loadCachedWearHomeData( - semesterCode: '2026-1', - now: now, - classTableCacheLoader: (_) => - FetchResult.cache(fetchTime: now, data: table, hintKey: null), - otherExperimentCacheLoader: () => null, - classTableFetcher: (_) async { - classNetworkCalled = true; - throw StateError('network class fetch should not run'); - }, - otherExperimentFetcher: () async { - experimentNetworkCalled = true; - throw StateError('network experiment fetch should not run'); - }, - balanceFetcher: () async { - balanceNetworkCalled = true; - return '¥0.00'; - }, - ); - - expect(result.data.todayItems.map((item) => item.title), ['离线课程']); - expect(result.data.balanceText, isNull); - expect(result.failures, isEmpty); - expect(classNetworkCalled, isFalse); - expect(experimentNetworkCalled, isFalse); - expect(balanceNetworkCalled, isFalse); - }); }); group('Wear companion sync interface', () { @@ -197,14 +126,14 @@ void main() { ); tempDir = await Directory.systemTemp.createTemp('wear-sync-test-'); network.supportPath = tempDir; - ClassTableSession.schoolClassDataCache = File( - '${tempDir.path}/${ClassTableSession.schoolClassName}', + WearClassTableCache.file = File( + '${tempDir.path}/${WearClassTableCache.fileName}', ); - SysjSession.otherExperimentCacheFile = File( - '${tempDir.path}/${SysjSession.otherExperimentCacheName}', + WearExperimentCache.file = File( + '${tempDir.path}/${WearExperimentCache.fileName}', ); - ClassTableSession.deleteCache(); - await SysjSession.deleteCache(); + await WearClassTableCache.clear(); + await WearExperimentCache.clear(); }); tearDown(() async { @@ -215,8 +144,8 @@ void main() { test('credential import clears previous user-scoped state', () async { SchoolCardSession.openid = 'old-openid'; - await ClassTableSession.updateCacheAndGroup(_singleCourseTable('旧课程')); - await SysjSession.writeCache([ + await WearClassTableCache.write(_singleCourseTable('旧课程')); + await WearExperimentCache.write([ ExperimentData( type: ExperimentType.others, name: '旧实验', @@ -243,8 +172,8 @@ void main() { ); expect(SchoolCardSession.openid, isEmpty); - expect(ClassTableSession.schoolClassDataCache.existsSync(), isFalse); - expect(SysjSession.otherExperimentCacheFile.existsSync(), isFalse); + expect(WearClassTableCache.file.existsSync(), isFalse); + expect(WearExperimentCache.file.existsSync(), isFalse); expect( preference.getString(preference.Preference.currentSemester), isEmpty, @@ -260,7 +189,7 @@ void main() { ); }); - test('imports credentials for future mobile-device transport', () async { + test('imports credentials for watch payment authentication', () async { await WearLocalCompanionSyncPort().importCredentials( const WearCredentialSyncPayload( idsAccount: '2200000000', @@ -301,11 +230,8 @@ void main() { ), ); - expect( - ClassTableSession.getCache()?.$2.classDetail.single.name, - '同步课程', - ); - expect(SysjSession.getCache()?.$2.single.name, '同步实验'); + expect(WearClassTableCache.read()?.$2.classDetail.single.name, '同步课程'); + expect(WearExperimentCache.read()?.$2.single.name, '同步实验'); final cachedHome = await loadCachedWearHomeData( semesterCode: '2026-1', now: DateTime(2026, 5, 19, 8), @@ -315,7 +241,7 @@ void main() { '2026-1', ); expect( - cachedHome.data.todayItems.map((item) => item.title), + cachedHome.todayItems.map((item) => item.title), contains('同步课程'), ); }, @@ -350,10 +276,7 @@ void main() { preference.getString(preference.Preference.currentSemester), '2026-1', ); - expect( - ClassTableSession.getCache()?.$2.classDetail.single.name, - '扫码同步课程', - ); + expect(WearClassTableCache.read()?.$2.classDetail.single.name, '扫码同步课程'); expect( File('${network.supportPath.path}/WearPaymentQr.png').readAsBytesSync(), [1, 2, 3], From f60ffce15afd66fada87062e2a753942095a25b6 Mon Sep 17 00:00:00 2001 From: brill594 Date: Tue, 4 Aug 2026 23:28:32 +0900 Subject: [PATCH 10/16] refactor(wear): migrate client to native compose --- .github/workflows/check_wearos.yaml | 26 +- README.md | 13 +- .../setting/wear_companion_sync_page.dart | 13 +- wearos/.gitignore | 55 +- wearos/.metadata | 30 - wearos/WEAR_SYNC_INTEGRATION.md | 21 +- wearos/analysis_options.yaml | 29 - wearos/android/.gitignore | 3 - wearos/android/app/build.gradle | 104 +- .../android/app/src/debug/AndroidManifest.xml | 9 +- .../android/app/src/main/AndroidManifest.xml | 26 +- .../benderblog/traintime_pda/MainActivity.kt | 248 +---- .../traintime_pda/WearAppContainer.kt | 22 + .../traintime_pda/data/WearCacheStore.kt | 125 +++ .../traintime_pda/data/WearPreferences.kt | 98 ++ .../traintime_pda/data/WearSyncImporter.kt | 103 ++ .../traintime_pda/domain/ClassTableModels.kt | 273 +++++ .../traintime_pda/domain/TimeList.kt | 21 + .../traintime_pda/domain/WearAgendaBuilder.kt | 155 +++ .../benderblog/traintime_pda/ids/IdsCrypto.kt | 59 ++ .../traintime_pda/ids/IdsLoginState.kt | 21 + .../benderblog/traintime_pda/ids/IdsReAuth.kt | 200 ++++ .../traintime_pda/ids/IdsSession.kt | 218 ++++ .../traintime_pda/ids/PersistentCookieJar.kt | 104 ++ .../traintime_pda/ids/SchoolCardSession.kt | 230 ++++ .../traintime_pda/ids/SliderCaptcha.kt | 307 ++++++ .../payment/PaymentQrRepository.kt | 113 ++ .../protocol/WearCompanionSync.kt | 186 ++++ .../traintime_pda/sync/WearCompanionClient.kt | 173 ++++ .../benderblog/traintime_pda/ui/WearApp.kt | 97 ++ .../traintime_pda/ui/WearViewModel.kt | 424 ++++++++ .../traintime_pda/ui/screens/HomeScreen.kt | 249 +++++ .../traintime_pda/ui/screens/PairingScreen.kt | 68 ++ .../traintime_pda/ui/screens/QrScreen.kt | 179 ++++ .../traintime_pda/ui/screens/ReAuthScreen.kt | 146 +++ .../src/main/res/values-night-v31/styles.xml | 17 +- .../app/src/main/res/values-night/styles.xml | 21 +- .../app/src/main/res/values-v31/styles.xml | 17 +- .../app/src/main/res/values/styles.xml | 24 +- .../main/res/xml/data_extraction_rules.xml | 15 + .../app/src/profile/AndroidManifest.xml | 9 +- .../domain/WearAgendaBuilderTest.kt | 122 +++ .../traintime_pda/ids/IdsCryptoTest.kt | 51 + .../ids/SliderCaptchaTracksTest.kt | 26 + .../protocol/WearCompanionSyncTest.kt | 228 ++++ wearos/android/build.gradle | 42 +- wearos/android/gradle.properties | 1 + .../android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 53636 bytes .../gradle/wrapper/gradle-wrapper.properties | 2 +- wearos/android/gradlew | 160 +++ wearos/android/gradlew.bat | 90 ++ wearos/android/settings.gradle | 23 +- wearos/lib/main.dart | 49 - wearos/lib/model/time_list.dart | 26 - wearos/lib/model/xidian_ids/classtable.dart | 327 ------ wearos/lib/model/xidian_ids/classtable.g.dart | 179 ---- wearos/lib/model/xidian_ids/experiment.dart | 54 - wearos/lib/model/xidian_ids/experiment.g.dart | 49 - wearos/lib/repository/logger.dart | 33 - wearos/lib/repository/network_session.dart | 45 - wearos/lib/repository/preference.dart | 64 -- .../repository/xidian_ids/ids_session.dart | 368 ------- .../xidian_ids/school_card_session.dart | 200 ---- wearos/lib/wearos/slider_captcha.dart | 381 ------- wearos/lib/wearos/wear_app.dart | 46 - wearos/lib/wearos/wear_cache_store.dart | 71 -- wearos/lib/wearos/wear_companion_sync.dart | 352 ------- wearos/lib/wearos/wear_home_page.dart | 351 ------- wearos/lib/wearos/wear_ids_reauth.dart | 391 ------- wearos/lib/wearos/wear_qr_page.dart | 299 ------ wearos/lib/wearos/wear_schedule_service.dart | 192 ---- wearos/lib/wearos/wear_sync_login_page.dart | 107 -- wearos/pubspec.lock | 978 ------------------ wearos/pubspec.yaml | 35 - wearos/test/ids_session_test.dart | 33 - wearos/test/slider_captcha_test.dart | 41 - wearos/test/wear_app_test.dart | 75 -- wearos/test/wear_schedule_service_test.dart | 326 ------ 78 files changed, 4471 insertions(+), 5597 deletions(-) delete mode 100644 wearos/.metadata delete mode 100644 wearos/analysis_options.yaml create mode 100644 wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/WearAppContainer.kt create mode 100644 wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/data/WearCacheStore.kt create mode 100644 wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/data/WearPreferences.kt create mode 100644 wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/data/WearSyncImporter.kt create mode 100644 wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/domain/ClassTableModels.kt create mode 100644 wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/domain/TimeList.kt create mode 100644 wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/domain/WearAgendaBuilder.kt create mode 100644 wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ids/IdsCrypto.kt create mode 100644 wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ids/IdsLoginState.kt create mode 100644 wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ids/IdsReAuth.kt create mode 100644 wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ids/IdsSession.kt create mode 100644 wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ids/PersistentCookieJar.kt create mode 100644 wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ids/SchoolCardSession.kt create mode 100644 wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ids/SliderCaptcha.kt create mode 100644 wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/payment/PaymentQrRepository.kt create mode 100644 wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/protocol/WearCompanionSync.kt create mode 100644 wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/sync/WearCompanionClient.kt create mode 100644 wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ui/WearApp.kt create mode 100644 wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ui/WearViewModel.kt create mode 100644 wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ui/screens/HomeScreen.kt create mode 100644 wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ui/screens/PairingScreen.kt create mode 100644 wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ui/screens/QrScreen.kt create mode 100644 wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ui/screens/ReAuthScreen.kt create mode 100644 wearos/android/app/src/main/res/xml/data_extraction_rules.xml create mode 100644 wearos/android/app/src/test/java/io/github/benderblog/traintime_pda/domain/WearAgendaBuilderTest.kt create mode 100644 wearos/android/app/src/test/java/io/github/benderblog/traintime_pda/ids/IdsCryptoTest.kt create mode 100644 wearos/android/app/src/test/java/io/github/benderblog/traintime_pda/ids/SliderCaptchaTracksTest.kt create mode 100644 wearos/android/app/src/test/java/io/github/benderblog/traintime_pda/protocol/WearCompanionSyncTest.kt create mode 100644 wearos/android/gradle/wrapper/gradle-wrapper.jar create mode 100755 wearos/android/gradlew create mode 100644 wearos/android/gradlew.bat delete mode 100644 wearos/lib/main.dart delete mode 100644 wearos/lib/model/time_list.dart delete mode 100644 wearos/lib/model/xidian_ids/classtable.dart delete mode 100644 wearos/lib/model/xidian_ids/classtable.g.dart delete mode 100644 wearos/lib/model/xidian_ids/experiment.dart delete mode 100644 wearos/lib/model/xidian_ids/experiment.g.dart delete mode 100644 wearos/lib/repository/logger.dart delete mode 100644 wearos/lib/repository/network_session.dart delete mode 100644 wearos/lib/repository/preference.dart delete mode 100644 wearos/lib/repository/xidian_ids/ids_session.dart delete mode 100644 wearos/lib/repository/xidian_ids/school_card_session.dart delete mode 100644 wearos/lib/wearos/slider_captcha.dart delete mode 100644 wearos/lib/wearos/wear_app.dart delete mode 100644 wearos/lib/wearos/wear_cache_store.dart delete mode 100644 wearos/lib/wearos/wear_companion_sync.dart delete mode 100644 wearos/lib/wearos/wear_home_page.dart delete mode 100644 wearos/lib/wearos/wear_ids_reauth.dart delete mode 100644 wearos/lib/wearos/wear_qr_page.dart delete mode 100644 wearos/lib/wearos/wear_schedule_service.dart delete mode 100644 wearos/lib/wearos/wear_sync_login_page.dart delete mode 100644 wearos/pubspec.lock delete mode 100644 wearos/pubspec.yaml delete mode 100644 wearos/test/ids_session_test.dart delete mode 100644 wearos/test/slider_captcha_test.dart delete mode 100644 wearos/test/wear_app_test.dart delete mode 100644 wearos/test/wear_schedule_service_test.dart diff --git a/.github/workflows/check_wearos.yaml b/.github/workflows/check_wearos.yaml index 75b5784b..a9a3c404 100644 --- a/.github/workflows/check_wearos.yaml +++ b/.github/workflows/check_wearos.yaml @@ -4,12 +4,20 @@ 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: @@ -27,18 +35,6 @@ jobs: distribution: temurin java-version: 17 - - name: Resolve dependencies - working-directory: wearos - run: ../.flutter/bin/flutter pub get - - - name: Analyze - working-directory: wearos - run: ../.flutter/bin/flutter analyze - - - name: Test - working-directory: wearos - run: ../.flutter/bin/flutter test - - - name: Build Wear OS APK - working-directory: wearos - run: ../.flutter/bin/flutter build apk --debug --target-platform android-arm + - 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 7ef76986..ecb595e5 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ XDYou,代码名称为 Traintime PDA,是为西电学生设计的开源信息 13. 上课前提醒。 14. 完备的国际化支持:支持繁体中文和英语。 15. 宿舍水机支持。 -16. 提供配套 Wear OS 应用,可同步课程、一卡通余额与付款码,并在断开手机时使用缓存数据。 +16. 提供原生 Compose Wear OS 配套应用,可同步课程与付款码,并在断开手机时使用缓存数据。 ## 其他特性 @@ -69,9 +69,9 @@ Tools • Dart 3.12.0 • DevTools 2.57.0 ### 仓库结构与 Wear OS 构建 -主应用位于仓库根目录,配套的 Wear OS Flutter 应用位于 [`wearos/`](./wearos)。两端通信协议需要同步演进,因此 Wear OS 源码直接维护在同一仓库中,不使用额外 submodule。 +主应用位于仓库根目录,原生 Kotlin + Compose for Wear OS 应用位于 [`wearos/android/`](./wearos/android)。两端通信协议需要同步演进,因此 Wear OS 源码直接维护在同一仓库中,不使用额外 submodule。 -首次拉取后初始化仓库共用的 Flutter SDK: +主应用仍使用仓库内 Flutter SDK,首次拉取后需要初始化子模块: ```bash git submodule update --init --recursive @@ -80,11 +80,8 @@ git submodule update --init --recursive 构建和测试 Wear OS 应用: ```bash -cd wearos -../.flutter/bin/flutter pub get -../.flutter/bin/flutter analyze -../.flutter/bin/flutter test -../.flutter/bin/flutter build apk --release --target-platform android-arm +cd wearos/android +./gradlew testDebugUnitTest assembleRelease ``` ## 授权信息 diff --git a/lib/page/setting/wear_companion_sync_page.dart b/lib/page/setting/wear_companion_sync_page.dart index 28050730..4f3e2af1 100644 --- a/lib/page/setting/wear_companion_sync_page.dart +++ b/lib/page/setting/wear_companion_sync_page.dart @@ -111,13 +111,12 @@ class _WearCompanionSyncPageState extends State { child: CircularProgressIndicator(strokeWidth: 2), ) : node.isPaired || _completedNodeId == node.id - ? const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.check_circle, color: Colors.green), - SizedBox(width: 6), - Text('已配对'), - ], + ? OutlinedButton.icon( + onPressed: _sendingNodeId == null + ? () => _pair(node) + : null, + icon: const Icon(Icons.sync), + label: const Text('同步'), ) : FilledButton( onPressed: _sendingNodeId == null diff --git a/wearos/.gitignore b/wearos/.gitignore index 3a4a1dc3..62a79a36 100644 --- a/wearos/.gitignore +++ b/wearos/.gitignore @@ -1,8 +1,17 @@ -# Miscellaneous -*.class -*.log -*.pyc -*.swp +# Android / Gradle +*.iml +.idea/ +.gradle/ +local.properties +**/build/ +captures/ +.externalNativeBuild/ +.cxx/ +*.APK +*.apk +*.aab + +# OS / editor .DS_Store .atom/ .build/ @@ -12,45 +21,11 @@ .swiftpm/ migrate_working_dir/ -# IntelliJ related -*.iml -*.ipr -*.iws -.idea/ - -# The .vscode folder contains launch configuration and tasks you configure in -# VS Code which you may wish to be included in version control, so this line -# is commented out by default. -#.vscode/ - -# Flutter/Dart/Pub related -**/doc/api/ -**/ios/Flutter/.last_build_id +# Legacy Flutter leftovers (if any reappear) .dart_tool/ .flutter-plugins .flutter-plugins-dependencies .packages .pub-cache/ .pub/ -/build/ -pubspec.lock -android/app/build/ -android/app/.cxx - -# Web related -lib/generated_plugin_registrant.dart - -# Symbolication related -app.*.symbols - -# Obfuscation related -app.*.map.json - -# Android Studio will place build artifacts here -/android/app/debug -/android/app/profile -/android/app/release - -# fvm flutter sdk .fvm/ -*.APK diff --git a/wearos/.metadata b/wearos/.metadata deleted file mode 100644 index 854c55db..00000000 --- a/wearos/.metadata +++ /dev/null @@ -1,30 +0,0 @@ -# This file tracks properties of this Flutter project. -# Used by Flutter tool to assess capabilities and perform upgrades etc. -# -# This file should be version controlled and should not be manually edited. - -version: - revision: "7482962148e8d758338d8a28f589f317e1e42ba4" - channel: "stable" - -project_type: app - -# Tracks metadata for the flutter migrate command -migration: - platforms: - - platform: root - create_revision: 7482962148e8d758338d8a28f589f317e1e42ba4 - base_revision: 7482962148e8d758338d8a28f589f317e1e42ba4 - - platform: windows - create_revision: 7482962148e8d758338d8a28f589f317e1e42ba4 - base_revision: 7482962148e8d758338d8a28f589f317e1e42ba4 - - # User provided section - - # List of Local paths (relative to this file) that should be - # ignored by the migrate tool. - # - # Files that are not part of the templates will be ignored by default. - unmanaged_files: - - 'lib/main.dart' - - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/wearos/WEAR_SYNC_INTEGRATION.md b/wearos/WEAR_SYNC_INTEGRATION.md index 0c0d97e8..597eeecd 100644 --- a/wearos/WEAR_SYNC_INTEGRATION.md +++ b/wearos/WEAR_SYNC_INTEGRATION.md @@ -1,14 +1,15 @@ # WearOS companion sync integration -XDYou Wear is a companion-only app. Pairing and subsequent synchronization use -the Wear OS Data Layer; camera/QR pairing is intentionally not used. +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 and the standalone Wear OS Flutter -target lives in `wearos/`. Both targets use the root `.flutter` submodule. +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 `配对手机` on the watch. The watch accepts a first pairing for five +1. Open the watch app while unpaired. The watch accepts a first pairing for five minutes. 2. Open `设置 > XDYou Wear` on the Android phone. 3. The phone obtains connected watches from `NodeClient.connectedNodes`. @@ -21,6 +22,9 @@ Wear OS Data Layer only transports messages between applications with the same package name and signing identity. The explicit five-minute window prevents an unexpected first import even from another matching development 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. @@ -55,3 +59,10 @@ The JSON envelope uses schema version `1` and contains: 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/analysis_options.yaml b/wearos/analysis_options.yaml deleted file mode 100644 index ae08714c..00000000 --- a/wearos/analysis_options.yaml +++ /dev/null @@ -1,29 +0,0 @@ -# This file configures the analyzer, which statically analyzes Dart code to -# check for errors, warnings, and lints. -# -# The issues identified by the analyzer are surfaced in the UI of Dart-enabled -# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be -# invoked from the command line by running `flutter analyze`. - -# The following line activates a set of recommended lints for Flutter apps, -# packages, and plugins designed to encourage good coding practices. -include: package:flutter_lints/flutter.yaml - -linter: - # The lint rules applied to this project can be customized in the - # section below to disable rules from the `package:flutter_lints/flutter.yaml` - # included above or to enable additional rules. A list of all available lints - # and their documentation is published at - # https://dart-lang.github.io/linter/lints/index.html. - # - # Instead of disabling a lint rule for the entire project in the - # section below, it can also be suppressed for a single line of code - # or a specific dart file by using the `// ignore: name_of_lint` and - # `// ignore_for_file: name_of_lint` syntax on the line or in the file - # producing the lint. - rules: - # avoid_print: false # Uncomment to disable the `avoid_print` rule - # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options diff --git a/wearos/android/.gitignore b/wearos/android/.gitignore index 6f568019..7760dbbd 100644 --- a/wearos/android/.gitignore +++ b/wearos/android/.gitignore @@ -1,8 +1,5 @@ -gradle-wrapper.jar /.gradle /captures/ -/gradlew -/gradlew.bat /local.properties GeneratedPluginRegistrant.java diff --git a/wearos/android/app/build.gradle b/wearos/android/app/build.gradle index d1325c90..a11fa06c 100644 --- a/wearos/android/app/build.gradle +++ b/wearos/android/app/build.gradle @@ -1,25 +1,7 @@ plugins { id "com.android.application" - id "kotlin-android" - id "dev.flutter.flutter-gradle-plugin" -} - -def localProperties = new Properties() -def localPropertiesFile = rootProject.file('local.properties') -if (localPropertiesFile.exists()) { - localPropertiesFile.withReader('UTF-8') { reader -> - localProperties.load(reader) - } -} - -def flutterVersionCode = localProperties.getProperty('flutter.versionCode') -if (flutterVersionCode == null) { - flutterVersionCode = '1' -} - -def flutterVersionName = localProperties.getProperty('flutter.versionName') -if (flutterVersionName == null) { - flutterVersionName = '1.0' + id "org.jetbrains.kotlin.android" + id "org.jetbrains.kotlin.plugin.compose" } def keystoreProperties = new Properties() @@ -29,30 +11,36 @@ if (keystorePropertiesFile.exists()) { } 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' + jvmTarget = "17" } - sourceSets { - main.java.srcDirs += 'src/main/kotlin' + buildFeatures { + compose true } - namespace = "io.github.benderblog.traintime_pda" - - defaultConfig { - applicationId "io.github.benderblog.traintime_pda" - minSdk 28 - targetSdkVersion 34 - versionCode flutterVersionCode.toInteger() - versionName flutterVersionName + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } } dependenciesInfo { @@ -71,28 +59,48 @@ android { buildTypes { release { + minifyEnabled true + shrinkResources true signingConfig keystoreProperties['storeFile'] ? signingConfigs.release : signingConfigs.debug + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + debug { + applicationIdSuffix "" } } -} -flutter { - source '../..' + testOptions { + unitTests { + includeAndroidResources = true + } + } } dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.2.20" - implementation 'com.google.android.material:material:1.8.0' - implementation 'com.google.android.gms:play-services-wearable:19.0.0' -} - -ext.abiCodes = ["x86_64": 1, "armeabi-v7a": 2, "arm64-v8a": 3] -import com.android.build.OutputFile -android.applicationVariants.all { variant -> - variant.outputs.each { output -> - def abiVersionCode = project.ext.abiCodes.get(output.getFilter(OutputFile.ABI)) - if (abiVersionCode != null) { - output.versionCodeOverride = variant.versionCode * 10 + abiVersionCode - } - } + 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/src/debug/AndroidManifest.xml b/wearos/android/app/src/debug/AndroidManifest.xml index 31f97076..fbb2ef83 100644 --- a/wearos/android/app/src/debug/AndroidManifest.xml +++ b/wearos/android/app/src/debug/AndroidManifest.xml @@ -1,8 +1,3 @@ - - - + + diff --git a/wearos/android/app/src/main/AndroidManifest.xml b/wearos/android/app/src/main/AndroidManifest.xml index b6f96cb8..d1cf50e1 100644 --- a/wearos/android/app/src/main/AndroidManifest.xml +++ b/wearos/android/app/src/main/AndroidManifest.xml @@ -1,37 +1,33 @@ - + - + android:theme="@style/Theme.XdyouWear"> - - - - - 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 index 87ca6fcb..d8d2191b 100644 --- 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 @@ -1,210 +1,66 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + package io.github.benderblog.traintime_pda -import android.os.Handler -import android.os.Looper +import android.os.Bundle import android.view.WindowManager -import com.google.android.gms.wearable.MessageClient -import com.google.android.gms.wearable.MessageEvent -import com.google.android.gms.wearable.Wearable -import io.flutter.embedding.android.FlutterActivity -import io.flutter.embedding.engine.FlutterEngine -import io.flutter.plugin.common.MethodChannel -import org.json.JSONObject - -class MainActivity : FlutterActivity(), MessageClient.OnMessageReceivedListener { - private var syncChannel: MethodChannel? = null - private var paymentChannel: MethodChannel? = null - private var pendingSyncPayload: String? = null - private var directPairingExpiresAtEpochMs: Long = 0 - private val mainHandler = Handler(Looper.getMainLooper()) +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.compose.runtime.DisposableEffect +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 + +class MainActivity : ComponentActivity() { + private val container by lazy { WearAppContainer(this) } + + private val viewModel: WearViewModel by viewModels { + WearViewModel.Factory(container) + } - override fun configureFlutterEngine(flutterEngine: FlutterEngine) { - super.configureFlutterEngine(flutterEngine) - syncChannel = MethodChannel( - flutterEngine.dartExecutor.binaryMessenger, - WEAR_COMPANION_SYNC_CHANNEL, - ).also { channel -> - channel.setMethodCallHandler { call, result -> - when (call.method) { - "beginDirectPairing" -> beginDirectPairing(result) - "isCompanionPaired" -> result.success(pairedPhoneNodeId() != null) - "setKeepScreenOn" -> { - val enabled = call.arguments as? Boolean ?: false - if (enabled) { - window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } else { - window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } - result.success(null) + 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 } - "readPendingSyncPayload" -> readPendingSyncPayload(result) - "requestCompanionSync" -> requestCompanionSync(result) - else -> result.notImplemented() } - } - } - paymentChannel = MethodChannel( - flutterEngine.dartExecutor.binaryMessenger, - WEAR_PAYMENT_CHANNEL, - ).also { channel -> - channel.setMethodCallHandler { call, result -> - when (call.method) { - "requestPaymentQr" -> requestPaymentQr(result) - else -> result.notImplemented() + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + viewModel.onBackground() } } - } - } - - override fun onResume() { - super.onResume() - Wearable.getMessageClient(this).addListener(this) - } - - override fun onPause() { - Wearable.getMessageClient(this).removeListener(this) - super.onPause() - } - - override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) { - syncChannel?.setMethodCallHandler(null) - syncChannel = null - paymentChannel?.setMethodCallHandler(null) - paymentChannel = null - super.cleanUpFlutterEngine(flutterEngine) - } - - private fun beginDirectPairing(result: MethodChannel.Result) { - directPairingExpiresAtEpochMs = System.currentTimeMillis() + SYNC_SESSION_TTL_MS - result.success(directPairingExpiresAtEpochMs) - } - - private fun readPendingSyncPayload(result: MethodChannel.Result) { - val payload = pendingSyncPayload - pendingSyncPayload = null - result.success(payload) - } - - override fun onMessageReceived(event: MessageEvent) { - if (event.path == WEAR_PAYMENT_RESPONSE_PATH) { - if (event.sourceNodeId != pairedPhoneNodeId()) return - val payload = event.data.toString(Charsets.UTF_8) - mainHandler.post { - paymentChannel?.invokeMethod("receivePaymentQrResponse", payload) - } - return - } - if (event.path != WEAR_COMPANION_SYNC_MESSAGE_PATH) return - val payload = event.data.toString(Charsets.UTF_8) - if (!isActiveSyncPayload(payload, event.sourceNodeId)) return - val channel = syncChannel - if (channel == null) { - rememberPairedPhone(event.sourceNodeId) - pendingSyncPayload = payload - return - } - mainHandler.post { - val currentChannel = syncChannel - if (currentChannel == null) { - rememberPairedPhone(event.sourceNodeId) - pendingSyncPayload = payload - } else { - currentChannel.invokeMethod( - "receiveSyncPayload", - payload, - object : MethodChannel.Result { - override fun success(result: Any?) { - rememberPairedPhone(event.sourceNodeId) - clearActiveSyncSession() - } - override fun error( - errorCode: String, - errorMessage: String?, - errorDetails: Any?, - ) { - // Keep the active session until expiry so the phone can retry. - } - - override fun notImplemented() { - pendingSyncPayload = payload - } - }, - ) - } - } - } - - private fun isActiveSyncPayload(payload: String, sourceNodeId: String): Boolean { - return try { - val json = JSONObject(payload) - if (json.optInt("schemaVersion") != 1) return false - val pairedNodeId = pairedPhoneNodeId() - if (pairedNodeId != null) return pairedNodeId == sourceNodeId - System.currentTimeMillis() <= directPairingExpiresAtEpochMs && - json.optBoolean("directPairing", false) - } catch (_: Exception) { - false - } - } - - private fun requestCompanionSync(result: MethodChannel.Result) { - val nodeId = pairedPhoneNodeId() - if (nodeId == null) { - result.error("not_paired", "No companion phone is paired", null) - return - } - Wearable.getMessageClient(this) - .sendMessage(nodeId, WEAR_COMPANION_REQUEST_PATH, ByteArray(0)) - .addOnSuccessListener { result.success(null) } - .addOnFailureListener { error -> - result.error("request_failed", error.message, null) + DisposableEffect(state.screen, state.qrResult) { + // Only the displayed payment code needs a continuously lit screen. + // Network/authentication waits must not hold a wake lock. + val keepOn = state.screen == WearScreen.QR && state.qrResult != null + if (keepOn) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + onDispose { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } } - } - private fun requestPaymentQr(result: MethodChannel.Result) { - val nodeId = pairedPhoneNodeId() - if (nodeId == null) { - result.error("not_paired", "No companion phone is paired", null) - return + WearApp(viewModel = viewModel) } - Wearable.getMessageClient(this) - .sendMessage(nodeId, WEAR_PAYMENT_REQUEST_PATH, ByteArray(0)) - .addOnSuccessListener { result.success(null) } - .addOnFailureListener { error -> - result.error("request_failed", error.message, null) - } - } - - private fun rememberPairedPhone(nodeId: String) { - getSharedPreferences(WEAR_COMPANION_PREFS, MODE_PRIVATE) - .edit().putString(PAIRED_PHONE_NODE_ID, nodeId).apply() - } - - private fun pairedPhoneNodeId(): String? = - getSharedPreferences(WEAR_COMPANION_PREFS, MODE_PRIVATE) - .getString(PAIRED_PHONE_NODE_ID, null) - - private fun clearActiveSyncSession() { - directPairingExpiresAtEpochMs = 0 - pendingSyncPayload = null - } - - companion object { - private const val WEAR_COMPANION_SYNC_CHANNEL = - "io.github.benderblog.traintime_pda/wear_companion_sync" - private const val WEAR_PAYMENT_CHANNEL = - "io.github.benderblog.traintime_pda/wear_payment" - private const val WEAR_COMPANION_SYNC_MESSAGE_PATH = - "/traintime_pda_wear_os/sync/v1" - private const val WEAR_COMPANION_REQUEST_PATH = - "/traintime_pda_wear_os/request/v1" - private const val WEAR_PAYMENT_REQUEST_PATH = - "/traintime_pda_wear_os/payment/request/v1" - private const val WEAR_PAYMENT_RESPONSE_PATH = - "/traintime_pda_wear_os/payment/response/v1" - private const val WEAR_COMPANION_PREFS = "wear_companion_transport" - private const val PAIRED_PHONE_NODE_ID = "paired_phone_node_id" - private const val SYNC_SESSION_TTL_MS = 5 * 60 * 1000L } } 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..3634a268 --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/data/WearPreferences.kt @@ -0,0 +1,98 @@ +// 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 + +/** + * 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() + + 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) + .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() + + 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" + } +} 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..296492a9 --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/data/WearSyncImporter.kt @@ -0,0 +1,103 @@ +// 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() + cache.clearCampusCaches() + cache.clearPaymentQr() + IdsLoginState.state = IdsLoginState.State.MANUAL + } + + private fun clearUserScopedState(clearPaymentQr: Boolean) { + cache.clearIdsCookies() + SchoolCardSession.resetOpenId() + 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..2cb46ac9 --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ids/IdsReAuth.kt @@ -0,0 +1,200 @@ +// 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, +) { + 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 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..542e9fea --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ids/IdsSession.kt @@ -0,0 +1,218 @@ +// 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 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") + } + + 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") + 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() + } + + 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..efa7bf07 --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ids/SchoolCardSession.kt @@ -0,0 +1,230 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +package io.github.benderblog.traintime_pda.ids + +import android.util.Log +import okhttp3.Request +import org.jsoup.Jsoup +import java.net.URI +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, +) : IdsSession(cookieJar, username, password) { + /** + * 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 (_: Exception) { + resetOpenId() + } + } + clearCookieJar() + 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, + ) + 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 + resetOpenId() + 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() + } + + 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 + } + } +} 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..684d1349 --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/payment/PaymentQrRepository.kt @@ -0,0 +1,113 @@ +// 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.Dispatchers +import kotlinx.coroutines.withContext +import java.net.URI + +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 phone proxy, then watch IDS auth, then offline cache. + */ +class PaymentQrRepository( + private val preferences: WearPreferences, + private val cache: WearCacheStore, + private val companionClient: WearCompanionClient, +) { + suspend fun load( + forceRefresh: Boolean = false, + preferWatchAuth: Boolean = false, + reAuthHandler: (suspend (WearIDSReAuthClient) -> URI)? = null, + ): PaymentQrResult { + if (!forceRefresh && !preferWatchAuth) { + cache.readPaymentQr()?.let { (bytes, fetchedAt) -> + return PaymentQrResult(bytes, fromCache = true, fetchedAtEpochMs = fetchedAt) + } + } + if (preferWatchAuth) { + return try { + requestDirectly(reAuthHandler) + } catch (primary: Exception) { + cache.readPaymentQr()?.let { (bytes, fetchedAt) -> + PaymentQrResult(bytes, fromCache = true, fetchedAtEpochMs = fetchedAt) + } ?: throw primary + } + } + return try { + requestFromPhone() + } catch (_: Exception) { + try { + requestDirectly(reAuthHandler) + } catch (direct: Exception) { + cache.readPaymentQr()?.let { (bytes, fetchedAt) -> + PaymentQrResult(bytes, fromCache = true, fetchedAtEpochMs = fetchedAt) + } ?: throw direct + } + } + } + + 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, + ) + session.authenticateWithStoredCredentials(reAuthHandler = reAuthHandler) + val bytes = session.getQRCode() + val fetchedAt = System.currentTimeMillis() + cache.writePaymentQr(bytes, fetchedAt) + PaymentQrResult( + bytes = bytes, + fromCache = false, + fetchedAtEpochMs = fetchedAt, + ) + } +} 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..ec093c2b --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/protocol/WearCompanionSync.kt @@ -0,0 +1,186 @@ +// 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 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..37b28e85 --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/sync/WearCompanionClient.kt @@ -0,0 +1,173 @@ +// 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 { rememberPairedPhone(it) } + 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 + System.currentTimeMillis() <= directPairingExpiresAtEpochMs && + json.optBoolean("directPairing", false) + } 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..e60c3f9a --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ui/WearApp.kt @@ -0,0 +1,97 @@ +// 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(0xFF00A3FF), + primaryVariant = Color(0xFF0077CC), + secondary = Color(0xFF4DD0E1), + secondaryVariant = Color(0xFF0097A7), + 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.cancelReAuth() + } + + 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, + usingWatchAuth = state.qrUsingWatchAuth, + result = state.qrResult, + error = state.qrError, + onBack = viewModel::closeQr, + onRetry = viewModel::retryQr, + onWatchAuth = viewModel::authenticateOnWatch, + ) + 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::cancelReAuth, + ) + } + } + } +} 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..778d7c28 --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ui/WearViewModel.kt @@ -0,0 +1,424 @@ +// 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.WearIDSReAuthCancelledException +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.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withContext +import java.net.URI +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +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 qrUsingWatchAuth: 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 pendingSyncJob: Job? = null + private var qrJob: Job? = 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() + } + + 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() { + _state.update { + it.copy( + screen = WearScreen.QR, + qrLoading = true, + qrUsingWatchAuth = false, + qrResult = null, + qrError = null, + ) + } + loadQr(forceRefresh = false, preferWatchAuth = false) + } + + fun closeQr() { + qrJob?.cancel() + cancelReAuth() + _state.update { + it.copy( + screen = WearScreen.HOME, + qrLoading = false, + qrResult = null, + qrError = null, + qrUsingWatchAuth = false, + ) + } + } + + fun retryQr() { + _state.update { + it.copy( + qrLoading = true, + qrUsingWatchAuth = false, + qrResult = null, + qrError = null, + ) + } + loadQr(forceRefresh = true, preferWatchAuth = false) + } + + fun authenticateOnWatch() { + _state.update { + it.copy( + qrLoading = true, + qrUsingWatchAuth = true, + qrResult = null, + qrError = null, + ) + } + loadQr(forceRefresh = true, preferWatchAuth = true) + } + + private fun loadQr(forceRefresh: Boolean, preferWatchAuth: Boolean) { + qrJob?.cancel() + qrJob = viewModelScope.launch { + try { + val result = paymentRepo.load( + forceRefresh = forceRefresh, + preferWatchAuth = preferWatchAuth, + reAuthHandler = { client -> awaitReAuth(client) }, + ) + _state.update { + it.copy( + qrLoading = false, + qrResult = result, + qrError = null, + ) + } + } catch (e: Exception) { + _state.update { + it.copy( + qrLoading = false, + qrResult = null, + qrError = e.message ?: "付款码获取失败", + ) + } + } + } + } + + private suspend fun awaitReAuth(client: WearIDSReAuthClient): URI = + kotlinx.coroutines.suspendCancellableCoroutine { cont -> + reAuthContinuation?.resumeWithException(WearIDSReAuthCancelledException()) + reAuthContinuation = cont + _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 client = _state.value.reAuthClient ?: return + if (_state.value.reAuthSending || _state.value.reAuthSecondsRemaining > 0) return + viewModelScope.launch { + _state.update { it.copy(reAuthSending = true, reAuthError = null) } + try { + val delivery = withContext(Dispatchers.IO) { client.sendSms() } + val notice = if (delivery.recipient == null) { + delivery.message + } else { + "${delivery.message}\n${delivery.recipient}" + } + _state.update { it.copy(reAuthNotice = notice) } + startCountdown(delivery.retryAfterSeconds) + } catch (e: Exception) { + _state.update { + it.copy(reAuthError = e.message ?: "短信验证码发送失败") + } + } finally { + _state.update { it.copy(reAuthSending = false) } + } + } + } + + private fun startCountdown(seconds: Int) { + 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) + 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 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 + viewModelScope.launch { + _state.update { it.copy(reAuthSubmitting = true, reAuthError = null) } + try { + val uri = withContext(Dispatchers.IO) { + client.submitSms(code, _state.value.reAuthTrustDevice) + } + val cont = reAuthContinuation + reAuthContinuation = null + _state.update { + it.copy( + screen = WearScreen.QR, + reAuthClient = null, + reAuthSubmitting = false, + ) + } + cont?.resume(uri) + } catch (e: Exception) { + _state.update { + it.copy( + reAuthSubmitting = false, + reAuthError = e.message ?: "验证失败", + reAuthCode = "", + ) + } + } + } + } + + fun cancelReAuth() { + val cont = reAuthContinuation + reAuthContinuation = null + countdownJob?.cancel() + cont?.resumeWithException(WearIDSReAuthCancelledException()) + _state.update { + it.copy( + screen = if (it.qrLoading || it.qrResult != null || it.qrError != null) { + WearScreen.QR + } else { + WearScreen.HOME + }, + reAuthClient = null, + ) + } + } + + override fun onCleared() { + qrJob?.cancel() + cancelReAuth() + 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..a6ce17c3 --- /dev/null +++ b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ui/screens/QrScreen.kt @@ -0,0 +1,179 @@ +// 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.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.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.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("MM-dd HH:mm") + +@Composable +fun QrScreen( + loading: Boolean, + usingWatchAuth: Boolean, + result: PaymentQrResult?, + error: String?, + onBack: () -> Unit, + onRetry: () -> Unit, + onWatchAuth: () -> 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 = if (usingWatchAuth) "正在由手表认证" else "正在向手机请求付款码", + textAlign = TextAlign.Center, + style = MaterialTheme.typography.body2, + ) + if (!usingWatchAuth) { + Spacer(Modifier.height(10.dp)) + Button( + onClick = onWatchAuth, + colors = ButtonDefaults.secondaryButtonColors(), + ) { + Text("改用手表认证") + } + } + } + } + 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.fillMaxSize()) { + 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 = 22.dp, top = 28.dp, end = 22.dp, bottom = 4.dp) + .clip(RoundedCornerShape(20.dp)) + .background(Color.White) + .padding(14.dp), + ) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = "付款码", + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxWidth(), + ) + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Button( + onClick = onBack, + colors = ButtonDefaults.secondaryButtonColors(), + ) { Text("←") } + Button( + onClick = onRetry, + colors = ButtonDefaults.secondaryButtonColors(), + ) { Text("↻") } + } + } + 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 = 34.dp, vertical = 8.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/values-night-v31/styles.xml b/wearos/android/app/src/main/res/values-night-v31/styles.xml index a3653cb1..95f3e559 100644 --- a/wearos/android/app/src/main/res/values-night-v31/styles.xml +++ b/wearos/android/app/src/main/res/values-night-v31/styles.xml @@ -1,19 +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 index e66efecc..34cc96bf 100644 --- a/wearos/android/app/src/main/res/values-night/styles.xml +++ b/wearos/android/app/src/main/res/values-night/styles.xml @@ -1,22 +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 index d0a68e92..95f3e559 100644 --- a/wearos/android/app/src/main/res/values-v31/styles.xml +++ b/wearos/android/app/src/main/res/values-v31/styles.xml @@ -1,19 +1,8 @@ - - - - diff --git a/wearos/android/app/src/main/res/values/styles.xml b/wearos/android/app/src/main/res/values/styles.xml index 564790a4..627b4493 100644 --- a/wearos/android/app/src/main/res/values/styles.xml +++ b/wearos/android/app/src/main/res/values/styles.xml @@ -1,22 +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/profile/AndroidManifest.xml b/wearos/android/app/src/profile/AndroidManifest.xml index 31f97076..fbb2ef83 100644 --- a/wearos/android/app/src/profile/AndroidManifest.xml +++ b/wearos/android/app/src/profile/AndroidManifest.xml @@ -1,8 +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 index eb2ccd7f..f60f590d 100644 --- a/wearos/android/build.gradle +++ b/wearos/android/build.gradle @@ -1,38 +1,12 @@ +// Root build for the native XDYou Wear OS client. +// Flutter is no longer used on the watch target. -allprojects { - repositories { - google() - mavenCentral() - } +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 } -// https://github.com/flutter/flutter/issues/153281#issuecomment-2292201697 -rootProject.buildDir = '../build' -subprojects { - afterEvaluate { project -> - if (project.extensions.findByName("android") != null) { - Integer pluginCompileSdk = project.android.compileSdk - if (pluginCompileSdk != null && pluginCompileSdk < 31) { - project.logger.error( - "Warning: Overriding compileSdk version in Flutter plugin: " - + project.name - + " from " - + pluginCompileSdk - + " to 31 (to work around https://issuetracker.google.com/issues/199180389)." - + "\nIf there is not a new version of " + project.name + ", consider filing an issue against " - + project.name - + " to increase their compileSdk to the latest (otherwise try updating to the latest version)." - ) - project.android { - compileSdk 31 - } - } - } - } - - project.buildDir = "${rootProject.buildDir}/${project.name}" - project.evaluationDependsOn(":app") -} tasks.register("clean", Delete) { - delete rootProject.buildDir -} \ No newline at end of file + delete rootProject.layout.buildDirectory +} diff --git a/wearos/android/gradle.properties b/wearos/android/gradle.properties index 40412928..86d4a2a4 100644 --- a/wearos/android/gradle.properties +++ b/wearos/android/gradle.properties @@ -1,3 +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 0000000000000000000000000000000000000000..13372aef5e24af05341d49695ee84e5f9b594659 GIT binary patch literal 53636 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ⅅ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 H-+%o-(+5aQ literal 0 HcmV?d00001 diff --git a/wearos/android/gradle/wrapper/gradle-wrapper.properties b/wearos/android/gradle/wrapper/gradle-wrapper.properties index 50f53e39..088b874c 100644 --- a/wearos/android/gradle/wrapper/gradle-wrapper.properties +++ b/wearos/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +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.11.1-bin.zip +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 index f3bad359..4af8f432 100644 --- a/wearos/android/settings.gradle +++ b/wearos/android/settings.gradle @@ -1,14 +1,4 @@ pluginManagement { - def flutterSdkPath = { - def properties = new Properties() - file("local.properties").withInputStream { properties.load(it) } - def flutterSdkPath = properties.getProperty("flutter.sdk") - assert flutterSdkPath != null, "flutter.sdk not set in local.properties" - return flutterSdkPath - }() - - includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") - repositories { google() mavenCentral() @@ -16,12 +6,13 @@ pluginManagement { } } -plugins { - id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version '8.10.0' apply false - id "org.jetbrains.kotlin.android" version "2.2.20" apply false +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } } +rootProject.name = "xdyou-wear" include ":app" - - diff --git a/wearos/lib/main.dart b/wearos/lib/main.dart deleted file mode 100644 index dfb56d09..00000000 --- a/wearos/lib/main.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:shared_preferences/util/legacy_to_async_migration_util.dart'; -import 'package:watermeter/repository/logger.dart'; -import 'package:watermeter/repository/network_session.dart' as network; -import 'package:watermeter/repository/preference.dart' as preference; -import 'package:watermeter/repository/xidian_ids/ids_session.dart'; -import 'package:watermeter/wearos/wear_app.dart'; -import 'package:watermeter/wearos/wear_cache_store.dart'; - -Future main() async { - WidgetsFlutterBinding.ensureInitialized(); - - log.info('Starting XDYou Wear.'); - network.supportPath = await getApplicationSupportDirectory(); - - const options = SharedPreferencesOptions(); - final legacyPrefs = await SharedPreferences.getInstance(); - if (legacyPrefs.getKeys().isNotEmpty) { - await migrateLegacySharedPreferencesToSharedPreferencesAsyncIfNecessary( - legacySharedPreferencesInstance: legacyPrefs, - sharedPreferencesAsyncOptions: options, - migrationCompletedKey: 'pdaMigrationCompleted', - ); - } - - preference.prefs = await SharedPreferencesWithCache.create( - cacheOptions: const SharedPreferencesWithCacheOptions(), - ); - - final semester = preference.getString(preference.Preference.currentSemester); - var isCompanionPaired = false; - try { - isCompanionPaired = - await const MethodChannel( - 'io.github.benderblog.traintime_pda/wear_companion_sync', - ).invokeMethod('isCompanionPaired') ?? - false; - } on PlatformException { - // Treat a missing/unavailable native pairing record as unpaired. - } - final isFirst = - !isCompanionPaired || semester.isEmpty || !WearClassTableCache.exists; - loginState = isFirst ? IDSLoginState.manual : IDSLoginState.none; - - runApp(WearApp(isFirst: isFirst)); -} diff --git a/wearos/lib/model/time_list.dart b/wearos/lib/model/time_list.dart deleted file mode 100644 index db209fbc..00000000 --- a/wearos/lib/model/time_list.dart +++ /dev/null @@ -1,26 +0,0 @@ -/// Time arrangements. -/// Even means start, odd means end. -List timeList = [ - "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/lib/model/xidian_ids/classtable.dart b/wearos/lib/model/xidian_ids/classtable.dart deleted file mode 100644 index 1685b7be..00000000 --- a/wearos/lib/model/xidian_ids/classtable.dart +++ /dev/null @@ -1,327 +0,0 @@ -// Copyright 2023-2025 BenderBlog Rodriguez and contributors -// Copyright 2025 Traintime PDA authors. -// SPDX-License-Identifier: MPL-2.0 OR Apache-2.0 - -import 'package:flutter/foundation.dart'; -import 'package:json_annotation/json_annotation.dart'; - -part 'classtable.g.dart'; - -enum Source { empty, school, user } - -@JsonSerializable(explicitToJson: true) -class NotArrangementClassDetail { - String name; // 名称 - String? code; // 课程序号 - String? number; // 班级序号 - String? teacher; // 老师 - - NotArrangementClassDetail({ - required this.name, - this.code, - this.number, - this.teacher, - }); - - factory NotArrangementClassDetail.from(NotArrangementClassDetail e) => - NotArrangementClassDetail( - name: e.name, - code: e.code, - number: e.number, - teacher: e.teacher, - ); - - factory NotArrangementClassDetail.fromJson(Map json) => - _$NotArrangementClassDetailFromJson(json); - - Map toJson() => _$NotArrangementClassDetailToJson(this); - - @override - int get hashCode => name.hashCode; - - @override - bool operator ==(Object other) => - other is ClassDetail && - other.runtimeType == runtimeType && - name == other.name; -} - -@JsonSerializable(explicitToJson: true) -class ClassDetail { - String name; // 名称 - String? code; // 课程序号 - String? number; // 班级序号 - - ClassDetail({required this.name, this.code, this.number}); - - factory ClassDetail.from(ClassDetail e) => - ClassDetail(name: e.name, code: e.code, number: e.number); - - factory ClassDetail.fromJson(Map json) => - _$ClassDetailFromJson(json); - - Map toJson() => _$ClassDetailToJson(this); - - @override - int get hashCode => name.hashCode; - - @override - bool operator ==(Object other) => - other is ClassDetail && - other.runtimeType == runtimeType && - name == other.name; - - @override - String toString() { - return "$name $code $number"; - } -} - -@JsonSerializable(explicitToJson: true) -class TimeArrangement { - /// 课程索引(注:是 `ClassDetail` 的索引,不是 `TimeArrangement` 的索引) - int index; - - /// 返回的是布尔类型列表,true 表示该周有课,false 表示该周无课 - /// 绕过 Swift 字符串不好处理的代价就是 json 要大很多了...... - @JsonKey(name: 'week_list') - List weekList; // 上课周次 - String? teacher; // 老师 - int day; // 星期几上课 - int start; // 上课开始 - int stop; // 上课结束 - Source source; // 数据来源 - @JsonKey(includeIfNull: false) - String? classroom; // 上课教室 - - int get step => stop - start; // 上课长度 - - factory TimeArrangement.fromJson(Map json) => - _$TimeArrangementFromJson(json); - - Map toJson() => _$TimeArrangementToJson(this); - - TimeArrangement({ - required this.source, - required this.index, - required this.weekList, - this.classroom, - this.teacher, - required this.day, - required this.start, - required this.stop, - }); - - @override - String toString() => "$source $index $classroom $teacher"; -} - -@JsonSerializable(explicitToJson: true) -class ClassTableData { - int semesterLength; - String semesterCode; - String termStartDay; - List classDetail; - List userDefinedDetail; - List notArranged; - List timeArrangement; - List classChanges; - - /// Only allowed to be used with classDetail - ClassDetail getClassDetail(TimeArrangement t) { - switch (t.source) { - case Source.school: - return classDetail[t.index]; - case Source.user: - return userDefinedDetail[t.index]; - case Source.empty: - throw NotImplementedException(); - } - } - - ClassTableData.from(ClassTableData c) - : this( - semesterLength: c.semesterLength, - semesterCode: c.semesterCode, - termStartDay: c.termStartDay, - classDetail: c.classDetail, - notArranged: c.notArranged, - timeArrangement: c.timeArrangement, - classChanges: c.classChanges, - ); - - ClassTableData({ - this.semesterLength = 1, - this.semesterCode = "", - this.termStartDay = "", - List? classDetail, - List? userDefinedDetail, - List? notArranged, - List? timeArrangement, - List? classChanges, - }) : classDetail = classDetail ?? [], - userDefinedDetail = userDefinedDetail ?? [], - notArranged = notArranged ?? [], - timeArrangement = timeArrangement ?? [], - classChanges = classChanges ?? [], - assert( - timeArrangement == null || - timeArrangement.isEmpty || - termStartDay.isNotEmpty, - "termStartDay is required when timeArrangement is not empty.", - ); - - factory ClassTableData.fromJson(Map json) => - _$ClassTableDataFromJson(json); - - Map toJson() => _$ClassTableDataToJson(this); -} - -class NotImplementedException implements Exception {} - -enum ChangeType { - change, // 调课 - stop, // 停课 - patch, // 补课 -} - -@JsonSerializable(explicitToJson: true) -class ClassChange { - final ChangeType type; - - /// KCH 课程号 - final String classCode; - - /// KXH 班级号 - final String classNumber; - - /// KCM 课程名 - final String className; - - /// 来自 SKZC 原周次信息,可能是空 - final List? originalAffectedWeeks; - - /// 来自 XSKZC 新周次信息,可能是空 - final List? newAffectedWeeks; - - /// YSKJS 原先的老师 - final String? originalTeacherData; - - /// XSKJS 新换的老师 - final String? newTeacherData; - - /// KSJS-JSJC 原先的课次信息 - final List originalClassRange; - - /// XKSJS-XJSJC 新的课次信息 - final List newClassRange; - - /// SKXQ 原先的星期 - final int? originalWeek; - - /// XSKXQ 现在的星期 - final int? newWeek; - - /// JASMC 旧教室 - final String? originalClassroom; - - /// XJASMC 新教室 - final String? newClassroom; - - ClassChange({ - required this.type, - required this.classCode, - required this.classNumber, - required this.className, - required this.originalAffectedWeeks, - required this.newAffectedWeeks, - required this.originalTeacherData, - required this.newTeacherData, - required this.originalClassRange, - required this.newClassRange, - required this.originalWeek, - required this.newWeek, - required this.originalClassroom, - required this.newClassroom, - }); - - /// 必须假设后台有问题,返回长度不一样的数组 - /// 亏他们想得出来用 01 表示布尔信息,日子不是这么省的啊 - List get originalAffectedWeeksList { - if (originalAffectedWeeks == null) return []; - List toReturn = []; - for (int i = 0; i < originalAffectedWeeks!.length; ++i) { - if (originalAffectedWeeks![i]) toReturn.add(i); - } - return toReturn; - } - - List get newAffectedWeeksList { - List toReturn = []; - for (int i = 0; i < (newAffectedWeeks?.length ?? 0); ++i) { - if (newAffectedWeeks![i]) toReturn.add(i); - } - return toReturn; - } - - String? get originalTeacher => - originalTeacherData?.replaceAll(RegExp(r'(/|[0-9a-zA-z])'), ''); - - String? get newTeacher => - newTeacherData?.replaceAll(RegExp(r'(/|[0-9a-zA-z])'), ''); - - String? get originalNewTeacher => newTeacherData; - - bool get isTeacherChanged { - List originalTeacherCode = - originalTeacherData?.replaceAll(' ', '').split(RegExp(r',|/')) ?? []; - - originalTeacherCode.retainWhere( - (element) => element.contains(RegExp(r'([0-9])')), - ); - - List newTeacherCode = - newTeacherData?.replaceAll(' ', '').split(RegExp(r',|/')) ?? []; - - newTeacherCode.retainWhere( - (element) => element.contains(RegExp(r'([0-9])')), - ); - - return !listEquals(originalTeacherCode, newTeacherCode); - } - - String get changeTypeString { - switch (type) { - case ChangeType.change: - return "调课"; - case ChangeType.patch: - return "补课"; - case ChangeType.stop: - return "停课"; - } - } - - factory ClassChange.fromJson(Map json) => - _$ClassChangeFromJson(json); - - Map toJson() => _$ClassChangeToJson(this); -} - -@JsonSerializable(explicitToJson: true) -class UserDefinedClassData { - List userDefinedDetail; - List timeArrangement; - - UserDefinedClassData({ - required this.userDefinedDetail, - required this.timeArrangement, - }); - - factory UserDefinedClassData.fromJson(Map json) => - _$UserDefinedClassDataFromJson(json); - - factory UserDefinedClassData.empty() => - UserDefinedClassData(userDefinedDetail: [], timeArrangement: []); - - Map toJson() => _$UserDefinedClassDataToJson(this); -} diff --git a/wearos/lib/model/xidian_ids/classtable.g.dart b/wearos/lib/model/xidian_ids/classtable.g.dart deleted file mode 100644 index fed21c88..00000000 --- a/wearos/lib/model/xidian_ids/classtable.g.dart +++ /dev/null @@ -1,179 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'classtable.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -NotArrangementClassDetail _$NotArrangementClassDetailFromJson( - Map json, -) => NotArrangementClassDetail( - name: json['name'] as String, - code: json['code'] as String?, - number: json['number'] as String?, - teacher: json['teacher'] as String?, -); - -Map _$NotArrangementClassDetailToJson( - NotArrangementClassDetail instance, -) => { - 'name': instance.name, - 'code': instance.code, - 'number': instance.number, - 'teacher': instance.teacher, -}; - -ClassDetail _$ClassDetailFromJson(Map json) => ClassDetail( - name: json['name'] as String, - code: json['code'] as String?, - number: json['number'] as String?, -); - -Map _$ClassDetailToJson(ClassDetail instance) => - { - 'name': instance.name, - 'code': instance.code, - 'number': instance.number, - }; - -TimeArrangement _$TimeArrangementFromJson(Map json) => - TimeArrangement( - source: $enumDecode(_$SourceEnumMap, json['source']), - index: (json['index'] as num).toInt(), - weekList: (json['week_list'] as List) - .map((e) => e as bool) - .toList(), - classroom: json['classroom'] as String?, - teacher: json['teacher'] as String?, - day: (json['day'] as num).toInt(), - start: (json['start'] as num).toInt(), - stop: (json['stop'] as num).toInt(), - ); - -Map _$TimeArrangementToJson(TimeArrangement instance) => - { - 'index': instance.index, - 'week_list': instance.weekList, - 'teacher': instance.teacher, - 'day': instance.day, - 'start': instance.start, - 'stop': instance.stop, - 'source': _$SourceEnumMap[instance.source]!, - 'classroom': ?instance.classroom, - }; - -const _$SourceEnumMap = { - Source.empty: 'empty', - Source.school: 'school', - Source.user: 'user', -}; - -ClassTableData _$ClassTableDataFromJson(Map json) => - ClassTableData( - semesterLength: (json['semesterLength'] as num?)?.toInt() ?? 1, - semesterCode: json['semesterCode'] as String? ?? "", - termStartDay: json['termStartDay'] as String? ?? "", - classDetail: (json['classDetail'] as List?) - ?.map((e) => ClassDetail.fromJson(e as Map)) - .toList(), - userDefinedDetail: (json['userDefinedDetail'] as List?) - ?.map((e) => ClassDetail.fromJson(e as Map)) - .toList(), - notArranged: (json['notArranged'] as List?) - ?.map( - (e) => - NotArrangementClassDetail.fromJson(e as Map), - ) - .toList(), - timeArrangement: (json['timeArrangement'] as List?) - ?.map((e) => TimeArrangement.fromJson(e as Map)) - .toList(), - classChanges: (json['classChanges'] as List?) - ?.map((e) => ClassChange.fromJson(e as Map)) - .toList(), - ); - -Map _$ClassTableDataToJson( - ClassTableData instance, -) => { - 'semesterLength': instance.semesterLength, - 'semesterCode': instance.semesterCode, - 'termStartDay': instance.termStartDay, - 'classDetail': instance.classDetail.map((e) => e.toJson()).toList(), - 'userDefinedDetail': instance.userDefinedDetail - .map((e) => e.toJson()) - .toList(), - 'notArranged': instance.notArranged.map((e) => e.toJson()).toList(), - 'timeArrangement': instance.timeArrangement.map((e) => e.toJson()).toList(), - 'classChanges': instance.classChanges.map((e) => e.toJson()).toList(), -}; - -ClassChange _$ClassChangeFromJson(Map json) => ClassChange( - type: $enumDecode(_$ChangeTypeEnumMap, json['type']), - classCode: json['classCode'] as String, - classNumber: json['classNumber'] as String, - className: json['className'] as String, - originalAffectedWeeks: (json['originalAffectedWeeks'] as List?) - ?.map((e) => e as bool) - .toList(), - newAffectedWeeks: (json['newAffectedWeeks'] as List?) - ?.map((e) => e as bool) - .toList(), - originalTeacherData: json['originalTeacherData'] as String?, - newTeacherData: json['newTeacherData'] as String?, - originalClassRange: (json['originalClassRange'] as List) - .map((e) => (e as num).toInt()) - .toList(), - newClassRange: (json['newClassRange'] as List) - .map((e) => (e as num).toInt()) - .toList(), - originalWeek: (json['originalWeek'] as num?)?.toInt(), - newWeek: (json['newWeek'] as num?)?.toInt(), - originalClassroom: json['originalClassroom'] as String?, - newClassroom: json['newClassroom'] as String?, -); - -Map _$ClassChangeToJson(ClassChange instance) => - { - 'type': _$ChangeTypeEnumMap[instance.type]!, - 'classCode': instance.classCode, - 'classNumber': instance.classNumber, - 'className': instance.className, - 'originalAffectedWeeks': instance.originalAffectedWeeks, - 'newAffectedWeeks': instance.newAffectedWeeks, - 'originalTeacherData': instance.originalTeacherData, - 'newTeacherData': instance.newTeacherData, - 'originalClassRange': instance.originalClassRange, - 'newClassRange': instance.newClassRange, - 'originalWeek': instance.originalWeek, - 'newWeek': instance.newWeek, - 'originalClassroom': instance.originalClassroom, - 'newClassroom': instance.newClassroom, - }; - -const _$ChangeTypeEnumMap = { - ChangeType.change: 'change', - ChangeType.stop: 'stop', - ChangeType.patch: 'patch', -}; - -UserDefinedClassData _$UserDefinedClassDataFromJson( - Map json, -) => UserDefinedClassData( - userDefinedDetail: (json['userDefinedDetail'] as List) - .map((e) => ClassDetail.fromJson(e as Map)) - .toList(), - timeArrangement: (json['timeArrangement'] as List) - .map((e) => TimeArrangement.fromJson(e as Map)) - .toList(), -); - -Map _$UserDefinedClassDataToJson( - UserDefinedClassData instance, -) => { - 'userDefinedDetail': instance.userDefinedDetail - .map((e) => e.toJson()) - .toList(), - 'timeArrangement': instance.timeArrangement.map((e) => e.toJson()).toList(), -}; diff --git a/wearos/lib/model/xidian_ids/experiment.dart b/wearos/lib/model/xidian_ids/experiment.dart deleted file mode 100644 index 2d73deb1..00000000 --- a/wearos/lib/model/xidian_ids/experiment.dart +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright 2023-2025 BenderBlog Rodriguez and contributors -// Copyright 2025 Traintime PDA authors. -// SPDX-License-Identifier: MPL-2.0 - -import 'package:json_annotation/json_annotation.dart'; - -part 'experiment.g.dart'; - -enum ExperimentType { others } - -@JsonSerializable(explicitToJson: true) -class ExperimentData { - final ExperimentType type; - final String name; - final String classroom; - final List<(DateTime, DateTime)> timeRanges; - final String teacher; - final String? reference; - - const ExperimentData({ - required this.type, - required this.name, - required this.classroom, - required this.timeRanges, - required this.teacher, - this.reference, - }); - - factory ExperimentData.fromJson(Map json) => - _$ExperimentDataFromJson(json); - - Map toJson() => _$ExperimentDataToJson(this); - - @override - String toString() { - return 'ExperimentData(' - 'type: $type, ' - 'name: $name, ' - 'classroom: $classroom, ' - 'timeRanges: ${timeRanges.map((range) => "[${range.$1.toIso8601String()} - ${range.$2.toIso8601String()}]").join(", ")}, ' - 'teacher: $teacher, ' - 'reference: ${reference ?? "N/A"}' - ')'; - } - - factory ExperimentData.from(ExperimentData src) => ExperimentData( - type: src.type, - name: src.name, - classroom: src.classroom, - timeRanges: src.timeRanges.toList(), - teacher: src.teacher, - reference: src.reference, - ); -} diff --git a/wearos/lib/model/xidian_ids/experiment.g.dart b/wearos/lib/model/xidian_ids/experiment.g.dart deleted file mode 100644 index 5ac6663b..00000000 --- a/wearos/lib/model/xidian_ids/experiment.g.dart +++ /dev/null @@ -1,49 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'experiment.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -ExperimentData _$ExperimentDataFromJson(Map json) => - ExperimentData( - type: $enumDecode(_$ExperimentTypeEnumMap, json['type']), - name: json['name'] as String, - classroom: json['classroom'] as String, - timeRanges: (json['timeRanges'] as List) - .map( - (e) => _$recordConvert( - e, - ($jsonValue) => ( - DateTime.parse($jsonValue[r'$1'] as String), - DateTime.parse($jsonValue[r'$2'] as String), - ), - ), - ) - .toList(), - teacher: json['teacher'] as String, - reference: json['reference'] as String?, - ); - -Map _$ExperimentDataToJson(ExperimentData instance) => - { - 'type': _$ExperimentTypeEnumMap[instance.type]!, - 'name': instance.name, - 'classroom': instance.classroom, - 'timeRanges': instance.timeRanges - .map( - (e) => { - r'$1': e.$1.toIso8601String(), - r'$2': e.$2.toIso8601String(), - }, - ) - .toList(), - 'teacher': instance.teacher, - 'reference': instance.reference, - }; - -const _$ExperimentTypeEnumMap = {ExperimentType.others: 'others'}; - -$Rec _$recordConvert<$Rec>(Object? value, $Rec Function(Map) convert) => - convert(value as Map); diff --git a/wearos/lib/repository/logger.dart b/wearos/lib/repository/logger.dart deleted file mode 100644 index 9e47c4bc..00000000 --- a/wearos/lib/repository/logger.dart +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright 2023-2025 BenderBlog Rodriguez and contributors -// Copyright 2025 Traintime PDA authors. -// SPDX-License-Identifier: MPL-2.0 - -import 'dart:typed_data'; - -import 'package:talker_dio_logger/talker_dio_logger.dart'; -import 'package:talker_flutter/talker_flutter.dart'; - -final log = TalkerFlutter.init(); -final logDioAdapter = TalkerDioLogger( - talker: log, - settings: TalkerDioLoggerSettings( - printRequestHeaders: true, - printResponseHeaders: true, - printResponseMessage: true, - responseFilter: (response) { - // 1. 忽略特定 URL - final url = response.requestOptions.uri.toString(); - if (url.contains('openSliderCaptcha.htl')) { - return false; - } - - // 2. 忽略二进制文件 (Uint8List) - // 通常通过检查 response.data 的类型或 Content-Type 头部 - if (response.data is List || response.data is Uint8List) { - return false; - } - - return true; - }, - ), -); diff --git a/wearos/lib/repository/network_session.dart b/wearos/lib/repository/network_session.dart deleted file mode 100644 index 52415f33..00000000 --- a/wearos/lib/repository/network_session.dart +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright 2023-2025 BenderBlog Rodriguez and contributors -// Copyright 2025 Traintime PDA authors. -// SPDX-License-Identifier: MPL-2.0 - -// General network class. - -import 'dart:io'; -import 'package:dio/dio.dart'; -import 'package:flutter/foundation.dart'; -import 'package:cookie_jar/cookie_jar.dart'; -import 'package:dio_cookie_manager/dio_cookie_manager.dart'; -import 'package:watermeter/repository/logger.dart'; - -late Directory supportPath; - -class NetworkSession { - //@protected - final PersistCookieJar cookieJar = PersistCookieJar( - persistSession: true, - storage: FileStorage("${supportPath.path}/cookie/general"), - ); - - Future clearCookieJar() => cookieJar.deleteAll(); - - @protected - Dio get dio => - Dio( - BaseOptions( - contentType: Headers.formUrlEncodedContentType, - headers: { - HttpHeaders.userAgentHeader: - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/130.0.0.0 Safari/537.36", - }, - ), - ) - ..interceptors.add(CookieManager(cookieJar, ignoreInvalidCookies: true)) - ..interceptors.add(logDioAdapter) - ..options.connectTimeout = const Duration(seconds: 10) - ..options.receiveTimeout = const Duration(seconds: 30) - ..options.followRedirects = false - ..options.validateStatus = (status) => - status != null && status >= 200 && status < 400; -} diff --git a/wearos/lib/repository/preference.dart b/wearos/lib/repository/preference.dart deleted file mode 100644 index 13069ba3..00000000 --- a/wearos/lib/repository/preference.dart +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright 2023-2025 BenderBlog Rodriguez and contributors -// Copyright 2025 Traintime PDA authors. -// SPDX-License-Identifier: MPL-2.0 - -// General user setting preference. - -import 'package:shared_preferences/shared_preferences.dart'; - -late SharedPreferencesWithCache prefs; - -enum Preference { - idsAccount(key: "idsAccount"), - idsPassword(key: "idsPassword"), - currentSemester(key: "currentSemester"), - isUserDefinedSemester(key: "isUserDefinedSemester", type: "bool"), - role(key: "role", type: "bool"); - - const Preference({required this.key, this.type = "String"}); - - final String key; - final String type; -} - -String getString(Preference key) { - if (key.type != 'String') { - throw WrongTypeException; - } - return prefs.getString(key.key) ?? ""; -} - -bool getBool(Preference key) { - if (key.type != 'bool') { - throw WrongTypeException; - } - - return prefs.getBool(key.key) ?? false; -} - -bool contains(Preference key) { - return prefs.containsKey(key.key); -} - -Future setString(Preference key, String value) async { - if (key.type != 'String') { - throw WrongTypeException; - } - await prefs.setString(key.key, value); - await prefs.reloadCache(); -} - -Future setBool(Preference key, bool value) async { - if (key.type != 'bool') { - throw WrongTypeException; - } - await prefs.setBool(key.key, value); - await prefs.reloadCache(); -} - -Future remove(Preference key) async { - await prefs.remove(key.key); - await prefs.reloadCache(); -} - -class WrongTypeException implements Exception {} diff --git a/wearos/lib/repository/xidian_ids/ids_session.dart b/wearos/lib/repository/xidian_ids/ids_session.dart deleted file mode 100644 index 005cea47..00000000 --- a/wearos/lib/repository/xidian_ids/ids_session.dart +++ /dev/null @@ -1,368 +0,0 @@ -// Copyright 2023-2025 BenderBlog Rodriguez and contributors -// Copyright 2025 Traintime PDA authors. -// SPDX-License-Identifier: MPL-2.0 - -// IDS (统一认证服务) login class. -// Thanks xidian-script and libxdauth! - -import 'dart:io'; -import 'package:dio/dio.dart'; -import 'package:html/parser.dart'; -import 'package:encrypter_plus/encrypter_plus.dart' as encrypt; -import 'package:synchronized/synchronized.dart'; -import 'package:watermeter/wearos/slider_captcha.dart'; -import 'package:watermeter/repository/logger.dart'; -import 'package:watermeter/repository/network_session.dart'; -import 'package:watermeter/repository/preference.dart' as preference; - -enum IDSLoginState { - none, - requesting, - success, - fail, - passwordWrong, - - /// Indicate that the user will login via LoginWindow - manual, -} - -IDSLoginState loginState = IDSLoginState.none; - -bool get offline => - loginState != IDSLoginState.success && loginState != IDSLoginState.manual; - -class IDSSession extends NetworkSession { - static final _idslock = Lock(); - static const _goLoginPasswordPrefix = - '................................................................'; - static const _goLoginPasswordIv = '................'; - - @override - Dio get dio => super.dio - ..interceptors.add( - InterceptorsWrapper( - onRequest: (options, handler) { - log.info( - "[IDSSession][OfflineCheckInspector]" - "Offline status: $offline", - ); - if (offline) { - handler.reject( - DioException.requestCancelled( - reason: "Offline mode, all ids function unuseable.", - requestOptions: options, - ), - ); - } else { - handler.next(options); - } - }, - ), - ); - - Dio get dioNoOfflineCheck => super.dio; - - Future _hasCastgcCookie() async { - final cookies = await cookieJar.loadForRequest( - Uri.parse('https://ids.xidian.edu.cn/authserver/login'), - ); - return cookies.any((cookie) => cookie.name == 'CASTGC'); - } - - /// Get base64 encoded data. Which is aes encrypted [toEnc] encoded string using [key]. - /// Matches the Go ids login payload: 64 fixed prefix bytes, AES-CBC, - /// PKCS7 padding, and the fixed `................` IV. - static String aesEncrypt(String toEnc, String key) { - final crypt = encrypt.AES( - encrypt.Key.fromUtf8(key), - mode: encrypt.AESMode.cbc, - ); - return encrypt.Encrypter(crypt) - .encrypt( - '$_goLoginPasswordPrefix$toEnc', - iv: encrypt.IV.fromUtf8(_goLoginPasswordIv), - ) - .base64; - } - - static Map buildUsernameLoginPayloadForTesting({ - required String username, - required String password, - required String salt, - required String execution, - }) => _buildUsernameLoginPayload( - username: username, - password: password, - salt: salt, - execution: execution, - ); - - static Map _buildUsernameLoginPayload({ - required String username, - required String password, - required String salt, - required String execution, - }) { - return { - 'username': username, - 'password': aesEncrypt(password, salt), - 'rememberMe': 'true', - 'cllt': 'userNameLogin', - 'dllt': 'generalLogin', - '_eventId': 'submit', - 'captcha': '', - 'lt': '', - 'execution': execution, - }; - } - - String _parsePasswordWrongMsg(String html) { - var form = parse(html).getElementById("showErrorTip"); - var msg = form?.text ?? "登录遇到问题"; - - // Simplify the error message because there is no '找回密码' button here XD. - // "用户名或密码有误,用户名为工号/学号,如果确认用户名无误,请点‘找回密码’自助重置密码。" - if (msg.contains(RegExp(r"(用户名|密码).*误", unicode: true, dotAll: true))) { - msg = "用户名或密码有误"; - } - return msg; - } - - Future checkAndLogin({ - required String target, - required Future Function(String) sliderCaptcha, - }) async { - return await _idslock.synchronized(() async { - log.info( - "[IDSSession][checkAndLogin] " - "Ready to get $target.", - ); - var data = await dioNoOfflineCheck.get( - "https://ids.xidian.edu.cn/authserver/login", - queryParameters: {'service': target, 'type': 'userNameLogin'}, - ); - log.info( - "[IDSSession][checkAndLogin] " - "Received: $data.", - ); - if (data.statusCode == 401) { - throw PasswordWrongException(msg: _parsePasswordWrongMsg(data.data)); - } else if (data.statusCode == 301 || data.statusCode == 302) { - /// Post login progress, due to something wrong, return the location here... - return data.headers[HttpHeaders.locationHeader]![0]; - } else { - var page = parse(data.data ?? ""); - var form = page.getElementsByTagName("form") - ..removeWhere((element) => element.id != "continue"); - log.info( - "[IDSSession][login] " - "form: $form.", - ); - if (form.isNotEmpty) { - var inputSearch = form[0].getElementsByTagName("input"); - Map toPostAgain = {}; - for (var i in inputSearch) { - toPostAgain[i.attributes["name"]!] = i.attributes["value"]!; - } - var data = await dioNoOfflineCheck.post( - "https://ids.xidian.edu.cn/authserver/login", - data: toPostAgain, - options: Options( - validateStatus: (status) => - status != null && status >= 200 && status < 400, - ), - ); - if (data.statusCode == 301 || data.statusCode == 302) { - return data.headers[HttpHeaders.locationHeader]![0]; - } - } - return await login( - username: preference.getString(preference.Preference.idsAccount), - password: preference.getString(preference.Preference.idsPassword), - sliderCaptcha: sliderCaptcha, - target: target, - ); - } - }); - } - - Future login({ - required String username, - required String password, - required Future Function(String) sliderCaptcha, - bool forceReLogin = false, - void Function(int, String)? onResponse, - String? target, - }) async { - /// Get the login webpage. - if (onResponse != null) { - onResponse(10, "login_process.ready_page"); - log.info( - "[IDSSession][login] " - "Ready to get the login webpage.", - ); - } - final queryParameters = {'type': 'userNameLogin'}; - if (target != null) { - queryParameters['service'] = target; - } - - var response = await dioNoOfflineCheck - .get( - "https://ids.xidian.edu.cn/authserver/login", - queryParameters: queryParameters, - ) - .then((value) => value.data); - - /// Start getting data from webpage. - var page = parse(response); - var form = page.getElementsByTagName("input") - ..removeWhere((element) => element.attributes["type"] != "hidden"); - - /// Check whether it need CAPTCHA or not:-P - /// Used in two captcha. - String cookieStr = ""; - var cookie = await cookieJar.loadForRequest( - Uri.parse("https://ids.xidian.edu.cn/authserver"), - ); - for (var i in cookie) { - cookieStr += "${i.name}=${i.value}; "; - } - log.info( - "[IDSSession][login] " - "cookie: $cookieStr.", - ); - - /// Get AES encrypt key. There must be. - if (onResponse != null) { - onResponse(30, "login_process.get_encrypt"); - } - String keys = form - .firstWhere((element) => element.id == "pwdEncryptSalt") - .attributes["value"]!; - log.info( - "[IDSSession][login] " - "encrypt key: $keys.", - ); - - /// Prepare for login. - if (onResponse != null) { - onResponse(40, "login_process.ready_login"); - } - final execution = form - .firstWhere( - (element) => - element.attributes["name"] == "execution" || - element.id == "execution", - ) - .attributes["value"]!; - final head = _buildUsernameLoginPayload( - username: username, - password: password, - salt: keys, - execution: execution, - ); - - if (onResponse != null) { - onResponse(45, "login_process.slider"); - } - - try { - await sliderCaptcha(cookieStr); - } on CaptchaSolveFailedException { - throw const LoginFailedException(msg: "验证码校验失败"); - } - - /// Post login request. - if (onResponse != null) { - onResponse(50, "login_process.ready_login"); - } - try { - var data = await dioNoOfflineCheck.post( - "https://ids.xidian.edu.cn/authserver/login", - queryParameters: target != null ? {'service': target} : null, - data: head, - options: Options( - validateStatus: (status) => - status != null && status >= 200 && status < 400, - ), - ); - final location = data.headers[HttpHeaders.locationHeader]?.first; - if (location != null && - (data.statusCode == 301 || - data.statusCode == 302 || - await _hasCastgcCookie())) { - /// Post login progress. - if (onResponse != null) { - onResponse(80, "login_process.after_process"); - } - return location; - } else { - /// Check whether need continue. - log.info( - "[IDSSession][login] " - "data: ${(data.data as String).length}.", - ); - - var page = parse(data.data ?? ""); - var form = page.getElementsByTagName("form") - ..removeWhere((element) => element.id != "continue"); - log.info( - "[IDSSession][login] " - "form: $form.", - ); - if (form.isNotEmpty) { - var inputSearch = form[0].getElementsByTagName("input"); - Map toPostAgain = {}; - for (var i in inputSearch) { - toPostAgain[i.attributes["name"]!] = i.attributes["value"]!; - } - var data = await dioNoOfflineCheck.post( - "https://ids.xidian.edu.cn/authserver/login", - data: toPostAgain, - options: Options( - validateStatus: (status) => - status != null && status >= 200 && status < 400, - ), - ); - final location = data.headers[HttpHeaders.locationHeader]?.first; - if (location != null && - (data.statusCode == 301 || - data.statusCode == 302 || - await _hasCastgcCookie())) { - /// Post login progress. - if (onResponse != null) { - onResponse(80, "login_process.after_process"); - } - return location; - } - } - throw LoginFailedException(msg: "登录失败,响应状态码:${data.statusCode}。"); - } - } on DioException catch (e) { - if (e.response?.statusCode == 401) { - throw PasswordWrongException( - msg: _parsePasswordWrongMsg(e.response!.data), - ); - } else { - rethrow; - } - } - } -} - -class NeedCaptchaException implements Exception {} - -class PasswordWrongException implements Exception { - final String msg; - const PasswordWrongException({required this.msg}); - @override - String toString() => msg; -} - -class LoginFailedException implements Exception { - final String msg; - const LoginFailedException({required this.msg}); - @override - String toString() => msg; -} diff --git a/wearos/lib/repository/xidian_ids/school_card_session.dart b/wearos/lib/repository/xidian_ids/school_card_session.dart deleted file mode 100644 index 74826bd6..00000000 --- a/wearos/lib/repository/xidian_ids/school_card_session.dart +++ /dev/null @@ -1,200 +0,0 @@ -// Copyright 2023-2025 BenderBlog Rodriguez and contributors -// Copyright 2025 Traintime PDA authors. -// SPDX-License-Identifier: MPL-2.0 - -// Get your school card money's info, unless you use wechat or alipay... - -import 'dart:io'; -import 'dart:convert'; -import 'dart:typed_data'; -import 'package:html/parser.dart'; -import 'package:watermeter/repository/logger.dart'; -import 'package:watermeter/repository/preference.dart' as preference; -import 'package:watermeter/repository/xidian_ids/ids_session.dart'; -import 'package:watermeter/wearos/wear_ids_reauth.dart'; -import 'package:watermeter/wearos/slider_captcha.dart'; - -class SchoolCardSession extends IDSSession { - static const _openOauthUrl = - "https://v8scan.xidian.edu.cn/home/openXDOAuth2Page"; - static String openid = ""; - static DateTime? _openidFetchedAt; - static const Duration _openidValidDuration = Duration(minutes: 5); - - static void resetOpenId() { - openid = ""; - _openidFetchedAt = null; - } - - bool get _isOpenIdValid => - openid.isNotEmpty && - _openidFetchedAt != null && - DateTime.now().difference(_openidFetchedAt!) < _openidValidDuration; - - Future _ensureOpenId({bool forceRefresh = false}) async { - if (!forceRefresh && _isOpenIdValid) return; - - resetOpenId(); - - var response = await dio.get(_openOauthUrl); - while (response.headers[HttpHeaders.locationHeader] != null) { - String location = response.headers[HttpHeaders.locationHeader]![0]; - log.info( - "[SchoolCardSession][_ensureOpenId] " - "Received location: $location.", - ); - response = await dio.get(location); - } - _captureOpenId(response.data); - } - - void _captureOpenId(dynamic html) { - final inputs = parse(html?.toString() ?? '').getElementsByTagName('input'); - for (final input in inputs) { - if (input.id == 'openid' && input.attributes['type'] == 'hidden') { - openid = input.attributes['value'] ?? ''; - break; - } - } - if (openid.isEmpty) throw Exception('School card openid not found.'); - _openidFetchedAt = DateTime.now(); - } - - Future _discoverIdsService() async { - var currentUrl = _openOauthUrl; - var response = await dioNoOfflineCheck.get(currentUrl); - for (var redirect = 0; redirect < 10; redirect++) { - final nextHeader = - response.headers[HttpHeaders.locationHeader]?.firstOrNull; - if (nextHeader == null) break; - final nextUrl = Uri.parse(currentUrl).resolve(nextHeader).toString(); - final nextUri = Uri.parse(nextUrl); - if (nextUri.host == 'ids.xidian.edu.cn' && - nextUri.path.endsWith('/authserver/login')) { - final service = nextUri.queryParameters['service']; - if (service != null && service.isNotEmpty) return service; - } - currentUrl = nextUrl; - response = await dioNoOfflineCheck.get(currentUrl); - } - throw Exception('School card IDS service not found.'); - } - - /// Authenticates only the payment-card flow with credentials synced from the - /// companion phone. Other Wear OS data remains cache-only. - Future authenticateWithStoredCredentials({ - WearIDSReAuthHandler? reAuthHandler, - }) async { - if (loginState == IDSLoginState.success) return; - - loginState = IDSLoginState.requesting; - try { - await clearCookieJar(); - final idsService = await _discoverIdsService(); - var location = await checkAndLogin( - target: idsService, - sliderCaptcha: (cookie) => - SliderCaptchaClientProvider(cookie: cookie).solveAutomatically(), - ); - final redirectUri = Uri.parse( - 'https://ids.xidian.edu.cn', - ).resolve(location); - if (redirectUri.host == 'ids.xidian.edu.cn' && - redirectUri.path == '/authserver/reAuthCheck/reAuthLoginView.do') { - final handler = reAuthHandler; - if (handler == null) { - throw const WearIDSReAuthExpiredException('需要短信二次认证'); - } - location = (await handler( - WearIDSReAuthClient( - dio: dioNoOfflineCheck, - challengeUri: redirectUri, - username: preference.getString(preference.Preference.idsAccount), - service: idsService, - ), - )).toString(); - } - var response = await dioNoOfflineCheck.get(location); - while (response.headers[HttpHeaders.locationHeader]?.isNotEmpty == true) { - location = Uri.parse(location) - .resolve(response.headers[HttpHeaders.locationHeader]!.first) - .toString(); - response = await dioNoOfflineCheck.get(location); - } - _captureOpenId(response.data); - loginState = IDSLoginState.success; - } on PasswordWrongException { - loginState = IDSLoginState.passwordWrong; - rethrow; - } catch (_) { - loginState = IDSLoginState.fail; - rethrow; - } - } - - Future _withOpenIdRetry(Future Function() action) async { - await _ensureOpenId(); - try { - return await action(); - } catch (e, s) { - log.warning( - "[SchoolCardSession][_withOpenIdRetry] " - "Request failed, retry with refreshed openid.", - e, - s, - ); - await _ensureOpenId(forceRefresh: true); - return await action(); - } - } - - Future getQRCode() async { - log.info( - "[SchoolCardSession][initSession] " - "Try to get QR Code", - ); - return _withOpenIdRetry(() async { - final homeUrl = - "https://v8scan.xidian.edu.cn/home/openHomePage?openid=$openid"; - final homeResp = await dio.get(homeUrl); - final homeDoc = parse(homeResp.data); - - final aTags = homeDoc.getElementsByTagName('a'); - String? id; - for (var a in aTags) { - final href = a.attributes['href'] ?? ''; - if (href.contains('/virtualcard/openVirtualcard') && - href.contains('id=')) { - final uri = Uri.parse(href.replaceAll('&', '&')); - id = uri.queryParameters['id']; - if (id != null && id.isNotEmpty) break; - } - } - if (id == null) { - throw Exception("aTag id not found."); - } - - final qrUrl = - "https://v8scan.xidian.edu.cn" - "/virtualcard/openVirtualcard?" - "openid=$openid&" - "displayflag=1&" - "id=$id"; - final qrResp = await dio.get(qrUrl); - final qrDoc = parse(qrResp.data); - final img = qrDoc.getElementById("qrcode"); - if (img == null) { - throw Exception("QR image not found."); - } - var src = img.attributes["src"] ?? ""; - // 提取 base64 数据 - var base64Data = src - .replaceAll("data:image/png;base64,", "") - .replaceAll("\n", ""); - if (base64Data.isEmpty) { - throw Exception("QR data is empty."); - } - return base64Decode(base64Data); - }); - } -} diff --git a/wearos/lib/wearos/slider_captcha.dart b/wearos/lib/wearos/slider_captcha.dart deleted file mode 100644 index 3ea86e6d..00000000 --- a/wearos/lib/wearos/slider_captcha.dart +++ /dev/null @@ -1,381 +0,0 @@ -// Copyright 2023-2025 BenderBlog Rodriguez and contributors -// Copyright 2025 Traintime PDA authors. -// SPDX-License-Identifier: MIT - -// https://juejin.cn/post/7284608063914622995 - -import 'dart:convert'; -import 'dart:io'; -import 'dart:math'; -import 'dart:typed_data'; - -import 'package:dio/dio.dart'; -import 'package:encrypter_plus/encrypter_plus.dart' as encrypt; -import 'package:image/image.dart' as img; -import 'package:watermeter/repository/logger.dart'; - -/// 轨迹点模型 -class TrackPoint { - final int a; // x 轴位移 - final int b; // y 轴位移 - final int c; // 时间戳 (毫秒) - - TrackPoint(this.a, this.b, this.c); - - Map toJson() => {'a': a, 'b': b, 'c': c}; -} - -class SliderCaptchaClientProvider { - static const int _captchaKeySize = 16; - static const String _captchaPayloadPrefix = - '................................................................'; - static final Random _random = Random.secure(); - - final String cookie; - Dio dio = Dio()..interceptors.add(logDioAdapter); - - static int solveSlideOffsetForTesting({ - required Uint8List puzzleBytes, - required Uint8List pieceBytes, - int border = 24, - }) { - final puzzle = img.decodeImage(puzzleBytes); - final piece = img.decodeImage(pieceBytes); - if (puzzle == null || piece == null) { - throw CaptchaSolveFailedException(); - } - return _solveSlideOffset(puzzle, piece, border); - } - - static String encryptCaptchaPayloadForTesting( - String payload, - Uint8List keyBytes, - ) => _encryptCaptchaPayload(payload, keyBytes); - - static int _solveSlideOffset(img.Image puzzle, img.Image piece, int border) { - final bbox = _nrgbaBbox(piece); - var xL = bbox.$1 + border; - var yT = bbox.$2 + border; - var xR = bbox.$3 - border; - var yB = bbox.$4 - border; - if (xL < 0 || yT < 0 || xR < xL || yB < yT) { - throw CaptchaSolveFailedException(); - } - - final windowWidth = xR - xL + 1; - final windowHeight = yB - yT + 1; - final 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(); - } - - final templateGray = _grayFromImage( - piece, - xL, - yT, - windowWidth, - windowHeight, - ); - final templateMean = - _graySum(templateGray, 0, 0, windowWidth, windowHeight) / - (windowWidth * windowHeight); - final template = _grayNorm( - templateGray, - 0, - 0, - windowWidth, - windowHeight, - templateMean, - ); - final puzzleGray = _grayFromImage(puzzle, xL, yT, bigWidth, windowHeight); - final columnSums = List.generate( - bigWidth, - (x) => _graySum(puzzleGray, x, 0, 1, windowHeight), - growable: false, - ); - - var windowSum = 0.0; - for (var x = 0; x < windowWidth; x++) { - windowSum += columnSums[x]; - } - final area = windowWidth * windowHeight; - var maxScore = _grayNccFast( - puzzleGray, - 0, - 0, - windowWidth, - windowHeight, - windowSum / area, - template, - ); - var bestX = 0; - for (var x = 1; x < bigWidth - windowWidth; x++) { - windowSum += columnSums[x + windowWidth - 1] - columnSums[x - 1]; - final score = _grayNccFast( - puzzleGray, - x, - 0, - windowWidth, - windowHeight, - windowSum / area, - template, - ); - if (score > maxScore) { - maxScore = score; - bestX = x; - } - } - return bestX; - } - - static (int, int, int, int) _nrgbaBbox(img.Image image) { - var xL = image.width; - var yT = image.height; - var xR = 0; - var yB = 0; - var found = false; - for (var y = 0; y < image.height; y++) { - for (var x = 0; x < image.width; x++) { - if (image.getPixel(x, y).a.toInt() == 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 (xL, yT, xR, yB); - } - - static ({List pixels, int stride}) _grayFromImage( - img.Image image, - int xL, - int yT, - int width, - int height, - ) { - final pixels = List.filled(width * height, 0, growable: false); - var index = 0; - for (var y = yT; y < yT + height; y++) { - for (var x = xL; x < xL + width; x++) { - final pixel = image.getPixel(x, y); - pixels[index++] = - (77 * pixel.r.toInt() + - 150 * pixel.g.toInt() + - 29 * pixel.b.toInt()) >> - 8; - } - } - return (pixels: pixels, stride: width); - } - - static double _graySum( - ({List pixels, int stride}) gray, - int xL, - int yT, - int width, - int height, - ) { - var sum = 0.0; - for (var y = yT; y < yT + height; y++) { - final rowOffset = y * gray.stride; - for (var x = xL; x < xL + width; x++) { - sum += gray.pixels[rowOffset + x]; - } - } - return sum; - } - - static List _grayNorm( - ({List pixels, int stride}) gray, - int xL, - int yT, - int width, - int height, - double mean, - ) { - final normalized = List.filled(width * height, 0, growable: false); - var index = 0; - for (var y = yT; y < yT + height; y++) { - final rowOffset = y * gray.stride; - for (var x = xL; x < xL + width; x++) { - normalized[index++] = gray.pixels[rowOffset + x] - mean; - } - } - return normalized; - } - - static double _grayNccFast( - ({List pixels, int stride}) windowImage, - int xL, - int yT, - int width, - int height, - double mean, - List template, - ) { - var sumWindowTemplate = 0.0; - var sumWindowWindow = 0.0; - var index = 0; - for (var y = yT; y < yT + height; y++) { - final rowOffset = y * windowImage.stride; - for (var x = xL; x < xL + width; x++) { - final window = windowImage.pixels[rowOffset + x] - mean; - sumWindowWindow += window * window; - sumWindowTemplate += window * template[index++]; - } - } - if (sumWindowWindow == 0) return double.negativeInfinity; - return sumWindowTemplate / sumWindowWindow; - } - - static List _generateAutoTracks(int targetX) { - if (targetX <= 0) { - return [TrackPoint(0, 0, 0), TrackPoint(0, 0, 0)]; - } - const norm = 1.0 / (1.0 + 0.017248380016648118); - final tracks = [TrackPoint(0, 0, 0)]; - final pointCount = _random.nextInt(5) + 10; - var y = 0; - for (var i = 0; i < pointCount; i++) { - final z = (1.0 / (1.0 + exp(-7.0 * (i / pointCount - 0.42)))) / norm; - final previousX = tracks.last.a; - final x = min(targetX - 1, max(previousX + 1, (targetX * z).round())); - final drift = _random.nextDouble(); - if (drift < 0.65) { - y--; - } else if (drift < 0.80) { - y++; - } - y = max(-10, min(10, y)); - tracks.add(TrackPoint(x, y, _random.nextInt(701) + 900)); - } - tracks.add(TrackPoint(targetX, y, _random.nextInt(701) + 900)); - return tracks; - } - - static String _encryptCaptchaPayload(String payload, Uint8List keyBytes) { - final key = encrypt.Key(Uint8List.fromList(keyBytes)); - final iv = encrypt.IV.fromUtf8('................'); - final aes = encrypt.Encrypter(encrypt.AES(key, mode: encrypt.AESMode.cbc)); - return aes.encrypt('$_captchaPayloadPrefix$payload', iv: iv).base64; - } - - Future _solveAutomatically() async { - await updatePuzzle(); - final puzzle = img.decodeImage(puzzleData!); - final piece = img.decodeImage(pieceData!); - if (puzzle == null || piece == null) return false; - final solvedOffset = _solveSlideOffset(puzzle, piece, 24); - final baseMove = solvedOffset * puzzleWidth.toInt() ~/ puzzle.width; - for (final delta in const [1, -1, 2, -2, 3, -3, 4]) { - final move = baseMove + delta; - if (move < 0 || move > puzzleWidth) continue; - final tracks = _generateAutoTracks(move); - await Future.delayed( - Duration(milliseconds: max(0, tracks.last.c - 100)), - ); - if (await verifyWithTracks(tracks)) return true; - } - return false; - } - - SliderCaptchaClientProvider({required this.cookie}); - - Uint8List? puzzleData; - Uint8List? pieceData; - - final double puzzleWidth = 280; - - Future updatePuzzle() async { - log.info("Fetching slider captcha..."); - var rsp = await dio.get( - "https://ids.xidian.edu.cn/authserver/common/openSliderCaptcha.htl", - queryParameters: {'_': DateTime.now().millisecondsSinceEpoch.toString()}, - options: Options(headers: {"Cookie": cookie}), - ); - log.info("Captcha fetched, decoding images."); - - String puzzleBase64 = rsp.data["bigImage"]; - String pieceBase64 = rsp.data["smallImage"]; - // double coordinatesY = double.parse(rsp.data["tagWidth"].toString()); - - puzzleData = const Base64Decoder().convert(puzzleBase64); - pieceData = const Base64Decoder().convert(pieceBase64); - } - - Future solveAutomatically() async { - log.info('Trying automatic slider captcha solve.'); - for (var attempt = 0; attempt < 5; attempt++) { - try { - if (await _solveAutomatically()) return; - } catch (error, stackTrace) { - log.warning( - 'Automatic slider captcha solve failed.', - error, - stackTrace, - ); - if (attempt < 4) { - await Future.delayed(Duration(seconds: attempt + 1)); - } - } - } - throw CaptchaSolveFailedException(); - } - - Future verifyWithTracks(List tracks) async { - final moveLength = tracks.isNotEmpty ? tracks.last.a : 0; - final payload = jsonEncode({ - "canvasLength": puzzleWidth.toInt(), - "moveLength": moveLength, - "tracks": tracks, - }); - log.info( - "Verify captcha with ${tracks.length} track points " - "(moveLength=$moveLength).", - ); - final sign = _encryptPayload(payload); - - dynamic result = await dio.post( - "https://ids.xidian.edu.cn/authserver/common/verifySliderCaptcha.htl", - data: "sign=${Uri.encodeQueryComponent(sign)}", - options: Options( - headers: { - HttpHeaders.acceptHeader: - "application/json, text/javascript, */*; q=0.01", - "Cookie": cookie, - HttpHeaders.contentTypeHeader: - "application/x-www-form-urlencoded;charset=UTF-8", - "Origin": "https://ids.xidian.edu.cn", - HttpHeaders.accessControlAllowOriginHeader: - "https://ids.xidian.edu.cn", - "X-Requested-With": "XMLHttpRequest", - }, - ), - ); - log.info("Verify response: ${result.data}"); - return result.data["errorMsg"] == "success" || - result.data["errorCode"] == 1; - } - - String _encryptPayload(String payload) { - if (pieceData == null || pieceData!.length < _captchaKeySize) { - throw StateError("Captcha image is too short to contain AES key."); - } - - return _encryptCaptchaPayload( - payload, - pieceData!.sublist(pieceData!.length - _captchaKeySize), - ); - } -} - -class CaptchaSolveFailedException implements Exception {} diff --git a/wearos/lib/wearos/wear_app.dart b/wearos/lib/wearos/wear_app.dart deleted file mode 100644 index 6144dab3..00000000 --- a/wearos/lib/wearos/wear_app.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:watermeter/wearos/wear_home_page.dart'; -import 'package:watermeter/wearos/wear_sync_login_page.dart'; - -class WearApp extends StatelessWidget { - final bool isFirst; - - const WearApp({super.key, required this.isFirst}); - - @override - Widget build(BuildContext context) { - final colorScheme = ColorScheme.fromSeed( - seedColor: const Color(0xFF00A3FF), - brightness: Brightness.dark, - ); - - return MaterialApp( - debugShowCheckedModeBanner: false, - title: 'XDYou Wear', - theme: ThemeData( - useMaterial3: true, - colorScheme: colorScheme, - scaffoldBackgroundColor: Colors.black, - filledButtonTheme: FilledButtonThemeData( - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight(48), - textStyle: const TextStyle(fontWeight: FontWeight.w700), - ), - ), - inputDecorationTheme: const InputDecorationTheme( - border: OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(18)), - ), - isDense: true, - ), - cardTheme: const CardThemeData( - margin: EdgeInsets.symmetric(vertical: 4), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(18)), - ), - ), - ), - home: isFirst ? const WearSyncLoginPage() : const WearHomePage(), - ); - } -} diff --git a/wearos/lib/wearos/wear_cache_store.dart b/wearos/lib/wearos/wear_cache_store.dart deleted file mode 100644 index 29f4dfc0..00000000 --- a/wearos/lib/wearos/wear_cache_store.dart +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright 2026 Traintime PDA authors. -// SPDX-License-Identifier: MPL-2.0 - -import 'dart:convert'; -import 'dart:io'; - -import 'package:watermeter/model/xidian_ids/classtable.dart'; -import 'package:watermeter/model/xidian_ids/experiment.dart'; -import 'package:watermeter/repository/logger.dart'; -import 'package:watermeter/repository/network_session.dart' as network; - -class WearClassTableCache { - WearClassTableCache._(); - - static const fileName = 'ClassTable.json'; - static File file = File('${network.supportPath.path}/$fileName'); - - static bool get exists => file.existsSync(); - - static Future write(ClassTableData data) => - file.writeAsString(jsonEncode(data.toJson())); - - static (DateTime, ClassTableData)? read() { - if (!exists) return null; - try { - return ( - file.lastModifiedSync(), - ClassTableData.fromJson(jsonDecode(file.readAsStringSync())), - ); - } catch (error, stackTrace) { - log.handle(error, stackTrace, '[WearClassTableCache] Invalid cache.'); - return null; - } - } - - static Future clear() async { - if (await file.exists()) await file.delete(); - } -} - -class WearExperimentCache { - WearExperimentCache._(); - - static const fileName = 'OtherExperiment.json'; - static File file = File('${network.supportPath.path}/$fileName'); - - static bool get exists => file.existsSync(); - - static Future write(List data) => - file.writeAsString(jsonEncode(data)); - - static (DateTime, List)? read() { - if (!exists) return null; - try { - final decoded = jsonDecode(file.readAsStringSync()) as List; - return ( - file.lastModifiedSync(), - decoded - .map((item) => ExperimentData.fromJson(item)) - .toList(growable: false), - ); - } catch (error, stackTrace) { - log.handle(error, stackTrace, '[WearExperimentCache] Invalid cache.'); - return null; - } - } - - static Future clear() async { - if (await file.exists()) await file.delete(); - } -} diff --git a/wearos/lib/wearos/wear_companion_sync.dart b/wearos/lib/wearos/wear_companion_sync.dart deleted file mode 100644 index baedd164..00000000 --- a/wearos/lib/wearos/wear_companion_sync.dart +++ /dev/null @@ -1,352 +0,0 @@ -// Copyright 2026 Traintime PDA authors. -// SPDX-License-Identifier: MPL-2.0 - -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutter/services.dart'; -import 'package:watermeter/model/xidian_ids/classtable.dart'; -import 'package:watermeter/model/xidian_ids/experiment.dart'; -import 'package:watermeter/repository/preference.dart' as preference; -import 'package:watermeter/repository/network_session.dart' as network; -import 'package:watermeter/repository/xidian_ids/ids_session.dart'; -import 'package:watermeter/repository/xidian_ids/school_card_session.dart'; -import 'package:watermeter/wearos/wear_cache_store.dart'; -import 'package:watermeter/wearos/wear_schedule_service.dart'; -import 'package:watermeter/wearos/wear_qr_page.dart'; - -const wearCompanionSyncEnvelopeExampleJson = ''' -{ - "schemaVersion": 1, - "sessionId": "", - "credentials": { - "idsAccount": "2200000000", - "idsPassword": "saved-password", - "isPostGraduate": false, - "currentSemester": "2026-1" - }, - "schedule": { - "classTable": { - "semesterLength": 16, - "semesterCode": "2026-1", - "termStartDay": "2026-05-18 00:00:00", - "classDetail": [{"name": "数据库系统"}], - "userDefinedDetail": [], - "notArranged": [], - "timeArrangement": [], - "classChanges": [] - } - } -} -'''; - -class WearCredentialSyncPayload { - final String idsAccount; - final String idsPassword; - final bool? isPostGraduate; - final String? currentSemester; - - const WearCredentialSyncPayload({ - required this.idsAccount, - required this.idsPassword, - this.isPostGraduate, - this.currentSemester, - }); -} - -class WearScheduleSyncPayload { - final ClassTableData? classTable; - final List? otherExperiments; - - const WearScheduleSyncPayload({this.classTable, this.otherExperiments}); -} - -class WearPaymentQrSyncPayload { - final Uint8List bytes; - final DateTime fetchedAt; - - const WearPaymentQrSyncPayload({ - required this.bytes, - required this.fetchedAt, - }); -} - -class WearCompanionSyncEnvelope { - final String sessionId; - final WearCredentialSyncPayload credentials; - final WearScheduleSyncPayload schedule; - final WearPaymentQrSyncPayload? paymentQr; - - const WearCompanionSyncEnvelope({ - required this.sessionId, - required this.credentials, - required this.schedule, - this.paymentQr, - }); - - factory WearCompanionSyncEnvelope.fromJson(Map json) { - final version = json['schemaVersion']; - if (version != 1) { - throw const FormatException('Unsupported Wear sync schema version.'); - } - final sessionId = json['sessionId']; - if (sessionId is! String || sessionId.isEmpty) { - throw const FormatException('Wear sync session is missing.'); - } - final credentials = _credentialPayloadFromJson(json['credentials']); - final schedule = _schedulePayloadFromJson(json['schedule']); - final paymentQr = _paymentQrPayloadFromJson(json['paymentQr']); - return WearCompanionSyncEnvelope( - sessionId: sessionId, - credentials: credentials, - schedule: schedule, - paymentQr: paymentQr, - ); - } - - static WearCompanionSyncEnvelope decode(Object? payload) { - if (payload is String) { - final decoded = jsonDecode(payload); - if (decoded is Map) { - return WearCompanionSyncEnvelope.fromJson(decoded); - } - throw const FormatException('Wear sync payload must be a JSON object.'); - } - if (payload is Map) { - return WearCompanionSyncEnvelope.fromJson(_stringKeyedMap(payload)); - } - throw const FormatException('Wear sync payload must be a string or map.'); - } - - Future importInto(WearCompanionSyncPort port) async { - await port.importCredentials(credentials); - await port.importSchedule(schedule); - final qr = paymentQr; - if (qr != null) await port.importPaymentQr(qr); - } -} - -class WearCompanionSyncBridge { - static const channelName = - 'io.github.benderblog.traintime_pda/wear_companion_sync'; - static const syncMessagePath = '/traintime_pda_wear_os/sync/v1'; - static const _channel = MethodChannel(channelName); - - final WearCompanionSyncPort _port; - final MethodChannel _methodChannel; - final _imports = StreamController.broadcast(); - - WearCompanionSyncBridge({ - WearCompanionSyncPort port = const WearLocalCompanionSyncPort(), - MethodChannel methodChannel = _channel, - }) : _port = port, - _methodChannel = methodChannel; - - Stream get imports => _imports.stream; - - Future beginDirectPairing() => - _methodChannel.invokeMethod('beginDirectPairing'); - - Future start() async { - _methodChannel.setMethodCallHandler(_handleNativeCall); - final pending = await _methodChannel.invokeMethod( - 'readPendingSyncPayload', - ); - if (pending != null) { - await _importNativePayload(pending); - } - } - - Future requestSync() => - _methodChannel.invokeMethod('requestCompanionSync'); - - Future stop() async { - _methodChannel.setMethodCallHandler(null); - } - - Future dispose() async { - await stop(); - await _imports.close(); - } - - Future _handleNativeCall(MethodCall call) async { - switch (call.method) { - case 'receiveSyncPayload': - try { - await _importNativePayload(call.arguments); - } catch (error, stackTrace) { - _imports.addError(error, stackTrace); - rethrow; - } - return; - default: - throw MissingPluginException('Unknown Wear sync method ${call.method}'); - } - } - - Future _importNativePayload(Object? payload) async { - final envelope = WearCompanionSyncEnvelope.decode(payload); - await envelope.importInto(_port); - _imports.add(envelope); - } -} - -WearCredentialSyncPayload _credentialPayloadFromJson(Object? value) { - if (value is! Map) { - throw const FormatException('Wear sync credentials are required.'); - } - final json = _stringKeyedMap(value); - final idsAccount = json['idsAccount']; - final idsPassword = json['idsPassword']; - if (idsAccount is! String || - idsAccount.isEmpty || - idsPassword is! String || - idsPassword.isEmpty) { - throw const FormatException('Wear sync credentials are invalid.'); - } - return WearCredentialSyncPayload( - idsAccount: idsAccount, - idsPassword: idsPassword, - isPostGraduate: json['isPostGraduate'] as bool?, - currentSemester: json['currentSemester'] as String?, - ); -} - -WearScheduleSyncPayload _schedulePayloadFromJson(Object? value) { - if (value is! Map) { - throw const FormatException('Wear sync schedule is required.'); - } - final json = _stringKeyedMap(value); - final classTableJson = json['classTable']; - if (classTableJson is! Map) { - throw const FormatException('Wear sync class table is required.'); - } - final experimentsJson = json['otherExperiments']; - if (experimentsJson != null && experimentsJson is! List) { - throw const FormatException('Wear sync experiments must be a list.'); - } - return WearScheduleSyncPayload( - classTable: ClassTableData.fromJson(_stringKeyedMap(classTableJson)), - otherExperiments: experimentsJson - ?.map((item) { - if (item is! Map) { - throw const FormatException('Wear sync experiment is invalid.'); - } - return ExperimentData.fromJson(_stringKeyedMap(item)); - }) - .toList(growable: false), - ); -} - -WearPaymentQrSyncPayload? _paymentQrPayloadFromJson(Object? value) { - if (value == null) return null; - if (value is! Map) { - throw const FormatException('Wear sync payment QR must be an object.'); - } - final json = _stringKeyedMap(value); - final encoded = json['pngBase64']; - final fetchedAt = json['fetchedAtEpochMs']; - if (encoded is! String || encoded.isEmpty || fetchedAt is! int) { - throw const FormatException('Wear sync payment QR is invalid.'); - } - try { - return WearPaymentQrSyncPayload( - bytes: base64Decode(encoded), - fetchedAt: DateTime.fromMillisecondsSinceEpoch(fetchedAt), - ); - } on FormatException { - throw const FormatException('Wear sync payment QR is invalid.'); - } -} - -Map _stringKeyedMap(Map value) => - value.map((key, value) => MapEntry(key as String, value)); - -abstract interface class WearCompanionSyncPort { - Future importCredentials(WearCredentialSyncPayload payload); - - Future importSchedule(WearScheduleSyncPayload payload); - - Future importPaymentQr(WearPaymentQrSyncPayload payload); -} - -class WearLocalCompanionSyncPort implements WearCompanionSyncPort { - const WearLocalCompanionSyncPort(); - - @override - Future importCredentials(WearCredentialSyncPayload payload) async { - final accountChanged = - preference.getString(preference.Preference.idsAccount) != - payload.idsAccount; - await _clearUserScopedState(clearPaymentQr: accountChanged); - await preference.setString( - preference.Preference.idsAccount, - payload.idsAccount, - ); - await preference.setString( - preference.Preference.idsPassword, - payload.idsPassword, - ); - final isPostGraduate = payload.isPostGraduate; - if (isPostGraduate != null) { - await preference.setBool(preference.Preference.role, isPostGraduate); - } - final currentSemester = payload.currentSemester; - if (currentSemester != null && currentSemester.isNotEmpty) { - await preference.setString( - preference.Preference.currentSemester, - currentSemester, - ); - await preference.setBool( - preference.Preference.isUserDefinedSemester, - false, - ); - } - } - - @override - Future importSchedule(WearScheduleSyncPayload payload) async { - final classTable = payload.classTable; - if (classTable != null) { - await WearClassTableCache.write(classTable); - if (classTable.semesterCode.isNotEmpty) { - await preference.setString( - preference.Preference.currentSemester, - classTable.semesterCode, - ); - await preference.setBool( - preference.Preference.isUserDefinedSemester, - false, - ); - } - } - - final otherExperiments = payload.otherExperiments; - if (otherExperiments != null) { - await WearExperimentCache.write(otherExperiments); - } - } - - @override - Future importPaymentQr(WearPaymentQrSyncPayload payload) => - storeCachedWearPaymentQr(payload.bytes, fetchedAt: payload.fetchedAt); -} - -Future _clearUserScopedState({required bool clearPaymentQr}) async { - await _deleteIdsCookieStore(); - SchoolCardSession.resetOpenId(); - await clearWearCampusCaches(); - if (clearPaymentQr) await clearCachedWearPaymentQr(); - loginState = IDSLoginState.none; - await preference.remove(preference.Preference.currentSemester); - await preference.remove(preference.Preference.role); - await preference.remove(preference.Preference.isUserDefinedSemester); -} - -Future _deleteIdsCookieStore() async { - final cookieStore = Directory('${network.supportPath.path}/cookie/general'); - if (await cookieStore.exists()) { - await cookieStore.delete(recursive: true); - } -} diff --git a/wearos/lib/wearos/wear_home_page.dart b/wearos/lib/wearos/wear_home_page.dart deleted file mode 100644 index 46e4cf3f..00000000 --- a/wearos/lib/wearos/wear_home_page.dart +++ /dev/null @@ -1,351 +0,0 @@ -import 'dart:async'; -import 'dart:math'; - -import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; -import 'package:watermeter/repository/preference.dart' as preference; -import 'package:watermeter/repository/xidian_ids/ids_session.dart'; -import 'package:watermeter/repository/xidian_ids/school_card_session.dart'; -import 'package:watermeter/wearos/wear_companion_sync.dart'; -import 'package:watermeter/wearos/wear_qr_page.dart'; -import 'package:watermeter/wearos/wear_schedule_service.dart'; -import 'package:watermeter/wearos/wear_sync_login_page.dart'; - -const _wearHomeDashboardPadding = EdgeInsets.fromLTRB(28, 40, 28, 28); -const double _wearHomeDashboardMaxWidth = 280; - -class WearHomePage extends StatefulWidget { - const WearHomePage({super.key}); - - @override - State createState() => _WearHomePageState(); -} - -class _WearHomePageState extends State { - late Future _loadFuture; - late final WearCompanionSyncBridge _companionBridge; - StreamSubscription? _syncSubscription; - Completer? _pendingSync; - - @override - void initState() { - super.initState(); - _loadFuture = _loadCached(); - _companionBridge = WearCompanionSyncBridge(); - _syncSubscription = _companionBridge.imports.listen( - (_) { - if (!mounted) return; - setState(() => _loadFuture = _loadCached()); - _pendingSync?.complete(); - _pendingSync = null; - }, - onError: (Object error, StackTrace stackTrace) { - _pendingSync?.completeError(error, stackTrace); - _pendingSync = null; - }, - ); - unawaited(_companionBridge.start()); - } - - Future _loadCached() async { - final semester = preference.getString( - preference.Preference.currentSemester, - ); - return loadCachedWearHomeData(semesterCode: semester); - } - - Future _manualSync() async { - if (_pendingSync != null) return _pendingSync!.future; - final completer = Completer(); - _pendingSync = completer; - try { - await _companionBridge.requestSync(); - await completer.future.timeout(const Duration(seconds: 15)); - } finally { - if (identical(_pendingSync, completer)) _pendingSync = null; - } - } - - Future _logout() async { - await preference.remove(preference.Preference.idsAccount); - await preference.remove(preference.Preference.idsPassword); - await preference.remove(preference.Preference.currentSemester); - await preference.remove(preference.Preference.role); - await preference.remove(preference.Preference.isUserDefinedSemester); - await IDSSession().clearCookieJar(); - SchoolCardSession.resetOpenId(); - await clearWearCampusCaches(); - await clearCachedWearPaymentQr(); - loginState = IDSLoginState.manual; - if (!mounted) return; - Navigator.of(context).pushReplacement( - MaterialPageRoute(builder: (_) => const WearSyncLoginPage()), - ); - } - - @override - void dispose() { - _syncSubscription?.cancel(); - unawaited(_companionBridge.dispose()); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: SafeArea( - child: FutureBuilder( - future: _loadFuture, - builder: (context, snapshot) { - if (snapshot.connectionState != ConnectionState.done) { - return const Center(child: CircularProgressIndicator()); - } - if (snapshot.hasError) { - return _ErrorView( - error: snapshot.error!, - onRetry: _manualSync, - onLogout: _logout, - ); - } - return WearHomeDashboard( - data: snapshot.requireData, - onRefresh: _manualSync, - onLogout: _logout, - ); - }, - ), - ), - ); - } -} - -class WearHomeDashboard extends StatelessWidget { - static final _timeFormat = DateFormat('HH:mm'); - final WearHomeData data; - final Future Function() onRefresh; - final VoidCallback onLogout; - - const WearHomeDashboard({ - super.key, - required this.data, - required this.onRefresh, - required this.onLogout, - }); - - @override - Widget build(BuildContext context) { - return RefreshIndicator( - onRefresh: onRefresh, - child: ListView( - padding: _wearHomeDashboardPadding, - physics: const AlwaysScrollableScrollPhysics(), - children: [ - Align( - alignment: Alignment.topCenter, - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: _wearHomeDashboardMaxWidth, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _AgendaSection( - title: '今天', - items: data.todayItems, - timeFormat: _timeFormat, - ), - _AgendaSection( - title: '明天', - items: data.tomorrowItems, - timeFormat: _timeFormat, - ), - const SizedBox(height: 8), - const _CampusCard(), - const SizedBox(height: 8), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - IconButton.filledTonal( - tooltip: '刷新', - onPressed: () => onRefresh(), - icon: const Icon(Icons.refresh), - ), - const SizedBox(width: 12), - IconButton.filledTonal( - tooltip: '退出', - onPressed: onLogout, - icon: const Icon(Icons.logout), - ), - ], - ), - ], - ), - ), - ), - ], - ), - ); - } -} - -class _CampusCard extends StatelessWidget { - const _CampusCard(); - - @override - Widget build(BuildContext context) { - return Card( - child: Padding( - padding: const EdgeInsets.all(14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text('校园卡', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 8), - FilledButton.icon( - onPressed: () => Navigator.of( - context, - ).push(MaterialPageRoute(builder: (_) => const WearQrPage())), - icon: const Icon(Icons.qr_code_2), - label: const Text('付款码'), - ), - ], - ), - ), - ); - } -} - -class _AgendaSection extends StatelessWidget { - final String title; - final List items; - final DateFormat timeFormat; - - const _AgendaSection({ - required this.title, - required this.items, - required this.timeFormat, - }); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(top: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text(title, style: Theme.of(context).textTheme.titleSmall), - if (items.isEmpty) - const Padding( - padding: EdgeInsets.symmetric(vertical: 8), - child: Text('没有安排'), - ) - else - for (final item in items) - Card( - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - _KindPill(kind: item.kind), - const SizedBox(width: 6), - Expanded( - child: Text( - item.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontWeight: FontWeight.w700, - ), - ), - ), - ], - ), - const SizedBox(height: 6), - Text( - '${timeFormat.format(item.start)}-${timeFormat.format(item.end)}', - ), - if (item.location != null) - Text( - item.location!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (item.subtitle != null) - Text( - item.subtitle!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ), - ], - ), - ); - } -} - -class _KindPill extends StatelessWidget { - final WearAgendaKind kind; - - const _KindPill({required this.kind}); - - @override - Widget build(BuildContext context) { - final (label, color) = switch (kind) { - WearAgendaKind.course => ('课', Theme.of(context).colorScheme.primary), - WearAgendaKind.otherExperiment => ('实', Colors.green), - }; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.22), - borderRadius: BorderRadius.circular(999), - ), - child: Text(label, style: TextStyle(color: color, fontSize: 11)), - ); - } -} - -class _ErrorView extends StatelessWidget { - final Object error; - final VoidCallback onRetry; - final VoidCallback onLogout; - - const _ErrorView({ - required this.error, - required this.onRetry, - required this.onLogout, - }); - - @override - Widget build(BuildContext context) { - final text = error.toString(); - return Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.error_outline, - color: Theme.of(context).colorScheme.error, - ), - const SizedBox(height: 8), - Text( - text.substring(0, min(text.length, 120)), - textAlign: TextAlign.center, - ), - const SizedBox(height: 12), - FilledButton(onPressed: onRetry, child: const Text('重试')), - TextButton(onPressed: onLogout, child: const Text('重新登录')), - ], - ), - ), - ); - } -} diff --git a/wearos/lib/wearos/wear_ids_reauth.dart b/wearos/lib/wearos/wear_ids_reauth.dart deleted file mode 100644 index 1d567130..00000000 --- a/wearos/lib/wearos/wear_ids_reauth.dart +++ /dev/null @@ -1,391 +0,0 @@ -// Copyright 2026 Traintime PDA authors. -// SPDX-License-Identifier: MPL-2.0 - -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:dio/dio.dart'; -import 'package:flutter/material.dart'; - -typedef WearIDSReAuthHandler = Future Function(WearIDSReAuthClient client); - -class WearIDSReAuthClient { - WearIDSReAuthClient({ - required Dio dio, - required this.challengeUri, - required this.username, - required this.service, - }) : _dio = dio; - - final Dio _dio; - final Uri challengeUri; - final String username; - final String service; - - String? recipientDescription; - bool _prepared = false; - - String get _isMultifactor => - challengeUri.queryParameters['isMultifactor'] ?? 'true'; - - Future prepare() async { - if (_prepared) return; - final challengeResponse = await _dio.getUri(challengeUri); - if (challengeResponse.statusCode != HttpStatus.ok) { - throw const WearIDSReAuthExpiredException('二次认证已失效,请重新登录'); - } - final response = await _dio.post( - 'https://ids.xidian.edu.cn/authserver/reAuthCheck/changeReAuthType.do', - data: { - 'isMultifactor': _isMultifactor, - 'reAuthType': '3', - 'service': service, - }, - ); - final json = _responseJson(response.data); - if (json['code']?.toString() != '1') { - throw WearIDSProtocolException( - json['message']?.toString() ?? '无法切换到短信二次认证', - ); - } - final data = json['data']; - if (data is Map) { - recipientDescription = data['reAuthUserNameInput']?.toString(); - } - _prepared = true; - } - - Future sendSms() async { - await prepare(); - final response = await _dio.post( - 'https://ids.xidian.edu.cn/authserver/dynamicCode/' - 'getDynamicCodeByReauth.do', - data: {'userName': username, 'authCodeTypeName': 'reAuthDynamicCodeType'}, - ); - final json = _responseJson(response.data); - final result = json['res']?.toString(); - if (result != 'success' && result != 'code_time_fail') { - throw WearIDSProtocolException( - json['returnMessage']?.toString() ?? '短信验证码发送失败', - ); - } - final rawSeconds = int.tryParse(json['codeTime']?.toString() ?? ''); - final seconds = rawSeconds == null || rawSeconds < 0 ? 0 : rawSeconds; - final mobile = json['mobile']?.toString(); - return WearIDSSmsDelivery( - message: json['returnMessage']?.toString() ?? '验证码已发送', - recipient: mobile == null || mobile.isEmpty - ? recipientDescription - : _maskPhoneNumber(mobile), - retryAfter: Duration(seconds: seconds), - ); - } - - Future submitSms({ - required String code, - required bool trustDevice, - }) async { - await prepare(); - final normalizedCode = code.trim(); - if (normalizedCode.isEmpty) { - throw const WearIDSReAuthCodeRejectedException('请输入短信验证码'); - } - final response = await _dio.post( - 'https://ids.xidian.edu.cn/authserver/reAuthCheck/reAuthSubmit.do', - data: { - 'service': service, - 'reAuthType': '3', - 'isMultifactor': _isMultifactor, - 'password': '', - 'dynamicCode': normalizedCode, - 'uuid': '', - 'answer1': '', - 'answer2': '', - 'otpCode': '', - 'skipTmpReAuth': trustDevice.toString(), - }, - ); - final json = _responseJson(response.data); - final result = json['code']?.toString(); - if (result == 'reAuth_failed') { - throw WearIDSReAuthCodeRejectedException( - json['msg']?.toString() ?? '验证码错误', - ); - } - if (result == 'reAuth_unauthorized') { - throw WearIDSReAuthExpiredException(json['msg']?.toString() ?? '二次认证已失效'); - } - if (result != 'reAuth_success') { - throw const WearIDSProtocolException('统一认证返回了未知的二次认证状态'); - } - - final loginResponse = await _dio.get( - 'https://ids.xidian.edu.cn/authserver/login', - queryParameters: {'service': service}, - ); - final location = loginResponse.headers.value(HttpHeaders.locationHeader); - if ((loginResponse.statusCode != HttpStatus.movedPermanently && - loginResponse.statusCode != HttpStatus.found) || - location == null) { - throw const WearIDSProtocolException('二次认证成功,但没有收到业务系统登录票据'); - } - final uri = Uri.parse('https://ids.xidian.edu.cn').resolve(location); - if (uri.host == 'ids.xidian.edu.cn' && - uri.path == '/authserver/reAuthCheck/reAuthLoginView.do') { - throw const WearIDSReAuthExpiredException('二次认证未完成,请重新登录'); - } - return uri; - } -} - -class WearIDSSmsDelivery { - const WearIDSSmsDelivery({ - required this.message, - required this.recipient, - required this.retryAfter, - }); - - final String message; - final String? recipient; - final Duration retryAfter; -} - -Future showWearIDSReAuthPage( - BuildContext context, - WearIDSReAuthClient client, -) async { - final result = await Navigator.of(context).push( - MaterialPageRoute( - fullscreenDialog: true, - builder: (_) => _WearIDSReAuthPage(client: client), - ), - ); - if (result == null) throw const WearIDSReAuthCancelledException(); - return result; -} - -class _WearIDSReAuthPage extends StatefulWidget { - const _WearIDSReAuthPage({required this.client}); - - final WearIDSReAuthClient client; - - @override - State<_WearIDSReAuthPage> createState() => _WearIDSReAuthPageState(); -} - -class _WearIDSReAuthPageState extends State<_WearIDSReAuthPage> { - final _codeController = TextEditingController(); - Timer? _timer; - int _secondsRemaining = 0; - bool _trustDevice = true; - bool _sending = false; - bool _submitting = false; - String? _notice; - String? _error; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) => _sendCode()); - } - - Future _sendCode() async { - if (_sending || _secondsRemaining > 0) return; - setState(() { - _sending = true; - _error = null; - }); - try { - final delivery = await widget.client.sendSms(); - if (!mounted) return; - setState(() { - _notice = delivery.recipient == null - ? delivery.message - : '${delivery.message}\n${delivery.recipient}'; - }); - _startCountdown(delivery.retryAfter.inSeconds); - } on DioException { - if (mounted) setState(() => _error = '网络连接失败'); - } on WearIDSReAuthExpiredException catch (error) { - if (mounted) setState(() => _error = error.message); - } on WearIDSProtocolException catch (error) { - if (mounted) setState(() => _error = error.message); - } finally { - if (mounted) setState(() => _sending = false); - } - } - - void _startCountdown(int seconds) { - _timer?.cancel(); - setState(() => _secondsRemaining = seconds); - if (seconds <= 0) return; - _timer = Timer.periodic(const Duration(seconds: 1), (timer) { - if (!mounted || _secondsRemaining <= 1) { - timer.cancel(); - if (mounted) setState(() => _secondsRemaining = 0); - } else { - setState(() => _secondsRemaining--); - } - }); - } - - Future _submit() async { - if (_submitting || _codeController.text.trim().isEmpty) { - if (_codeController.text.trim().isEmpty) { - setState(() => _error = '请输入短信验证码'); - } - return; - } - setState(() { - _submitting = true; - _error = null; - }); - try { - final uri = await widget.client.submitSms( - code: _codeController.text, - trustDevice: _trustDevice, - ); - if (mounted) Navigator.of(context).pop(uri); - } on WearIDSReAuthCodeRejectedException catch (error) { - _codeController.clear(); - if (mounted) setState(() => _error = error.message); - } on DioException { - if (mounted) setState(() => _error = '网络连接失败'); - } on WearIDSReAuthExpiredException catch (error) { - if (mounted) setState(() => _error = error.message); - } on WearIDSProtocolException catch (error) { - if (mounted) setState(() => _error = error.message); - } finally { - if (mounted) setState(() => _submitting = false); - } - } - - @override - void dispose() { - _timer?.cancel(); - _codeController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final busy = _sending || _submitting; - return Scaffold( - body: SafeArea( - child: ListView( - padding: const EdgeInsets.fromLTRB(34, 30, 34, 40), - children: [ - Text( - '短信认证', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 10), - Text( - _notice ?? '正在准备验证码…', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall, - ), - if (_error != null) ...[ - const SizedBox(height: 8), - Text( - _error!, - textAlign: TextAlign.center, - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), - ], - const SizedBox(height: 10), - TextField( - controller: _codeController, - enabled: !busy, - keyboardType: TextInputType.number, - autofillHints: const [AutofillHints.oneTimeCode], - textAlign: TextAlign.center, - maxLength: 8, - decoration: const InputDecoration( - hintText: '验证码', - counterText: '', - ), - onSubmitted: (_) => _submit(), - ), - const SizedBox(height: 8), - OutlinedButton( - onPressed: busy || _secondsRemaining > 0 ? null : _sendCode, - child: Text( - _secondsRemaining > 0 ? '${_secondsRemaining}s 后重发' : '发送验证码', - ), - ), - SwitchListTile( - contentPadding: EdgeInsets.zero, - dense: true, - value: _trustDevice, - onChanged: busy - ? null - : (value) => setState(() => _trustDevice = value), - title: const Text('信任此手表'), - ), - FilledButton( - onPressed: busy ? null : _submit, - child: _submitting - ? const SizedBox.square( - dimension: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Text('确认'), - ), - TextButton( - onPressed: busy ? null : () => Navigator.of(context).pop(), - child: const Text('取消'), - ), - ], - ), - ), - ); - } -} - -Map _responseJson(dynamic data) { - if (data is Map) return data; - if (data is String) { - try { - final decoded = jsonDecode(data); - if (decoded is Map) return decoded; - } on FormatException { - // Fall through to the protocol exception below. - } - } - throw const WearIDSProtocolException('统一认证返回了非 JSON 响应'); -} - -String _maskPhoneNumber(String value) { - if (value.length < 7) return '****'; - return '${value.substring(0, 3)}****${value.substring(value.length - 4)}'; -} - -class WearIDSProtocolException implements Exception { - const WearIDSProtocolException(this.message); - final String message; - @override - String toString() => message; -} - -class WearIDSReAuthCodeRejectedException implements Exception { - const WearIDSReAuthCodeRejectedException(this.message); - final String message; - @override - String toString() => message; -} - -class WearIDSReAuthExpiredException implements Exception { - const WearIDSReAuthExpiredException(this.message); - final String message; - @override - String toString() => message; -} - -class WearIDSReAuthCancelledException implements Exception { - const WearIDSReAuthCancelledException(); - @override - String toString() => '已取消短信认证'; -} diff --git a/wearos/lib/wearos/wear_qr_page.dart b/wearos/lib/wearos/wear_qr_page.dart deleted file mode 100644 index da720cdf..00000000 --- a/wearos/lib/wearos/wear_qr_page.dart +++ /dev/null @@ -1,299 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:intl/intl.dart'; -import 'package:watermeter/repository/network_session.dart' as network; -import 'package:watermeter/repository/xidian_ids/school_card_session.dart'; -import 'package:watermeter/wearos/wear_ids_reauth.dart'; - -typedef _PaymentQrResult = ({ - Uint8List bytes, - bool fromCache, - DateTime fetchedAt, -}); - -File get _paymentQrCache => - File('${network.supportPath.path}/WearPaymentQr.png'); - -Future clearCachedWearPaymentQr() async { - if (await _paymentQrCache.exists()) await _paymentQrCache.delete(); -} - -Future storeCachedWearPaymentQr( - Uint8List bytes, { - required DateTime fetchedAt, -}) async { - await _paymentQrCache.writeAsBytes(bytes, flush: true); - await _paymentQrCache.setLastModified(fetchedAt); -} - -class WearQrPage extends StatefulWidget { - const WearQrPage({super.key}); - - @override - State createState() => _WearQrPageState(); -} - -class _WearQrPageState extends State { - static const _nativeChannel = MethodChannel( - 'io.github.benderblog.traintime_pda/wear_companion_sync', - ); - static const _paymentChannel = MethodChannel( - 'io.github.benderblog.traintime_pda/wear_payment', - ); - late Future<_PaymentQrResult> _qrFuture; - bool _usingWatchAuthentication = false; - - @override - void initState() { - super.initState(); - unawaited(_setKeepScreenOn(true)); - _qrFuture = _loadQrWithCache(); - } - - Future _setKeepScreenOn(bool enabled) async { - try { - await _nativeChannel.invokeMethod('setKeepScreenOn', enabled); - } on PlatformException { - // The QR flow still works when the host cannot expose this optimization. - } - } - - @override - void dispose() { - unawaited(_setKeepScreenOn(false)); - super.dispose(); - } - - void _retry() { - setState(() { - _usingWatchAuthentication = false; - _qrFuture = _loadQrWithCache(forceRefresh: true); - }); - } - - void _authenticateOnWatch() { - _paymentChannel.setMethodCallHandler(null); - setState(() { - _usingWatchAuthentication = true; - _qrFuture = _loadQrDirectlyWithCache(); - }); - } - - Future<_PaymentQrResult> _loadQrDirectlyWithCache() async { - try { - return await _requestQrDirectly(); - } catch (_) { - if (!await _paymentQrCache.exists()) rethrow; - return ( - bytes: await _paymentQrCache.readAsBytes(), - fromCache: true, - fetchedAt: await _paymentQrCache.lastModified(), - ); - } - } - - Future<_PaymentQrResult> _loadQrWithCache({bool forceRefresh = false}) async { - if (!forceRefresh && await _paymentQrCache.exists()) { - return ( - bytes: await _paymentQrCache.readAsBytes(), - fromCache: true, - fetchedAt: await _paymentQrCache.lastModified(), - ); - } - try { - return await _requestQrFromPhone(); - } catch (_) { - try { - return await _requestQrDirectly(); - } catch (_) { - if (!await _paymentQrCache.exists()) rethrow; - return ( - bytes: await _paymentQrCache.readAsBytes(), - fromCache: true, - fetchedAt: await _paymentQrCache.lastModified(), - ); - } - } - } - - Future<_PaymentQrResult> _requestQrFromPhone() async { - try { - final completer = Completer(); - _paymentChannel.setMethodCallHandler((call) async { - if (call.method == 'receivePaymentQrResponse' && - call.arguments is String && - !completer.isCompleted) { - completer.complete(call.arguments as String); - } - }); - await _paymentChannel.invokeMethod('requestPaymentQr'); - final raw = await completer.future.timeout(const Duration(minutes: 3)); - final json = jsonDecode(raw); - if (json is! Map || json['ok'] != true) { - throw StateError('Companion phone could not provide a payment QR.'); - } - final encoded = json['pngBase64']; - final fetchedAtEpochMs = json['fetchedAtEpochMs']; - if (encoded is! String || fetchedAtEpochMs is! int) { - throw const FormatException('Invalid companion payment QR response.'); - } - final bytes = base64Decode(encoded); - final fetchedAt = DateTime.fromMillisecondsSinceEpoch(fetchedAtEpochMs); - await storeCachedWearPaymentQr(bytes, fetchedAt: fetchedAt); - return (bytes: bytes, fromCache: false, fetchedAt: fetchedAt); - } finally { - _paymentChannel.setMethodCallHandler(null); - } - } - - Future<_PaymentQrResult> _requestQrDirectly() async { - final session = SchoolCardSession(); - await session.authenticateWithStoredCredentials( - reAuthHandler: (client) { - if (!mounted) throw const WearIDSReAuthCancelledException(); - return showWearIDSReAuthPage(context, client); - }, - ); - final bytes = await session.getQRCode(); - final fetchedAt = DateTime.now(); - await storeCachedWearPaymentQr(bytes, fetchedAt: fetchedAt); - return (bytes: bytes, fromCache: false, fetchedAt: fetchedAt); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.black, - body: SafeArea( - child: FutureBuilder<_PaymentQrResult>( - future: _qrFuture, - builder: (context, snapshot) { - if (snapshot.connectionState != ConnectionState.done) { - return Center( - child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(34, 28, 34, 28), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const CircularProgressIndicator(), - const SizedBox(height: 12), - Text( - _usingWatchAuthentication ? '正在由手表认证' : '正在向手机请求付款码', - textAlign: TextAlign.center, - ), - if (!_usingWatchAuthentication) ...[ - const SizedBox(height: 10), - OutlinedButton( - onPressed: _authenticateOnWatch, - child: const Text('改用手表认证'), - ), - ], - ], - ), - ), - ); - } - if (snapshot.hasError) { - return Center( - child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(28, 24, 28, 24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.qr_code_2), - const SizedBox(height: 6), - const Text('付款码获取失败', textAlign: TextAlign.center), - const SizedBox(height: 8), - Wrap( - alignment: WrapAlignment.center, - spacing: 8, - children: [ - FilledButton( - onPressed: _retry, - child: const Text('重试'), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('返回'), - ), - ], - ), - ], - ), - ), - ); - } - - final result = snapshot.requireData; - return Column( - children: [ - Expanded( - child: Stack( - children: [ - Center( - child: Container( - margin: const EdgeInsets.fromLTRB(22, 28, 22, 4), - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(20), - ), - child: Image.memory( - result.bytes, - fit: BoxFit.contain, - filterQuality: FilterQuality.none, - ), - ), - ), - Positioned( - top: 4, - left: 4, - child: IconButton( - onPressed: () => Navigator.of(context).pop(), - icon: const Icon(Icons.arrow_back), - ), - ), - Positioned( - top: 4, - right: 4, - child: IconButton( - onPressed: _retry, - icon: const Icon(Icons.refresh), - ), - ), - ], - ), - ), - if (result.fromCache) - Padding( - padding: const EdgeInsets.fromLTRB(34, 2, 34, 12), - child: DecoratedBox( - decoration: BoxDecoration( - color: Colors.orange.shade900, - borderRadius: BorderRadius.circular(12), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 6, - ), - child: Text( - '缓存 ${DateFormat('MM-dd HH:mm').format(result.fetchedAt)},可能失效', - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 10), - ), - ), - ), - ), - ], - ); - }, - ), - ), - ); - } -} diff --git a/wearos/lib/wearos/wear_schedule_service.dart b/wearos/lib/wearos/wear_schedule_service.dart deleted file mode 100644 index 4a33c576..00000000 --- a/wearos/lib/wearos/wear_schedule_service.dart +++ /dev/null @@ -1,192 +0,0 @@ -import 'package:watermeter/model/time_list.dart'; -import 'package:watermeter/model/xidian_ids/classtable.dart'; -import 'package:watermeter/model/xidian_ids/experiment.dart'; -import 'package:watermeter/wearos/wear_cache_store.dart'; - -typedef ClassTableCacheLoader = ClassTableData? Function(String semesterCode); -typedef ExperimentCacheLoader = List? Function(); - -Future clearWearCampusCaches() async { - await WearClassTableCache.clear(); - await WearExperimentCache.clear(); -} - -enum WearAgendaKind { course, otherExperiment } - -class WearAgendaItem { - final WearAgendaKind kind; - final String title; - final String? subtitle; - final String? location; - final DateTime start; - final DateTime end; - - const WearAgendaItem({ - required this.kind, - required this.title, - required this.start, - required this.end, - this.subtitle, - this.location, - }); -} - -class WearHomeData { - final List todayItems; - final List tomorrowItems; - - const WearHomeData({required this.todayItems, required this.tomorrowItems}); -} - -class WearAgendaBuilder { - const WearAgendaBuilder._(); - - static List courseItemsForDay( - ClassTableData table, - DateTime day, - ) { - final weekIndex = weekIndexForDay(table, day); - if (weekIndex < 0 || weekIndex >= table.semesterLength) { - return const []; - } - - final items = []; - for (final arrangement in table.timeArrangement) { - if (arrangement.source == Source.empty || - arrangement.day != day.weekday || - arrangement.weekList.length <= weekIndex || - !arrangement.weekList[weekIndex]) { - continue; - } - - final startIndex = (arrangement.start - 1) * 2; - final endIndex = (arrangement.stop - 1) * 2 + 1; - if (startIndex < 0 || endIndex >= timeList.length) continue; - - final ClassDetail detail; - try { - detail = table.getClassDetail(arrangement); - } on Object { - continue; - } - - items.add( - WearAgendaItem( - kind: WearAgendaKind.course, - title: detail.name, - subtitle: _blankToNull(arrangement.teacher), - location: _blankToNull(arrangement.classroom), - start: _dateAtClassTime(day, timeList[startIndex]), - end: _dateAtClassTime(day, timeList[endIndex]), - ), - ); - } - items.sort(_compareAgendaItems); - return items; - } - - static List experimentItemsForDay( - List experiments, - DateTime day, - ) { - final items = []; - for (final experiment in experiments) { - for (final range in experiment.timeRanges) { - if (!_isSameDate(range.$1, day)) continue; - items.add( - WearAgendaItem( - kind: WearAgendaKind.otherExperiment, - title: experiment.name, - subtitle: _blankToNull(experiment.teacher), - location: _blankToNull(experiment.classroom), - start: range.$1, - end: range.$2, - ), - ); - } - } - items.sort(_compareAgendaItems); - return items; - } - - static int weekIndexForDay(ClassTableData table, DateTime day) { - if (table.termStartDay.isEmpty) return -1; - final start = DateTime.parse(table.termStartDay); - final delta = _dateOnly(day).difference(_dateOnly(start)).inDays; - return delta < 0 ? -1 : delta ~/ DateTime.daysPerWeek; - } -} - -Future loadCachedWearHomeData({ - required String semesterCode, - DateTime? now, - ClassTableCacheLoader? classTableCacheLoader, - ExperimentCacheLoader? otherExperimentCacheLoader, -}) async { - final effectiveNow = now ?? DateTime.now(); - final today = _dateOnly(effectiveNow); - final tomorrow = today.add(const Duration(days: 1)); - final classTable = (classTableCacheLoader ?? _loadClassTableCache)( - semesterCode, - ); - final experiments = - (otherExperimentCacheLoader ?? _loadOtherExperimentCache)(); - final todayItems = []; - final tomorrowItems = []; - - if (classTable != null) { - todayItems.addAll(WearAgendaBuilder.courseItemsForDay(classTable, today)); - tomorrowItems.addAll( - WearAgendaBuilder.courseItemsForDay(classTable, tomorrow), - ); - } - if (experiments != null) { - todayItems.addAll( - WearAgendaBuilder.experimentItemsForDay(experiments, today), - ); - tomorrowItems.addAll( - WearAgendaBuilder.experimentItemsForDay(experiments, tomorrow), - ); - } - - todayItems.sort(_compareAgendaItems); - tomorrowItems.sort(_compareAgendaItems); - return WearHomeData( - todayItems: List.unmodifiable(todayItems), - tomorrowItems: List.unmodifiable(tomorrowItems), - ); -} - -ClassTableData? _loadClassTableCache(String semesterCode) { - final cache = WearClassTableCache.read(); - if (cache == null || cache.$2.semesterCode != semesterCode) return null; - return cache.$2; -} - -List? _loadOtherExperimentCache() => - WearExperimentCache.read()?.$2; - -DateTime _dateOnly(DateTime value) => - DateTime(value.year, value.month, value.day); - -DateTime _dateAtClassTime(DateTime day, String hhmm) { - final hour = (hhmm.codeUnitAt(0) - 48) * 10 + hhmm.codeUnitAt(1) - 48; - final minute = (hhmm.codeUnitAt(3) - 48) * 10 + hhmm.codeUnitAt(4) - 48; - return DateTime(day.year, day.month, day.day, hour, minute); -} - -bool _isSameDate(DateTime left, DateTime right) => - left.year == right.year && - left.month == right.month && - left.day == right.day; - -String? _blankToNull(String? value) { - if (value == null || value.isEmpty) return null; - return value; -} - -int _compareAgendaItems(WearAgendaItem left, WearAgendaItem right) { - final start = left.start.compareTo(right.start); - if (start != 0) return start; - return left.end.compareTo(right.end); -} diff --git a/wearos/lib/wearos/wear_sync_login_page.dart b/wearos/lib/wearos/wear_sync_login_page.dart deleted file mode 100644 index 58870cba..00000000 --- a/wearos/lib/wearos/wear_sync_login_page.dart +++ /dev/null @@ -1,107 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:watermeter/repository/logger.dart'; -import 'package:watermeter/wearos/wear_companion_sync.dart'; -import 'package:watermeter/wearos/wear_home_page.dart'; - -class WearSyncLoginPage extends StatefulWidget { - const WearSyncLoginPage({super.key}); - - @override - State createState() => _WearSyncLoginPageState(); -} - -class _WearSyncLoginPageState extends State { - late final WearCompanionSyncBridge _bridge; - StreamSubscription? _subscription; - String _status = '请在手机端打开“设置 > XDYou Wear”,选择这块手表'; - bool _starting = true; - - @override - void initState() { - super.initState(); - _bridge = WearCompanionSyncBridge(); - _subscription = _bridge.imports.listen( - (_) { - if (!mounted) return; - Navigator.of(context).pushReplacement( - MaterialPageRoute(builder: (_) => const WearHomePage()), - ); - }, - onError: (Object error, StackTrace stackTrace) { - log.warning( - '[WearSyncLoginPage] Direct pairing failed', - error, - stackTrace, - ); - if (mounted) setState(() => _status = '同步失败:$error'); - }, - ); - unawaited(_start()); - } - - Future _start() async { - try { - await _bridge.start(); - await _bridge.beginDirectPairing(); - if (mounted) setState(() => _starting = false); - } catch (error, stackTrace) { - log.warning( - '[WearSyncLoginPage] Cannot start pairing', - error, - stackTrace, - ); - if (mounted) { - setState(() { - _starting = false; - _status = '无法开始配对:$error'; - }); - } - } - } - - @override - void dispose() { - _subscription?.cancel(); - unawaited(_bridge.dispose()); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: SafeArea( - child: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(28), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 260), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(_starting ? Icons.sync : Icons.watch_outlined, size: 54), - const SizedBox(height: 12), - Text( - '等待手机配对', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w800, - ), - ), - const SizedBox(height: 10), - Text(_status, textAlign: TextAlign.center), - const SizedBox(height: 12), - if (_starting) const CircularProgressIndicator(), - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('返回'), - ), - ], - ), - ), - ), - ), - ), - ); - } -} diff --git a/wearos/pubspec.lock b/wearos/pubspec.lock deleted file mode 100644 index 076eac7f..00000000 --- a/wearos/pubspec.lock +++ /dev/null @@ -1,978 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - _fe_analyzer_shared: - dependency: transitive - description: - name: _fe_analyzer_shared - sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d" - url: "https://pub.dev" - source: hosted - version: "93.0.0" - analyzer: - dependency: transitive - description: - name: analyzer - sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b - url: "https://pub.dev" - source: hosted - version: "10.0.1" - ansicolor: - dependency: transitive - description: - name: ansicolor - sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f" - url: "https://pub.dev" - source: hosted - version: "2.0.3" - archive: - dependency: transitive - description: - name: archive - sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff - url: "https://pub.dev" - source: hosted - version: "4.0.9" - args: - dependency: transitive - description: - name: args - sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 - url: "https://pub.dev" - source: hosted - version: "2.7.0" - asn1lib: - dependency: transitive - description: - name: asn1lib - sha256: "9a8f69025044eb466b9b60ef3bc3ac99b4dc6c158ae9c56d25eeccf5bc56d024" - url: "https://pub.dev" - source: hosted - version: "1.6.5" - async: - dependency: transitive - description: - name: async - sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 - url: "https://pub.dev" - source: hosted - version: "2.13.1" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - build: - dependency: transitive - description: - name: build - sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10 - url: "https://pub.dev" - source: hosted - version: "4.0.6" - build_config: - dependency: transitive - description: - name: build_config - sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" - url: "https://pub.dev" - source: hosted - version: "1.3.0" - build_daemon: - dependency: transitive - description: - name: build_daemon - sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 - url: "https://pub.dev" - source: hosted - version: "4.1.1" - build_runner: - dependency: "direct dev" - description: - name: build_runner - sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6" - url: "https://pub.dev" - source: hosted - version: "2.15.0" - built_collection: - dependency: transitive - description: - name: built_collection - sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" - url: "https://pub.dev" - source: hosted - version: "5.1.1" - built_value: - dependency: transitive - description: - name: built_value - sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56" - url: "https://pub.dev" - source: hosted - version: "8.12.6" - characters: - dependency: transitive - description: - name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b - url: "https://pub.dev" - source: hosted - version: "1.4.1" - checked_yaml: - dependency: transitive - description: - name: checked_yaml - sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" - url: "https://pub.dev" - source: hosted - version: "2.0.4" - clock: - dependency: transitive - description: - name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b - url: "https://pub.dev" - source: hosted - version: "1.1.2" - code_assets: - dependency: transitive - description: - name: code_assets - sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - collection: - dependency: transitive - description: - name: collection - sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" - url: "https://pub.dev" - source: hosted - version: "1.19.1" - convert: - dependency: transitive - description: - name: convert - sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 - url: "https://pub.dev" - source: hosted - version: "3.1.2" - cookie_jar: - dependency: "direct main" - description: - name: cookie_jar - sha256: "963da02c1ef64cb5ac20de948c9e5940aa351f1e34a12b1d327c83d85b7e8fff" - url: "https://pub.dev" - source: hosted - version: "4.0.9" - cross_file: - dependency: transitive - description: - name: cross_file - sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" - url: "https://pub.dev" - source: hosted - version: "0.3.5+2" - crypto: - dependency: transitive - description: - name: crypto - sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf - url: "https://pub.dev" - source: hosted - version: "3.0.7" - csslib: - dependency: transitive - description: - name: csslib - sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" - url: "https://pub.dev" - source: hosted - version: "1.0.2" - dart_style: - dependency: transitive - description: - name: dart_style - sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2" - url: "https://pub.dev" - source: hosted - version: "3.1.7" - dio: - dependency: "direct main" - description: - name: dio - sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c - url: "https://pub.dev" - source: hosted - version: "5.9.2" - dio_cookie_manager: - dependency: "direct main" - description: - name: dio_cookie_manager - sha256: "0db1a7b997a0455e488ac35744c68eed3f2a4280d3ab531835a65641b0a08744" - url: "https://pub.dev" - source: hosted - version: "3.4.0" - dio_web_adapter: - dependency: transitive - description: - name: dio_web_adapter - sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - encrypter_plus: - dependency: "direct main" - description: - name: encrypter_plus - sha256: "6f6f3c73e26058af4fd138369a928ccae667e45d254cf6ded6301a2d99551a67" - url: "https://pub.dev" - source: hosted - version: "5.1.0" - fake_async: - dependency: transitive - description: - name: fake_async - sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" - url: "https://pub.dev" - source: hosted - version: "1.3.3" - ffi: - dependency: transitive - description: - name: ffi - sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" - url: "https://pub.dev" - source: hosted - version: "2.2.0" - ffi_leak_tracker: - dependency: transitive - description: - name: ffi_leak_tracker - sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" - url: "https://pub.dev" - source: hosted - version: "0.1.2" - file: - dependency: transitive - description: - name: file - sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 - url: "https://pub.dev" - source: hosted - version: "7.0.1" - fixnum: - dependency: transitive - description: - name: fixnum - sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be - url: "https://pub.dev" - source: hosted - version: "1.1.1" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_lints: - dependency: "direct dev" - description: - name: flutter_lints - sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" - url: "https://pub.dev" - source: hosted - version: "6.0.0" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - flutter_web_plugins: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - glob: - dependency: transitive - description: - name: glob - sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de - url: "https://pub.dev" - source: hosted - version: "2.1.3" - graphs: - dependency: transitive - description: - name: graphs - sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" - url: "https://pub.dev" - source: hosted - version: "2.3.2" - group_button: - dependency: transitive - description: - name: group_button - sha256: "0610fcf28ed122bfb4b410fce161a390f7f2531d55d1d65c5375982001415940" - url: "https://pub.dev" - source: hosted - version: "5.3.4" - hooks: - dependency: transitive - description: - name: hooks - sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e" - url: "https://pub.dev" - source: hosted - version: "1.0.3" - html: - dependency: "direct main" - description: - name: html - sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" - url: "https://pub.dev" - source: hosted - version: "0.15.6" - http_multi_server: - dependency: transitive - description: - name: http_multi_server - sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 - url: "https://pub.dev" - source: hosted - version: "3.2.2" - http_parser: - dependency: transitive - description: - name: http_parser - sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" - url: "https://pub.dev" - source: hosted - version: "4.1.2" - image: - dependency: "direct main" - description: - name: image - sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce - url: "https://pub.dev" - source: hosted - version: "4.8.0" - intl: - dependency: "direct main" - description: - name: intl - sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" - url: "https://pub.dev" - source: hosted - version: "0.20.2" - io: - dependency: transitive - description: - name: io - sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b - url: "https://pub.dev" - source: hosted - version: "1.0.5" - jni: - dependency: transitive - description: - name: jni - sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f - url: "https://pub.dev" - source: hosted - version: "1.0.0" - jni_flutter: - dependency: transitive - description: - name: jni_flutter - sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" - url: "https://pub.dev" - source: hosted - version: "1.0.1" - json_annotation: - dependency: "direct main" - description: - name: json_annotation - sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 - url: "https://pub.dev" - source: hosted - version: "4.11.0" - json_serializable: - dependency: "direct dev" - description: - name: json_serializable - sha256: "2c15e78e1cc6e62aadecf59f81566fd56829713d96a8c4177699e2b2e17f20db" - url: "https://pub.dev" - source: hosted - version: "6.13.2" - leak_tracker: - dependency: transitive - description: - name: leak_tracker - sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" - url: "https://pub.dev" - source: hosted - version: "11.0.2" - leak_tracker_flutter_testing: - dependency: transitive - description: - name: leak_tracker_flutter_testing - sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" - url: "https://pub.dev" - source: hosted - version: "3.0.10" - leak_tracker_testing: - dependency: transitive - description: - name: leak_tracker_testing - sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" - url: "https://pub.dev" - source: hosted - version: "3.0.2" - lints: - dependency: transitive - description: - name: lints - sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" - url: "https://pub.dev" - source: hosted - version: "6.1.0" - logging: - dependency: transitive - description: - name: logging - sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 - url: "https://pub.dev" - source: hosted - version: "1.3.0" - matcher: - dependency: transitive - description: - name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 - url: "https://pub.dev" - source: hosted - version: "0.12.19" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" - url: "https://pub.dev" - source: hosted - version: "0.13.0" - meta: - dependency: transitive - description: - name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" - url: "https://pub.dev" - source: hosted - version: "1.18.0" - mime: - dependency: transitive - description: - name: mime - sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" - url: "https://pub.dev" - source: hosted - version: "2.0.0" - native_toolchain_c: - dependency: transitive - description: - name: native_toolchain_c - sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" - url: "https://pub.dev" - source: hosted - version: "0.17.6" - objective_c: - dependency: transitive - description: - name: objective_c - sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" - url: "https://pub.dev" - source: hosted - version: "9.3.0" - package_config: - dependency: transitive - description: - name: package_config - sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc - url: "https://pub.dev" - source: hosted - version: "2.2.0" - path: - dependency: transitive - description: - name: path - sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" - url: "https://pub.dev" - source: hosted - version: "1.9.1" - path_provider: - dependency: "direct main" - description: - name: path_provider - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" - url: "https://pub.dev" - source: hosted - version: "2.1.5" - path_provider_android: - dependency: transitive - description: - name: path_provider_android - sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" - url: "https://pub.dev" - source: hosted - version: "2.3.1" - path_provider_foundation: - dependency: transitive - description: - name: path_provider_foundation - sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" - url: "https://pub.dev" - source: hosted - version: "2.6.0" - path_provider_linux: - dependency: transitive - description: - name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 - url: "https://pub.dev" - source: hosted - version: "2.2.1" - path_provider_platform_interface: - dependency: transitive - description: - name: path_provider_platform_interface - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - path_provider_windows: - dependency: transitive - description: - name: path_provider_windows - sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 - url: "https://pub.dev" - source: hosted - version: "2.3.0" - petitparser: - dependency: transitive - description: - name: petitparser - sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" - url: "https://pub.dev" - source: hosted - version: "7.0.2" - platform: - dependency: transitive - description: - name: platform - sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" - url: "https://pub.dev" - source: hosted - version: "3.1.6" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" - url: "https://pub.dev" - source: hosted - version: "2.1.8" - pointycastle: - dependency: transitive - description: - name: pointycastle - sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" - url: "https://pub.dev" - source: hosted - version: "4.0.0" - pool: - dependency: transitive - description: - name: pool - sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" - url: "https://pub.dev" - source: hosted - version: "1.5.2" - posix: - dependency: transitive - description: - name: posix - sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" - url: "https://pub.dev" - source: hosted - version: "6.5.0" - pub_semver: - dependency: transitive - description: - name: pub_semver - sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" - url: "https://pub.dev" - source: hosted - version: "2.2.0" - pubspec_parse: - dependency: transitive - description: - name: pubspec_parse - sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" - url: "https://pub.dev" - source: hosted - version: "1.5.0" - record_use: - dependency: transitive - description: - name: record_use - sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" - url: "https://pub.dev" - source: hosted - version: "0.6.0" - share_plus: - dependency: transitive - description: - name: share_plus - sha256: a857d8b1479250aff6b57a51b2c02d31ca05848d441817c43f1640c885c286c0 - url: "https://pub.dev" - source: hosted - version: "13.1.0" - share_plus_platform_interface: - dependency: transitive - description: - name: share_plus_platform_interface - sha256: "7f7ae28cf400d13f811e297ff37742dba83b79e0a6f5dce14eec0248274e6ce9" - url: "https://pub.dev" - source: hosted - version: "7.1.0" - shared_preferences: - dependency: "direct main" - description: - name: shared_preferences - sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf - url: "https://pub.dev" - source: hosted - version: "2.5.5" - shared_preferences_android: - dependency: transitive - description: - name: shared_preferences_android - sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 - url: "https://pub.dev" - source: hosted - version: "2.4.23" - shared_preferences_foundation: - dependency: transitive - description: - name: shared_preferences_foundation - sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" - url: "https://pub.dev" - source: hosted - version: "2.5.6" - shared_preferences_linux: - dependency: transitive - description: - name: shared_preferences_linux - sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - shared_preferences_platform_interface: - dependency: "direct dev" - description: - name: shared_preferences_platform_interface - sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" - url: "https://pub.dev" - source: hosted - version: "2.4.2" - shared_preferences_web: - dependency: transitive - description: - name: shared_preferences_web - sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 - url: "https://pub.dev" - source: hosted - version: "2.4.3" - shared_preferences_windows: - dependency: transitive - description: - name: shared_preferences_windows - sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - shelf: - dependency: transitive - description: - name: shelf - sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 - url: "https://pub.dev" - source: hosted - version: "1.4.2" - shelf_web_socket: - dependency: transitive - description: - name: shelf_web_socket - sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" - url: "https://pub.dev" - source: hosted - version: "3.0.0" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - source_gen: - dependency: transitive - description: - name: source_gen - sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02 - url: "https://pub.dev" - source: hosted - version: "4.2.3" - source_helper: - dependency: transitive - description: - name: source_helper - sha256: "4227d54ceefd0bb8ca4c8fcb96e1719dc53f1ee1b6e2ca9d7a6069da160e4eae" - url: "https://pub.dev" - source: hosted - version: "1.3.12" - source_span: - dependency: transitive - description: - name: source_span - sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" - url: "https://pub.dev" - source: hosted - version: "1.10.2" - stack_trace: - dependency: transitive - description: - name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" - url: "https://pub.dev" - source: hosted - version: "1.12.1" - stream_channel: - dependency: transitive - description: - name: stream_channel - sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - stream_transform: - dependency: transitive - description: - name: stream_transform - sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 - url: "https://pub.dev" - source: hosted - version: "2.1.1" - string_scanner: - dependency: transitive - description: - name: string_scanner - sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" - url: "https://pub.dev" - source: hosted - version: "1.4.1" - synchronized: - dependency: "direct main" - description: - name: synchronized - sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5" - url: "https://pub.dev" - source: hosted - version: "3.4.0+1" - talker: - dependency: transitive - description: - name: talker - sha256: f1a14d623f1d1bec42bb3bb77674eb766ffe8d26e5f79af652d85cb097c3e757 - url: "https://pub.dev" - source: hosted - version: "5.1.17" - talker_dio_logger: - dependency: "direct main" - description: - name: talker_dio_logger - sha256: "6dba5c29afb566c6efe1a2c1b676488ea7c727b486bda0654d965a1cfad6ea9b" - url: "https://pub.dev" - source: hosted - version: "5.1.17" - talker_flutter: - dependency: "direct main" - description: - name: talker_flutter - sha256: "7e4b5fb520b4dadfc8db97e73a2a76ea5d6eda471a51489f3c0bd58b96a1ed43" - url: "https://pub.dev" - source: hosted - version: "5.1.17" - talker_logger: - dependency: transitive - description: - name: talker_logger - sha256: "459205c3e571f97ecc6be6e1b1b7e6b97b853e78ea458894650be407596e3216" - url: "https://pub.dev" - source: hosted - version: "5.1.17" - term_glyph: - dependency: transitive - description: - name: term_glyph - sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" - url: "https://pub.dev" - source: hosted - version: "1.2.2" - test_api: - dependency: transitive - description: - name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" - url: "https://pub.dev" - source: hosted - version: "0.7.11" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.dev" - source: hosted - version: "1.4.0" - universal_io: - dependency: transitive - description: - name: universal_io - sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 - url: "https://pub.dev" - source: hosted - version: "2.3.1" - url_launcher_linux: - dependency: transitive - description: - name: url_launcher_linux - sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a - url: "https://pub.dev" - source: hosted - version: "3.2.2" - url_launcher_platform_interface: - dependency: transitive - description: - name: url_launcher_platform_interface - sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" - url: "https://pub.dev" - source: hosted - version: "2.3.2" - url_launcher_web: - dependency: transitive - description: - name: url_launcher_web - sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" - url: "https://pub.dev" - source: hosted - version: "2.4.3" - url_launcher_windows: - dependency: transitive - description: - name: url_launcher_windows - sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" - url: "https://pub.dev" - source: hosted - version: "3.1.5" - uuid: - dependency: transitive - description: - name: uuid - sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" - url: "https://pub.dev" - source: hosted - version: "4.5.3" - vector_math: - dependency: transitive - description: - name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b - url: "https://pub.dev" - source: hosted - version: "2.2.0" - vm_service: - dependency: transitive - description: - name: vm_service - sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" - url: "https://pub.dev" - source: hosted - version: "15.2.0" - watcher: - dependency: transitive - description: - name: watcher - sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" - url: "https://pub.dev" - source: hosted - version: "1.2.1" - web: - dependency: transitive - description: - name: web - sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" - url: "https://pub.dev" - source: hosted - version: "1.1.1" - web_socket: - dependency: transitive - description: - name: web_socket - sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" - url: "https://pub.dev" - source: hosted - version: "1.0.1" - web_socket_channel: - dependency: transitive - description: - name: web_socket_channel - sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 - url: "https://pub.dev" - source: hosted - version: "3.0.3" - win32: - dependency: transitive - description: - name: win32 - sha256: a1fc9eb9248baa05dfc12ed5b66e377b3e23f095eec078e0371622b9033810d9 - url: "https://pub.dev" - source: hosted - version: "6.2.0" - xdg_directories: - dependency: transitive - description: - name: xdg_directories - sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - xml: - dependency: transitive - description: - name: xml - sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" - url: "https://pub.dev" - source: hosted - version: "6.6.1" - yaml: - dependency: transitive - description: - name: yaml - sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce - url: "https://pub.dev" - source: hosted - version: "3.1.3" -sdks: - dart: ">=3.11.0 <4.0.0" - flutter: ">=3.38.4" diff --git a/wearos/pubspec.yaml b/wearos/pubspec.yaml deleted file mode 100644 index 6d23e316..00000000 --- a/wearos/pubspec.yaml +++ /dev/null @@ -1,35 +0,0 @@ -name: watermeter -description: Another personal data assistant for XDU. -publish_to: "none" -version: 1.5.13+43 - -environment: - sdk: ^3.8.0 - -dependencies: - dio: ^5.0.0 - encrypter_plus: ^5.1.0 - talker_flutter: ^5.0.0 - talker_dio_logger: ^5.0.0 - synchronized: ^3.1.0+1 - shared_preferences: ^2.5.3 - dio_cookie_manager: ^3.0.0 - cookie_jar: ^4.0.3 - path_provider: ^2.0.11 - json_annotation: ^4.9.0 - html: ^0.15.4 - image: ^4.5.4 - flutter: - sdk: flutter - intl: - -dev_dependencies: - flutter_test: - sdk: flutter - build_runner: ^2.6.0 - json_serializable: ^6.10.0 - flutter_lints: ^6.0.0 - shared_preferences_platform_interface: ^2.4.2 - -flutter: - uses-material-design: true diff --git a/wearos/test/ids_session_test.dart b/wearos/test/ids_session_test.dart deleted file mode 100644 index 5c3b1915..00000000 --- a/wearos/test/ids_session_test.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:watermeter/repository/xidian_ids/ids_session.dart'; - -void main() { - test('password encryption matches the Go ids login payload', () { - expect( - IDSSession.aesEncrypt('secret', '1234567890abcdef'), - 'Y2fkMlmY/KyUHnWiA9lVrnC8HHWUFePOo/JLpbpV/XfZ/zE6Tk2WrZMyCYY1f9ael+nb8OZB4B2EmFM6G18SWNpKTmuSEP0PjuxgVXBdI90=', - ); - }); - - test('username login payload matches Go ids fields', () { - final payload = IDSSession.buildUsernameLoginPayloadForTesting( - username: '2200000000', - password: 'secret', - salt: '1234567890abcdef', - execution: 'exec-token', - ); - - expect(payload, { - 'username': '2200000000', - 'password': - 'Y2fkMlmY/KyUHnWiA9lVrnC8HHWUFePOo/JLpbpV/XfZ/zE6Tk2WrZMyCYY1f9ael+nb8OZB4B2EmFM6G18SWNpKTmuSEP0PjuxgVXBdI90=', - 'rememberMe': 'true', - 'cllt': 'userNameLogin', - 'dllt': 'generalLogin', - '_eventId': 'submit', - 'captcha': '', - 'lt': '', - 'execution': 'exec-token', - }); - }); -} diff --git a/wearos/test/slider_captcha_test.dart b/wearos/test/slider_captcha_test.dart deleted file mode 100644 index 18ee1078..00000000 --- a/wearos/test/slider_captcha_test.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'dart:convert'; -import 'dart:typed_data'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:watermeter/wearos/slider_captcha.dart'; - -void main() { - group('Go-compatible IDS slider captcha', () { - test('solves slide offset using the Go cross-correlation algorithm', () { - final puzzleBytes = base64Decode( - 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAECAYAAACHtL/sAAAA70lEQVR4nB3OQQfCAACA0RHRrFtEh93GWIwOMRo7LRazbh1GdNsYo+MYo+PotGN02rFrx64dO8WOO40dY8QOX/QHnieMx2NkWUbXdSzLYrvdcjgcOB6PnE4niqKgLEvu9zvP55Oqqmjblr7vkSQJYT6fY5omruuy3++J45gsyxBFkclk8sdVVeX9ftM0Dd/vl9FoxGw2Q9M0hM1mg+/7RFFEmqacz2eu1yuLxYLVaoVt23ieR9d1DIdDptPpHzQMA8dxEMIwJEkS8jzncrlwu914PB68Xi/quubz+TAYDP4bRVFYLpes12t2ux1BEPADQTaOsaGO5RAAAAAASUVORK5CYII=', - ); - final pieceBytes = base64Decode( - 'iVBORw0KGgoAAAANSUhEUgAAAAYAAAAECAYAAACtBE5DAAAAM0lEQVR4nGNgwAe4uLj+i4iI/JeTk/uvoaHxHy5hZGT038bG5r+bm9v/gICA/3jMYGAAADvqDDHTarfFAAAAAElFTkSuQmCC', - ); - - expect( - SliderCaptchaClientProvider.solveSlideOffsetForTesting( - puzzleBytes: puzzleBytes, - pieceBytes: pieceBytes, - border: 0, - ), - 5, - ); - }); - - test('encrypts captcha payload with the Go fixed-prefix AES-CBC shape', () { - final key = Uint8List.fromList('1234567890abcdef'.codeUnits); - const payload = - '{"canvasLength":280,"moveLength":42,"tracks":[{"a":0,"b":0,"c":0},{"a":42,"b":0,"c":900}]}'; - - expect( - SliderCaptchaClientProvider.encryptCaptchaPayloadForTesting( - payload, - key, - ), - 'Y2fkMlmY/KyUHnWiA9lVrnC8HHWUFePOo/JLpbpV/XfZ/zE6Tk2WrZMyCYY1f9ael+nb8OZB4B2EmFM6G18SWMo6nGxXZr4TTOiHUUTFXkeQQVaF2RoG1CsaDxyrQkchEx7YVCH+3fSUlX8CKpybb7jJnIbccr2rP1538MId2OLPck1g1XaCwAOtLK+LyyKILKYdFAT061XHTpBZZfvJOg==', - ); - }); - }); -} diff --git a/wearos/test/wear_app_test.dart b/wearos/test/wear_app_test.dart deleted file mode 100644 index b0b36e57..00000000 --- a/wearos/test/wear_app_test.dart +++ /dev/null @@ -1,75 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:watermeter/wearos/wear_app.dart'; -import 'package:watermeter/wearos/wear_home_page.dart'; -import 'package:watermeter/wearos/wear_schedule_service.dart'; - -void main() { - testWidgets('first launch is companion-only and has no IDS login form', ( - tester, - ) async { - const channel = MethodChannel( - 'io.github.benderblog.traintime_pda/wear_companion_sync', - ); - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - channel, - (call) async => null, - ); - addTearDown( - () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - channel, - null, - ), - ); - await tester.pumpWidget(const WearApp(isFirst: true)); - await tester.pump(); - - expect(find.text('等待手机配对'), findsOneWidget); - expect(find.byType(TextField), findsNothing); - expect(find.text('登录'), findsNothing); - expect(find.textContaining('设置 > XDYou Wear'), findsOneWidget); - }); - - testWidgets('home dashboard bounds long text on a round watch', ( - tester, - ) async { - tester.view.physicalSize = const Size(384, 384); - tester.view.devicePixelRatio = 1; - addTearDown(() { - tester.view.resetPhysicalSize(); - tester.view.resetDevicePixelRatio(); - }); - const longTitle = '很长很长很长很长很长的课程名称'; - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: WearHomeDashboard( - data: WearHomeData( - todayItems: [ - WearAgendaItem( - kind: WearAgendaKind.course, - title: longTitle, - start: DateTime(2026, 5, 19, 8, 30), - end: DateTime(2026, 5, 19, 10, 5), - location: '很长很长很长很长很长的教室名称', - subtitle: '很长很长很长很长很长的教师名称', - ), - ], - tomorrowItems: const [], - ), - onRefresh: () async {}, - onLogout: () {}, - ), - ), - ), - ); - - expect(tester.takeException(), isNull); - final titleText = tester.widget(find.text(longTitle)); - expect(titleText.maxLines, 1); - expect(titleText.overflow, TextOverflow.ellipsis); - expect(find.text('校园卡'), findsOneWidget); - }); -} diff --git a/wearos/test/wear_schedule_service_test.dart b/wearos/test/wear_schedule_service_test.dart deleted file mode 100644 index ac6482bc..00000000 --- a/wearos/test/wear_schedule_service_test.dart +++ /dev/null @@ -1,326 +0,0 @@ -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; -import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; -import 'package:watermeter/model/xidian_ids/classtable.dart'; -import 'package:watermeter/model/xidian_ids/experiment.dart'; -import 'package:watermeter/repository/network_session.dart' as network; -import 'package:watermeter/repository/preference.dart' as preference; -import 'package:watermeter/repository/xidian_ids/school_card_session.dart'; -import 'package:watermeter/wearos/wear_cache_store.dart'; -import 'package:watermeter/wearos/wear_companion_sync.dart'; -import 'package:watermeter/wearos/wear_schedule_service.dart'; - -void main() { - group('Wear agenda conversion', () { - test('course items use target date week and class-period times', () { - final table = ClassTableData( - semesterLength: 2, - semesterCode: '2026-1', - termStartDay: '2026-05-18 00:00:00', - classDetail: [ClassDetail(name: '编译原理', code: 'CS301', number: '01')], - timeArrangement: [ - TimeArrangement( - source: Source.school, - index: 0, - weekList: [true, false], - teacher: '张老师', - classroom: 'B-101', - day: DateTime.tuesday, - start: 1, - stop: 2, - ), - ], - ); - - final firstWeekItems = WearAgendaBuilder.courseItemsForDay( - table, - DateTime(2026, 5, 19), - ); - final secondWeekItems = WearAgendaBuilder.courseItemsForDay( - table, - DateTime(2026, 5, 26), - ); - - expect(firstWeekItems, hasLength(1)); - expect(firstWeekItems.single.kind, WearAgendaKind.course); - expect(firstWeekItems.single.title, '编译原理'); - expect(firstWeekItems.single.subtitle, '张老师'); - expect(firstWeekItems.single.location, 'B-101'); - expect(firstWeekItems.single.start, DateTime(2026, 5, 19, 8, 30)); - expect(firstWeekItems.single.end, DateTime(2026, 5, 19, 10, 5)); - expect(secondWeekItems, isEmpty); - }); - - test('other experiment items keep target-day ranges', () { - final experiments = [ - ExperimentData( - type: ExperimentType.others, - name: '电工实习', - classroom: '工程坊', - timeRanges: [(DateTime(2026, 5, 19, 14), DateTime(2026, 5, 19, 16))], - teacher: '王老师', - ), - ExperimentData( - type: ExperimentType.others, - name: '工程训练', - classroom: '工程坊', - timeRanges: [(DateTime(2026, 5, 20, 14), DateTime(2026, 5, 20, 16))], - teacher: '刘老师', - ), - ]; - - final items = WearAgendaBuilder.experimentItemsForDay( - experiments, - DateTime(2026, 5, 19), - ); - - expect(items, hasLength(1)); - expect(items.single.kind, WearAgendaKind.otherExperiment); - expect(items.single.title, '电工实习'); - expect(items.single.subtitle, '王老师'); - expect(items.single.location, '工程坊'); - expect(items.single.start, DateTime(2026, 5, 19, 14)); - expect(items.single.end, DateTime(2026, 5, 19, 16)); - }); - - test('school card reset clears cached openid between users', () { - SchoolCardSession.openid = 'previous-user-openid'; - - SchoolCardSession.resetOpenId(); - - expect(SchoolCardSession.openid, isEmpty); - }); - }); - - group('Wear home loading', () { - test( - 'cache-only load builds the agenda without network fetchers', - () async { - final now = DateTime(2026, 5, 19, 8); - final table = _singleCourseTable('离线课程'); - - final data = await loadCachedWearHomeData( - semesterCode: '2026-1', - now: now, - classTableCacheLoader: (_) => table, - otherExperimentCacheLoader: () => null, - ); - - expect(data.todayItems.map((item) => item.title), ['离线课程']); - }, - ); - }); - - group('Wear companion sync interface', () { - late Directory tempDir; - - setUp(() async { - TestWidgetsFlutterBinding.ensureInitialized(); - SharedPreferencesAsyncPlatform.instance = - InMemorySharedPreferencesAsync.empty(); - preference.prefs = await SharedPreferencesWithCache.create( - cacheOptions: const SharedPreferencesWithCacheOptions(), - ); - tempDir = await Directory.systemTemp.createTemp('wear-sync-test-'); - network.supportPath = tempDir; - WearClassTableCache.file = File( - '${tempDir.path}/${WearClassTableCache.fileName}', - ); - WearExperimentCache.file = File( - '${tempDir.path}/${WearExperimentCache.fileName}', - ); - await WearClassTableCache.clear(); - await WearExperimentCache.clear(); - }); - - tearDown(() async { - if (await tempDir.exists()) { - await tempDir.delete(recursive: true); - } - }); - - test('credential import clears previous user-scoped state', () async { - SchoolCardSession.openid = 'old-openid'; - await WearClassTableCache.write(_singleCourseTable('旧课程')); - await WearExperimentCache.write([ - ExperimentData( - type: ExperimentType.others, - name: '旧实验', - classroom: '实验楼', - timeRanges: [(DateTime(2026, 5, 19, 10), DateTime(2026, 5, 19, 11))], - teacher: '旧老师', - ), - ]); - await preference.setString( - preference.Preference.currentSemester, - 'old-term', - ); - await preference.setBool(preference.Preference.role, true); - await preference.setBool( - preference.Preference.isUserDefinedSemester, - true, - ); - - await WearLocalCompanionSyncPort().importCredentials( - const WearCredentialSyncPayload( - idsAccount: '2200000001', - idsPassword: 'new-secret', - ), - ); - - expect(SchoolCardSession.openid, isEmpty); - expect(WearClassTableCache.file.existsSync(), isFalse); - expect(WearExperimentCache.file.existsSync(), isFalse); - expect( - preference.getString(preference.Preference.currentSemester), - isEmpty, - ); - expect(preference.getBool(preference.Preference.role), isFalse); - expect( - preference.getBool(preference.Preference.isUserDefinedSemester), - isFalse, - ); - expect( - preference.getString(preference.Preference.idsAccount), - '2200000001', - ); - }); - - test('imports credentials for watch payment authentication', () async { - await WearLocalCompanionSyncPort().importCredentials( - const WearCredentialSyncPayload( - idsAccount: '2200000000', - idsPassword: 'secret', - isPostGraduate: true, - currentSemester: '2026-1', - ), - ); - - expect( - preference.getString(preference.Preference.idsAccount), - '2200000000', - ); - expect(preference.getString(preference.Preference.idsPassword), 'secret'); - expect(preference.getBool(preference.Preference.role), isTrue); - expect( - preference.getString(preference.Preference.currentSemester), - '2026-1', - ); - }); - - test( - 'imports class table and other experiments into local caches', - () async { - final table = _singleCourseTable('同步课程'); - final experiment = ExperimentData( - type: ExperimentType.others, - name: '同步实验', - classroom: '实验楼', - timeRanges: [(DateTime(2026, 5, 19, 10), DateTime(2026, 5, 19, 11))], - teacher: '同步老师', - ); - - await WearLocalCompanionSyncPort().importSchedule( - WearScheduleSyncPayload( - classTable: table, - otherExperiments: [experiment], - ), - ); - - expect(WearClassTableCache.read()?.$2.classDetail.single.name, '同步课程'); - expect(WearExperimentCache.read()?.$2.single.name, '同步实验'); - final cachedHome = await loadCachedWearHomeData( - semesterCode: '2026-1', - now: DateTime(2026, 5, 19, 8), - ); - expect( - preference.getString(preference.Preference.currentSemester), - '2026-1', - ); - expect( - cachedHome.todayItems.map((item) => item.title), - contains('同步课程'), - ); - }, - ); - - test('imports bundled native sync payload from companion phone', () async { - final table = _singleCourseTable('扫码同步课程'); - final envelope = WearCompanionSyncEnvelope.fromJson({ - 'schemaVersion': 1, - 'sessionId': 'session-123', - 'credentials': { - 'idsAccount': '2200000002', - 'idsPassword': 'synced-secret', - 'isPostGraduate': false, - 'currentSemester': 'fallback-term', - }, - 'schedule': {'classTable': table.toJson()}, - 'paymentQr': {'pngBase64': 'AQID', 'fetchedAtEpochMs': 1785816000000}, - }); - - await envelope.importInto(const WearLocalCompanionSyncPort()); - - expect( - preference.getString(preference.Preference.idsAccount), - '2200000002', - ); - expect( - preference.getString(preference.Preference.idsPassword), - 'synced-secret', - ); - expect( - preference.getString(preference.Preference.currentSemester), - '2026-1', - ); - expect(WearClassTableCache.read()?.$2.classDetail.single.name, '扫码同步课程'); - expect( - File('${network.supportPath.path}/WearPaymentQr.png').readAsBytesSync(), - [1, 2, 3], - ); - }); - - test('rejects malformed native sync payloads', () { - expect( - () => WearCompanionSyncEnvelope.fromJson({ - 'schemaVersion': 1, - 'sessionId': 'session-123', - 'schedule': {'classTable': _singleCourseTable('缺少凭据').toJson()}, - }), - throwsFormatException, - ); - expect( - () => WearCompanionSyncEnvelope.fromJson({ - 'schemaVersion': 1, - 'sessionId': 'session-123', - 'credentials': {'idsAccount': '2200000002', 'idsPassword': 'secret'}, - }), - throwsFormatException, - ); - }); - }); -} - -ClassTableData _singleCourseTable(String name) { - return ClassTableData( - semesterLength: 1, - semesterCode: '2026-1', - termStartDay: '2026-05-18 00:00:00', - classDetail: [ClassDetail(name: name)], - timeArrangement: [ - TimeArrangement( - source: Source.school, - index: 0, - weekList: [true], - teacher: '赵老师', - classroom: 'A-301', - day: DateTime.tuesday, - start: 3, - stop: 4, - ), - ], - ); -} From c4fdf48aae3b62e7b2e4c40a7652b64cacaf59f5 Mon Sep 17 00:00:00 2001 From: brill594 Date: Wed, 5 Aug 2026 16:44:26 +0900 Subject: [PATCH 11/16] fix(wear): confirm pairing after sync import --- .../benderblog/traintime_pda/MainActivity.kt | 67 +++++++++++++++++-- .../traintime_pda/WearCompanionTransport.kt | 1 + wearos/WEAR_SYNC_INTEGRATION.md | 14 ++-- .../protocol/WearCompanionSync.kt | 1 + .../traintime_pda/sync/WearCompanionClient.kt | 15 ++++- 5 files changed, 85 insertions(+), 13 deletions(-) diff --git a/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/MainActivity.kt b/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/MainActivity.kt index b8ff81f1..f81418c3 100644 --- a/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/MainActivity.kt +++ b/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/MainActivity.kt @@ -1,6 +1,8 @@ package io.github.benderblog.traintime_pda import android.os.Bundle +import android.os.Handler +import android.os.Looper import androidx.core.view.WindowCompat import com.google.android.gms.wearable.MessageClient import com.google.android.gms.wearable.MessageEvent @@ -8,10 +10,19 @@ import com.google.android.gms.wearable.Wearable import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel +import org.json.JSONObject class MainActivity : FlutterActivity(), MessageClient.OnMessageReceivedListener { private var companionChannel: MethodChannel? = null + private val mainHandler = Handler(Looper.getMainLooper()) + private val pendingSyncResults = mutableMapOf() + + private data class PendingSync( + val sessionId: String, + val result: MethodChannel.Result, + val timeout: Runnable, + ) override fun onCreate(savedInstanceState: Bundle?) { // Enable edge-to-edge display @@ -56,14 +67,43 @@ class MainActivity : FlutterActivity(), MessageClient.OnMessageReceivedListener 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)) - .addOnSuccessListener { - WearCompanionTransport.rememberPairedWatch(this, nodeId) - result.success(null) - } .addOnFailureListener { error -> - result.error("send_failed", error.message, null) + mainHandler.post { + val pending = pendingSyncResults.remove(nodeId) + if (pending != null) { + mainHandler.removeCallbacks(pending.timeout) + pending.result.error("send_failed", error.message, null) + } + } } } "cacheSyncPayload" -> { @@ -120,6 +160,19 @@ class MainActivity : FlutterActivity(), MessageClient.OnMessageReceivedListener } 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 @@ -127,4 +180,8 @@ class MainActivity : FlutterActivity(), MessageClient.OnMessageReceivedListener 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 index 8378a878..68f77561 100644 --- 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 @@ -8,6 +8,7 @@ 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" diff --git a/wearos/WEAR_SYNC_INTEGRATION.md b/wearos/WEAR_SYNC_INTEGRATION.md index 597eeecd..39c3d31a 100644 --- a/wearos/WEAR_SYNC_INTEGRATION.md +++ b/wearos/WEAR_SYNC_INTEGRATION.md @@ -9,18 +9,22 @@ The phone app lives at the repository root. The Wear OS target lives in ## Direct pairing -1. Open the watch app while unpaired. The watch accepts a first pairing for five - minutes. +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. +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. The explicit five-minute window prevents an -unexpected first import even from another matching development installation. +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). 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 index ec093c2b..ad1edf3e 100644 --- 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 @@ -11,6 +11,7 @@ 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" 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 index 37b28e85..941d0b6d 100644 --- 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 @@ -121,7 +121,16 @@ class WearCompanionClient( try { val envelope = WearCompanionSyncEnvelope.decode(payload) importer.importEnvelope(envelope, payload) - sourceNodeId?.let { rememberPairedPhone(it) } + 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) { @@ -137,8 +146,8 @@ class WearCompanionClient( if (json.optInt("schemaVersion") != WearCompanionPaths.SCHEMA_VERSION) return false val paired = pairedPhoneNodeId() if (paired != null) return paired == sourceNodeId - System.currentTimeMillis() <= directPairingExpiresAtEpochMs && - json.optBoolean("directPairing", false) + json.optBoolean("directPairing", false) && + (listening.get() || System.currentTimeMillis() <= directPairingExpiresAtEpochMs) } catch (_: Exception) { false } From 9aee38bac24e9caddb08ba3dcd5f10e7804c20c5 Mon Sep 17 00:00:00 2001 From: brill594 Date: Wed, 5 Aug 2026 17:39:11 +0900 Subject: [PATCH 12/16] fix(wear): refine payment qr controls --- .../benderblog/traintime_pda/ui/WearApp.kt | 8 ++-- .../traintime_pda/ui/screens/QrScreen.kt | 41 +++++++++++-------- 2 files changed, 27 insertions(+), 22 deletions(-) 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 index e60c3f9a..0a81e92d 100644 --- 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 @@ -19,10 +19,10 @@ import io.github.benderblog.traintime_pda.ui.screens.QrScreen import io.github.benderblog.traintime_pda.ui.screens.ReAuthScreen private val WearColorPalette = Colors( - primary = Color(0xFF00A3FF), - primaryVariant = Color(0xFF0077CC), - secondary = Color(0xFF4DD0E1), - secondaryVariant = Color(0xFF0097A7), + primary = Color(0xFF70D6FF), + primaryVariant = Color(0xFF38BDF2), + secondary = Color(0xFFA7E8F0), + secondaryVariant = Color(0xFF62CEDB), background = Color.Black, surface = Color(0xFF1C1C1E), error = Color(0xFFFF6B6B), 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 index a6ce17c3..80a8c0c2 100644 --- 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 @@ -15,6 +15,7 @@ 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.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -108,6 +109,25 @@ fun QrScreen( } result != null -> { Column(Modifier.fillMaxSize()) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(46.dp) + .padding(horizontal = 34.dp, vertical = 5.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Button( + onClick = onBack, + modifier = Modifier.size(36.dp), + colors = ButtonDefaults.secondaryButtonColors(), + ) { Text("←", fontSize = 17.sp) } + Button( + onClick = onRetry, + modifier = Modifier.size(36.dp), + colors = ButtonDefaults.secondaryButtonColors(), + ) { Text("↻", fontSize = 17.sp) } + } Box( modifier = Modifier .weight(1f) @@ -120,10 +140,10 @@ fun QrScreen( Box( modifier = Modifier .align(Alignment.Center) - .padding(start = 22.dp, top = 28.dp, end = 22.dp, bottom = 4.dp) - .clip(RoundedCornerShape(20.dp)) + .padding(horizontal = 44.dp, vertical = 2.dp) + .clip(RoundedCornerShape(18.dp)) .background(Color.White) - .padding(14.dp), + .padding(12.dp), ) { Image( bitmap = bitmap.asImageBitmap(), @@ -133,21 +153,6 @@ fun QrScreen( ) } } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(4.dp), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Button( - onClick = onBack, - colors = ButtonDefaults.secondaryButtonColors(), - ) { Text("←") } - Button( - onClick = onRetry, - colors = ButtonDefaults.secondaryButtonColors(), - ) { Text("↻") } - } } if (result.fromCache) { val label = remember(result.fetchedAtEpochMs) { From e76494e1c38e9c93ff4fe3bab3effef1acb31cc6 Mon Sep 17 00:00:00 2001 From: brill594 Date: Wed, 5 Aug 2026 17:52:22 +0900 Subject: [PATCH 13/16] feat(wear): prioritize on-watch payment auth --- wearos/WEAR_SYNC_INTEGRATION.md | 9 ++- .../payment/PaymentQrRepository.kt | 26 ++----- .../benderblog/traintime_pda/ui/WearApp.kt | 2 - .../traintime_pda/ui/WearViewModel.kt | 24 +------ .../traintime_pda/ui/screens/QrScreen.kt | 70 +++++++++---------- 5 files changed, 45 insertions(+), 86 deletions(-) diff --git a/wearos/WEAR_SYNC_INTEGRATION.md b/wearos/WEAR_SYNC_INTEGRATION.md index 39c3d31a..5abe6002 100644 --- a/wearos/WEAR_SYNC_INTEGRATION.md +++ b/wearos/WEAR_SYNC_INTEGRATION.md @@ -36,11 +36,10 @@ 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, the watch first asks the foreground phone app to use the -phone's current IDS session. The user can immediately choose `改用手表认证`; -the watch then uses the synchronized account/password and its own persistent -cookie store. Automatic slider verification and an on-watch SMS MFA page are -supported. +For a payment QR, the watch first uses the synchronized account/password and +its own persistent cookie store. Automatic slider verification and an on-watch +SMS MFA page are supported. The phone proxy and the last cached QR are fallback +paths. Pulling down on a displayed QR requests a fresh code from the watch. 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 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 index 684d1349..cd9f526c 100644 --- 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 @@ -35,7 +35,7 @@ data class PaymentQrResult( } /** - * Payment QR: prefer phone proxy, then watch IDS auth, then offline cache. + * Payment QR: prefer watch IDS auth, then phone proxy, then offline cache. */ class PaymentQrRepository( private val preferences: WearPreferences, @@ -43,33 +43,17 @@ class PaymentQrRepository( private val companionClient: WearCompanionClient, ) { suspend fun load( - forceRefresh: Boolean = false, - preferWatchAuth: Boolean = false, reAuthHandler: (suspend (WearIDSReAuthClient) -> URI)? = null, ): PaymentQrResult { - if (!forceRefresh && !preferWatchAuth) { - cache.readPaymentQr()?.let { (bytes, fetchedAt) -> - return PaymentQrResult(bytes, fromCache = true, fetchedAtEpochMs = fetchedAt) - } - } - if (preferWatchAuth) { - return try { - requestDirectly(reAuthHandler) - } catch (primary: Exception) { - cache.readPaymentQr()?.let { (bytes, fetchedAt) -> - PaymentQrResult(bytes, fromCache = true, fetchedAtEpochMs = fetchedAt) - } ?: throw primary - } - } return try { - requestFromPhone() + requestDirectly(reAuthHandler) } catch (_: Exception) { try { - requestDirectly(reAuthHandler) - } catch (direct: Exception) { + requestFromPhone() + } catch (phone: Exception) { cache.readPaymentQr()?.let { (bytes, fetchedAt) -> PaymentQrResult(bytes, fromCache = true, fetchedAtEpochMs = fetchedAt) - } ?: throw direct + } ?: throw phone } } } 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 index 0a81e92d..6ba9848f 100644 --- 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 @@ -70,12 +70,10 @@ fun WearApp(viewModel: WearViewModel) { ) WearScreen.QR -> QrScreen( loading = state.qrLoading, - usingWatchAuth = state.qrUsingWatchAuth, result = state.qrResult, error = state.qrError, onBack = viewModel::closeQr, onRetry = viewModel::retryQr, - onWatchAuth = viewModel::authenticateOnWatch, ) WearScreen.REAUTH -> ReAuthScreen( notice = state.reAuthNotice, 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 index 778d7c28..22a2e8ed 100644 --- 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 @@ -46,7 +46,6 @@ data class WearUiState( val pairingStarting: Boolean = true, val pairingStatus: String = "请在手机端打开“设置 > XDYou Wear”,选择这块手表", val qrLoading: Boolean = false, - val qrUsingWatchAuth: Boolean = false, val qrResult: PaymentQrResult? = null, val qrError: String? = null, val reAuthClient: WearIDSReAuthClient? = null, @@ -209,12 +208,11 @@ class WearViewModel( it.copy( screen = WearScreen.QR, qrLoading = true, - qrUsingWatchAuth = false, qrResult = null, qrError = null, ) } - loadQr(forceRefresh = false, preferWatchAuth = false) + loadQr() } fun closeQr() { @@ -226,7 +224,6 @@ class WearViewModel( qrLoading = false, qrResult = null, qrError = null, - qrUsingWatchAuth = false, ) } } @@ -235,33 +232,18 @@ class WearViewModel( _state.update { it.copy( qrLoading = true, - qrUsingWatchAuth = false, qrResult = null, qrError = null, ) } - loadQr(forceRefresh = true, preferWatchAuth = false) + loadQr() } - fun authenticateOnWatch() { - _state.update { - it.copy( - qrLoading = true, - qrUsingWatchAuth = true, - qrResult = null, - qrError = null, - ) - } - loadQr(forceRefresh = true, preferWatchAuth = true) - } - - private fun loadQr(forceRefresh: Boolean, preferWatchAuth: Boolean) { + private fun loadQr() { qrJob?.cancel() qrJob = viewModelScope.launch { try { val result = paymentRepo.load( - forceRefresh = forceRefresh, - preferWatchAuth = preferWatchAuth, reAuthHandler = { client -> awaitReAuth(client) }, ) _state.update { 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 index 80a8c0c2..1fa8d8ec 100644 --- 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 @@ -6,6 +6,7 @@ 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 @@ -15,7 +16,7 @@ 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.layout.aspectRatio import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -27,6 +28,7 @@ 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 @@ -40,17 +42,15 @@ import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter -private val CacheFmt: DateTimeFormatter = DateTimeFormatter.ofPattern("MM-dd HH:mm") +private val CacheFmt: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm") @Composable fun QrScreen( loading: Boolean, - usingWatchAuth: Boolean, result: PaymentQrResult?, error: String?, onBack: () -> Unit, onRetry: () -> Unit, - onWatchAuth: () -> Unit, ) { when { loading -> { @@ -65,19 +65,10 @@ fun QrScreen( CircularProgressIndicator() Spacer(Modifier.height(12.dp)) Text( - text = if (usingWatchAuth) "正在由手表认证" else "正在向手机请求付款码", + text = "正在由手表认证", textAlign = TextAlign.Center, style = MaterialTheme.typography.body2, ) - if (!usingWatchAuth) { - Spacer(Modifier.height(10.dp)) - Button( - onClick = onWatchAuth, - colors = ButtonDefaults.secondaryButtonColors(), - ) { - Text("改用手表认证") - } - } } } error != null && result == null -> { @@ -108,26 +99,30 @@ fun QrScreen( } } result != null -> { - Column(Modifier.fillMaxSize()) { - Row( - modifier = Modifier - .fillMaxWidth() - .height(46.dp) - .padding(horizontal = 34.dp, vertical = 5.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Button( - onClick = onBack, - modifier = Modifier.size(36.dp), - colors = ButtonDefaults.secondaryButtonColors(), - ) { Text("←", fontSize = 17.sp) } - Button( - onClick = onRetry, - modifier = Modifier.size(36.dp), - colors = ButtonDefaults.secondaryButtonColors(), - ) { Text("↻", fontSize = 17.sp) } - } + 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) @@ -140,7 +135,8 @@ fun QrScreen( Box( modifier = Modifier .align(Alignment.Center) - .padding(horizontal = 44.dp, vertical = 2.dp) + .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), @@ -159,7 +155,7 @@ fun QrScreen( val time = Instant.ofEpochMilli(result.fetchedAtEpochMs) .atZone(ZoneId.systemDefault()) .toLocalDateTime() - "缓存 ${CacheFmt.format(time)},可能失效" + "缓存 ${CacheFmt.format(time)} · 可能失效" } Text( text = label, @@ -167,7 +163,7 @@ fun QrScreen( textAlign = TextAlign.Center, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 34.dp, vertical = 8.dp) + .padding(horizontal = 48.dp, vertical = 6.dp) .clip(RoundedCornerShape(12.dp)) .background(Color(0xFF7A3E00)) .padding(horizontal = 8.dp, vertical = 6.dp), From 845b011ddd499b448c29da29039161f8ffb0281f Mon Sep 17 00:00:00 2001 From: brill594 Date: Wed, 5 Aug 2026 18:05:54 +0900 Subject: [PATCH 14/16] fix(wear): stabilize payment auth lifecycle --- .../traintime_pda/data/WearPreferences.kt | 11 ++ .../benderblog/traintime_pda/ids/IdsReAuth.kt | 13 ++ .../traintime_pda/ids/IdsSession.kt | 18 +++ .../traintime_pda/ids/SchoolCardSession.kt | 4 +- .../payment/PaymentQrRepository.kt | 40 ++++-- .../benderblog/traintime_pda/ui/WearApp.kt | 4 +- .../traintime_pda/ui/WearViewModel.kt | 120 +++++++++++++----- 7 files changed, 166 insertions(+), 44 deletions(-) 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 index 3634a268..f25266f3 100644 --- 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 @@ -5,6 +5,7 @@ package io.github.benderblog.traintime_pda.data import android.content.Context import android.content.SharedPreferences +import java.security.SecureRandom /** * Credential / semester preferences. @@ -71,6 +72,15 @@ class WearPreferences(context: Context) { 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 + } + private fun readString(key: String): String { val local = prefs.getString(key, null) if (!local.isNullOrEmpty()) return local @@ -94,5 +104,6 @@ class WearPreferences(context: Context) { 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" } } 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 index 2cb46ac9..7df2f2a0 100644 --- 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 @@ -28,6 +28,7 @@ class WearIDSReAuthClient( val challengeUri: URI, val username: String, val service: String, + private val browserFingerprint: String, ) { var recipientDescription: String? = null private set @@ -54,6 +55,18 @@ class WearIDSReAuthClient( 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") 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 index 542e9fea..fd85f736 100644 --- 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 @@ -22,6 +22,7 @@ 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) @@ -47,6 +48,7 @@ open class IdsSession( return first.location ?: throw LoginFailedException("登录重定向缺少 Location") } + registerBrowserFingerprint() val continueForm = Jsoup.parse(first.body).select("form#continue") if (continueForm.isNotEmpty()) { @@ -84,6 +86,7 @@ open class IdsSession( 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]") @@ -155,6 +158,21 @@ open class IdsSession( 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 -> 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 index efa7bf07..d31d2180 100644 --- 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 @@ -17,7 +17,8 @@ class SchoolCardSession( cookieJar: PersistentCookieJar, username: String, password: String, -) : IdsSession(cookieJar, username, password) { + browserFingerprint: String, +) : IdsSession(cookieJar, username, password, browserFingerprint) { /** * Optional SMS re-auth handler. Returns the post-reauth location URI. * When null and re-auth is required, [WearIDSReAuthExpiredException] is thrown. @@ -57,6 +58,7 @@ class SchoolCardSession( challengeUri = redirectUri, username = username, service = idsService, + browserFingerprint = browserFingerprint, ) location = handler(reAuthClient).toString() } 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 index cd9f526c..b1f60919 100644 --- 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 @@ -9,9 +9,13 @@ 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, @@ -42,15 +46,27 @@ class PaymentQrRepository( private val cache: WearCacheStore, private val companionClient: WearCompanionClient, ) { + private val activeSession = AtomicReference(null) + + fun cancelActiveRequests() { + activeSession.getAndSet(null)?.cancelRequests() + } + suspend fun load( reAuthHandler: (suspend (WearIDSReAuthClient) -> URI)? = null, ): PaymentQrResult { 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 @@ -83,15 +99,21 @@ class PaymentQrRepository( cookieJar = PersistentCookieJar(cache.cookieDir), username = account, password = password, + browserFingerprint = preferences.getOrCreateIdsBrowserFingerprint(), ) - session.authenticateWithStoredCredentials(reAuthHandler = reAuthHandler) - val bytes = session.getQRCode() - val fetchedAt = System.currentTimeMillis() - cache.writePaymentQr(bytes, fetchedAt) - PaymentQrResult( - bytes = bytes, - fromCache = false, - fetchedAtEpochMs = 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/ui/WearApp.kt b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ui/WearApp.kt index 6ba9848f..d0ee5e3d 100644 --- 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 @@ -47,7 +47,7 @@ fun WearApp(viewModel: WearViewModel) { viewModel.closeQr() } BackHandler(enabled = state.screen == WearScreen.REAUTH) { - viewModel.cancelReAuth() + viewModel.closeQr() } MaterialTheme(colors = WearColorPalette) { @@ -87,7 +87,7 @@ fun WearApp(viewModel: WearViewModel) { onTrustChange = viewModel::updateReAuthTrustDevice, onSend = viewModel::sendReAuthSms, onSubmit = viewModel::submitReAuth, - onCancel = viewModel::cancelReAuth, + 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 index 22a2e8ed..7b667e54 100644 --- 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 @@ -10,25 +10,26 @@ 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.WearIDSReAuthCancelledException 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 -import kotlin.coroutines.resumeWithException enum class WearScreen { PAIRING, @@ -76,8 +77,12 @@ class WearViewModel( 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 { @@ -204,6 +209,7 @@ class WearViewModel( } fun openQr() { + val flowId = startNewQrFlow() _state.update { it.copy( screen = WearScreen.QR, @@ -212,12 +218,12 @@ class WearViewModel( qrError = null, ) } - loadQr() + loadQr(flowId) } fun closeQr() { - qrJob?.cancel() - cancelReAuth() + qrFlowGeneration++ + cancelActiveQrWork() _state.update { it.copy( screen = WearScreen.HOME, @@ -229,6 +235,7 @@ class WearViewModel( } fun retryQr() { + val flowId = startNewQrFlow() _state.update { it.copy( qrLoading = true, @@ -236,16 +243,23 @@ class WearViewModel( qrError = null, ) } - loadQr() + loadQr(flowId) } - private fun loadQr() { - qrJob?.cancel() + private fun loadQr(flowId: Long) { qrJob = viewModelScope.launch { try { val result = paymentRepo.load( - reAuthHandler = { client -> awaitReAuth(client) }, + reAuthHandler = { client -> + currentCoroutineContext().ensureActive() + if (!isQrFlowActive(flowId)) { + throw CancellationException("付款码流程已失效") + } + awaitReAuth(client, flowId) + }, ) + ensureActive() + if (!isQrFlowActive(flowId)) return@launch _state.update { it.copy( qrLoading = false, @@ -253,7 +267,10 @@ class WearViewModel( qrError = null, ) } + } catch (cancelled: CancellationException) { + throw cancelled } catch (e: Exception) { + if (!isQrFlowActive(flowId)) return@launch _state.update { it.copy( qrLoading = false, @@ -265,10 +282,17 @@ class WearViewModel( } } - private suspend fun awaitReAuth(client: WearIDSReAuthClient): URI = - kotlinx.coroutines.suspendCancellableCoroutine { cont -> - reAuthContinuation?.resumeWithException(WearIDSReAuthCancelledException()) + 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, @@ -287,32 +311,44 @@ class WearViewModel( 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 - viewModelScope.launch { + 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) + 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 { - _state.update { it.copy(reAuthSending = false) } + if (isQrFlowActive(flowId) && _state.value.screen == WearScreen.REAUTH) { + _state.update { it.copy(reAuthSending = false) } + } } } } - private fun startCountdown(seconds: Int) { + 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 @@ -320,6 +356,7 @@ class WearViewModel( var remaining = seconds while (remaining > 0) { kotlinx.coroutines.delay(1_000L) + if (!isQrFlowActive(flowId)) return@launch remaining-- _state.update { it.copy(reAuthSecondsRemaining = remaining) } } @@ -335,6 +372,8 @@ class WearViewModel( } 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()) { @@ -342,14 +381,18 @@ class WearViewModel( return } if (_state.value.reAuthSubmitting) return - viewModelScope.launch { + 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, @@ -358,7 +401,10 @@ class WearViewModel( ) } cont?.resume(uri) + } catch (cancelled: CancellationException) { + throw cancelled } catch (e: Exception) { + if (!isQrFlowActive(flowId)) return@launch _state.update { it.copy( reAuthSubmitting = false, @@ -370,26 +416,36 @@ class WearViewModel( } } - fun cancelReAuth() { + 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 - countdownJob?.cancel() - cont?.resumeWithException(WearIDSReAuthCancelledException()) - _state.update { - it.copy( - screen = if (it.qrLoading || it.qrResult != null || it.qrError != null) { - WearScreen.QR - } else { - WearScreen.HOME - }, - reAuthClient = null, - ) - } + activeReAuthGeneration = null + cont?.cancel(cancellation) } override fun onCleared() { - qrJob?.cancel() - cancelReAuth() + qrFlowGeneration++ + cancelActiveQrWork() container.companionClient.stopListening() super.onCleared() } From a89a45607e716c17dd3daa9cb0a52e33a0b74e9a Mon Sep 17 00:00:00 2001 From: brill594 Date: Wed, 5 Aug 2026 18:37:09 +0900 Subject: [PATCH 15/16] perf(wear): open payment qr from cache --- wearos/WEAR_SYNC_INTEGRATION.md | 11 ++++--- .../traintime_pda/data/WearPreferences.kt | 28 ++++++++++++++++ .../traintime_pda/data/WearSyncImporter.kt | 2 ++ .../traintime_pda/ids/SchoolCardSession.kt | 32 +++++++++++++++++-- .../payment/PaymentQrRepository.kt | 15 +++++++++ .../traintime_pda/ui/WearViewModel.kt | 7 ++-- 6 files changed, 85 insertions(+), 10 deletions(-) diff --git a/wearos/WEAR_SYNC_INTEGRATION.md b/wearos/WEAR_SYNC_INTEGRATION.md index 5abe6002..7ac70470 100644 --- a/wearos/WEAR_SYNC_INTEGRATION.md +++ b/wearos/WEAR_SYNC_INTEGRATION.md @@ -36,10 +36,13 @@ 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, the watch first uses the synchronized account/password and -its own persistent cookie store. Automatic slider verification and an on-watch -SMS MFA page are supported. The phone proxy and the last cached QR are fallback -paths. Pulling down on a displayed QR requests a fresh code from the watch. +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 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 index f25266f3..f751cb50 100644 --- 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 @@ -49,6 +49,16 @@ class WearPreferences(context: Context) { 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") @@ -59,6 +69,8 @@ class WearPreferences(context: Context) { .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") @@ -81,6 +93,20 @@ class WearPreferences(context: Context) { 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 @@ -105,5 +131,7 @@ class WearPreferences(context: Context) { 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 index 296492a9..c6ef671f 100644 --- 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 @@ -85,6 +85,7 @@ class WearSyncImporter( preferences.clearCredentials() cache.clearIdsCookies() SchoolCardSession.resetOpenId() + preferences.clearSchoolCardOpenId() cache.clearCampusCaches() cache.clearPaymentQr() IdsLoginState.state = IdsLoginState.State.MANUAL @@ -93,6 +94,7 @@ class WearSyncImporter( private fun clearUserScopedState(clearPaymentQr: Boolean) { cache.clearIdsCookies() SchoolCardSession.resetOpenId() + preferences.clearSchoolCardOpenId() cache.clearCampusCaches() if (clearPaymentQr) cache.clearPaymentQr() IdsLoginState.state = IdsLoginState.State.NONE 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 index d31d2180..3aabd711 100644 --- 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 @@ -4,9 +4,11 @@ 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 @@ -18,7 +20,13 @@ class SchoolCardSession( 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. @@ -37,11 +45,14 @@ class SchoolCardSession( ensureOpenId(forceRefresh = true) IdsLoginState.state = IdsLoginState.State.SUCCESS return + } catch (cancelled: CancellationException) { + throw cancelled + } catch (network: IOException) { + throw network } catch (_: Exception) { - resetOpenId() + clearOpenId() } } - clearCookieJar() val idsService = discoverIdsService() var location = checkAndLogin( target = idsService, @@ -128,7 +139,7 @@ class SchoolCardSession( private fun ensureOpenId(forceRefresh: Boolean = false) { if (!forceRefresh && isOpenIdValid) return - resetOpenId() + clearOpenId() var response = executeGetFollowResult(OPEN_OAUTH_URL) // follow already done; capture from final HTML captureOpenId(response) @@ -144,6 +155,12 @@ class SchoolCardSession( } 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 { @@ -228,5 +245,14 @@ class SchoolCardSession( 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/payment/PaymentQrRepository.kt b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/payment/PaymentQrRepository.kt index b1f60919..376152e3 100644 --- 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 @@ -53,8 +53,14 @@ class PaymentQrRepository( } 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) { @@ -100,6 +106,15 @@ class PaymentQrRepository( 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 { 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 index 7b667e54..b2e559c6 100644 --- 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 @@ -218,7 +218,7 @@ class WearViewModel( qrError = null, ) } - loadQr(flowId) + loadQr(flowId, forceRefresh = false) } fun closeQr() { @@ -243,13 +243,14 @@ class WearViewModel( qrError = null, ) } - loadQr(flowId) + loadQr(flowId, forceRefresh = true) } - private fun loadQr(flowId: Long) { + private fun loadQr(flowId: Long, forceRefresh: Boolean) { qrJob = viewModelScope.launch { try { val result = paymentRepo.load( + forceRefresh = forceRefresh, reAuthHandler = { client -> currentCoroutineContext().ensureActive() if (!isQrFlowActive(flowId)) { From 742f013bfed58ee1b3a70e7c64d30f3b787d6d42 Mon Sep 17 00:00:00 2001 From: brill594 Date: Wed, 5 Aug 2026 21:32:42 +0900 Subject: [PATCH 16/16] fix(wear): limit payment flow battery usage --- .../benderblog/traintime_pda/MainActivity.kt | 20 ++++++++++++------ .../traintime_pda/ui/WearViewModel.kt | 21 +++++++++++++++++++ 2 files changed, 35 insertions(+), 6 deletions(-) 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 index d8d2191b..0a4cc7f4 100644 --- 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 @@ -9,6 +9,7 @@ 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 @@ -17,6 +18,7 @@ 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) } @@ -46,21 +48,27 @@ class MainActivity : ComponentActivity() { } } - DisposableEffect(state.screen, state.qrResult) { - // Only the displayed payment code needs a continuously lit screen. - // Network/authentication waits must not hold a wake lock. + 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) } - onDispose { - 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/ui/WearViewModel.kt b/wearos/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/ui/WearViewModel.kt index b2e559c6..c32bc53c 100644 --- 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 @@ -112,6 +112,27 @@ class WearViewModel( 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() {