add sentence service
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user