diff --git a/.gitignore b/.gitignore
index 587723f..03c9c87 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@
*.aar
*.ap_
*.aab
+output-metadata.json
# Files for the ART/Dalvik VM
*.dex
diff --git a/README.md b/README.md
index bb63985..2db776c 100644
--- a/README.md
+++ b/README.md
@@ -2,10 +2,10 @@
Next generation of DanXi.
-# 🚧 Maintenance Notice
-Because of a severe lack of human resources, we decide to pause the development on Android native version of DanXi. Please check our main repository: .
-
# Development Track
+
See issue [#5](https://github.com/DanXi-Dev/DanXi-NG/issues/5).
+
# Contribution Guides
+
Please first have a look at our [style guidelines](STYLE-PRACTICES.md).
diff --git a/STYLE-PRACTICES.md b/STYLE-PRACTICES.md
index 91d8191..101ec31 100644
--- a/STYLE-PRACTICES.md
+++ b/STYLE-PRACTICES.md
@@ -23,69 +23,37 @@ clean
## 应用规范
-### **要**使用 ViewBinding。
+### **要**使用依赖注入(Dependency Injection)。
-ViewBinding 是新版本 Android 开发支持库提供的自动生成类,其名称是布局文件名的驼峰转写。
+旦夕引入了 [Hilt](https://developer.android.com/training/dependency-injection/hilt-android) 作为 ViewModel 的依赖管理工具,
+因为 Hilt 可以把依赖作用域限制在 `ViewModel` 或者 `Application` 的级别,不同的 `ViewModel` 可以共享不同的全局变量。
+同时 Hilt 是编译时注入,不会影响运行时性能。
#### 好👍
```kotlin
-private val binding: ActivityMainBinding by lazy { ActivityMainBinding.inflate(LayoutInflater.from(this)) }
-// 在稍后的地方设置布局
-setContentView(binding.root)
-```
-
-#### 坏👎
-
-```kotlin
-// 直接设置布局 ID
-setContentView(R.layout.activity_main)
-```
-
-### **要**使用服务定位器(Service Locator)模式。
-
-旦夕引入了 [Koin](https://insert-koin.io/) 作为全局变量的管理工具,而非 Android 官方提供的 Dagger 或其他依赖注入(Dependency Injection)库,这是因为后者的灵活性很差,仅允许对 Activity、ViewModel 和 Fragment 注入依赖。
-
-#### 好👍
-
-```kotlin
-// 在 GlobalState.kt 设置全局变量的生成器
-val appModule = module {
+// 使用 Annotation 设置依赖
+@Singleton
+class MyGlobalClass @Inject constructor() {
// ...
- single { MyGlobalVariable() }
-}
-// 在导入 KoinComponent 其他地方使用
-private val globalValue: MyGlobalVariable by inject()
-```
-
-#### 不是很坏,但是不推荐😕
-
-```kotlin
-// 在任何地方都用 KoinComponent 导入全局变量,无论它是哪一层的控件,甚至根本不是控件。
-class MySimpleToolClass : KoinComponent {
- private val globalValue: MyGlobalVariable by inject()
}
-// ❗在这种时候,应优先让类接受参数,而不是自己硬编码全局变量:
-class MySimpleToolClass(private val globalValue: MyGlobalVariable) {
-}
-```
-
-#### 坏👎
-
-```kotlin
-// 直接创建全局静态变量
-companion object {
- var globalValue = MyGlobalValue()
+// 在其他地方注入依赖
+@ViewModelScoped
+class MyViewModelScopedClass @Inject constructor(
+ myGlobalClass: MyGlobalClass
+) {
+ // ...
}
-```
-#### 坏👎
-
-```kotlin
-// 储存 Context(如 Application、Activity、Service 等都是 Context)
-companion object {
- var globalValue: Acitivty = getActivity()
+// 在 `ViewModel` 中注入依赖
+@HiltViewModel
+class MyViewModel @inject constructor(
+ myGlobalClass: MyGlobalClass
+) : ViewModel() {
+ // 获取 Context
+ @ApplicationContext lateinit var context: Context
+ // ...
}
```
@@ -140,13 +108,8 @@ suspend fun getDataFromNetwork(): String? {
#### 好👍
```kotlin
-suspend fun getDataFromNetwork(): String {
- withContext(Dispatchers.IO) {
- suspendCancellableCoroutine {
- val res = innerCallThatBlocksThread()
- it.resume(res)
- }
- }
+suspend fun getDataFromNetwork() = withContext(Dispatchers.IO){
+ networkRequest()
}
```
@@ -154,15 +117,10 @@ suspend fun getDataFromNetwork(): String {
```kotlin
fun getDataFromNetwork(): String {
- val res = innerCallThatBlocksThread()
- return res
+ return networkRequest()
}
```
-> **例外**
->
-> 在网络请求层内的私有方法可以任意决定。实际上,对于总是被网络请求层的其他 suspend 方法调用的方法,建议使用「坏👎」中的写法。
-
### **不要**在网络请求层过多地「消化」掉异常。
#### 好👍
@@ -215,7 +173,7 @@ suspend fun getDataFromNetwork(): String {
>
> 除非该异常是**可恢复的**。可恢复的定义是:即便不执行本身的主要逻辑第二次(例如:重新请求网络),也可以返回正确的结果。
-### **要**在控制层处理来自数据请求层的异常。
+### **要**在控制层( `ViewModel` 层)处理来自数据请求层的异常。
#### 好👍
@@ -249,6 +207,10 @@ fun clickRefreshData() {
```kotlin
// ViewModel 中
+data class MyUiState(
+ val clicked: Boolean = false
+)
+
private val _uiState = MutableStateFlow(MyUiState())
val uiState: StateFlow = _uiState.asStateFlow()
@@ -259,22 +221,12 @@ fun onClick() {
// --------------------------------
-// Fragment/Activity 中
-data class MyUiState(
- val clicked: Boolean = false
-)
-
-// 或者对于 Activity,使用 onCreate() 回调
-override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
- lifecycleScope.launch {
- repeatOnLifecycle(Lifecycle.State.STARTED) {
- viewModel.uiState.apply {
- watch(this@repeatOnLifecycle, { it.clicked }) {
- // clicked 变更的事件发生
- }
- }
- }
+// Compose 中
+@Composable
+fun MyComposable(viewModel: MyViewModel) {
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ Button(onClick = { viewModel.onClick() }) {
+ Text("Click me")
}
}
```
@@ -285,28 +237,30 @@ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
### **不推荐**在视图层以外调用界面方法。
-视图层(如 `Activity` 和 `Fragment`)理应承担所有视图任务,如显示动画、显示对话框、显示上下文菜单、跳转到新页面等等。其他层,尤其是 `ViewModel`,不应当执行任何有关方法。
+视图层(如 `Compose`)理应承担所有视图任务,如显示动画、显示对话框、显示上下文菜单、跳转到新页面等等。其他层,尤其是 `ViewModel`,不应当执行任何有关方法。
> **例外**
>
-> 在自定义的视图-控制器一体类(如 `Feature`)中,`startActivity` 是可容忍的。
+> 在自定义的视图-控制器一体类(如 `Feature`)中,`navigate` 是可容忍的。
## 应用惯例
-### **不要**过多地在通用类中使用只针对复旦 UIS 账号的实现。
+### **要**针对不同账户系统使用不同的数据类型。
-旦夕是面向多类型账户系统的,不应想当然地把 `PersonInfo` 视作只包含 UIS 账号密码的数据类型,也不要在设置中过多出现「复旦」有关的字样。
+旦夕是面向多类型账户系统的,各账户系统可以独立登录登出,因此需要用独立的数据类型存储。`UISInfo` 是只针对 UIS 账号密码的数据类型,`OTJWTToken` 是只针对 FDUHole 凭证的数据类型。
+
+另外,需要区分账户「凭证」和账户「信息」,前者仅包含登录所需的信息,可以本地存储;后者包含所有信息,应该在登录后或者启动应用时从后端获取。
```
# 好👍
+登录复旦 UIS 账户
+使用树洞账号登录旦课
+树洞登录
+无法连接至复旦 UIS 服务器
+
+# 坏👎
登录账户
使用旦夕账号登录旦课
树洞登录
无法连接至服务器
-
-# 坏👎
-登录 UIS 账户
-使用复旦树洞账号登录旦课
-复旦树洞登录
-无法连接至复旦服务器
```
\ No newline at end of file
diff --git a/app/build.gradle b/app/build.gradle
deleted file mode 100644
index 850c755..0000000
--- a/app/build.gradle
+++ /dev/null
@@ -1,90 +0,0 @@
-plugins {
- id 'com.android.application'
- id 'org.jetbrains.kotlin.android'
- id 'org.jetbrains.kotlin.plugin.serialization' version '1.7.10'
-}
-
-android {
- compileSdk 32
-
- defaultConfig {
- applicationId "com.fduhole.danxinative"
- minSdk 21
- targetSdk 32
- versionCode 1
- versionName "1.0"
-
- testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
- }
-
- buildTypes {
- release {
- minifyEnabled false
- proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
- }
- }
- compileOptions {
- sourceCompatibility JavaVersion.VERSION_1_8
- targetCompatibility JavaVersion.VERSION_1_8
- }
- kotlinOptions {
- jvmTarget = '1.8'
- }
- buildFeatures {
- viewBinding true
- }
-}
-
-dependencies {
- // Dependency injection
- implementation 'io.insert-koin:koin-android:3.2.0'
- // HTTP client
- implementation 'com.squareup.okhttp3:okhttp:4.10.0'
- implementation 'com.squareup.okhttp3:okhttp-urlconnection:4.10.0'
- // HTTP API client
- implementation 'com.squareup.retrofit2:retrofit:2.9.0'
- // JSON serialization
- implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.0-RC"
- implementation "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:0.8.0"
- // Datetime support
- implementation "org.jetbrains.kotlinx:kotlinx-datetime:0.4.0"
- // HTML parser
- implementation 'org.jsoup:jsoup:1.15.2'
- // Paging support
- implementation "androidx.paging:paging-runtime:3.1.1"
- // Regex and other support
- implementation "org.jetbrains.kotlin:kotlin-stdlib:1.7.10"
- // Encrypted SharedPreferences
- // 2022-08-27 Note (@w568w): `security-crypto-ktx:1.1.0-alpha03` seems to only support API 23+, while the library
- // without `-ktx` supports 21+.
- implementation "androidx.security:security-crypto:1.1.0-alpha03"
- // About page
- implementation 'com.drakeet.about:about:2.5.2'
- implementation 'com.drakeet.multitype:multitype:4.3.0'
- // Permission request
- implementation 'com.guolindev.permissionx:permissionx:1.6.4'
- // QRCode
- implementation 'com.google.zxing:core:3.5.0'
-
-
- implementation 'androidx.core:core-ktx:1.8.0'
- implementation 'androidx.appcompat:appcompat:1.4.2'
- implementation 'com.google.android.material:material:1.6.1'
- implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
- implementation 'androidx.navigation:navigation-fragment-ktx:2.5.0'
- implementation 'androidx.navigation:navigation-ui-ktx:2.5.0'
- implementation 'androidx.legacy:legacy-support-v4:1.0.0'
- implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.0'
- implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.0'
- implementation 'androidx.preference:preference:1.2.0'
- implementation 'androidx.annotation:annotation:1.4.0'
-
- testImplementation 'junit:junit:4.13.2'
- testImplementation "io.insert-koin:koin-test:3.2.0"
- testImplementation "io.insert-koin:koin-test-junit4:3.2.0"
- testImplementation 'io.github.ivanshafran:shared-preferences-mock:1.2.4'
- androidTestImplementation 'androidx.test.ext:junit:1.1.3'
- androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
- // https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-test
- testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4")
-}
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..e93297e
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,128 @@
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlin.serialization)
+ alias(libs.plugins.kapt)
+ alias(libs.plugins.hilt)
+ alias(libs.plugins.ksp)
+ alias(libs.plugins.ktorfit)
+}
+
+android {
+ compileSdk = 34
+ namespace = "com.fduhole.danxi"
+
+ defaultConfig {
+ applicationId = "com.fduhole.danxi"
+ minSdk = 26
+ targetSdk = 34
+ versionCode = 1
+ versionName = "1.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+
+ vectorDrawables.useSupportLibrary = true
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = true
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ buildFeatures {
+ compose = true
+ buildConfig = true
+ }
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
+ }
+ androidResources {
+ generateLocaleConfig = true
+ }
+}
+
+dependencies {
+
+ val composeBom = platform(libs.androidx.compose.bom)
+ implementation(composeBom)
+ androidTestImplementation(composeBom)
+
+ // Kotlin
+ implementation(libs.kotlin.stdlib)
+ implementation(libs.kotlinx.coroutines.core)
+ implementation(libs.kotlinx.serialization.json)
+ implementation(libs.kotlinx.datetime)
+
+ // Androidx
+ implementation(libs.androidx.appcompat)
+ implementation(libs.androidx.core)
+ implementation(libs.androidx.lifecycle.runtime)
+ implementation(libs.androidx.lifecycle.runtime.compose)
+ implementation(libs.androidx.lifecycle.viewmodel)
+ implementation(libs.androidx.lifecycle.viewmodel.compose)
+ implementation(libs.androidx.navigation.compose)
+ implementation(libs.androidx.annotation)
+ implementation(libs.androidx.paging)
+ implementation(libs.androidx.security)
+ implementation(libs.androidx.datastore.preferences)
+ implementation(libs.androidx.hilt.navigation.compose)
+
+ // Androidx Compose
+ implementation(libs.androidx.compose.foundation)
+ implementation(libs.androidx.compose.foundation.layout)
+ implementation(libs.androidx.compose.material.iconsExtended)
+ implementation(libs.androidx.compose.material3)
+ implementation(libs.androidx.compose.runtime)
+ implementation(libs.androidx.compose.runtime.livedata)
+ implementation(libs.androidx.compose.ui)
+ implementation(libs.androidx.compose.ui.tooling.preview)
+ implementation(libs.androidx.compose.ui.googlefonts)
+ implementation(libs.androidx.activity.compose)
+ implementation(libs.androidx.paging.compose)
+ implementation(libs.google.accompanist.permissions)
+
+ // Security DataStore
+ implementation(libs.security.crypto.datastore.preferences)
+
+ // Dependency injection
+ implementation(libs.hilt.android)
+ kapt(libs.hilt.compiler)
+ // HTTP client
+ implementation(libs.ktor.client)
+ implementation(libs.ktor.client.cio)
+ implementation(libs.ktor.client.logging)
+ implementation(libs.ktor.client.content.negotiation)
+ implementation(libs.ktor.serialization.kotlinx.json)
+ implementation(libs.ktorfit.lib)
+ ksp(libs.ktorfit.ksp)
+ // HTML parser
+ implementation(libs.jsoup)
+ // QRCode
+ implementation(libs.google.zxing.core)
+
+ implementation(libs.google.errorprone.annotations)
+ implementation(libs.slf4j.simple)
+
+ testImplementation(libs.junit)
+ androidTestImplementation(libs.androidx.junit.ext)
+ androidTestImplementation(libs.androidx.test.espresso)
+ androidTestImplementation(composeBom)
+ androidTestImplementation(libs.androidx.compose.test.junit4)
+ // https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-test
+ testImplementation(libs.kotlinx.coroutines.test)
+
+ debugImplementation(libs.androidx.compose.ui.tooling)
+ debugImplementation(libs.androidx.compose.test.manifest)
+}
+
+kapt {
+ correctErrorTypes = true
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/fduhole/danxinative/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/fduhole/danxi/ExampleInstrumentedTest.kt
similarity index 84%
rename from app/src/androidTest/java/com/fduhole/danxinative/ExampleInstrumentedTest.kt
rename to app/src/androidTest/java/com/fduhole/danxi/ExampleInstrumentedTest.kt
index 67ceb34..4a9f715 100644
--- a/app/src/androidTest/java/com/fduhole/danxinative/ExampleInstrumentedTest.kt
+++ b/app/src/androidTest/java/com/fduhole/danxi/ExampleInstrumentedTest.kt
@@ -1,4 +1,4 @@
-package com.fduhole.danxinative
+package com.fduhole.danxi
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -19,6 +19,6 @@ class ExampleInstrumentedTest {
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
- assertEquals("com.fduhole.danxinative", appContext.packageName)
+ assertEquals("com.fduhole.danxi", appContext.packageName)
}
}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 661ec05..5ebd261 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,7 +1,6 @@
+ xmlns:tools="http://schemas.android.com/tools">
@@ -14,32 +13,19 @@
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
- android:theme="@style/Theme.DanXiNative"
+ android:theme="@style/Theme.DanXi"
android:usesCleartextTraffic="true"
- tools:targetApi="s">
-
-
+ android:enableOnBackInvokedCallback="true"
+ tools:targetApi="tiramisu">
+
-
-
+ android:theme="@style/Theme.DanXi">
diff --git a/app/src/main/java/com/fduhole/danxi/DanXiApplication.kt b/app/src/main/java/com/fduhole/danxi/DanXiApplication.kt
new file mode 100644
index 0000000..3ff8563
--- /dev/null
+++ b/app/src/main/java/com/fduhole/danxi/DanXiApplication.kt
@@ -0,0 +1,44 @@
+package com.fduhole.danxi
+
+import android.app.Application
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.dataStoreFile
+import androidx.datastore.preferences.core.PreferenceDataStoreFactory
+import androidx.datastore.preferences.core.Preferences
+import androidx.security.crypto.EncryptedFile
+import androidx.security.crypto.MasterKey
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.HiltAndroidApp
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import io.github.osipxd.security.crypto.createEncrypted
+import javax.inject.Singleton
+
+@HiltAndroidApp
+class DanXiApplication : Application() {
+
+ companion object {
+ const val DAN_XI_PREFERENCE_NAME = "settings"
+ }
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+object DataStoreModule {
+
+ @Provides
+ @Singleton
+ fun provideDataStore(@ApplicationContext context: Context): DataStore {
+ return PreferenceDataStoreFactory.createEncrypted {
+ EncryptedFile.Builder(
+ context,
+ context.dataStoreFile("${DanXiApplication.DAN_XI_PREFERENCE_NAME}.preferences_pb"), // The file should have extension .preferences_pb
+ MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(),
+ EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB
+ ).build()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/fduhole/danxi/MainActivity.kt b/app/src/main/java/com/fduhole/danxi/MainActivity.kt
new file mode 100644
index 0000000..2bdb23a
--- /dev/null
+++ b/app/src/main/java/com/fduhole/danxi/MainActivity.kt
@@ -0,0 +1,42 @@
+package com.fduhole.danxi
+
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.fduhole.danxi.ui.DanXiNavGraph
+import com.fduhole.danxi.ui.GlobalViewModel
+import com.fduhole.danxi.ui.theme.DanXiNativeTheme
+import dagger.hilt.android.AndroidEntryPoint
+
+// @see https://github.com/android/user-interface-samples/blob/main/PerAppLanguages/compose_app/app/src/main/java/com/example/perapplanguages/MainActivity.kt
+@AndroidEntryPoint
+class MainActivity : AppCompatActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ MainApp()
+ }
+ }
+}
+
+@Composable
+fun MainApp() {
+ val globalViewModel: GlobalViewModel = viewModel()
+ val uiState by globalViewModel.uiState.collectAsStateWithLifecycle()
+ val isDarkTheme = uiState.isDarkTheme ?: isSystemInDarkTheme()
+ DanXiNativeTheme(isDarkTheme) {
+ Surface(color = MaterialTheme.colorScheme.background) {
+ DanXiNavGraph(
+ globalViewModel = globalViewModel,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/fduhole/danxi/model/fdu/AAONotice.kt b/app/src/main/java/com/fduhole/danxi/model/fdu/AAONotice.kt
new file mode 100644
index 0000000..dcb0a83
--- /dev/null
+++ b/app/src/main/java/com/fduhole/danxi/model/fdu/AAONotice.kt
@@ -0,0 +1,10 @@
+package com.fduhole.danxi.model.fdu
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class AAONotice(
+ val title: String,
+ val url: String,
+ val time: String,
+)
diff --git a/app/src/main/java/com/fduhole/danxi/model/fdu/CardPersonInfo.kt b/app/src/main/java/com/fduhole/danxi/model/fdu/CardPersonInfo.kt
new file mode 100644
index 0000000..0245009
--- /dev/null
+++ b/app/src/main/java/com/fduhole/danxi/model/fdu/CardPersonInfo.kt
@@ -0,0 +1,11 @@
+package com.fduhole.danxi.model.fdu
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class CardPersonInfo(
+ val balance: String,
+ val name: String,
+ val recentRecord: List
+)
+
diff --git a/app/src/main/java/com/fduhole/danxi/model/fdu/CardRecord.kt b/app/src/main/java/com/fduhole/danxi/model/fdu/CardRecord.kt
new file mode 100644
index 0000000..0b429f2
--- /dev/null
+++ b/app/src/main/java/com/fduhole/danxi/model/fdu/CardRecord.kt
@@ -0,0 +1,14 @@
+package com.fduhole.danxi.model.fdu
+
+import kotlinx.datetime.Instant
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class CardRecord(
+ val time: Instant,
+ val type: String,
+ val location: String,
+ val amount: String,
+ val balance: String,
+)
+
diff --git a/app/src/main/java/com/fduhole/danxi/model/fdu/EhallStudentInfo.kt b/app/src/main/java/com/fduhole/danxi/model/fdu/EhallStudentInfo.kt
new file mode 100644
index 0000000..a2b984e
--- /dev/null
+++ b/app/src/main/java/com/fduhole/danxi/model/fdu/EhallStudentInfo.kt
@@ -0,0 +1,3 @@
+package com.fduhole.danxi.model.fdu
+
+data class EhallStudentInfo(val name: String, val userTypeName: String, val userDepartment: String)
\ No newline at end of file
diff --git a/app/src/main/java/com/fduhole/danxi/model/fdu/LibraryInfo.kt b/app/src/main/java/com/fduhole/danxi/model/fdu/LibraryInfo.kt
new file mode 100644
index 0000000..678c82a
--- /dev/null
+++ b/app/src/main/java/com/fduhole/danxi/model/fdu/LibraryInfo.kt
@@ -0,0 +1,13 @@
+package com.fduhole.danxi.model.fdu
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class LibraryInfo(
+ val campusId: String,
+ val campusName: String,
+ val inNum: String,
+ val libraryOpenTime: String,
+ val placeNum: String,
+)
+
diff --git a/app/src/main/java/com/fduhole/danxi/model/fdu/UISInfo.kt b/app/src/main/java/com/fduhole/danxi/model/fdu/UISInfo.kt
new file mode 100644
index 0000000..1adc423
--- /dev/null
+++ b/app/src/main/java/com/fduhole/danxi/model/fdu/UISInfo.kt
@@ -0,0 +1,12 @@
+package com.fduhole.danxi.model.fdu
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class UISInfo(
+ val id: String,
+ val password: String,
+ val name: String = "",
+) {
+ override fun toString(): String = String.format("%s (%s)", name, id)
+}
diff --git a/app/src/main/java/com/fduhole/danxinative/model/opentreehole/Beans.kt b/app/src/main/java/com/fduhole/danxi/model/opentreehole/Beans.kt
similarity index 89%
rename from app/src/main/java/com/fduhole/danxinative/model/opentreehole/Beans.kt
rename to app/src/main/java/com/fduhole/danxi/model/opentreehole/Beans.kt
index b826637..ef8c9f8 100644
--- a/app/src/main/java/com/fduhole/danxinative/model/opentreehole/Beans.kt
+++ b/app/src/main/java/com/fduhole/danxi/model/opentreehole/Beans.kt
@@ -1,4 +1,4 @@
-package com.fduhole.danxinative.model.opentreehole
+package com.fduhole.danxi.model.opentreehole
import kotlinx.serialization.Serializable
diff --git a/app/src/main/java/com/fduhole/danxinative/model/opentreehole/Models.kt b/app/src/main/java/com/fduhole/danxi/model/opentreehole/Models.kt
similarity index 98%
rename from app/src/main/java/com/fduhole/danxinative/model/opentreehole/Models.kt
rename to app/src/main/java/com/fduhole/danxi/model/opentreehole/Models.kt
index 8d57187..0d13c61 100644
--- a/app/src/main/java/com/fduhole/danxinative/model/opentreehole/Models.kt
+++ b/app/src/main/java/com/fduhole/danxi/model/opentreehole/Models.kt
@@ -1,4 +1,4 @@
-package com.fduhole.danxinative.model.opentreehole
+package com.fduhole.danxi.model.opentreehole
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
diff --git a/app/src/main/java/com/fduhole/danxi/repository/BaseRepository.kt b/app/src/main/java/com/fduhole/danxi/repository/BaseRepository.kt
new file mode 100644
index 0000000..393c2b7
--- /dev/null
+++ b/app/src/main/java/com/fduhole/danxi/repository/BaseRepository.kt
@@ -0,0 +1,65 @@
+package com.fduhole.danxi.repository
+
+import com.fduhole.danxi.util.net.MemoryCookiesStorage
+import io.ktor.client.HttpClient
+import io.ktor.client.HttpClientConfig
+import io.ktor.client.engine.cio.CIO
+import io.ktor.client.engine.cio.CIOEngineConfig
+import io.ktor.client.engine.cio.endpoint
+import io.ktor.client.plugins.HttpRedirect
+import io.ktor.client.plugins.cookies.AcceptAllCookiesStorage
+import io.ktor.client.plugins.cookies.CookiesStorage
+import io.ktor.client.plugins.cookies.HttpCookies
+
+
+abstract class BaseRepository {
+ companion object {
+ private val clients: MutableMap = mutableMapOf()
+ private val cookiesStorages: MutableMap = mutableMapOf()
+
+ fun createTmpClient(
+ cookiesStorage: CookiesStorage = AcceptAllCookiesStorage(),
+ block: HttpClientConfig.() -> Unit = {},
+ ) = HttpClient(CIO) {
+ engine {
+ endpoint {
+ connectTimeout = 50_000
+ requestTimeout = 50_000
+ }
+ }
+ install(HttpCookies) {
+ storage = cookiesStorage
+ }
+ install(HttpRedirect) {
+ checkHttpMethod = false // not work with CIO
+ }
+ block()
+ }
+ }
+
+ val cookiesStorage: MemoryCookiesStorage
+ get() {
+ if (!cookiesStorages.containsKey(scopeId)) {
+ cookiesStorages[scopeId] = MemoryCookiesStorage(AcceptAllCookiesStorage())
+ }
+ return cookiesStorages[scopeId]!!
+ }
+
+ val client: HttpClient
+ get() {
+ if (!clients.containsKey(scopeId)) {
+ clients[scopeId] = createClient()
+ }
+ return clients[scopeId]!!
+ }
+
+ open fun createClient(): HttpClient = createTmpClient(cookiesStorage)
+
+
+ /**
+ * Get the scope id of the repository.
+ *
+ * The repositories with the same id will share one client and cookie jar.
+ */
+ abstract val scopeId: String
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/fduhole/danxi/repository/fdu/AAORepository.kt b/app/src/main/java/com/fduhole/danxi/repository/fdu/AAORepository.kt
new file mode 100644
index 0000000..45c4381
--- /dev/null
+++ b/app/src/main/java/com/fduhole/danxi/repository/fdu/AAORepository.kt
@@ -0,0 +1,41 @@
+package com.fduhole.danxi.repository.fdu
+
+import com.fduhole.danxi.model.fdu.AAONotice
+import com.fduhole.danxi.repository.settings.SettingsRepository
+import io.ktor.client.call.body
+import io.ktor.http.Url
+import kotlinx.coroutines.Dispatchers
+import org.jsoup.Jsoup
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class AAORepository @Inject constructor(
+ settingsRepository: SettingsRepository,
+) : BaseFDURepository(settingsRepository) {
+ companion object {
+ const val TYPE_NOTICE_ANNOUNCEMENT = "9397"
+ private val LOGIN_URL =
+ Url("https://uis.fudan.edu.cn/authserver/login?service=http%3A%2F%2Fjwc.fudan.edu.cn%2Feb%2Fb7%2Fc9397a388023%2Fpage.psp")
+ private const val HOST = "https://jwc.fudan.edu.cn"
+ }
+
+ override fun getUISLoginUrl() = LOGIN_URL
+
+ override val scopeId = "fudan.edu.cn"
+
+ private fun getNoticeListUrl(type: String, page: Int): String = "$HOST/$type/list${if (page <= 1) "" else page}.htm"
+
+ suspend fun getNoticeList(page: Int, type: String = TYPE_NOTICE_ANNOUNCEMENT): List = with(Dispatchers.IO) {
+ val res: String = client.getUIS(getNoticeListUrl(type, page)).body()
+ val doc = Jsoup.parse(res)
+ doc.select(".wp_article_list_table > tbody > tr > td > table > tbody").map {
+ val noticeInfo = it.select("tr").select("td")
+ AAONotice(
+ title = noticeInfo[0].text().trim(),
+ url = HOST + noticeInfo[0].select("a").attr("href"),
+ time = noticeInfo[1].text().trim()
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/fduhole/danxi/repository/fdu/BaseFDURepository.kt b/app/src/main/java/com/fduhole/danxi/repository/fdu/BaseFDURepository.kt
new file mode 100644
index 0000000..5a2c08a
--- /dev/null
+++ b/app/src/main/java/com/fduhole/danxi/repository/fdu/BaseFDURepository.kt
@@ -0,0 +1,176 @@
+package com.fduhole.danxi.repository.fdu
+
+import androidx.annotation.StringRes
+import com.fduhole.danxi.R
+import com.fduhole.danxi.repository.BaseRepository
+import com.fduhole.danxi.repository.settings.SettingsRepository
+import com.fduhole.danxi.util.net.MemoryCookiesStorage
+import io.ktor.client.HttpClient
+import io.ktor.client.call.body
+import io.ktor.client.plugins.cookies.CookiesStorage
+import io.ktor.client.request.HttpRequestBuilder
+import io.ktor.client.request.forms.FormDataContent
+import io.ktor.client.request.forms.submitForm
+import io.ktor.client.request.get
+import io.ktor.client.request.request
+import io.ktor.client.request.setBody
+import io.ktor.client.request.url
+import io.ktor.client.statement.HttpResponse
+import io.ktor.client.statement.request
+import io.ktor.http.HttpMethod
+import io.ktor.http.Parameters
+import io.ktor.http.Url
+import io.ktor.http.parameters
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import org.jsoup.Jsoup
+
+abstract class BaseFDURepository(
+ val settingsRepository: SettingsRepository,
+) : BaseRepository() {
+
+ abstract fun getUISLoginUrl(): Url
+
+ open val maxRetryTimes = 2
+
+ companion object {
+ val uisLoginMutex = Mutex()
+ const val UIS_HOST = "uis.fudan.edu.cn"
+
+ sealed class UISLoginException(@StringRes val id: Int, message: String) : Throwable(message) {
+ data object WeakPassword : UISLoginException(R.string.weak_password_note, "week_password")
+ data object UnderMaintenance : UISLoginException(R.string.under_maintenance_note, "under_maintenance")
+ data object Unknown : UISLoginException(R.string.uis_login_unknown_note, "unknown")
+ data object NeedCaptcha : UISLoginException(R.string.need_captcha_note, "need_captcha")
+ data object InvalidCredentials : UISLoginException(R.string.invalid_credentials_note, "invalid_credentials")
+ }
+
+ private val uisMessageExceptionMap = mapOf(
+ "请输入验证码" to UISLoginException.NeedCaptcha,
+ "密码有误" to UISLoginException.InvalidCredentials,
+ "弱密码提示" to UISLoginException.WeakPassword,
+ "网络维护中 | Under Maintenance" to UISLoginException.UnderMaintenance,
+ )
+
+ suspend fun login(
+ id: String,
+ password: String,
+ loginUrl: Url,
+ ): CookiesStorage {
+ // 创建临时的 cookiesStorage,用于登录
+ val tmpCookiesStorage = MemoryCookiesStorage()
+ // 创建临时的 HttpClient,用于登录
+ val tmpClient = createTmpClient(tmpCookiesStorage)
+
+ // 获取登录页面
+ val res = tmpClient.get(loginUrl)
+ val doc = Jsoup.parse(res.body())
+
+ // 登录
+ var response = tmpClient.submitForm(
+ loginUrl.toString(),
+ formParameters = parameters {
+ for (element in doc.select("input")) {
+ if (element.attr("type") != "button" && element.attr("name") != "username" && element.attr("name") != "password") {
+ append(element.attr("name"), element.attr("value"))
+ }
+ }
+ append("username", id)
+ append("password", password)
+ }
+ )
+
+ // force redirect, see https://github.com/ktorio/ktor/issues/1623
+ while (response.headers.contains("Location")) {
+ val location = response.headers["Location"]!!
+ response = tmpClient.get(location)
+ }
+
+ val body = response.body()
+ uisMessageExceptionMap.forEach { (message, exception) ->
+ if (body.contains(message)) {
+ throw exception
+ }
+ }
+ return tmpCookiesStorage
+ }
+ }
+
+ suspend inline fun HttpClient.getUIS(
+ url: Url,
+ block: HttpRequestBuilder.() -> Unit = {},
+ ): HttpResponse = getUIS {
+ url(url)
+ block()
+ }
+
+ suspend inline fun HttpClient.getUIS(
+ urlString: String,
+ block: HttpRequestBuilder.() -> Unit = {},
+ ): HttpResponse = getUIS {
+ url(urlString)
+ block()
+ }
+
+ suspend inline fun HttpClient.getUIS(block: HttpRequestBuilder.() -> Unit) =
+ getUIS(HttpRequestBuilder().apply(block))
+
+ suspend inline fun HttpClient.getUIS(builder: HttpRequestBuilder): HttpResponse {
+ builder.method = HttpMethod.Get
+ return requestUIS(builder)
+ }
+
+ suspend inline fun HttpClient.submitFormUIS(
+ url: Url,
+ formParameters: Parameters = Parameters.Empty,
+ encodeInQuery: Boolean = false,
+ block: HttpRequestBuilder.() -> Unit = {}
+ ): HttpResponse = submitFormUIS(formParameters, encodeInQuery) {
+ url(url)
+ block()
+ }
+
+ suspend inline fun HttpClient.submitFormUIS(
+ formParameters: Parameters = Parameters.Empty,
+ encodeInQuery: Boolean = false,
+ block: HttpRequestBuilder.() -> Unit = {}
+ ): HttpResponse = requestUIS {
+ if (encodeInQuery) {
+ method = HttpMethod.Get
+ url.parameters.appendAll(formParameters)
+ } else {
+ method = HttpMethod.Post
+ setBody(FormDataContent(formParameters))
+ }
+
+ block()
+ }
+
+ suspend inline fun HttpClient.requestUIS(
+ block: HttpRequestBuilder.() -> Unit = {},
+ ) = requestUIS(HttpRequestBuilder().apply(block))
+
+ suspend inline fun HttpClient.requestUIS(builder: HttpRequestBuilder): HttpResponse {
+ var response = request(builder) // process request with redirect
+ repeat(maxRetryTimes) { _ ->
+ if (builder.url.host.contains(UIS_HOST))
+ return response
+ if (response.request.url.host.contains(UIS_HOST)) {
+ uisLoginMutex.withLock {
+ val uisInfo = settingsRepository.uisInfo.get() ?: throw UISLoginException.Unknown
+ cookiesStorage.replaceBy(
+ login(
+ id = uisInfo.id,
+ password = uisInfo.password,
+ loginUrl = getUISLoginUrl(),
+ )
+ )
+ }
+ response = request(builder)
+ } else {
+ return response
+ }
+ }
+ throw UISLoginException.Unknown
+ }
+}
diff --git a/app/src/main/java/com/fduhole/danxi/repository/fdu/ECardRepository.kt b/app/src/main/java/com/fduhole/danxi/repository/fdu/ECardRepository.kt
new file mode 100644
index 0000000..9145083
--- /dev/null
+++ b/app/src/main/java/com/fduhole/danxi/repository/fdu/ECardRepository.kt
@@ -0,0 +1,170 @@
+package com.fduhole.danxi.repository.fdu
+
+import com.fduhole.danxi.model.fdu.CardPersonInfo
+import com.fduhole.danxi.model.fdu.CardRecord
+import com.fduhole.danxi.repository.settings.SettingsRepository
+import com.fduhole.danxi.util.appendAll
+import com.fduhole.danxi.util.between
+import com.fduhole.danxi.util.toDateTimeString
+import io.ktor.client.call.body
+import io.ktor.http.HttpHeaders
+import io.ktor.http.Url
+import io.ktor.http.headers
+import io.ktor.http.parameters
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlinx.datetime.Clock
+import kotlinx.datetime.LocalDateTime
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.toInstant
+import org.jsoup.Jsoup
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.time.Duration.Companion.days
+
+@Singleton
+class ECardRepository @Inject constructor(
+ settingsRepository: SettingsRepository
+) : BaseFDURepository(settingsRepository) {
+ override fun getUISLoginUrl() = LOGIN_URL
+ override val scopeId: String = "fudan.edu.cn"
+
+ companion object {
+ private val LOGIN_URL =
+ Url("https://uis.fudan.edu.cn/authserver/login?service=https%3A%2F%2Fecard.fudan.edu.cn%2Fepay%2Fj_spring_cas_security_check")
+ val USER_DETAIL_URL = Url("https://ecard.fudan.edu.cn/epay/myepay/index")
+ val CONSUME_DETAIL_URL = Url("https://ecard.fudan.edu.cn/epay/consume/query")
+ val CONSUME_DETAIL_CSRF_URL = Url("https://ecard.fudan.edu.cn/epay/consume/index")
+ private val CONSUME_DETAIL_HEADER = mapOf(
+ HttpHeaders.UserAgent to "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:84.0) Gecko/20100101 Firefox/84.0",
+ HttpHeaders.Accept to "text/xml",
+ HttpHeaders.AcceptLanguage to "zh-CN,en-US;q=0.7,en;q=0.3",
+ HttpHeaders.ContentType to "application/x-www-form-urlencoded",
+ HttpHeaders.Origin to "https://ecard.fudan.edu.cn",
+ "DNT" to "1",
+ HttpHeaders.Connection to "keep-alive",
+ HttpHeaders.Referrer to "https://ecard.fudan.edu.cn/epay/consume/index",
+ "Sec-GPC" to "1"
+ )
+ val QR_URL = Url("https://ecard.fudan.edu.cn/epay/wxpage/fudan/zfm/qrcode")
+ }
+
+ /**
+ * Get personal info (and card info).
+ */
+ suspend fun getCardPersonInfo(): CardPersonInfo = withContext(Dispatchers.IO) {
+ val body: String = client.getUIS(USER_DETAIL_URL).body()
+ val soup = Jsoup.parse(body)
+ val balance = requireNotNull(soup.selectFirst(".payway-box-bottom-item > p")?.text()) { "balance cannot be null." }
+ val name = requireNotNull(soup.selectFirst(".custname")?.text()?.between("您好,", "!")) { "name cannot be null." }
+ val recentRecord = soup.select(".tab-content > #all tbody tr").map {
+ val details = requireNotNull(it.getElementsByTag("td")) { "td element cannot be null." }
+ val dateStr = details[0].child(0).text().replace(".", "-")
+ val timeStr = details[0].child(1).text().trim()
+ CardRecord(
+ time = LocalDateTime.parse("${dateStr}T${timeStr}").toInstant(TimeZone.currentSystemDefault()),
+ type = details[1].child(0).text().trim(),
+ location = details[2].text().trim().replace(" ", "").trim(),
+ amount = details[3].text().trim().replace(" ", "").trim(),
+ balance = details[4].text().trim().replace(" ", "").trim(),
+ )
+ }
+ CardPersonInfo(balance, name, recentRecord)
+ }
+
+ /**
+ * Get the card records of last [dayNum] days.
+ *
+ * If [dayNum] = 0, it returns the last ten records;
+ * if [dayNum] < 0, it throws an error.
+ */
+ suspend fun getCardRecords(dayNum: Int): List = withContext(Dispatchers.IO) {
+ val payloadAndPageNum = getPagedCardRecordsPayloadAndPageNum(dayNum)
+ val list = arrayListOf()
+ for (i in 1..payloadAndPageNum.second) {
+ list.addAll(getPagedCardRecords(payloadAndPageNum.first, i))
+ }
+ list
+ }
+
+ suspend fun getPagedCardRecords(payload: Map, pageIndex: Int): List = withContext(Dispatchers.IO) {
+ requireNotNull(loadOnePageCardRecord(payload, pageIndex)) { "Got null data at page index $pageIndex" }
+ }
+
+ suspend fun getPagedCardRecordsPayloadAndPageNum(dayNum: Int): Pair
")) { "name cannot be null." }
- it.resume(CardPersonInfo(balance, name))
- }
- }
-
- /**
- * Get the card records of last [dayNum] days.
- *
- * If [dayNum] = 0, it returns the last ten records;
- * if [dayNum] < 0, it throws an error.
- */
- suspend fun getCardRecords(dayNum: Int): List {
- val payloadAndPageNum = getPagedCardRecordsPayloadAndPageNum(dayNum)
- val list = arrayListOf()
- for (i in 1..payloadAndPageNum.second) {
- list.addAll(getPagedCardRecords(payloadAndPageNum.first, i))
- }
- return list
- }
-
- suspend fun getPagedCardRecords(payload: Map, pageIndex: Int): List = withContext(Dispatchers.IO) {
- suspendCancellableCoroutine {
- it.resume(requireNotNull(loadOnePageCardRecord(payload, pageIndex)) { "Got null data at page index $pageIndex" })
- }
- }
-
- suspend fun getPagedCardRecordsPayloadAndPageNum(dayNum: Int): Pair