Skip to content

Commit

Permalink
[Code] Optimize architecture
Browse files Browse the repository at this point in the history
  • Loading branch information
Konyaco committed Feb 24, 2023
1 parent 1006a15 commit c3200e0
Show file tree
Hide file tree
Showing 9 changed files with 332 additions and 585 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package me.konyaco.collinsdictionary.service

import android.content.Context
import android.media.AudioAttributes
import android.media.MediaPlayer
import android.util.Log
import androidx.core.net.toUri
import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import kotlinx.coroutines.*
import java.io.File
import kotlin.coroutines.resume

private const val TAG = "AndroidSoundPlayer.android"

class AndroidSoundPlayer : SoundPlayer {
var context: Context? = null

override fun play(
url: String,
onStart: () -> Unit,
onStop: () -> Unit,
onError: (e: Throwable) -> Unit
) {
job.let {
job = scope.async(Dispatchers.IO) {
val file = try {
getFile(url)
} catch (e: Throwable) {
Log.w(TAG, "Failed to download sound", e)
onError(e)
return@async
}
it?.cancelAndJoin()
try {
onStart()
playMedia(file)
onStop()
} catch (e: Throwable) {
Log.w("Failed to play sound", e)
onError(e)
}
}
}
}

private val scope = CoroutineScope(Dispatchers.Default)
private var job: Job? = null

private suspend fun playMedia(file: File) {
suspendCancellableCoroutine<Unit> { continuation ->
val mediaPlayer = MediaPlayer()
continuation.invokeOnCancellation {
mediaPlayer.pause()
mediaPlayer.release()
}
with(mediaPlayer) {
setOnCompletionListener {
release()
continuation.resume(Unit)
}
setAudioAttributes(
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.setUsage(AudioAttributes.USAGE_MEDIA)
.build()
)
setDataSource(context!!, file.toUri())
prepare()
start()
}
}
}

private fun directory(): File =
context!!.externalCacheDir!!.resolve("sound").also { it.mkdir() }

// Get filename part of url (xx://xx/xxx.mp3)
private fun fileNameFromUrl(url: String) = url.substringAfterLast("/")

private suspend fun getFile(url: String): File {
val fileName = fileNameFromUrl(url)
val file = directory().resolve(fileName)
if (!file.exists()) {
val bytes = HttpClient().get(url).readBytes()
withContext(Dispatchers.IO) {
file.outputStream().use {
it.write(bytes)
}
}
}
return file
}
}
Original file line number Diff line number Diff line change
@@ -1,107 +1,14 @@
package me.konyaco.collinsdictionary.service

import android.content.Context
import android.media.AudioAttributes
import android.media.MediaPlayer
import android.util.Log
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.core.net.toUri
import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import kotlinx.coroutines.*
import java.io.File
import kotlin.coroutines.resume

private const val TAG = "SoundPlayer.android"

class SoundPlayerImpl : SoundPlayer {
var context: Context? = null

override fun play(
url: String,
onStart: () -> Unit,
onStop: () -> Unit,
onError: (e: Throwable) -> Unit
) {
job.let {
job = scope.async(Dispatchers.IO) {
val file = try {
getFile(url)
} catch (e: Throwable) {
Log.w(TAG, "Failed to download sound", e)
onError(e)
return@async
}
it?.cancelAndJoin()
try {
onStart()
playMedia(file)
onStop()
} catch (e: Throwable) {
Log.w("Failed to play sound", e)
onError(e)
}
}
}
}

private val scope = CoroutineScope(Dispatchers.Default)
private var job: Job? = null

private suspend fun playMedia(file: File) {
suspendCancellableCoroutine<Unit> { continuation ->
val mediaPlayer = MediaPlayer()
continuation.invokeOnCancellation {
mediaPlayer.pause()
mediaPlayer.release()
}
with(mediaPlayer) {
setOnCompletionListener {
release()
continuation.resume(Unit)
}
setAudioAttributes(
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.setUsage(AudioAttributes.USAGE_MEDIA)
.build()
)
setDataSource(context!!, file.toUri())
prepare()
start()
}
}
}

private fun directory(): File =
context!!.externalCacheDir!!.resolve("sound").also { it.mkdir() }

// Get filename part of url (xx://xx/xxx.mp3)
private fun fileNameFromUrl(url: String) = url.substringAfterLast("/")

private suspend fun getFile(url: String): File {
val fileName = fileNameFromUrl(url)
val file = directory().resolve(fileName)
if (!file.exists()) {
val bytes = HttpClient().get(url).readBytes()
withContext(Dispatchers.IO) {
file.outputStream().use {
it.write(bytes)
}
}
}
return file
}
}

