add sentence service

This commit is contained in:
2026-08-14 19:06:04 +08:00
parent f8a6bf24e4
commit 69d2ba986f
19 changed files with 2379 additions and 109 deletions

View File

@@ -242,6 +242,18 @@ class OralTrainerController internal constructor(
return true
}
fun previousSentenceOrRewind() {
if (!config.sentenceMode || !seekToPreviousSentence()) {
rewind()
}
}
fun nextSentenceOrForward() {
if (!config.sentenceMode || !seekToNextSentence()) {
fastForward()
}
}
fun performSwipeAction(action: SwipeAction) {
when (action) {
SwipeAction.NONE -> Unit
@@ -249,16 +261,8 @@ class OralTrainerController internal constructor(
SwipeAction.FORWARD -> fastForward()
SwipeAction.PREVIOUS_SENTENCE -> seekToPreviousSentence()
SwipeAction.NEXT_SENTENCE -> seekToNextSentence()
SwipeAction.PREVIOUS_SENTENCE_OR_REWIND -> {
if (!config.sentenceMode || !seekToPreviousSentence()) {
rewind()
}
}
SwipeAction.NEXT_SENTENCE_OR_FORWARD -> {
if (!config.sentenceMode || !seekToNextSentence()) {
fastForward()
}
}
SwipeAction.PREVIOUS_SENTENCE_OR_REWIND -> previousSentenceOrRewind()
SwipeAction.NEXT_SENTENCE_OR_FORWARD -> nextSentenceOrForward()
}
}

View File

@@ -9,6 +9,7 @@ class OralTrainerSdk private constructor(
private val appContext = context.applicationContext
val cache: OralTrainerCache = OralTrainerCache(appContext, config)
val sentenceBoundaryApi: SentenceBoundaryApi = SentenceBoundaryApi(appContext, config)
@JvmOverloads
fun createController(
@@ -24,6 +25,7 @@ class OralTrainerSdk private constructor(
}
fun release() {
sentenceBoundaryApi.release()
StreamingCache.release()
}

View File

@@ -8,4 +8,5 @@ data class OralTrainerSdkConfig @JvmOverloads constructor(
val userAgent: String = "OralTrainerSdk/0.1.0",
val connectTimeoutMs: Int = 15_000,
val readTimeoutMs: Int = 30_000,
val sentenceBoundaryApiBaseUrl: String = "https://video_service.d1kt.cn",
)

View File

@@ -0,0 +1,196 @@
package cn.learningpad.oraltrainer.sdk
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.os.Handler
import android.os.Looper
import org.json.JSONObject
import java.io.BufferedInputStream
import java.io.IOException
import java.io.InterruptedIOException
import java.net.HttpURLConnection
import java.net.URL
import java.security.MessageDigest
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.Future
data class SentenceBoundaryApiResult(
val videoHash: String,
val durationMs: Long,
val algorithmVersion: String,
val sentences: List<SentenceBoundary>,
)
interface SentenceBoundaryApiCallback {
fun onSuccess(result: SentenceBoundaryApiResult)
fun onError(error: Throwable)
}
class CancellableRequest internal constructor(
private val future: Future<*>,
) {
fun cancel() {
future.cancel(true)
}
}
class SentenceBoundaryApi 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 = Uri.parse(config.sentenceBoundaryApiBaseUrl.trimEnd('/'))
init {
require(baseUri.scheme == "https" || baseUri.scheme == "http") {
"sentenceBoundaryApiBaseUrl must use http or https."
}
require(!baseUri.host.isNullOrBlank()) {
"sentenceBoundaryApiBaseUrl must include a host."
}
}
fun fetch(
videoHash: String,
callback: SentenceBoundaryApiCallback,
): CancellableRequest {
validateHash(videoHash)
return submit(callback) { fetchBlocking(videoHash.lowercase()) }
}
fun fetchForUri(
uri: Uri,
contentResolver: ContentResolver = appContext.contentResolver,
callback: SentenceBoundaryApiCallback,
): CancellableRequest {
return submit(callback) {
val videoHash = sha256(uri, contentResolver)
fetchBlocking(videoHash)
}
}
fun release() {
executor.shutdownNow()
}
private fun submit(
callback: SentenceBoundaryApiCallback,
operation: () -> SentenceBoundaryApiResult,
): CancellableRequest {
val future = executor.submit {
try {
val result = operation()
if (!Thread.currentThread().isInterrupted) {
mainHandler.post { callback.onSuccess(result) }
}
} catch (error: Throwable) {
if (!Thread.currentThread().isInterrupted) {
mainHandler.post { callback.onError(error) }
}
}
}
return CancellableRequest(future)
}
private fun fetchBlocking(videoHash: String): SentenceBoundaryApiResult {
val endpoint = baseUri.buildUpon()
.appendPath("api")
.appendPath("v1")
.appendPath("videos")
.appendPath(videoHash)
.appendPath("sentence-boundaries")
.build()
val connection = URL(endpoint.toString()).openConnection() as HttpURLConnection
return try {
connection.requestMethod = "GET"
connection.connectTimeout = config.connectTimeoutMs
connection.readTimeout = config.readTimeoutMs
connection.setRequestProperty("Accept", "application/json")
connection.setRequestProperty("User-Agent", config.userAgent)
val statusCode = connection.responseCode
val stream = if (statusCode in 200..299) {
connection.inputStream
} else {
connection.errorStream
}
val body = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
if (statusCode !in 200..299) {
throw SentenceBoundaryApiException(statusCode, body)
}
parseResponse(body, videoHash)
} finally {
connection.disconnect()
}
}
private fun parseResponse(
body: String,
requestedHash: String,
): SentenceBoundaryApiResult {
val root = JSONObject(body)
val responseHash = root.getString("video_hash").lowercase()
if (responseHash != requestedHash.lowercase()) {
throw IOException("Sentence boundary response hash does not match the request.")
}
val jsonSentences = root.getJSONArray("sentences")
val sentences = buildList(jsonSentences.length()) {
for (index in 0 until jsonSentences.length()) {
val sentence = jsonSentences.getJSONObject(index)
add(
SentenceBoundary(
index = sentence.getInt("index"),
startMs = sentence.getLong("start_ms"),
endMs = sentence.getLong("end_ms"),
text = if (sentence.isNull("text")) null else sentence.getString("text"),
)
)
}
}
return SentenceBoundaryApiResult(
videoHash = responseHash,
durationMs = root.getLong("duration_ms"),
algorithmVersion = root.getString("algorithm_version"),
sentences = sentences,
)
}
private fun sha256(uri: Uri, contentResolver: ContentResolver): String {
val digest = MessageDigest.getInstance("SHA-256")
contentResolver.openInputStream(uri)?.use { input ->
BufferedInputStream(input).use { buffered ->
val buffer = ByteArray(1024 * 1024)
while (true) {
if (Thread.currentThread().isInterrupted) {
throw InterruptedIOException()
}
val count = buffered.read(buffer)
if (count < 0) {
break
}
digest.update(buffer, 0, count)
}
}
} ?: throw IOException("Cannot open video URI: $uri")
return digest.digest().joinToString("") { byte -> "%02x".format(byte.toInt() and 0xff) }
}
private fun validateHash(videoHash: String) {
require(SHA256_PATTERN.matches(videoHash)) {
"videoHash must be a 64-character SHA-256 hex digest."
}
}
companion object {
private val SHA256_PATTERN = Regex("^[0-9a-fA-F]{64}$")
}
}
class SentenceBoundaryApiException(
val statusCode: Int,
responseBody: String,
) : IOException("Sentence boundary API returned HTTP $statusCode: $responseBody")

View File

@@ -5,6 +5,7 @@ package cn.learningpad.oraltrainer.sdk
import android.content.Context
import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
@@ -32,11 +33,12 @@ internal object StreamingCache {
context: Context,
config: OralTrainerSdkConfig,
): DataSource.Factory {
val upstreamFactory = DefaultHttpDataSource.Factory()
val httpDataSourceFactory = DefaultHttpDataSource.Factory()
.setUserAgent(config.userAgent)
.setConnectTimeoutMs(config.connectTimeoutMs)
.setReadTimeoutMs(config.readTimeoutMs)
.setAllowCrossProtocolRedirects(true)
val upstreamFactory = DefaultDataSource.Factory(context.applicationContext, httpDataSourceFactory)
return CacheDataSource.Factory()
.setCache(get(context, config))