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"),
),
)
}
}