@Composable
actual fun getSoundPlayer(): SoundPlayer {
val context = LocalContext.current.applicationContext
val soundPlayer = remember { SoundPlayerImpl() }
val soundPlayer = remember { AndroidSoundPlayer() }
DisposableEffect(Unit) {
soundPlayer.context = context
onDispose { soundPlayer.context = null }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,22 @@
package me.konyaco.collinsdictionary.service

expect class CollinsOnlineDictionary(): CollinsDictionary
import me.konyaco.collinsdictionary.service.internal.JsoupCollinsParser
import me.konyaco.collinsdictionary.service.internal.CollinsHttpRequester

class CollinsOnlineDictionary : CollinsDictionary {
private val parser = JsoupCollinsParser()
private val requester = CollinsHttpRequester()

override suspend fun search(word: String): SearchResult {
return when (val result = requester.search(word)) {
is CollinsHttpRequester.SearchResult.PreciseWord -> SearchResult.PreciseWord(word)
is CollinsHttpRequester.SearchResult.Redirect -> SearchResult.Redirect(result.redirectWord)
is CollinsHttpRequester.SearchResult.NotFound -> SearchResult.NotFound(parser.parseAlternatives(result.wordListHtml))
}
}

override suspend fun getDefinition(word: String): Word? {
val html = requester.getDefinition(word)
return parser.parseContent(html)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package me.konyaco.collinsdictionary.service.internal

import io.ktor.client.*
import io.ktor.client.engine.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import java.net.ProxySelector
import java.net.URI

internal class CollinsHttpRequester {
private val client = HttpClient(CIO) {
setupClient()
followRedirects = false
}
private val clientFollowRedirect = HttpClient(CIO) {
setupClient()
}

private fun HttpClientConfig<*>.setupClient() {
engine {
val proxy =
ProxySelector.getDefault().select(URI("https://www.collinsdictionary.com"))
.firstOrNull()
if (proxy != null && proxy.type != ProxyType.UNKNOWN) {
this.proxy = proxy
}
}
}

companion object {
private const val SEARCH_URL = "https://www.collinsdictionary.com/search"
private const val SPELL_CHECK_URL = "https://www.collinsdictionary.com/spellcheck/english"
private const val DICTIONARY_URL = "https://www.collinsdictionary.com/dictionary/english"
private fun buildSearchURL(word: String) = "$SEARCH_URL/?dictCode=english&q=$word"
private fun buildDictionaryURL(word: String) = "$DICTIONARY_URL/$word"
}

suspend fun getDefinition(word: String): String {
return clientFollowRedirect.get(buildDictionaryURL(word)).bodyAsText()
}

sealed interface SearchResult {
data class PreciseWord(val contentHtml: String): SearchResult
data class Redirect(val redirectWord: String): SearchResult
data class NotFound(val wordListHtml: String): SearchResult
}

suspend fun search(word: String): SearchResult {
val response = try {
client.get(buildSearchURL(word))
} catch (e: RedirectResponseException) {
e.response
}

if (response.status == HttpStatusCode.Found) {
val redirectedUrl =
response.headers[HttpHeaders.Location] ?: error("Redirect to header was not found.")

return if (isPrecise(redirectedUrl)) {
val redirectWord = getRedirectedWord(redirectedUrl)
if (redirectWord == word) {
SearchResult.PreciseWord(redirectWord)
} else {
SearchResult.Redirect(redirectWord)
}
} else {
val html = client.get(redirectedUrl).bodyAsText() // Get response in [spellcheck]
SearchResult.NotFound(html)
}
} else {
error("Response code is: ${response.status}")
}
}

/**
* If the search result redirects to a precise page. Or to a spellcheck page.
*/
private fun isPrecise(redirectedUrl: String): Boolean {
return when {
redirectedUrl.startsWith(DICTIONARY_URL) -> true
redirectedUrl.startsWith(SPELL_CHECK_URL) -> false
else -> error("Could not search word.")
}
}

private fun getRedirectedWord(redirectedUrl: String): String =
redirectedUrl.substringAfterLast("/")

}
Loading

0 comments on commit c3200e0

Please sign in to comment.