added a user system

This commit is contained in:
2026-08-30 17:07:55 +08:00
parent 0b85a2ed57
commit 637101bcdb
13 changed files with 1082 additions and 20 deletions

View File

@@ -11,6 +11,7 @@ class OralTrainerSdk private constructor(
val cache: OralTrainerCache = OralTrainerCache(appContext, config)
val sentenceBoundaryApi: SentenceBoundaryApi = SentenceBoundaryApi(appContext, config)
val userApi: UserApi = UserApi(appContext, config)
val videoCatalogApi: VideoCatalogApi = VideoCatalogApi(appContext, config)
private val remoteAssessors = CopyOnWriteArrayList<RemoteImitationQualityAssessor>()

View File

@@ -10,5 +10,6 @@ data class OralTrainerSdkConfig @JvmOverloads constructor(
val readTimeoutMs: Int = 30_000,
val sentenceBoundaryApiBaseUrl: String = "https://videoservice.d1kt.cn",
val assessmentApiKey: String? = null,
var userToken: String? = null,
val allowHttpFallback: Boolean = false,
)

View File

@@ -89,6 +89,9 @@ class RemoteImitationQualityAssessor internal constructor(
config.assessmentApiKey?.takeIf { it.isNotBlank() }?.let {
connection.setRequestProperty("X-Client-Key", it)
}
config.userToken?.takeIf { it.isNotBlank() }?.let {
connection.setRequestProperty("Authorization", "Bearer $it")
}
BufferedOutputStream(connection.outputStream).use { output ->
writeFormField(output, boundary, "language", request.locale ?: request.sentence.language.orEmpty())
writeFilePart(output, boundary, request.recordingUri)

View File

@@ -114,6 +114,9 @@ class SentenceBoundaryApi internal constructor(
connection.useCaches = false
connection.setRequestProperty("Accept", "application/json")
connection.setRequestProperty("User-Agent", config.userAgent)
config.userToken?.takeIf { it.isNotBlank() }?.let {
connection.setRequestProperty("Authorization", "Bearer $it")
}
val statusCode = connection.responseCode
val stream = if (statusCode in 200..299) {
connection.inputStream

View File

@@ -0,0 +1,256 @@
package cn.learningpad.oraltrainer.sdk
import android.content.Context
import android.os.Handler
import android.os.Looper
import org.json.JSONArray
import org.json.JSONObject
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.Future
data class AuthUser(
val id: String,
val username: String,
val nickname: String,
)
data class AuthSession(
val token: String,
val user: AuthUser,
)
data class Course(
val videoHash: String,
val title: String,
val durationMs: Long?,
val language: String?,
val sentenceCount: Int,
val streamUrl: String,
val enrolled: Boolean,
) {
fun toVideoSummary(): TrainingVideoSummary {
return TrainingVideoSummary(
videoHash = videoHash,
title = title,
streamUri = android.net.Uri.parse(streamUrl),
durationMs = durationMs,
sizeBytes = 0L,
language = language,
sentenceCount = sentenceCount,
status = "ready",
)
}
}
data class UserResult(
val attemptId: String,
val videoHash: String,
val courseTitle: String,
val sentenceIndex: Int,
val sentenceText: String,
val overallScore: Float,
val passed: Boolean,
val createdAt: String,
)
data class UserDubShare(
val shareId: String,
val videoHash: String,
val courseTitle: String,
val title: String,
val segmentCount: Int,
val averageScore: Float,
val createdAt: String,
)
class UserApiException(
val statusCode: Int,
responseBody: String,
) : IOException("HTTP $statusCode: ${responseBody.take(300)}")
class UserApi internal constructor(
context: Context,
private val config: OralTrainerSdkConfig,
) {
private val appContext = context.applicationContext
private val mainHandler = Handler(Looper.getMainLooper())
private val executor: ExecutorService = Executors.newCachedThreadPool()
private val baseUri = android.net.Uri.parse(config.sentenceBoundaryApiBaseUrl.trimEnd('/'))
init {
require(baseUri.scheme == "https" || baseUri.scheme == "http") {
"sentenceBoundaryApiBaseUrl must use http or https."
}
}
fun register(
username: String,
password: String,
onComplete: (Result<AuthSession>) -> Unit,
): CancellableRequest = submit(onComplete) { requestBlocking("auth/register", username, password, auth = false) }
fun login(
username: String,
password: String,
onComplete: (Result<AuthSession>) -> Unit,
): CancellableRequest = submit(onComplete) { requestBlocking("auth/login", username, password, auth = false) }
fun courses(
token: String,
onComplete: (Result<List<Course>>) -> Unit,
): CancellableRequest = submitObject(onComplete, token) { parseCourses(getBlocking(it, "courses")) }
fun enroll(
token: String,
videoHash: String,
onComplete: (Result<Unit>) -> Unit,
): CancellableRequest = submitUnit(onComplete, token) {
request("courses/$videoHash/enroll", token, method = "POST")
}
fun results(
token: String,
onComplete: (Result<List<UserResult>>) -> Unit,
): CancellableRequest = submitObject(onComplete, token) { parseResults(getBlocking(it, "me/results")) }
fun dubShares(
token: String,
onComplete: (Result<List<UserDubShare>>) -> Unit,
): CancellableRequest = submitObject(onComplete, token) { parseDubShares(getBlocking(it, "me/dub-shares")) }
fun release() {
executor.shutdownNow()
}
private fun <T> submit(
onComplete: (Result<T>) -> Unit,
operation: () -> T,
): CancellableRequest {
val future: Future<*> = executor.submit {
post(onComplete, runCatching(operation))
}
return CancellableRequest(future)
}
private fun <T> submitObject(
onComplete: (Result<T>) -> Unit,
token: String,
operation: (String) -> T,
): CancellableRequest = submit(onComplete) { operation(token) }
private fun submitUnit(
onComplete: (Result<Unit>) -> Unit,
token: String,
operation: (String) -> Unit,
): CancellableRequest = submit(onComplete) {
operation(token)
Unit
}
private fun <T> post(onComplete: (Result<T>) -> Unit, result: Result<T>) {
if (!Thread.currentThread().isInterrupted) {
mainHandler.post { onComplete(result) }
}
}
private fun requestBlocking(path: String, username: String, password: String, auth: Boolean): AuthSession {
val root = JSONObject().put("username", username).put("password", password)
val response = request(path, body = root.toString(), method = "POST")
return parseSession(JSONObject(response))
}
private fun getBlocking(token: String, path: String): JSONArray {
val body = request(path, token = token, method = "GET")
return JSONArray(body)
}
private fun parseCourses(items: JSONArray): List<Course> = List(items.length()) { index ->
val item = items.getJSONObject(index)
Course(
videoHash = item.getString("video_hash"),
title = item.getString("title"),
durationMs = if (item.isNull("duration_ms")) null else item.getLong("duration_ms"),
language = if (item.isNull("language")) null else item.getString("language"),
sentenceCount = item.getInt("sentence_count"),
streamUrl = item.getString("stream_url"),
enrolled = item.getBoolean("enrolled"),
)
}
private fun parseResults(items: JSONArray): List<UserResult> = List(items.length()) { index ->
val item = items.getJSONObject(index)
UserResult(
attemptId = item.getString("attempt_id"),
videoHash = item.getString("video_hash"),
courseTitle = item.getString("course_title"),
sentenceIndex = item.getInt("sentence_index"),
sentenceText = item.getString("sentence_text"),
overallScore = item.getDouble("overall_score").toFloat(),
passed = item.getBoolean("passed"),
createdAt = item.getString("created_at"),
)
}
private fun parseDubShares(items: JSONArray): List<UserDubShare> = List(items.length()) { index ->
val item = items.getJSONObject(index)
UserDubShare(
shareId = item.getString("share_id"),
videoHash = item.getString("video_hash"),
courseTitle = item.getString("course_title"),
title = item.getString("title"),
segmentCount = item.getInt("segment_count"),
averageScore = item.getDouble("average_score").toFloat(),
createdAt = item.getString("created_at"),
)
}
private fun request(
path: String,
token: String? = null,
body: String? = null,
method: String = body?.let { "POST" } ?: "GET",
): String {
val endpoint = android.net.Uri.parse(config.sentenceBoundaryApiBaseUrl.trimEnd('/'))
.buildUpon().appendEncodedPath(path).build()
val connection = URL(endpoint.toString()).openConnection() as HttpURLConnection
try {
connection.requestMethod = method
connection.connectTimeout = config.connectTimeoutMs
connection.readTimeout = config.readTimeoutMs
connection.useCaches = false
connection.setRequestProperty("Accept", "application/json")
connection.setRequestProperty("User-Agent", config.userAgent)
token?.takeIf { it.isNotBlank() }?.let {
connection.setRequestProperty("Authorization", "Bearer $it")
}
if (body != null) {
connection.doOutput = true
connection.setRequestProperty("Content-Type", "application/json")
connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
}
val statusCode = connection.responseCode
val stream = if (statusCode in 200..299) connection.inputStream else connection.errorStream
val text = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
if (statusCode !in 200..299) throw UserApiException(statusCode, text)
return text
} finally {
connection.disconnect()
}
}
private fun parseSession(root: JSONObject): AuthSession {
val user = root.getJSONObject("user")
return AuthSession(
token = root.getString("token"),
user = AuthUser(
id = user.getString("id"),
username = user.getString("username"),
nickname = user.getString("nickname"),
),
)
}
}

View File

@@ -32,16 +32,19 @@ import android.view.ViewGroup
import android.view.animation.Animation
import android.view.animation.TranslateAnimation
import android.widget.Button
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.HorizontalScrollView
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.SeekBar
import android.widget.Switch
import android.widget.TextView
import cn.learningpad.oraltrainer.sdk.GestureEvent
import cn.learningpad.oraltrainer.sdk.GestureKind
import cn.learningpad.oraltrainer.sdk.AuthSession
import cn.learningpad.oraltrainer.sdk.ImitationAssessmentCallback
import cn.learningpad.oraltrainer.sdk.ImitationAssessmentResult
import cn.learningpad.oraltrainer.sdk.LoopMode
@@ -107,8 +110,15 @@ class MainActivity : Activity() {
private var activeItemId: String = ""
private var currentSentenceCount = 0
private var authToken: String = ""
private var authNickname: String = ""
private var authUsernameInput: EditText? = null
private var authPasswordInput: EditText? = null
private var authStatusText: TextView? = null
private var myContentList: LinearLayout? = null
private var sentenceBoundaryRequestGeneration = 0
private var catalogVideos: List<TrainingVideoSummary> = emptyList()
private val courseEnrollment = mutableMapOf<String, Boolean>()
private var catalogStatusTextValue = "正在同步"
private var activeModule = Module.TRAIN
private var continuousPlaybackEnabled = false
@@ -131,11 +141,16 @@ class MainActivity : Activity() {
super.onCreate(savedInstanceState)
configureWindow()
val authPreferences = getSharedPreferences("oral_trainer_auth", MODE_PRIVATE)
authToken = authPreferences.getString("token", "").orEmpty()
authNickname = authPreferences.getString("nickname", "").orEmpty()
sdk = OralTrainerSdk.init(
this,
OralTrainerSdkConfig(
sentenceBoundaryApiBaseUrl = SERVER_BASE_URL,
assessmentApiKey = ASSESSMENT_API_KEY,
userToken = authToken.takeIf { it.isNotBlank() },
allowHttpFallback = true,
)
)
@@ -152,9 +167,9 @@ class MainActivity : Activity() {
controllerListener = createControllerListener()
controller.addListener(controllerListener)
setContentView(createContentView())
setRootView()
loadCatalog()
refreshCurrentUi()
if (isLoggedIn()) refreshCurrentUi()
registerReceiver(
screenActionReceiver,
@@ -164,7 +179,7 @@ class MainActivity : Activity() {
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
setContentView(createContentView())
setRootView()
renderCatalog()
refreshCurrentUi()
if (activeModule == Module.TEST) {
@@ -281,6 +296,22 @@ class MainActivity : Activity() {
private fun createContentView(): View {
val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
if (activeModule == Module.COURSE) {
return LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setBackgroundColor(COLOR_BACKGROUND)
addView(createModuleTabBar())
addView(createCourseSection())
}
}
if (activeModule == Module.MINE) {
return LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setBackgroundColor(COLOR_BACKGROUND)
addView(createModuleTabBar())
addView(createMySection())
}
}
return if (isLandscape) {
createLandscapeContentView()
} else {
@@ -288,6 +319,106 @@ class MainActivity : Activity() {
}
}
private fun setRootView() {
setContentView(if (isLoggedIn()) createContentView() else createAuthView())
if (isLoggedIn()) {
renderCatalog()
refreshCurrentUi()
}
}
private fun isLoggedIn(): Boolean = authToken.isNotBlank()
private fun createAuthView(): View {
return LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER
setBackgroundColor(COLOR_BACKGROUND)
setPadding(28.dp, 28.dp, 28.dp, 28.dp)
addView(TextView(this@MainActivity).apply {
text = "八哥口语"
textSize = 32f
typeface = Typeface.DEFAULT_BOLD
setTextColor(Color.WHITE)
})
addView(TextView(this@MainActivity).apply {
text = "请先注册或登录后开始学习"
textSize = 15f
setTextColor(COLOR_TEXT_MUTED)
setPadding(0, 6.dp, 0, 24.dp)
})
authUsernameInput = EditText(this@MainActivity).apply {
hint = "用户名"
setSingleLine(true)
}
authPasswordInput = EditText(this@MainActivity).apply {
hint = "密码(至少 8 位)"
inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD
setSingleLine(true)
}
authStatusText = TextView(this@MainActivity).apply {
setTextColor(COLOR_ACCENT_LIGHT)
textSize = 13f
setPadding(0, 14.dp, 0, 0)
}
addView(authUsernameInput)
addView(authPasswordInput)
addView(Button(this@MainActivity).apply {
text = "登录"
background = rounded(COLOR_ACCENT, 8f)
setTextColor(Color.WHITE)
setOnClickListener { authenticate(register = false) }
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 44.dp).withMargins(0, 20.dp, 0, 8.dp)
})
addView(Button(this@MainActivity).apply {
text = "注册"
setOnClickListener { authenticate(register = true) }
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 44.dp).withMargins(0, 0, 0, 0)
})
addView(authStatusText)
}
}
private fun authenticate(register: Boolean) {
val username = authUsernameInput?.text?.toString()?.trim().orEmpty()
val password = authPasswordInput?.text?.toString().orEmpty()
authStatusText?.text = if (register) "注册中..." else "登录中..."
val callback = { result: Result<AuthSession> ->
result.fold(
onSuccess = ::saveAuthSession,
onFailure = { error -> authStatusText?.text = "失败:${error.message}" },
)
}
if (register) sdk.userApi.register(username, password, callback)
else sdk.userApi.login(username, password, callback)
}
private fun saveAuthSession(session: AuthSession) {
authToken = session.token
authNickname = session.user.nickname
sdk.config.userToken = session.token
getSharedPreferences("oral_trainer_auth", MODE_PRIVATE).edit()
.putString("token", session.token)
.putString("nickname", session.user.nickname)
.apply()
activeModule = Module.COURSE
setRootView()
loadCatalog()
}
private fun logout() {
getSharedPreferences("oral_trainer_auth", MODE_PRIVATE).edit().clear().apply()
authToken = ""
authNickname = ""
sdk.config.userToken = null
controller.pause()
activeModule = Module.TRAIN
setRootView()
}
private fun createPortraitContentView(): View {
return LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
@@ -322,8 +453,10 @@ class MainActivity : Activity() {
return LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
setPadding(16.dp, 40.dp, 16.dp, 0)
addView(moduleTab("课程", Module.COURSE))
addView(moduleTab("训练", Module.TRAIN))
addView(moduleTab("测试", Module.TEST))
addView(moduleTab("我的", Module.MINE))
}
}
@@ -343,10 +476,13 @@ class MainActivity : Activity() {
}
layoutParams = LinearLayout.LayoutParams(0, 44.dp, 1f).withMargins(0, 0, 8.dp, 0)
setOnClickListener {
if (activeModule == module) {
return@setOnClickListener
}
activeModule = module
if (activeModule == module) {
return@setOnClickListener
}
if (module == Module.COURSE || module == Module.MINE) {
loadUserData()
}
activeModule = module
controller.setContinuousPlayback(
if (activeModule == Module.TRAIN) continuousPlaybackEnabled else false
)
@@ -1404,10 +1540,36 @@ class MainActivity : Activity() {
}
private fun loadCatalog() {
if (!isLoggedIn()) return
catalogStatusTextValue = "正在同步"
if (::catalogStatusText.isInitialized) {
catalogStatusText.text = catalogStatusTextValue
}
if (::catalogList.isInitialized) {
catalogList.removeAllViews()
catalogList.addView(infoCard("课程加载中..."))
}
sdk.userApi.courses(authToken) { result ->
result.fold(
onSuccess = { courses ->
catalogVideos = courses.map { it.toVideoSummary() }
courseEnrollment.clear()
courses.forEach { courseEnrollment[it.videoHash] = it.enrolled }
catalogStatusTextValue = "${catalogVideos.size} 个课程"
if (::catalogStatusText.isInitialized) catalogStatusText.text = catalogStatusTextValue
renderCatalog()
},
onFailure = {
catalogStatusTextValue = "课程加载失败:${it.message}"
if (::catalogStatusText.isInitialized) catalogStatusText.text = catalogStatusTextValue
courseEnrollment.clear()
renderCatalog()
},
)
}
}
private fun loadLegacyCatalog() {
sdk.videoCatalogApi.fetch(object : VideoCatalogCallback {
override fun onSuccess(videos: List<TrainingVideoSummary>) {
catalogVideos = videos
@@ -1435,24 +1597,168 @@ class MainActivity : Activity() {
})
}
private fun loadUserData() {
when (activeModule) {
Module.COURSE -> loadCatalog()
Module.MINE -> refreshMyContent()
else -> Unit
}
}
private fun createCourseSection(): View {
return ScrollView(this).apply {
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)
addView(LinearLayout(this@MainActivity).apply {
orientation = LinearLayout.VERTICAL
setPadding(16.dp, 12.dp, 16.dp, 16.dp)
addView(TextView(this@MainActivity).apply {
text = "课程首页"
textSize = 26f
typeface = Typeface.DEFAULT_BOLD
setTextColor(Color.WHITE)
})
addView(TextView(this@MainActivity).apply {
text = "选择一门课程开始训练"
textSize = 13f
setTextColor(COLOR_TEXT_MUTED)
setPadding(0, 4.dp, 0, 16.dp)
})
addView(catalogList)
})
}
}
private fun createMySection(): View {
myContentList = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
}
return ScrollView(this).apply {
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)
addView(LinearLayout(this@MainActivity).apply {
orientation = LinearLayout.VERTICAL
setPadding(16.dp, 12.dp, 16.dp, 16.dp)
addView(TextView(this@MainActivity).apply {
text = authNickname.ifBlank { "我的学习" }
textSize = 26f
typeface = Typeface.DEFAULT_BOLD
setTextColor(Color.WHITE)
})
addView(Button(this@MainActivity).apply {
text = "退出登录"
setOnClickListener { logout() }
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 44.dp).withMargins(0, 12.dp, 0, 20.dp)
})
addView(TextView(this@MainActivity).apply {
text = "我的测试结果"
textSize = 18f
typeface = Typeface.DEFAULT_BOLD
setTextColor(Color.WHITE)
})
addView(myContentList)
})
}
}
private fun refreshMyContent() {
val list = myContentList ?: return
list.removeAllViews()
list.addView(infoCard("加载中..."))
sdk.userApi.results(authToken) { result ->
list.removeAllViews()
result.fold(
onSuccess = { rows ->
if (rows.isEmpty()) list.addView(infoCard("还没有测试记录"))
rows.forEach { row ->
list.addView(infoCard(
"${row.courseTitle} · 第 ${row.sentenceIndex + 1}\n" +
"得分 ${row.overallScore.toInt()} · ${if (row.passed) "通过" else "未通过"}"
))
}
},
onFailure = { list.addView(infoCard("测试记录加载失败:${it.message}")) },
)
sdk.userApi.dubShares(authToken) { shareResult ->
list.post {
list.addView(titleDivider("我的配音分享"))
shareResult.fold(
onSuccess = { shares ->
if (shares.isEmpty()) list.addView(infoCard("还没有分享配音"))
shares.forEach { row ->
list.addView(infoCard(
"${row.title}\n${row.courseTitle} · ${row.segmentCount} 句 · 平均 ${row.averageScore.toInt()}"
))
}
},
onFailure = { list.addView(infoCard("配音分享加载失败:${it.message}")) },
)
}
}
}
}
private fun infoCard(text: String): TextView {
return TextView(this).apply {
this.text = text
setTextColor(COLOR_TEXT_DARK)
textSize = 13f
background = rounded(COLOR_SELECTED, 8f)
setPadding(12.dp, 10.dp, 12.dp, 10.dp)
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
).withMargins(0, 0, 0, 8.dp)
}
}
private fun titleDivider(text: String): TextView {
return TextView(this).apply {
this.text = text
setTextColor(Color.WHITE)
textSize = 18f
typeface = Typeface.DEFAULT_BOLD
setPadding(0, 20.dp, 0, 8.dp)
}
}
private fun renderCatalog() {
if (!::catalogList.isInitialized) {
return
}
catalogList.removeAllViews()
catalogVideos.forEach { video ->
val enrolled = courseEnrollment[video.videoHash] == true
catalogList.addView(
videoCard(
id = video.videoHash,
title = video.title,
meta = "${video.sentenceCount} 句 · ${formatTime(video.durationMs ?: -1L)}",
meta = if (enrolled) {
"已选修 · ${video.sentenceCount}"
} else {
"未选修 · 点击选修"
},
) {
loadRemoteVideo(video)
if (enrolled) loadRemoteVideo(video) else enrollCourse(video)
}
)
}
}
private fun enrollCourse(video: TrainingVideoSummary) {
sdk.userApi.enroll(authToken, video.videoHash) { result ->
result.fold(
onSuccess = {
courseEnrollment[video.videoHash] = true
renderCatalog()
},
onFailure = {
if (::catalogStatusText.isInitialized) {
catalogStatusText.text = "选修失败:${it.message}"
}
},
)
}
}
private fun loadRemoteVideo(video: TrainingVideoSummary) {
activeItemId = video.videoHash
lessonTitleText.text = video.title
@@ -1668,8 +1974,10 @@ class MainActivity : Activity() {
private val SHA256_PATTERN = Regex("^[0-9a-fA-F]{64}$")
enum class Module {
COURSE,
TRAIN,
TEST,
MINE,
}
val COLOR_BACKGROUND: Int = Color.rgb(12, 15, 18)