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