diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2fd6c848..7bdc8fed 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -28,11 +28,11 @@ jobs: - uses: papeloto/action-zip@v1 with: files: build/windows/runner/Release/ - dest: ecnu_timetable_windows.zip + dest: windows-release.zip - uses: actions/upload-artifact@v2 with: - name: ecnu_timetable_windows - path: ecnu_timetable_windows.zip + name: ecnu_timetable-windows + path: windows-release.zip build-android: runs-on: ubuntu-latest @@ -45,7 +45,7 @@ jobs: - uses: actions/upload-artifact@v2 with: - name: ecnu_timetable_android + name: ecnu_timetable-android path: build/app/outputs/apk/release/*.apk release: @@ -59,4 +59,4 @@ jobs: prerelease: false files: | **/*.apk - **/ecnu_timetable_windows.zip + **/windows-release.zip diff --git a/.run/all tests.run.xml b/.run/all tests.run.xml new file mode 100644 index 00000000..2d328fac --- /dev/null +++ b/.run/all tests.run.xml @@ -0,0 +1,8 @@ + + + + diff --git a/lib/home/home_view.dart b/lib/home/home_view.dart index 29dc7124..4fbf3875 100644 --- a/lib/home/home_view.dart +++ b/lib/home/home_view.dart @@ -7,7 +7,7 @@ import '../settings/settings_view.dart'; import '../timetable/timetable_logic.dart'; import '../timetable/timetable_view.dart'; import '../toolbox/toolbox_view.dart'; -import '../utils/messages.dart'; +import '../utils/string.dart'; import 'home_logic.dart'; class HomePage extends StatelessWidget { diff --git a/lib/main.dart b/lib/main.dart index 9f257403..59166aa6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,7 +8,7 @@ import 'package:window_size/window_size.dart'; import 'home/home_view.dart'; import 'settings/theme.dart'; import 'utils/log.dart'; -import 'utils/messages.dart'; +import 'utils/string.dart'; void main() async { await Settings.init(); @@ -48,8 +48,10 @@ void initDesktop() { } Future initSentry(Widget app) async { - final id = Settings.getValue('ecnu.id', null); - final username = Settings.getValue('ecnu.username', null); + final id_ = Settings.getValue('ecnu.id', ''); + final id = id_.isEmpty ? null : id_; + final username_ = Settings.getValue('ecnu.name', ''); + final username = username_.isEmpty ? null : username_; logInfo('id: $id, username: $username'); logInfo('release: $release'); diff --git a/lib/settings/settings_logic.dart b/lib/settings/settings_logic.dart index 29b29ed8..721c1065 100644 --- a/lib/settings/settings_logic.dart +++ b/lib/settings/settings_logic.dart @@ -1,12 +1,17 @@ +import 'package:dio/dio.dart'; import 'package:get/get.dart'; -import 'package:url_launcher/url_launcher.dart'; +import 'package:quiver/iterables.dart'; -import '../utils/dio.dart'; import '../utils/log.dart'; -import '../utils/messages.dart'; +import '../utils/string.dart'; +import '../utils/web.dart'; import 'theme.dart' as theme; class SettingsLogic extends GetxController with L { + final Dio dio; + + SettingsLogic({Dio? dio}) : dio = dio ?? defaultDio; + void updateTheme(_) => theme.updateTheme(); /// - null: loading @@ -27,14 +32,25 @@ class SettingsLogic extends GetxController with L { } } - bool get updateAvailable => - latestVer.value != null && latestVer.value != version; + bool get updateAvailable { + final latest = latestVer.value; + + if (latest == null || latest.isEmpty) return false; + + final l = latest.split('.').map(int.parse); + final v = version.split('.').map(int.parse); + for (final pair in zip([l, v])) { + if (pair[0] > pair[1]) return true; + } + + return false; + } /// Get latest release version from GitHub. Future _getLatestVer() async { try { final r = await dio.get( - 'https://api.github.com/repos/ccxxxi/ecnu_timetable/releases', + Api.releases, queryParameters: {'per_page': 1}, ); return (r.data[0]['name'] as String).substring(1); @@ -44,14 +60,9 @@ class SettingsLogic extends GetxController with L { } } - static const _repoUrl = 'https://github.com/CCXXXI/ecnu_timetable'; - static const latestUrl = '$_repoUrl/releases/latest'; - - static String _getVerUrl(String v) => '$_repoUrl/releases/tag/v$v'; - - void curVerOnTap() => launch(_getVerUrl(version)); + void curVerOnTap() => Url.version(version).launch(); - void latestVerOnTap() => launch(latestUrl); + void latestVerOnTap() => Url.latest.launch(); - void feedbackOnTap() => launch('$_repoUrl/issues'); + void feedbackOnTap() => Url.issues.launch(); } diff --git a/lib/settings/settings_view.dart b/lib/settings/settings_view.dart index 8792fd91..04f35efc 100644 --- a/lib/settings/settings_view.dart +++ b/lib/settings/settings_view.dart @@ -5,7 +5,7 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:get/get.dart'; import '../utils/loading.dart'; -import '../utils/messages.dart'; +import '../utils/string.dart'; import 'settings_logic.dart'; import 'theme.dart'; import 'trivia.dart'; diff --git a/lib/settings/theme.dart b/lib/settings/theme.dart index 892ac323..46bcd6cb 100644 --- a/lib/settings/theme.dart +++ b/lib/settings/theme.dart @@ -4,7 +4,7 @@ import 'package:get/get.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:loggy/loggy.dart'; -import '../utils/messages.dart'; +import '../utils/string.dart'; // region colorScheme const _ecnuColorStr = '#ffa41f35'; diff --git a/lib/settings/trivia.dart b/lib/settings/trivia.dart index 080b2751..20e43f10 100644 --- a/lib/settings/trivia.dart +++ b/lib/settings/trivia.dart @@ -1,10 +1,9 @@ import 'dart:math'; import 'package:dart_random_choice/dart_random_choice.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; -import '../utils/messages.dart'; +import '../utils/string.dart'; final _triviaStr = [ 'GitHub的仓库名通常使用分隔符`ecnu-timetable`,但为了让Dart开心,本仓库使用了下划线`ecnu_timetable`。'.s, @@ -40,4 +39,4 @@ final _triviaStr = [ final trivia = _triviaStr.map((e) => MarkdownBody(data: e)).toList(growable: false); -Widget get randomTrivia => randomChoice(trivia); +MarkdownBody get randomTrivia => randomChoice(trivia); diff --git a/lib/timetable/ecnu/des.dart b/lib/timetable/ecnu/des.dart new file mode 100644 index 00000000..47012482 --- /dev/null +++ b/lib/timetable/ecnu/des.dart @@ -0,0 +1,689 @@ +// 这是我近几年写过最烂的代码 +// 反正能跑,别往下看 :) + +import 'dart:math'; + +String strEnc(String data) { + var len = data.length; + var encData = StringBuffer(); + var firstKeyBt = [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ]; + var secondKeyBt = [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ]; + var thirdKeyBt = [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 0, + 0, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ]; + var iterator = (len / 4); + for (var i = 0; i < iterator; i++) { + var tempData = data.substring(i * 4, min(i * 4 + 4, len)); + var tempByte = _strToBt(tempData); + var tempBt = tempByte; + tempBt = _enc(tempBt, firstKeyBt); + tempBt = _enc(tempBt, secondKeyBt); + tempBt = _enc(tempBt, thirdKeyBt); + var encByte = tempBt; + encData.write(_bt64ToHex(encByte)); + } + return encData.toString(); +} + +List _strToBt(String str) { + var len = str.length; + var bt = List.filled(64, 0); + if (len < 4) { + var i = 0, j = 0; + for (i = 0; i < len; i++) { + var k = str.codeUnitAt(i); + for (j = 0; j < 16; j++) { + var pow = 1; + for (var m = 15; m > j; m--) { + pow *= 2; + } + bt[16 * i + j] = (k ~/ pow) % 2; + } + } + for (var p = len; p < 4; p++) { + var k = 0; + for (var q = 0; q < 16; q++) { + var pow = 1; + for (var m = 15; m > q; m--) { + pow *= 2; + } + bt[16 * p + q] = (k ~/ pow) % 2; + } + } + } else { + for (var i = 0; i < 4; i++) { + var k = str.codeUnitAt(i); + for (var j = 0; j < 16; j++) { + var pow = 1; + for (var m = 15; m > j; m--) { + pow *= 2; + } + bt[16 * i + j] = (k ~/ pow) % 2; + } + } + } + return bt; +} + +String _bt4ToHex(String binary) => + int.parse(binary, radix: 2).toRadixString(16).toUpperCase(); + +String _bt64ToHex(List byteData) { + var hex = StringBuffer(); + for (var i = 0; i < 16; i++) { + var bt = StringBuffer(); + for (var j = 0; j < 4; j++) { + bt.write(byteData[i * 4 + j].toString()); + } + hex.write(_bt4ToHex(bt.toString())); + } + return hex.toString(); +} + +List _enc(List dataByte, List keyByte) { + var keys = _generateKeys(keyByte); + var ipByte = _initPermute(dataByte); + var ipLeft = List.filled(32, 0); + var ipRight = List.filled(32, 0); + var tempLeft = List.filled(32, 0); + for (var k = 0; k < 32; k++) { + ipLeft[k] = ipByte[k]; + ipRight[k] = ipByte[32 + k]; + } + for (var i = 0; i < 16; i++) { + for (var j = 0; j < 32; j++) { + tempLeft[j] = ipLeft[j]; + ipLeft[j] = ipRight[j]; + } + var key = List.filled(48, 0); + for (var m = 0; m < 48; m++) { + key[m] = keys[i][m]; + } + var tempRight = _xor( + _pPermute(_sBoxPermute(_xor(_expandPermute(ipRight), key))), tempLeft); + for (var n = 0; n < 32; n++) { + ipRight[n] = tempRight[n]; + } + } + + var finalData = List.filled(64, 0); + for (var i = 0; i < 32; i++) { + finalData[i] = ipRight[i]; + finalData[32 + i] = ipLeft[i]; + } + return _finallyPermute(finalData); +} + +List _initPermute(List originalData) { + var ipByte = List.filled(64, 0); + for (var i = 0, m = 1, n = 0; i < 4; i++, m += 2, n += 2) { + for (var j = 7, k = 0; j >= 0; j--, k++) { + ipByte[i * 8 + k] = originalData[j * 8 + m]; + ipByte[i * 8 + k + 32] = originalData[j * 8 + n]; + } + } + return ipByte; +} + +List _expandPermute(List rightData) { + var epByte = List.filled(48, 0); + for (var i = 0; i < 8; i++) { + if (i == 0) { + epByte[i * 6] = rightData[31]; + } else { + epByte[i * 6] = rightData[i * 4 - 1]; + } + epByte[i * 6 + 1] = rightData[i * 4]; + epByte[i * 6 + 2] = rightData[i * 4 + 1]; + epByte[i * 6 + 3] = rightData[i * 4 + 2]; + epByte[i * 6 + 4] = rightData[i * 4 + 3]; + if (i == 7) { + epByte[i * 6 + 5] = rightData[0]; + } else { + epByte[i * 6 + 5] = rightData[i * 4 + 4]; + } + } + return epByte; +} + +List _xor(List byteOne, List byteTwo) { + var xorByte = List.filled(byteOne.length, 0); + for (var i = 0; i < byteOne.length; i++) { + xorByte[i] = byteOne[i] ^ byteTwo[i]; + } + return xorByte; +} + +List _sBoxPermute(List expandByte) { + var sBoxByte = List.filled(32, 0); + var binary = ''; + var s1 = [ + [14, 4, 13, 1, 2, 15, 11, 8, 3, 10, 6, 12, 5, 9, 0, 7], + [0, 15, 7, 4, 14, 2, 13, 1, 10, 6, 12, 11, 9, 5, 3, 8], + [4, 1, 14, 8, 13, 6, 2, 11, 15, 12, 9, 7, 3, 10, 5, 0], + [15, 12, 8, 2, 4, 9, 1, 7, 5, 11, 3, 14, 10, 0, 6, 13] + ]; + + var s2 = [ + [15, 1, 8, 14, 6, 11, 3, 4, 9, 7, 2, 13, 12, 0, 5, 10], + [3, 13, 4, 7, 15, 2, 8, 14, 12, 0, 1, 10, 6, 9, 11, 5], + [0, 14, 7, 11, 10, 4, 13, 1, 5, 8, 12, 6, 9, 3, 2, 15], + [13, 8, 10, 1, 3, 15, 4, 2, 11, 6, 7, 12, 0, 5, 14, 9] + ]; + + var s3 = [ + [10, 0, 9, 14, 6, 3, 15, 5, 1, 13, 12, 7, 11, 4, 2, 8], + [13, 7, 0, 9, 3, 4, 6, 10, 2, 8, 5, 14, 12, 11, 15, 1], + [13, 6, 4, 9, 8, 15, 3, 0, 11, 1, 2, 12, 5, 10, 14, 7], + [1, 10, 13, 0, 6, 9, 8, 7, 4, 15, 14, 3, 11, 5, 2, 12] + ]; + + var s4 = [ + [7, 13, 14, 3, 0, 6, 9, 10, 1, 2, 8, 5, 11, 12, 4, 15], + [13, 8, 11, 5, 6, 15, 0, 3, 4, 7, 2, 12, 1, 10, 14, 9], + [10, 6, 9, 0, 12, 11, 7, 13, 15, 1, 3, 14, 5, 2, 8, 4], + [3, 15, 0, 6, 10, 1, 13, 8, 9, 4, 5, 11, 12, 7, 2, 14] + ]; + + var s5 = [ + [2, 12, 4, 1, 7, 10, 11, 6, 8, 5, 3, 15, 13, 0, 14, 9], + [14, 11, 2, 12, 4, 7, 13, 1, 5, 0, 15, 10, 3, 9, 8, 6], + [4, 2, 1, 11, 10, 13, 7, 8, 15, 9, 12, 5, 6, 3, 0, 14], + [11, 8, 12, 7, 1, 14, 2, 13, 6, 15, 0, 9, 10, 4, 5, 3] + ]; + + var s6 = [ + [12, 1, 10, 15, 9, 2, 6, 8, 0, 13, 3, 4, 14, 7, 5, 11], + [10, 15, 4, 2, 7, 12, 9, 5, 6, 1, 13, 14, 0, 11, 3, 8], + [9, 14, 15, 5, 2, 8, 12, 3, 7, 0, 4, 10, 1, 13, 11, 6], + [4, 3, 2, 12, 9, 5, 15, 10, 11, 14, 1, 7, 6, 0, 8, 13] + ]; + + var s7 = [ + [4, 11, 2, 14, 15, 0, 8, 13, 3, 12, 9, 7, 5, 10, 6, 1], + [13, 0, 11, 7, 4, 9, 1, 10, 14, 3, 5, 12, 2, 15, 8, 6], + [1, 4, 11, 13, 12, 3, 7, 14, 10, 15, 6, 8, 0, 5, 9, 2], + [6, 11, 13, 8, 1, 4, 10, 7, 9, 5, 0, 15, 14, 2, 3, 12] + ]; + + var s8 = [ + [13, 2, 8, 4, 6, 15, 11, 1, 10, 9, 3, 14, 5, 0, 12, 7], + [1, 15, 13, 8, 10, 3, 7, 4, 12, 5, 6, 11, 0, 14, 9, 2], + [7, 11, 4, 1, 9, 12, 14, 2, 0, 6, 10, 13, 15, 3, 5, 8], + [2, 1, 14, 7, 4, 10, 8, 13, 15, 12, 9, 0, 3, 5, 6, 11] + ]; + + for (var m = 0; m < 8; m++) { + var i = 0, j = 0; + i = expandByte[m * 6] * 2 + expandByte[m * 6 + 5]; + j = expandByte[m * 6 + 1] * 2 * 2 * 2 + + expandByte[m * 6 + 2] * 2 * 2 + + expandByte[m * 6 + 3] * 2 + + expandByte[m * 6 + 4]; + switch (m) { + case 0: + binary = _getBoxBinary(s1[i][j]); + break; + case 1: + binary = _getBoxBinary(s2[i][j]); + break; + case 2: + binary = _getBoxBinary(s3[i][j]); + break; + case 3: + binary = _getBoxBinary(s4[i][j]); + break; + case 4: + binary = _getBoxBinary(s5[i][j]); + break; + case 5: + binary = _getBoxBinary(s6[i][j]); + break; + case 6: + binary = _getBoxBinary(s7[i][j]); + break; + case 7: + binary = _getBoxBinary(s8[i][j]); + break; + } + sBoxByte[m * 4] = int.parse(binary.substring(0, 1)); + sBoxByte[m * 4 + 1] = int.parse(binary.substring(1, 2)); + sBoxByte[m * 4 + 2] = int.parse(binary.substring(2, 3)); + sBoxByte[m * 4 + 3] = int.parse(binary.substring(3, 4)); + } + return sBoxByte; +} + +List _pPermute(List sBoxByte) { + var pBoxPermute = List.filled(32, 0); + pBoxPermute[0] = sBoxByte[15]; + pBoxPermute[1] = sBoxByte[6]; + pBoxPermute[2] = sBoxByte[19]; + pBoxPermute[3] = sBoxByte[20]; + pBoxPermute[4] = sBoxByte[28]; + pBoxPermute[5] = sBoxByte[11]; + pBoxPermute[6] = sBoxByte[27]; + pBoxPermute[7] = sBoxByte[16]; + pBoxPermute[8] = sBoxByte[0]; + pBoxPermute[9] = sBoxByte[14]; + pBoxPermute[10] = sBoxByte[22]; + pBoxPermute[11] = sBoxByte[25]; + pBoxPermute[12] = sBoxByte[4]; + pBoxPermute[13] = sBoxByte[17]; + pBoxPermute[14] = sBoxByte[30]; + pBoxPermute[15] = sBoxByte[9]; + pBoxPermute[16] = sBoxByte[1]; + pBoxPermute[17] = sBoxByte[7]; + pBoxPermute[18] = sBoxByte[23]; + pBoxPermute[19] = sBoxByte[13]; + pBoxPermute[20] = sBoxByte[31]; + pBoxPermute[21] = sBoxByte[26]; + pBoxPermute[22] = sBoxByte[2]; + pBoxPermute[23] = sBoxByte[8]; + pBoxPermute[24] = sBoxByte[18]; + pBoxPermute[25] = sBoxByte[12]; + pBoxPermute[26] = sBoxByte[29]; + pBoxPermute[27] = sBoxByte[5]; + pBoxPermute[28] = sBoxByte[21]; + pBoxPermute[29] = sBoxByte[10]; + pBoxPermute[30] = sBoxByte[3]; + pBoxPermute[31] = sBoxByte[24]; + return pBoxPermute; +} + +List _finallyPermute(List endByte) { + var fpByte = List.filled(64, 0); + fpByte[0] = endByte[39]; + fpByte[1] = endByte[7]; + fpByte[2] = endByte[47]; + fpByte[3] = endByte[15]; + fpByte[4] = endByte[55]; + fpByte[5] = endByte[23]; + fpByte[6] = endByte[63]; + fpByte[7] = endByte[31]; + fpByte[8] = endByte[38]; + fpByte[9] = endByte[6]; + fpByte[10] = endByte[46]; + fpByte[11] = endByte[14]; + fpByte[12] = endByte[54]; + fpByte[13] = endByte[22]; + fpByte[14] = endByte[62]; + fpByte[15] = endByte[30]; + fpByte[16] = endByte[37]; + fpByte[17] = endByte[5]; + fpByte[18] = endByte[45]; + fpByte[19] = endByte[13]; + fpByte[20] = endByte[53]; + fpByte[21] = endByte[21]; + fpByte[22] = endByte[61]; + fpByte[23] = endByte[29]; + fpByte[24] = endByte[36]; + fpByte[25] = endByte[4]; + fpByte[26] = endByte[44]; + fpByte[27] = endByte[12]; + fpByte[28] = endByte[52]; + fpByte[29] = endByte[20]; + fpByte[30] = endByte[60]; + fpByte[31] = endByte[28]; + fpByte[32] = endByte[35]; + fpByte[33] = endByte[3]; + fpByte[34] = endByte[43]; + fpByte[35] = endByte[11]; + fpByte[36] = endByte[51]; + fpByte[37] = endByte[19]; + fpByte[38] = endByte[59]; + fpByte[39] = endByte[27]; + fpByte[40] = endByte[34]; + fpByte[41] = endByte[2]; + fpByte[42] = endByte[42]; + fpByte[43] = endByte[10]; + fpByte[44] = endByte[50]; + fpByte[45] = endByte[18]; + fpByte[46] = endByte[58]; + fpByte[47] = endByte[26]; + fpByte[48] = endByte[33]; + fpByte[49] = endByte[1]; + fpByte[50] = endByte[41]; + fpByte[51] = endByte[9]; + fpByte[52] = endByte[49]; + fpByte[53] = endByte[17]; + fpByte[54] = endByte[57]; + fpByte[55] = endByte[25]; + fpByte[56] = endByte[32]; + fpByte[57] = endByte[0]; + fpByte[58] = endByte[40]; + fpByte[59] = endByte[8]; + fpByte[60] = endByte[48]; + fpByte[61] = endByte[16]; + fpByte[62] = endByte[56]; + fpByte[63] = endByte[24]; + return fpByte; +} + +String _getBoxBinary(int i) { + var binary = ''; + switch (i) { + case 0: + binary = '0000'; + break; + case 1: + binary = '0001'; + break; + case 2: + binary = '0010'; + break; + case 3: + binary = '0011'; + break; + case 4: + binary = '0100'; + break; + case 5: + binary = '0101'; + break; + case 6: + binary = '0110'; + break; + case 7: + binary = '0111'; + break; + case 8: + binary = '1000'; + break; + case 9: + binary = '1001'; + break; + case 10: + binary = '1010'; + break; + case 11: + binary = '1011'; + break; + case 12: + binary = '1100'; + break; + case 13: + binary = '1101'; + break; + case 14: + binary = '1110'; + break; + case 15: + binary = '1111'; + break; + } + return binary; +} + +List> _generateKeys(List keyByte) { + var key = List.filled(56, 0); + var keys = List.generate(16, (_) => List.filled(48, 0)); + var loop = [1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1]; + + for (var i = 0; i < 7; i++) { + for (var j = 0, k = 7; j < 8; j++, k--) { + key[i * 8 + j] = keyByte[8 * k + i]; + } + } + + for (var i = 0; i < 16; i++) { + var tempLeft = 0; + var tempRight = 0; + for (var j = 0; j < loop[i]; j++) { + tempLeft = key[0]; + tempRight = key[28]; + for (var k = 0; k < 27; k++) { + key[k] = key[k + 1]; + key[28 + k] = key[29 + k]; + } + key[27] = tempLeft; + key[55] = tempRight; + } + var tempKey = List.filled(48, 0); + tempKey[0] = key[13]; + tempKey[1] = key[16]; + tempKey[2] = key[10]; + tempKey[3] = key[23]; + tempKey[4] = key[0]; + tempKey[5] = key[4]; + tempKey[6] = key[2]; + tempKey[7] = key[27]; + tempKey[8] = key[14]; + tempKey[9] = key[5]; + tempKey[10] = key[20]; + tempKey[11] = key[9]; + tempKey[12] = key[22]; + tempKey[13] = key[18]; + tempKey[14] = key[11]; + tempKey[15] = key[3]; + tempKey[16] = key[25]; + tempKey[17] = key[7]; + tempKey[18] = key[15]; + tempKey[19] = key[6]; + tempKey[20] = key[26]; + tempKey[21] = key[19]; + tempKey[22] = key[12]; + tempKey[23] = key[1]; + tempKey[24] = key[40]; + tempKey[25] = key[51]; + tempKey[26] = key[30]; + tempKey[27] = key[36]; + tempKey[28] = key[46]; + tempKey[29] = key[54]; + tempKey[30] = key[29]; + tempKey[31] = key[39]; + tempKey[32] = key[50]; + tempKey[33] = key[44]; + tempKey[34] = key[32]; + tempKey[35] = key[47]; + tempKey[36] = key[43]; + tempKey[37] = key[48]; + tempKey[38] = key[38]; + tempKey[39] = key[55]; + tempKey[40] = key[33]; + tempKey[41] = key[52]; + tempKey[42] = key[45]; + tempKey[43] = key[41]; + tempKey[44] = key[49]; + tempKey[45] = key[35]; + tempKey[46] = key[28]; + tempKey[47] = key[31]; + for (var m = 0; m < 48; m++) { + keys[i][m] = tempKey[m]; + } + } + return keys; +} diff --git a/lib/timetable/ecnu/ecnu_logic.dart b/lib/timetable/ecnu/ecnu_logic.dart index 412a428d..341ce0a6 100644 --- a/lib/timetable/ecnu/ecnu_logic.dart +++ b/lib/timetable/ecnu/ecnu_logic.dart @@ -1,3 +1,234 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_settings_screens/flutter_settings_screens.dart'; import 'package:get/get.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:universal_html/parsing.dart'; + +import '../../utils/log.dart'; +import '../../utils/web.dart'; +import 'des.dart'; + +enum S { login, check } + +class EcnuLogic extends GetxController with L { + final Dio dio; + + EcnuLogic({Dio? dio}) : dio = dio ?? defaultDio; + + final step = S.login.obs; + + final loginFormKey = GlobalKey(); + final idController = TextEditingController( + text: Settings.getValue('ecnu.id', ''), + ); + final passwordController = TextEditingController( + text: Settings.getValue('ecnu.password', ''), + ); + final captchaController = Rx(null); + + final checkFormKey = GlobalKey(); + + // todo: controllers + + @override + void onClose() { + super.onClose(); + idController.dispose(); + passwordController.dispose(); + captchaController.value?.dispose(); + } + + static String? idValidator(String? value) => + value?.length == 11 ? null : '11位'; + + static String? passwordValidator(String? value) => + value?.isEmpty ?? true ? '非空' : null; + + static String? captchaValidator(String? value) => + value?.length == 4 ? null : '4位'; + + final isLoading = true.obs; + + void onStepContinue() async { + l.debug('step: ${step.value}, isLoading: ${isLoading.value}'); + if (isLoading.isTrue || + step.value == S.login && !loginFormKey.currentState!.validate()) return; + + if (step.value == S.login) { + isLoading.value = true; + try { + final loginResult = await login(); + if (loginResult == null) { + step.value = S.check; + getTable(); + } else { + Get.back(); + Get.snackbar('登录失败', loginResult); + } + } catch (e) { + l.error(e); + Get.back(); + Get.snackbar('登录失败', e.toString()); + } + } else { + Get.back(); + } + + isLoading.value = false; + } + + @override + void onInit() { + super.onInit(); + initEcnu(); + } + + final captchaImage = Rx(Uint8List.fromList([])); + + void initEcnu() async { + try { + // set cookie + await dio.get(Url.portal); + l.debug(await cookieJar.loadForRequest(Uri.parse(Url.portal))); + + // get captcha image + final img = await dio.get( + Url.captcha, + options: Options(responseType: ResponseType.bytes), + ); + captchaImage.value = img.data; + + // get captcha value + final token = (await dio.get( + Api.baiduToken, + queryParameters: { + 'grant_type': 'client_credentials', + 'client_id': 'gIGpKT20OzkxymfIGH5L8pho', + 'client_secret': '9O1EO7CuixZqF0oBxYZbKmeCuqHoRlMk', + }, + )) + .data['access_token']; + final value = (await dio.post( + Api.baiduOcr, + queryParameters: { + 'access_token': token, + }, + options: Options( + contentType: Headers.formUrlEncodedContentType, + ), + data: { + 'image': base64Encode(img.data), + }, + )) + .data['words_result'][0]['words']; + captchaController.value = TextEditingController(text: value); + + isLoading.value = false; + } catch (e) { + l.error(e); + Get.back(); + Get.snackbar('连接公共数据库失败', e.toString()); + } + } + + /// Returns null if success. + Future login() async { + final id = idController.text; + final password = passwordController.text; + final captcha = captchaController.value!.text; + l.debug('id: $id, password: $password, captcha: $captcha'); + + var r = await dio.post( + Url.portal, + data: { + 'rsa': strEnc(id + password), + 'ul': id.length, + 'pl': password.length, + 'code': captcha, + 'lt': 'LT-211100-OG7kcGcBAxSpyGub3FC9LU6BtINhGg-cas', + 'execution': 'e1s1', + '_eventId': 'submit', + }, + options: Options( + contentType: Headers.formUrlEncodedContentType, + validateStatus: (status) => status != null && status < 400, + ), + ); + while ([301, 302].contains(r.statusCode)) { + // 按规范来说post请求不应该返回302进行重定向 + // 但是这个世界并不是很规范 + // 手动处理一下这串连环重定向 + // 不能直接让dio处理,dio(或者说底层的http)太规范了,在重定向过程中不会更新cookie + l.info('redirect'); + r = await dio.get( + r.headers['location']![0], + options: Options( + followRedirects: false, + validateStatus: (status) => status != null && status < 400, + ), + ); + } + + final document = parseHtmlDocument(r.data); + + final nameId = document.querySelector('a[title="查看登录记录"]')?.text; + if (nameId != null) { + final m = RegExp(r'(.*)\((.*)\)').firstMatch(nameId); + final name = m!.group(1); + final id_ = m.group(2); + assert(id_ == id); + + Settings.setValue('ecnu.id', id); + Settings.setValue('ecnu.name', name); + Settings.setValue('ecnu.password', password); + Sentry.configureScope((scope) => scope.user = + SentryUser(id: id, username: name, ipAddress: '{{auto}}')); + + return null; + } + + final error = document.querySelector('#errormsg')?.text; + if (error != null) return error; + + return '未知错误'; + } + + final table = ''.obs; + + void getTable() async { + final r0 = await dio.get(Url.ids); + final ids = RegExp(r'bg\.form\.addInput\(form,"ids","(\d+)"\);') + .firstMatch(r0.data)! + .group(1)!; + + final r1 = await dio.post( + Url.table, + data: { + // todo: let user determines these options + 'ignoreHead': 1, + 'setting.kind': 'std', + 'startWeek': 1, + 'semester.id': semId(2021, 0), + 'ids': ids, + }, + options: Options( + contentType: Headers.formUrlEncodedContentType, + ), + ); + final document = parseHtmlDocument(r1.data); + final js = document.querySelectorAll('script[language]').last.text; + parseJs(js!); + table.value = r1.data; + } + + /// 2018-2019学年度上学期为705,每向前/向后一个学期就增加/减少32 + static int semId(int year, int sem) => 705 + (year - 2018) * 96 + sem * 32; -class EcnuLogic extends GetxController {} + static void parseJs(String js) { + // todo + } +} diff --git a/lib/timetable/ecnu/ecnu_view.dart b/lib/timetable/ecnu/ecnu_view.dart index 572e13f0..1443b75b 100644 --- a/lib/timetable/ecnu/ecnu_view.dart +++ b/lib/timetable/ecnu/ecnu_view.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:get/get.dart'; +import '../../utils/loading.dart'; +import '../../utils/string.dart'; import 'ecnu_logic.dart'; class EcnuPage extends StatelessWidget { @@ -14,22 +17,99 @@ class EcnuPage extends StatelessWidget { appBar: AppBar( title: const Text('自公共数据库导入课表'), ), - body: Stepper( - steps: const [ - Step( - title: Text('登录公共数据库'), - content: Placeholder( - color: Colors.red, + body: Obx( + () => Stepper( + currentStep: logic.step.value.index, + onStepContinue: logic.onStepContinue, + controlsBuilder: controlsBuilder, + steps: [ + Step( + title: const Text('登录公共数据库'), + subtitle: Text('密码仅用于登录,可至GitHub检查源码。'.s), + content: Form( + key: logic.loginFormKey, + child: Column( + children: [ + TextFormField( + decoration: const InputDecoration( + label: Text('学号'), + ), + controller: logic.idController, + validator: EcnuLogic.idValidator, + maxLength: 11, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + keyboardType: TextInputType.number, + textInputAction: TextInputAction.next, + ), + TextFormField( + decoration: const InputDecoration( + label: Text('密码'), + ), + controller: logic.passwordController, + validator: EcnuLogic.passwordValidator, + maxLength: TextField.noMaxLength, + keyboardType: TextInputType.visiblePassword, + obscureText: true, + onEditingComplete: logic.onStepContinue, + ), + Row( + children: [ + Expanded( + child: Obx( + () => logic.captchaController.value == null + ? Loading() + : TextFormField( + decoration: const InputDecoration( + label: Text('验证码'), + ), + controller: logic.captchaController.value, + validator: EcnuLogic.captchaValidator, + maxLength: 4, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + keyboardType: TextInputType.number, + onEditingComplete: logic.onStepContinue, + ), + ), + ), + const SizedBox(width: 42), + Obx( + () => logic.captchaImage.value.isEmpty + ? Loading() + : Image.memory(logic.captchaImage.value), + ), + ], + ), + ], + ), + ), + state: logic.step.value == S.login + ? StepState.indexed + : StepState.disabled, ), - ), - Step( - title: Text('确认课表内容'), - content: Placeholder( - color: Colors.green, + Step( + title: const Text('确认课表内容'), + subtitle: Text('有误可至GitHub反馈。'.s), + content: Text(logic.table.value), + state: logic.step.value == S.check + ? StepState.indexed + : StepState.disabled, ), - ), - ], + ], + ), ), ); } + + Widget controlsBuilder(BuildContext context, ControlsDetails details) => Obx( + () => logic.isLoading.isTrue + ? Loading() + : ElevatedButton( + onPressed: details.onStepContinue, + child: Text(['登录', '完成'][details.stepIndex]), + ), + ); } diff --git a/lib/timetable/timetable_menu/timetable_menu_logic.dart b/lib/timetable/timetable_menu/timetable_menu_logic.dart index f0f7f96e..0c3601a9 100644 --- a/lib/timetable/timetable_menu/timetable_menu_logic.dart +++ b/lib/timetable/timetable_menu/timetable_menu_logic.dart @@ -1,10 +1,24 @@ import 'package:get/get.dart'; import '../../utils/gu.dart'; +import '../../utils/string.dart'; +import '../../utils/web.dart'; import '../ecnu/ecnu_view.dart'; class TimetableMenuLogic extends GetxController { - void ecnuOnTap() => Get.to(() => EcnuPage()); + void ecnuOnTap() { + if (GetPlatform.isWeb) { + Get.defaultDialog( + title: 'Web端功能受限'.s, + middleText: '因跨域资源共享(CORS)问题,无法连接公共数据库。', + textConfirm: '下载完整版', + textCancel: '返回', + onConfirm: Url.latest.launch, + ); + } else { + Get.to(() => EcnuPage()); + } + } void htmlOnTap() => gu(); } diff --git a/lib/timetable/timetable_menu/timetable_menu_view.dart b/lib/timetable/timetable_menu/timetable_menu_view.dart index 183a20a0..40f43a8a 100644 --- a/lib/timetable/timetable_menu/timetable_menu_view.dart +++ b/lib/timetable/timetable_menu/timetable_menu_view.dart @@ -4,7 +4,7 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:get/get.dart'; import '../../utils/gu.dart'; -import '../../utils/messages.dart'; +import '../../utils/string.dart'; import 'timetable_menu_logic.dart'; class TimetableMenuPage extends StatelessWidget { diff --git a/lib/toolbox/calendar/calendar_logic.dart b/lib/toolbox/calendar/calendar_logic.dart index 402d5d8c..46719995 100644 --- a/lib/toolbox/calendar/calendar_logic.dart +++ b/lib/toolbox/calendar/calendar_logic.dart @@ -1,11 +1,14 @@ +import 'package:dio/dio.dart'; import 'package:get/get.dart'; import 'package:universal_html/parsing.dart'; -import '../../utils/dio.dart'; import '../../utils/log.dart'; +import '../../utils/web.dart'; class CalendarLogic extends GetxController with L { - static const url = 'http://www.u-office.ecnu.edu.cn/xiaoli/main.htm'; + final Dio dio; + + CalendarLogic({Dio? dio}) : dio = dio ?? defaultDio; @override void onInit() { @@ -17,10 +20,10 @@ class CalendarLogic extends GetxController with L { void getCalendar() async { try { - final r = await dio.get(url); + final r = await dio.get(Url.calendar); final document = parseHtmlDocument(r.data); - final src = document.querySelector('img')!.attributes['src']; - imgUrl.value = 'http://www.u-office.ecnu.edu.cn$src'; + final src = document.querySelector('img')?.parent?.attributes['href']; + imgUrl.value = Url.uOffice + src!; } catch (e) { l.error(e); Get.snackbar('获取校历失败', e.toString()); diff --git a/lib/toolbox/cheater.dart b/lib/toolbox/cheater.dart index adefb67b..5b411998 100644 --- a/lib/toolbox/cheater.dart +++ b/lib/toolbox/cheater.dart @@ -1,6 +1,6 @@ import 'package:dart_random_choice/dart_random_choice.dart'; -import '../utils/messages.dart'; +import '../utils/string.dart'; final cheaters = [ '我觉得你俩在一起特别合适,真的!因为你都不知道你有多优秀呢。', diff --git a/lib/toolbox/juan/juan_logic.dart b/lib/toolbox/juan/juan_logic.dart index 2239242d..fef0650a 100644 --- a/lib/toolbox/juan/juan_logic.dart +++ b/lib/toolbox/juan/juan_logic.dart @@ -5,29 +5,29 @@ import 'package:get/get.dart'; class JuanLogic extends GetxController { final formKey = GlobalKey(); - final controllerA = TextEditingController(); - final controllerB = TextEditingController(); + final aController = TextEditingController(); + final bController = TextEditingController(); @override void onClose() { super.onClose(); - controllerA.dispose(); - controllerB.dispose(); + aController.dispose(); + bController.dispose(); } + String? aValidator(String? value) => + int.tryParse(value ?? '') == null ? 'invalid' : null; + + String? bValidator(String? value) => + [null, 0].contains(int.tryParse(value ?? '')) ? 'invalid' : null; + final r = 0.obs; void updateR() { if (!formKey.currentState!.validate()) return; - final a = int.parse(controllerA.text); - final b = int.parse(controllerB.text); + final a = int.parse(aController.text); + final b = int.parse(bController.text); r.value = a <= b ? 0 : (5 * (2 * a / b - 1) * log(a - b + 1)).ceil(); } - - String? validatorA(String? value) => - int.tryParse(value ?? '') == null ? 'invalid' : null; - - String? validatorB(String? value) => - [null, 0].contains(int.tryParse(value ?? '')) ? 'invalid' : null; } diff --git a/lib/toolbox/juan/juan_view.dart b/lib/toolbox/juan/juan_view.dart index fd00d738..95b82245 100644 --- a/lib/toolbox/juan/juan_view.dart +++ b/lib/toolbox/juan/juan_view.dart @@ -19,8 +19,8 @@ class JuanWidget extends StatelessWidget { Expanded( child: TextFormField( decoration: const InputDecoration(label: Text('已选')), - controller: logic.controllerA, - validator: logic.validatorA, + controller: logic.aController, + validator: logic.aValidator, inputFormatters: [FilteringTextInputFormatter.digitsOnly], keyboardType: TextInputType.number, textAlign: TextAlign.center, @@ -32,8 +32,8 @@ class JuanWidget extends StatelessWidget { Expanded( child: TextFormField( decoration: const InputDecoration(label: Text('上限')), - controller: logic.controllerB, - validator: logic.validatorB, + controller: logic.bController, + validator: logic.bValidator, inputFormatters: [FilteringTextInputFormatter.digitsOnly], keyboardType: TextInputType.number, textAlign: TextAlign.center, diff --git a/lib/toolbox/toolbox_logic.dart b/lib/toolbox/toolbox_logic.dart index eaf60650..e333fd76 100644 --- a/lib/toolbox/toolbox_logic.dart +++ b/lib/toolbox/toolbox_logic.dart @@ -1,20 +1,19 @@ -import 'package:dart_random_choice/dart_random_choice.dart'; +import 'package:dio/dio.dart'; import 'package:flutter/services.dart'; import 'package:flutter_settings_screens/flutter_settings_screens.dart'; import 'package:get/get.dart'; -import 'package:url_launcher/url_launcher.dart'; -import '../settings/settings_logic.dart'; -import '../utils/dio.dart'; import '../utils/log.dart'; -import '../utils/messages.dart'; -import 'calendar/calendar_logic.dart'; +import '../utils/string.dart'; +import '../utils/web.dart'; import 'calendar/calendar_view.dart'; import 'cheater.dart'; import 'juan/juan_view.dart'; class ToolboxLogic extends GetxController with L { - void Function() url(String url) => () => launch(url); + final Dio dio; + + ToolboxLogic({Dio? dio}) : dio = dio ?? defaultDio; @override void onInit() { @@ -38,16 +37,11 @@ class ToolboxLogic extends GetxController with L { sucker.value = await _getSucker() ?? '获取失败'; } - static const _suckerApis = [ - 'https://api.vience.cn/api/tiangou', - 'http://api.ay15.cn/api/tiangou/api.php', - ]; - Future _getSucker() async { if (GetPlatform.isWeb) return 'Web端不可用'.s; try { - final r = await dio.get(randomChoice(_suckerApis)); + final r = await dio.get(Api.randomSucker); return (r.data as String).s; } catch (e) { l.error(e); @@ -113,11 +107,11 @@ class ToolboxLogic extends GetxController with L { if (GetPlatform.isWeb) { Get.defaultDialog( title: 'Web端功能受限'.s, - middleText: '因跨域资源共享(CORS)问题,无法获取并显示校历图片。'.s, + middleText: '因跨域资源共享(CORS)问题,无法获取并显示校历图片。', textConfirm: '跳转网页', textCancel: '下载完整版', - onConfirm: url(CalendarLogic.url), - onCancel: url(SettingsLogic.latestUrl), + onConfirm: Url.calendar.launch, + onCancel: Url.latest.launch, ); } else { Get.to(() => CalendarPage()); diff --git a/lib/toolbox/toolbox_view.dart b/lib/toolbox/toolbox_view.dart index 81296b6a..64209088 100644 --- a/lib/toolbox/toolbox_view.dart +++ b/lib/toolbox/toolbox_view.dart @@ -5,8 +5,8 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:get/get.dart'; import '../utils/loading.dart'; -import '../utils/messages.dart'; -import 'calendar/calendar_logic.dart'; +import '../utils/string.dart'; +import '../utils/web.dart'; import 'toolbox_logic.dart'; class ToolboxPage extends StatelessWidget { @@ -54,37 +54,37 @@ class ToolboxPage extends StatelessWidget { '校历', '长按打开网页版', onTap: logic.calendarOnTap, - onLongPress: logic.url(CalendarLogic.url), + onLongPress: Url.calendar.launch, ), Tool( FontAwesomeIcons.scroll, '公告', '善用搜索', - onTap: logic.url('https://www.ecnu.edu.cn/tzgg.htm'), + onTap: Url.announcements.launch, ), Tool( FontAwesomeIcons.mapMarked, '校内地图', '2D/3D', - onTap: logic.url('https://eoffice.ecnu.edu.cn/ecnu3d/main.psp'), + onTap: Url.map.launch, ), Tool( FontAwesomeIcons.bus, '校车时刻表', '需要连学校Wifi/VPN'.s, - onTap: logic.url('http://houqin.ecnu.edu.cn/28837/list.psp'), + onTap: Url.bus.launch, ), Tool( FontAwesomeIcons.cube, 'ECNU软件镜像站'.s, '内容很少', - onTap: logic.url('https://mirrors.ecnu.edu.cn/'), + onTap: Url.mirrors.launch, ), Tool( FontAwesomeIcons.key, '学校VPN'.s, '对校外网站有减速作用', - onTap: logic.url('https://docs.ecnu.edu.cn/index/Network/vpn.html'), + onTap: Url.vpn.launch, ), ], ); diff --git a/lib/utils/dio.dart b/lib/utils/dio.dart deleted file mode 100644 index f9aca250..00000000 --- a/lib/utils/dio.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutter_loggy_dio/flutter_loggy_dio.dart'; -import 'package:loggy/loggy.dart'; - -final dio = Dio() - ..interceptors.add(LoggyDioInterceptor( - requestHeader: true, - requestBody: true, - responseHeader: true, - responseBody: true, - error: true, - requestLevel: LogLevel.debug, - responseLevel: LogLevel.debug, - )); diff --git a/lib/utils/log.dart b/lib/utils/log.dart index 84d814f3..d7d2c8dd 100644 --- a/lib/utils/log.dart +++ b/lib/utils/log.dart @@ -2,10 +2,10 @@ import 'package:flutter_loggy/flutter_loggy.dart'; import 'package:flutter_settings_screens/flutter_settings_screens.dart'; import 'package:loggy/loggy.dart'; -void initLog() => Loggy.initLoggy( +void initLog({LogLevel? level}) => Loggy.initLoggy( logPrinter: StreamPrinter(const PrettyPrinter(showColors: true)), logOptions: LogOptions( - LogLevel.values[Settings.getValue('log.level', 2)], + level ?? LogLevel.values[Settings.getValue('log.level', 2)], stackTraceLevel: LogLevel.values[Settings.getValue('log.stackTraceLevel', 5)], includeCallerInfo: Settings.getValue('log.includeCallerInfo', false), diff --git a/lib/utils/messages.dart b/lib/utils/string.dart similarity index 93% rename from lib/utils/messages.dart rename to lib/utils/string.dart index 34086f73..aafa6a72 100644 --- a/lib/utils/messages.dart +++ b/lib/utils/string.dart @@ -7,8 +7,8 @@ import 'pangu.dart'; // record them manually const appName = 'ECNU Timetable'; const packageName = 'io.github.ccxxxi.ecnu_timetable'; -const version = '0.10.0'; -const buildNumber = '14'; +const version = '0.11.0'; +const buildNumber = '15'; const release = '$packageName@$version+$buildNumber'; diff --git a/lib/utils/web.dart b/lib/utils/web.dart new file mode 100644 index 00000000..4ed040a0 --- /dev/null +++ b/lib/utils/web.dart @@ -0,0 +1,82 @@ +import 'package:cookie_jar/cookie_jar.dart'; +import 'package:dart_random_choice/dart_random_choice.dart'; +import 'package:dio/dio.dart'; +import 'package:dio_cookie_manager/dio_cookie_manager.dart'; +import 'package:flutter_loggy_dio/flutter_loggy_dio.dart'; +import 'package:loggy/loggy.dart'; +import 'package:url_launcher/url_launcher.dart' as launcher; + +final cookieJar = CookieJar(); + +/// The default Dio instance used by all production code. +/// Test code may use a fake one. +final defaultDio = Dio() + ..interceptors.addAll([ + LoggyDioInterceptor( + requestHeader: true, + requestBody: true, + responseHeader: true, + responseBody: true, + error: true, + requestLevel: LogLevel.debug, + responseLevel: LogLevel.debug, + ), + CookieManager(cookieJar), + ]); + +extension Launch on String { + void launch() => launcher.launch(this); +} + +const _repo = 'CCXXXI/ecnu_timetable'; + +class Api { + static const _suckers = [ + 'https://api.vience.cn/api/tiangou', + 'http://api.ay15.cn/api/tiangou/api.php', + ]; + + static String get randomSucker => randomChoice(_suckers); + + static const releases = 'https://api.github.com/repos/$_repo/releases'; + + static const _baidu = 'https://aip.baidubce.com'; + + static const baiduToken = '$_baidu/oauth/2.0/token'; + static const baiduOcr = '$_baidu/rest/2.0/ocr/v1/general_basic'; +} + +class Url { +// region GitHub + static const _gh = 'https://github.com'; + + static const latest = '$_gh/$_repo/releases/latest'; + + static String version(String v) => '$_gh/$_repo/releases/tag/v$v'; + static const issues = '$_gh/$_repo/issues'; + +// endregion + +// region ECNU + static const _ecnu = 'ecnu.edu.cn'; + + static const uOffice = 'http://www.u-office.$_ecnu'; + static const calendar = '$uOffice/xiaoli'; + static const announcements = 'https://www.$_ecnu/tzgg.htm'; + static const map = 'https://eoffice.$_ecnu/ecnu3d/main.psp'; + static const bus = 'http://houqin.$_ecnu/28837/list.psp'; + static const mirrors = 'https://mirrors.$_ecnu'; + static const vpn = 'https://docs.$_ecnu/index/Network/vpn.html'; + +// endregion + +// region idc + static const _cas = 'https://portal1.$_ecnu/cas'; + static const _eams = 'https://applicationnewjw.$_ecnu/eams'; + + static const portal = '$_cas/login?service=$_eams/home.action'; + static const captcha = '$_cas/code'; + static const ids = '$_eams/courseTableForStd!index.action'; + static const table = '$_eams/courseTableForStd!courseTable.action'; +// endregion +} diff --git a/pubspec.lock b/pubspec.lock index d281e644..29f66ff7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -7,7 +7,7 @@ packages: name: archive url: "https://pub.flutter-io.cn" source: hosted - version: "3.1.5" + version: "3.1.6" args: dependency: transitive description: @@ -106,6 +106,13 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.15.0" + cookie_jar: + dependency: "direct main" + description: + name: cookie_jar + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.1" crypto: dependency: transitive description: @@ -141,6 +148,13 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "4.0.0" + dio_cookie_manager: + dependency: "direct main" + description: + name: dio_cookie_manager + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.0" fake_async: dependency: transitive description: @@ -330,7 +344,7 @@ packages: name: json_annotation url: "https://pub.flutter-io.cn" source: hosted - version: "4.1.0" + version: "4.3.0" lints: dependency: transitive description: @@ -421,7 +435,7 @@ packages: name: package_info_plus_macos url: "https://pub.flutter-io.cn" source: hosted - version: "1.2.0" + version: "1.3.0" package_info_plus_platform_interface: dependency: transitive description: @@ -541,6 +555,13 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.1.0" + quiver: + dependency: "direct main" + description: + name: quiver + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.1+1" rxdart: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4606ba0d..70ac0093 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,7 +4,7 @@ description: 更美观更智能的ECNU课程表。 # Prevent the package from being accidentally published to pub.dev. publish_to: "none" -version: 0.10.0+14 +version: 0.11.0+15 environment: sdk: ">=2.15.0-172.0.dev" @@ -23,6 +23,8 @@ dependencies: flutter_settings_screens: ">0.3.0" google_fonts: dio: + dio_cookie_manager: + cookie_jar: flutter_spinkit: url_launcher: badges: @@ -35,6 +37,7 @@ dependencies: path_provider: universal_html: cached_network_image: + quiver: dev_dependencies: flutter_test: diff --git a/test/settings/settings_logic_test.dart b/test/settings/settings_logic_test.dart index ee4e4722..57544518 100644 --- a/test/settings/settings_logic_test.dart +++ b/test/settings/settings_logic_test.dart @@ -1,29 +1,270 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; import 'package:ecnu_timetable/settings/settings_logic.dart'; -import 'package:ecnu_timetable/utils/messages.dart'; +import 'package:ecnu_timetable/utils/string.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:get/get.dart'; +import 'package:get/get.dart' hide Response; -class MockSettingsLogic extends SettingsLogic { - final String? latestVer_; +class FakeDio extends Fake implements Dio { + @override + Future> get( + String path, { + Map? queryParameters, + Options? options, + CancelToken? cancelToken, + ProgressCallback? onReceiveProgress, + }) async { + await Future.delayed(const Duration(milliseconds: 100)); - MockSettingsLogic({this.latestVer_}); + return Response( + requestOptions: RequestOptions(path: path), + data: fakeData as T, + ); + } +} +class FakeErrorDio extends Fake implements Dio { @override - void updateVerInfo() async => latestVer.value = latestVer_; + Future> get( + String path, { + Map? queryParameters, + Options? options, + CancelToken? cancelToken, + ProgressCallback? onReceiveProgress, + }) async { + await Future.delayed(const Duration(milliseconds: 100)); + + throw DioError( + requestOptions: RequestOptions(path: path), + ); + } } void main() { - group('Update', () { - final logic0 = Get.put(MockSettingsLogic()); - test('not available', () => expect(logic0.updateAvailable, isFalse)); - Get.delete(); - - final logic1 = Get.put(MockSettingsLogic(latestVer_: version)); - test('not available', () => expect(logic1.updateAvailable, isFalse)); - Get.delete(); - - final logic2 = Get.put(MockSettingsLogic(latestVer_: '1024.2048.4096')); - test('available', () => expect(logic2.updateAvailable, isTrue)); - Get.delete(); + runApp(const GetMaterialApp()); + + test('fake version', () async { + final logic = Get.put(SettingsLogic(dio: FakeDio())); + + expect(logic.latestVer.value, isNull); + expect(logic.updateAvailable, isFalse); + + await Future.delayed(const Duration(milliseconds: 200)); + expect(logic.latestVer.value, '1024.2048.4096'); + expect(logic.updateAvailable, isTrue); + + logic.latestVer.value = version; + expect(logic.latestVer.value, version); + expect(logic.updateAvailable, isFalse); + + Get.delete(); + }); + + test('error version', () async { + final logic = Get.put(SettingsLogic(dio: FakeErrorDio())); + + expect(logic.latestVer.value, isNull); + expect(logic.updateAvailable, isFalse); + + await Future.delayed(const Duration(milliseconds: 200)); + expect(logic.latestVer.value, isEmpty); + expect(logic.updateAvailable, isFalse); + + Get.delete(); + }); + + test('real version', () async { + final logic = Get.put(SettingsLogic()); + + expect(logic.latestVer.value, isNull); + expect(logic.updateAvailable, isFalse); + + await Future.delayed(const Duration(seconds: 1)); + expect(logic.latestVer.value, isNotEmpty); + + Get.delete(); }); } + +final fakeData = json.decode(r''' +[ + { + "url": "https://api.github.com/repos/CCXXXI/ecnu_timetable/releases/51086607", + "assets_url": "https://api.github.com/repos/CCXXXI/ecnu_timetable/releases/51086607/assets", + "upload_url": "https://uploads.github.com/repos/CCXXXI/ecnu_timetable/releases/51086607/assets{?name,label}", + "html_url": "https://github.com/CCXXXI/ecnu_timetable/releases/tag/v1024.2048.4096", + "id": 51086607, + "author": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "site_admin": false + }, + "node_id": "RE_kwDOFjHll84DC4UP", + "tag_name": "v1024.2048.4096", + "target_commitish": "main", + "name": "v1024.2048.4096", + "draft": false, + "prerelease": false, + "created_at": "2021-10-09T21:15:35Z", + "published_at": "2021-10-09T21:22:53Z", + "assets": [ + { + "url": "https://api.github.com/repos/CCXXXI/ecnu_timetable/releases/assets/46611305", + "id": 46611305, + "node_id": "RA_kwDOFjHll84Cxztp", + "name": "app-arm64-v8a-release.apk", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "site_admin": false + }, + "content_type": "application/vnd.android.package-archive", + "state": "uploaded", + "size": 25932872, + "download_count": 0, + "created_at": "2021-10-09T21:22:53Z", + "updated_at": "2021-10-09T21:22:54Z", + "browser_download_url": "https://github.com/CCXXXI/ecnu_timetable/releases/download/v1024.2048.4096/app-arm64-v8a-release.apk" + }, + { + "url": "https://api.github.com/repos/CCXXXI/ecnu_timetable/releases/assets/46611307", + "id": 46611307, + "node_id": "RA_kwDOFjHll84Cxztr", + "name": "app-armeabi-v7a-release.apk", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "site_admin": false + }, + "content_type": "application/vnd.android.package-archive", + "state": "uploaded", + "size": 25679147, + "download_count": 0, + "created_at": "2021-10-09T21:22:54Z", + "updated_at": "2021-10-09T21:22:55Z", + "browser_download_url": "https://github.com/CCXXXI/ecnu_timetable/releases/download/v1024.2048.4096/app-armeabi-v7a-release.apk" + }, + { + "url": "https://api.github.com/repos/CCXXXI/ecnu_timetable/releases/assets/46611310", + "id": 46611310, + "node_id": "RA_kwDOFjHll84Cxztu", + "name": "app-x86_64-release.apk", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "site_admin": false + }, + "content_type": "application/vnd.android.package-archive", + "state": "uploaded", + "size": 26159376, + "download_count": 0, + "created_at": "2021-10-09T21:22:55Z", + "updated_at": "2021-10-09T21:22:56Z", + "browser_download_url": "https://github.com/CCXXXI/ecnu_timetable/releases/download/v1024.2048.4096/app-x86_64-release.apk" + }, + { + "url": "https://api.github.com/repos/CCXXXI/ecnu_timetable/releases/assets/46611312", + "id": 46611312, + "node_id": "RA_kwDOFjHll84Cxztw", + "name": "ecnu_timetable_windows.zip", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "site_admin": false + }, + "content_type": "application/octet-stream", + "state": "uploaded", + "size": 28032749, + "download_count": 1, + "created_at": "2021-10-09T21:22:56Z", + "updated_at": "2021-10-09T21:22:57Z", + "browser_download_url": "https://github.com/CCXXXI/ecnu_timetable/releases/download/v1024.2048.4096/ecnu_timetable_windows.zip" + } + ], + "tarball_url": "https://api.github.com/repos/CCXXXI/ecnu_timetable/tarball/v1024.2048.4096", + "zipball_url": "https://api.github.com/repos/CCXXXI/ecnu_timetable/zipball/v1024.2048.4096", + "body": "## Features\n- **timetable**: update menu [#112](https://github.com/CCXXXI/ecnu_timetable/pull/112) ([CCXXXI](https://github.com/CCXXXI/ecnu_timetable/commit/200d6a05cce708b0bba86d995152e9a5a5935fd4))\n- **utils**: set responseLevel of dio to debug [#112](https://github.com/CCXXXI/ecnu_timetable/pull/112) ([CCXXXI](https://github.com/CCXXXI/ecnu_timetable/commit/71f8585b9e6414588a6db26eebc0362928480712))\n- **toolbox**: show academic calendar as image [#112](https://github.com/CCXXXI/ecnu_timetable/pull/112) ([CCXXXI](https://github.com/CCXXXI/ecnu_timetable/commit/441a5ca4530d80e4f45fc571e581e738eab88134))\n- **toolbox**: InteractiveViewer for calendar [#112](https://github.com/CCXXXI/ecnu_timetable/pull/112) ([CCXXXI](https://github.com/CCXXXI/ecnu_timetable/commit/bef4367c09f4e006acc3e44793729d24dfa79aeb))\n- **toolbox**: set maxScale of InteractiveViewer to infinity [#112](https://github.com/CCXXXI/ecnu_timetable/pull/112) ([CCXXXI](https://github.com/CCXXXI/ecnu_timetable/commit/47d4fe2162d42d78877ce01a8f545534d74da271))\n- **toolbox**: basic idc [#112](https://github.com/CCXXXI/ecnu_timetable/pull/112) ([CCXXXI](https://github.com/CCXXXI/ecnu_timetable/commit/284fe9fda4564cab3a3794d9498286b121080d49))\n\n## Bug Fixes\n- **toolbox**: disable academic calendar image on web [#112](https://github.com/CCXXXI/ecnu_timetable/pull/112) ([CCXXXI](https://github.com/CCXXXI/ecnu_timetable/commit/ae5f0a78754407bd19a78ef26dcf1fb71fba2811))\n\n## Builds\n- use mirror by https://pub.flutter-io.cn [#112](https://github.com/CCXXXI/ecnu_timetable/pull/112) ([CCXXXI](https://github.com/CCXXXI/ecnu_timetable/commit/bea470f673c00f5443bdf2f16e4d4eef43651073))\n\n## Chores\n- **deps**: universal_html [#112](https://github.com/CCXXXI/ecnu_timetable/pull/112) ([CCXXXI](https://github.com/CCXXXI/ecnu_timetable/commit/982959ee859dd676fdce9f6465f4e451b2b605ca))\n- **deps**: cached_network_image [#112](https://github.com/CCXXXI/ecnu_timetable/pull/112) ([CCXXXI](https://github.com/CCXXXI/ecnu_timetable/commit/cfd0932118051ab58ecc9b13ece48b871d5b9902))\n- 1024.2048.4096+14 [#112](https://github.com/CCXXXI/ecnu_timetable/pull/112) ([CCXXXI](https://github.com/CCXXXI/ecnu_timetable/commit/82c7c2289d3ec7872b936fb2561b76698d588e22))" + } +] +'''); diff --git a/test/settings/trivia_test.dart b/test/settings/trivia_test.dart new file mode 100644 index 00000000..cf1c6fdd --- /dev/null +++ b/test/settings/trivia_test.dart @@ -0,0 +1,6 @@ +import 'package:ecnu_timetable/settings/trivia.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('cheater', () => expect(randomTrivia.data, isNotEmpty)); +} diff --git a/test/timetable/ecnu/des_test.dart b/test/timetable/ecnu/des_test.dart new file mode 100644 index 00000000..00b6acf8 --- /dev/null +++ b/test/timetable/ecnu/des_test.dart @@ -0,0 +1,13 @@ +import 'package:ecnu_timetable/timetable/ecnu/des.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('des', () { + expect(strEnc('data'), '34AE5DC1AD2BDA92'); + expect(strEnc('ecnu'), '0DB9498F9C7706F3'); + expect(strEnc('0DB9498F9C7706F3'), + '794EE509D7F1CBF640C9ED7B4EB5AB94E88F1C4B9F0D24DE709C5839C7BCB533'); + expect(strEnc('10101001000@abcdefg.ecnu.edu.cn'), + 'D12781756C6D8485844A7F48326289AF905F80BCD2274407A9CF2704230383D1DEC97A59E1771F1C0DB9498F9C7706F32D0C03A9D6850DDCE4F8A1319CB7AA56'); + }); +} diff --git a/test/timetable/ecnu/ecnu_logic_test.dart b/test/timetable/ecnu/ecnu_logic_test.dart new file mode 100644 index 00000000..58fd4cdd --- /dev/null +++ b/test/timetable/ecnu/ecnu_logic_test.dart @@ -0,0 +1,90 @@ +import 'package:ecnu_timetable/timetable/ecnu/ecnu_logic.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('idValidator', () { + expect(EcnuLogic.idValidator(null), isNotEmpty); + expect(EcnuLogic.idValidator(''), isNotEmpty); + expect(EcnuLogic.idValidator('123456'), isNotEmpty); + expect(EcnuLogic.idValidator('123456' * 10), isNotEmpty); + expect(EcnuLogic.idValidator('10101001000'), isNull); + }); + + test('passwordValidator', () { + expect(EcnuLogic.passwordValidator(null), isNotEmpty); + expect(EcnuLogic.passwordValidator(''), isNotEmpty); + expect(EcnuLogic.passwordValidator('123456'), isNull); + expect(EcnuLogic.passwordValidator('123456' * 10), isNull); + expect(EcnuLogic.passwordValidator('10101001000'), isNull); + }); + + test('captchaValidator', () { + expect(EcnuLogic.captchaValidator(null), isNotEmpty); + expect(EcnuLogic.captchaValidator(''), isNotEmpty); + expect(EcnuLogic.captchaValidator('1234'), isNull); + expect(EcnuLogic.captchaValidator('123456'), isNotEmpty); + expect(EcnuLogic.captchaValidator('10101001000'), isNotEmpty); + }); + + test('semester.id', () { + expect(EcnuLogic.semId(2018, 0), 705); + expect(EcnuLogic.semId(2018, 1), 705 + 32); + expect(EcnuLogic.semId(2017, 2), 705 - 32); + }); + + test('parseJs', () { + EcnuLogic.parseJs(js); + }); +} + +const js = ''' + // function CourseTable in TaskActivity.js + var table0 = new CourseTable(2021,98); + var unitCount = 14; + var index=0; + var activity=null; + activity = new TaskActivity("76694","赵慧","340936(SOFT0031132992.01)","非关系型数据存储技术及其应用(SOFT0031132992.01)","2375","教书院226","01111111111111111100000000000000000000000000000000000",null,"","","",""); + index =3*unitCount+2; + table0.activities[index][table0.activities[index].length]=activity; + index =3*unitCount+3; + table0.activities[index][table0.activities[index].length]=activity; + activity = new TaskActivity("76646","姜宁康","302608(SOFT0031131018.01)","面向对象分析和设计实践(SOFT0031131018.01)","5036","理科大楼B517","00101010101010101000000000000000000000000000000000000",null,"","","",""); + index =1*unitCount+5; + table0.activities[index][table0.activities[index].length]=activity; + index =1*unitCount+6; + table0.activities[index][table0.activities[index].length]=activity; + activity = new TaskActivity("76646","姜宁康","292975(SOFT0031131073.01)","面向对象分析和设计(SOFT0031131073.01)","2375","教书院226","01111111111111111100000000000000000000000000000000000",null,"","","",""); + index =1*unitCount+0; + table0.activities[index][table0.activities[index].length]=activity; + index =1*unitCount+1; + table0.activities[index][table0.activities[index].length]=activity; + activity = new TaskActivity("76848","羊丹平","340398(MATH0031112991.02)","逻辑·推理·证明(MATH0031112991.02)","2370","教书院218","01111111111111111100000000000000000000000000000000000",null,"","","",""); + index =0*unitCount+10; + table0.activities[index][table0.activities[index].length]=activity; + index =0*unitCount+11; + table0.activities[index][table0.activities[index].length]=activity; + index =0*unitCount+12; + table0.activities[index][table0.activities[index].length]=activity; + activity = new TaskActivity("76447","应琼","343774(COEN0031162002.04)","中西文化比较(COEN0031162002.04)","2377","教书院302","01111111111111111000000000000000000000000000000000000",null,"","","",""); + index =4*unitCount+2; + table0.activities[index][table0.activities[index].length]=activity; + index =4*unitCount+3; + table0.activities[index][table0.activities[index].length]=activity; + activity = new TaskActivity("76743","赵世忠","295546(SOFT0031132019.01)","数学建模(SOFT0031132019.01)","2370","教书院218","01111111111111111100000000000000000000000000000000000",null,"","","",""); + index =0*unitCount+7; + table0.activities[index][table0.activities[index].length]=activity; + index =0*unitCount+8; + table0.activities[index][table0.activities[index].length]=activity; + activity = new TaskActivity("76663","孙海英","331020(SOFT0031132228.01)","软件测试和验证(SOFT0031132228.01)","2609","理科大楼B226","01111111111111111100000000000000000000000000000000000",null,"","","",""); + index =1*unitCount+2; + table0.activities[index][table0.activities[index].length]=activity; + index =1*unitCount+3; + table0.activities[index][table0.activities[index].length]=activity; + activity = new TaskActivity("24935984","程鹏","334873(SOFT0031132231.01)","算法设计与分析(SOFT0031132231.01)","2370","教书院218","01111111111111111100000000000000000000000000000000000",null,"","","",""); + index =3*unitCount+5; + table0.activities[index][table0.activities[index].length]=activity; + index =3*unitCount+6; + table0.activities[index][table0.activities[index].length]=activity; + table0.marshalTable(2,1,18); + fillTable(table0,7,14,0); +'''; diff --git a/test/toolbox/calendar/calendar_logic_test.dart b/test/toolbox/calendar/calendar_logic_test.dart new file mode 100644 index 00000000..685d2786 --- /dev/null +++ b/test/toolbox/calendar/calendar_logic_test.dart @@ -0,0 +1,211 @@ +import 'package:dio/dio.dart'; +import 'package:ecnu_timetable/toolbox/calendar/calendar_logic.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get/get.dart' hide Response; + +class FakeDio extends Fake implements Dio { + @override + Future> get( + String path, { + Map? queryParameters, + Options? options, + CancelToken? cancelToken, + ProgressCallback? onReceiveProgress, + }) async { + await Future.delayed(const Duration(milliseconds: 100)); + + return Response( + requestOptions: RequestOptions(path: path), + data: fakeData as T, + ); + } +} + +class FakeErrorDio extends Fake implements Dio { + @override + Future> get( + String path, { + Map? queryParameters, + Options? options, + CancelToken? cancelToken, + ProgressCallback? onReceiveProgress, + }) async { + await Future.delayed(const Duration(milliseconds: 100)); + + throw DioError( + requestOptions: RequestOptions(path: path), + ); + } +} + +void main() { + runApp(const GetMaterialApp()); + + test('fake calendar', () async { + final logic = Get.put(CalendarLogic(dio: FakeDio())); + + expect(logic.imgUrl.value, isEmpty); + + await Future.delayed(const Duration(milliseconds: 200)); + expect(logic.imgUrl.value, fakeImgUrl); + + Get.delete(); + }); + + test('error calendar', () async { + final logic = Get.put(CalendarLogic(dio: FakeErrorDio())); + + expect(logic.imgUrl.value, isEmpty); + + await Future.delayed(const Duration(milliseconds: 200)); + expect(logic.imgUrl.value, isEmpty); + + Get.delete(); + }); + + test('real calendar', () async { + final logic = Get.put(CalendarLogic()); + + expect(logic.imgUrl.value, isEmpty); + + await Future.delayed(const Duration(seconds: 10)); + expect(logic.imgUrl.value, isNotEmpty); + + Get.delete(); + }, skip: '网络连接不稳定'); +} + +const fakeData = ''' + + + + + +校历 + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + +
+
+
+
+ + + + +
+ + +
+ +
+
+
+ + + + + + + + + +
+   +
+ +
+
+ + + + + + + +
+ + + + + '''; +const fakeImgUrl = + 'http://www.u-office.ecnu.edu.cn/_upload/article/images/4f/e8/1a1c59344523a4be4cf6a90081c6/b1de9633-5f49-4511-b63d-236f44b327d5.jpg'; diff --git a/test/toolbox/cheater_test.dart b/test/toolbox/cheater_test.dart new file mode 100644 index 00000000..04cbba7c --- /dev/null +++ b/test/toolbox/cheater_test.dart @@ -0,0 +1,6 @@ +import 'package:ecnu_timetable/toolbox/cheater.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('cheater', () => expect(randomCheater, isNotEmpty)); +} diff --git a/test/utils/log_test.dart b/test/utils/log_test.dart new file mode 100644 index 00000000..87635b22 --- /dev/null +++ b/test/utils/log_test.dart @@ -0,0 +1,42 @@ +import 'package:ecnu_timetable/utils/log.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_loggy/flutter_loggy.dart'; +import 'package:flutter_settings_screens/flutter_settings_screens.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get/get.dart'; +import 'package:loggy/loggy.dart'; + +const testMessage = 'Lorem ipsum dolor sit'; + +class LogTest with L { + void testLoggy() => loggy.debug(testMessage); + + void testL() => l.debug(testMessage); +} + +void main() async { + testWidgets( + 'loggy', + (tester) async { + await Settings.init(); + initLog(level: LogLevel.all); + final logTest = LogTest(); + + expect(logTest.testLoggy, throwsUnsupportedError); + + await tester.pumpWidget( + const MaterialApp( + home: LoggyStreamScreen(), + ), + ); + + for (int i = 0; i != 3; ++i) { + await tester.pumpAndSettle(); + expect(find.text(testMessage), findsNWidgets(i)); + logTest.testL(); + } + }, + // Some tests are endless on GitHub Actions. Skip them. + skip: GetPlatform.isLinux, + ); +} diff --git a/test/utils/messages_test.dart b/test/utils/messages_test.dart deleted file mode 100644 index bc244364..00000000 --- a/test/utils/messages_test.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:ecnu_timetable/utils/messages.dart'; -import 'package:flutter_test/flutter_test.dart'; - -const _num = r'(0|[1-9]\d*)'; -final _semVer = RegExp('^$_num\\.$_num\\.$_num\$'); - -void main() { - group('盘古', () { - test('空字符串仍为空', () => expect(''.s, '')); - test('中英之间加空格', () => expect('更美观更智能的ECNU课程表。'.s, '更美观更智能的 ECNU 课程表。')); - test('对已加空格的字符串重复处理无影响', - () => expect('更美观更智能的 ECNU 课程表。'.s.s.s, '更美观更智能的 ECNU 课程表。')); - }); - - group('package info', () { - test('appName', () => expect(appName, 'ECNU Timetable')); - test('packageName', - () => expect(packageName, 'io.github.ccxxxi.ecnu_timetable')); - test('version', () => expect(_semVer.hasMatch(version), isTrue)); - test('buildNumber', - () => expect(RegExp('^$_num\$').hasMatch(buildNumber), isTrue)); - }); -} diff --git a/test/utils/string_test.dart b/test/utils/string_test.dart new file mode 100644 index 00000000..5e4bf6e4 --- /dev/null +++ b/test/utils/string_test.dart @@ -0,0 +1,41 @@ +import 'package:ecnu_timetable/utils/string.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:quiver/pattern.dart'; + +const _num = r'(0|[1-9]\d*)'; +final _semVer = RegExp('$_num\\.$_num\\.$_num'); + +void main() { + group('盘古', () { + test('空字符串仍为空', () => expect(''.s, '')); + test('中英之间加空格', () => expect('更美观更智能的ECNU课程表。'.s, '更美观更智能的 ECNU 课程表。')); + test('对已加空格的字符串重复处理无影响', + () => expect('更美观更智能的 ECNU 课程表。'.s.s.s, '更美观更智能的 ECNU 课程表。')); + }); + + group('package info', () { + test( + 'appName', + () => expect(appName, 'ECNU Timetable'), + ); + test( + 'packageName', + () => expect(packageName, 'io.github.ccxxxi.ecnu_timetable'), + ); + test( + 'version', + () => expect(matchesFull(_semVer, version), isTrue), + ); + test( + 'buildNumber', + () => expect(matchesFull(RegExp(_num), buildNumber), isTrue), + ); + }); + + testWidgets('license', (tester) async { + await tester.pumpWidget(const MaterialApp()); + await initMessages(); + expect(license.startsWith('MIT License'), isTrue); + }); +} diff --git a/test/utils/web_test.dart b/test/utils/web_test.dart new file mode 100644 index 00000000..816ae146 --- /dev/null +++ b/test/utils/web_test.dart @@ -0,0 +1,12 @@ +import 'package:ecnu_timetable/utils/web.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test( + 'version url', + () => expect( + Url.version('0.1.0'), + 'https://github.com/CCXXXI/ecnu_timetable/releases/tag/v0.1.0', + ), + ); +} diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc index fcc2efea..ae0f17d7 100644 --- a/windows/runner/Runner.rc +++ b/windows/runner/Runner.rc @@ -90,7 +90,7 @@ BEGIN BLOCK "040904e4" BEGIN VALUE "CompanyName", "io.github.ccxxxi" "\0" - VALUE "FileDescription", "更美观更智能的ECNU课程表。" "\0" + VALUE "FileDescription", "ECNU Timetable" "\0" VALUE "FileVersion", VERSION_AS_STRING "\0" VALUE "InternalName", "ecnu_timetable" "\0" VALUE "LegalCopyright", "Copyright (C) 2021 io.github.ccxxxi. All rights reserved." "\0"