From f54e9b33a590a46d4df89927a704a303c4421c0a Mon Sep 17 00:00:00 2001 From: Daniel Kao Date: Sun, 16 Apr 2023 17:21:05 +0800 Subject: [PATCH] feat: add google translate feature to text selection --- .../einkbro/activity/BrowserActivity.kt | 9 + .../einkbro/pocket/PocketNetwork.kt | 39 ---- .../einkbro/service/TranslateRepository.kt | 68 +++++++ .../info/plateaukao/einkbro/util/Constants.kt | 1 + .../einkbro/view/compose/SelectableItem.kt | 3 + .../view/dialog/compose/GPTDialogFragment.kt | 9 +- .../dialog/compose/TranslateDialogFragment.kt | 177 ++++++++++++++++++ .../viewmodel/ActionModeMenuViewModel.kt | 21 ++- .../einkbro/viewmodel/GptViewModel.kt | 2 +- .../einkbro/viewmodel/TranslationViewModel.kt | 55 ++++++ app/src/main/res/values-zh-rCN/strings.xml | 1 + app/src/main/res/values-zh-rTW/strings.xml | 3 +- app/src/main/res/values/strings.xml | 1 + 13 files changed, 337 insertions(+), 52 deletions(-) create mode 100644 app/src/main/java/info/plateaukao/einkbro/service/TranslateRepository.kt create mode 100644 app/src/main/java/info/plateaukao/einkbro/view/dialog/compose/TranslateDialogFragment.kt create mode 100644 app/src/main/java/info/plateaukao/einkbro/viewmodel/TranslationViewModel.kt diff --git a/app/src/main/java/info/plateaukao/einkbro/activity/BrowserActivity.kt b/app/src/main/java/info/plateaukao/einkbro/activity/BrowserActivity.kt index fb107593c..10e559ae0 100755 --- a/app/src/main/java/info/plateaukao/einkbro/activity/BrowserActivity.kt +++ b/app/src/main/java/info/plateaukao/einkbro/activity/BrowserActivity.kt @@ -60,6 +60,7 @@ import info.plateaukao.einkbro.unit.* import info.plateaukao.einkbro.unit.BrowserUnit.createDownloadReceiver import info.plateaukao.einkbro.unit.HelperUnit.toNormalScheme import info.plateaukao.einkbro.util.Constants.Companion.ACTION_GPT +import info.plateaukao.einkbro.util.Constants.Companion.ACTION_GTRANSLATE import info.plateaukao.einkbro.util.DebugT import info.plateaukao.einkbro.view.* import info.plateaukao.einkbro.view.GestureType.* @@ -78,6 +79,7 @@ import info.plateaukao.einkbro.viewmodel.BookmarkViewModelFactory import info.plateaukao.einkbro.viewmodel.GptViewModel import info.plateaukao.einkbro.viewmodel.PocketViewModel import info.plateaukao.einkbro.viewmodel.PocketViewModelFactory +import info.plateaukao.einkbro.viewmodel.TranslationViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -111,6 +113,7 @@ open class BrowserActivity : FragmentActivity(), BrowserController { private val backupUnit: BackupUnit by lazy { BackupUnit(this) } private val gptViewModel: GptViewModel by viewModels() + private val translationViewModel: TranslationViewModel by viewModels() private fun prepareRecord(): Boolean { val webView = currentAlbumController as NinjaWebView @@ -684,6 +687,12 @@ open class BrowserActivity : FragmentActivity(), BrowserController { } when (intent.action) { + ACTION_GTRANSLATE -> { + translationViewModel.updateInputMessage(actionModeMenuViewModel.selectedText.value) + TranslateDialogFragment(translationViewModel, actionModeMenuViewModel.clickedPoint.value) + .show(supportFragmentManager, "translateDialog") + + } ACTION_GPT -> { gptViewModel.updateInputMessage(actionModeMenuViewModel.selectedText.value) if (gptViewModel.hasApiKey()) { diff --git a/app/src/main/java/info/plateaukao/einkbro/pocket/PocketNetwork.kt b/app/src/main/java/info/plateaukao/einkbro/pocket/PocketNetwork.kt index 154089999..53030cedd 100644 --- a/app/src/main/java/info/plateaukao/einkbro/pocket/PocketNetwork.kt +++ b/app/src/main/java/info/plateaukao/einkbro/pocket/PocketNetwork.kt @@ -70,45 +70,6 @@ class PocketNetwork { }) } - fun addUrlToPocket( - accessToken: String, - url: String, - title: String? = null, - tags: String? = null, - callback: (Boolean) -> Unit - ) { - val requestBodyBuilder = FormBody.Builder() - .add("url", url) - .add("consumer_key", consumerKey) - .add("access_token", accessToken) - - title?.let { - requestBodyBuilder.add("title", it) - } - - tags?.let { - requestBodyBuilder.add("tags", it) - } - - val requestBody = requestBodyBuilder.build() - - val request = Request.Builder() - .url("https://getpocket.com/v3/add") - .post(requestBody) - .build() - - client.newCall(request).enqueue(object : Callback { - override fun onFailure(call: Call, e: IOException) { - // Handle error - callback(false) - } - - override fun onResponse(call: Call, response: Response) { - callback(response.isSuccessful) - } - }) - } - @OptIn(ExperimentalCoroutinesApi::class) suspend fun addUrlToPocket( accessToken: String, diff --git a/app/src/main/java/info/plateaukao/einkbro/service/TranslateRepository.kt b/app/src/main/java/info/plateaukao/einkbro/service/TranslateRepository.kt new file mode 100644 index 000000000..b07508864 --- /dev/null +++ b/app/src/main/java/info/plateaukao/einkbro/service/TranslateRepository.kt @@ -0,0 +1,68 @@ +package info.plateaukao.einkbro.service + +import android.net.Uri +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.suspendCancellableCoroutine +import okhttp3.Call +import okhttp3.Callback +import okhttp3.HttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okio.IOException +import org.apache.commons.text.StringEscapeUtils +import org.jsoup.Jsoup + +class TranslateRepository { + private val client = OkHttpClient() + + @OptIn(ExperimentalCoroutinesApi::class) + suspend fun gTranslate( + text: String, + targetLanguage: String = "en", + sourceLanguage: String = "auto", + ): String? { + return suspendCancellableCoroutine { continuation -> + val url = HttpUrl.Builder() + .scheme("https") + .host("translate.google.com") + .addPathSegment("m") + .addQueryParameter("tl", targetLanguage) + .addQueryParameter("sl", sourceLanguage) + .addQueryParameter("q", text) + .build() + + val request = Request.Builder() + .url(url) + .build() + + client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + if (continuation.isActive) { + continuation.resume(null) {} + } + } + + override fun onResponse(call: Call, response: Response) { + if (continuation.isActive) { + val body = response.body?.string() + if (body != null) { + Jsoup.parse(body) + .body() + .getElementsByClass("result-container") + .first()?.text()?.let { + continuation.resume(StringEscapeUtils.unescapeJava(Uri.decode(it))) {} + } ?: continuation.resume(null) {} + } else { + continuation.resume(null) {} + } + } + } + }) + + continuation.invokeOnCancellation { + client.dispatcher.executorService.shutdownNow() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/info/plateaukao/einkbro/util/Constants.kt b/app/src/main/java/info/plateaukao/einkbro/util/Constants.kt index 2db39b3ad..4fa1267e9 100644 --- a/app/src/main/java/info/plateaukao/einkbro/util/Constants.kt +++ b/app/src/main/java/info/plateaukao/einkbro/util/Constants.kt @@ -10,6 +10,7 @@ class Constants { const val MIME_TYPE_FONT = "application/x-font-ttf" // from https://github.com/Smile4ever/Neat-URL const val ACTION_GPT = "info.plateaukao.einkbro.gpt" + const val ACTION_GTRANSLATE = "info.plateaukao.einkbro.gtranslate" const val NEAT_URL_DATA = """ { diff --git a/app/src/main/java/info/plateaukao/einkbro/view/compose/SelectableItem.kt b/app/src/main/java/info/plateaukao/einkbro/view/compose/SelectableItem.kt index e5feb5ae7..7eb1d8c63 100644 --- a/app/src/main/java/info/plateaukao/einkbro/view/compose/SelectableItem.kt +++ b/app/src/main/java/info/plateaukao/einkbro/view/compose/SelectableItem.kt @@ -10,6 +10,7 @@ import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -18,6 +19,7 @@ fun SelectableText( modifier: Modifier, selected: Boolean, text: String, + textAlign: TextAlign = TextAlign.Start, onClick: () -> Unit, ) { val interactionSource = remember { MutableInteractionSource() } @@ -28,6 +30,7 @@ fun SelectableText( style = MaterialTheme.typography.button, maxLines = 1, overflow = TextOverflow.Ellipsis, + textAlign = textAlign, modifier = modifier .border(borderWidth, MaterialTheme.colors.onBackground, RoundedCornerShape(7.dp)) .padding(horizontal = 6.dp, vertical = 6.dp) diff --git a/app/src/main/java/info/plateaukao/einkbro/view/dialog/compose/GPTDialogFragment.kt b/app/src/main/java/info/plateaukao/einkbro/view/dialog/compose/GPTDialogFragment.kt index a6b14aa27..9d1ef20fe 100644 --- a/app/src/main/java/info/plateaukao/einkbro/view/dialog/compose/GPTDialogFragment.kt +++ b/app/src/main/java/info/plateaukao/einkbro/view/dialog/compose/GPTDialogFragment.kt @@ -47,9 +47,7 @@ class GPTDialogFragment( override fun setupComposeView() = composeView.setContent { MyTheme { - GptResponse(gptViewModel) { - dismiss() - } + GptResponse(gptViewModel) } } @@ -122,10 +120,7 @@ class GPTDialogFragment( } @Composable -private fun GptResponse( - gptViewModel: GptViewModel, - onCloseAction: () -> Unit -) { +private fun GptResponse(gptViewModel: GptViewModel) { val requestMessage by gptViewModel.inputMessage.collectAsState() val responseMessage by gptViewModel.responseMessage.collectAsState() val showRequest = remember { mutableStateOf(false) } diff --git a/app/src/main/java/info/plateaukao/einkbro/view/dialog/compose/TranslateDialogFragment.kt b/app/src/main/java/info/plateaukao/einkbro/view/dialog/compose/TranslateDialogFragment.kt new file mode 100644 index 000000000..83d21f619 --- /dev/null +++ b/app/src/main/java/info/plateaukao/einkbro/view/dialog/compose/TranslateDialogFragment.kt @@ -0,0 +1,177 @@ +package info.plateaukao.einkbro.view.dialog.compose + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Point +import android.os.Bundle +import android.view.Gravity +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.Window +import android.view.WindowManager +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import info.plateaukao.einkbro.R +import info.plateaukao.einkbro.view.compose.MyTheme +import info.plateaukao.einkbro.view.compose.SelectableText +import info.plateaukao.einkbro.view.dialog.TranslationLanguageDialog +import info.plateaukao.einkbro.viewmodel.TranslationViewModel + +class TranslateDialogFragment( + private val translationViewModel: TranslationViewModel, + private val anchorPoint: Point, +) : ComposeDialogFragment() { + + init { + shouldShowInCenter = true + } + + override fun setupComposeView() = composeView.setContent { + MyTheme { + TranslateResponse(translationViewModel) { + TranslationLanguageDialog(requireActivity()).show { translationLanguage -> + translationViewModel.updateTranslationLanguage(translationLanguage) + } + } + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val view = super.onCreateView(inflater, container, savedInstanceState) + setupDialogPosition(anchorPoint) + + translationViewModel.query() + return view + } + + private var initialTouchX: Float = 0f + private var initialTouchY: Float = 0f + private var initialX: Int = 0 + private var initialY: Int = 0 + + @SuppressLint("ClickableViewAccessibility") + private fun setupDialogPosition(position: Point) { + val window = dialog?.window ?: return + window.setGravity(Gravity.TOP or Gravity.LEFT) + + if (position.isValid()) { + val params = window.attributes.apply { + x = position.x + y = position.y + } + window.attributes = params + } + + supportDragToMove(window) + } + + @SuppressLint("ClickableViewAccessibility") + private fun supportDragToMove(window: Window) { + val windowManager = + requireContext().getSystemService(Context.WINDOW_SERVICE) as WindowManager + window.decorView.setOnTouchListener { _, event -> + when (event.action) { + MotionEvent.ACTION_DOWN -> { + // Get the initial touch position and dialog window position + initialTouchX = event.rawX + initialTouchY = event.rawY + initialX = window.attributes.x + initialY = window.attributes.y + true + } + + MotionEvent.ACTION_MOVE -> { + // Calculate the new position of the dialog window + val newX = initialX + (event.rawX - initialTouchX).toInt() + val newY = initialY + (event.rawY - initialTouchY).toInt() + + // Update the position of the dialog window + window.attributes.x = newX + window.attributes.y = newY + windowManager.updateViewLayout(window.decorView, window.attributes) + true + } + + else -> false + } + } + } + + private fun Point.isValid() = x != 0 && y != 0 +} + +@Composable +private fun TranslateResponse( + translationViewModel: TranslationViewModel, + onLanguageClick: () -> Unit +) { + val requestMessage by translationViewModel.inputMessage.collectAsState() + val responseMessage by translationViewModel.responseMessage.collectAsState() + val targetLanguage by translationViewModel.translationLanguage.collectAsState() + val showRequest = remember { mutableStateOf(false) } + + Column( + modifier = Modifier + .wrapContentHeight() + .wrapContentWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + SelectableText( + modifier = Modifier + .weight(1f) + .padding(10.dp), + selected = true, + text = targetLanguage.language, + textAlign = TextAlign.Center, + onClick = onLanguageClick + ) + Icon( + painter = painterResource( + id = if (showRequest.value) R.drawable.icon_arrow_up_gest else R.drawable.icon_arrow_down_gest + ), + contentDescription = "Info Icon", + modifier = Modifier + .size(32.dp) + .clickable { showRequest.value = !showRequest.value } + ) + } + if (showRequest.value) { + Text( + text = requestMessage, + modifier = Modifier.padding(10.dp) + ) + Divider() + } + Text( + text = responseMessage, + modifier = Modifier.padding(10.dp) + ) + } +} diff --git a/app/src/main/java/info/plateaukao/einkbro/viewmodel/ActionModeMenuViewModel.kt b/app/src/main/java/info/plateaukao/einkbro/viewmodel/ActionModeMenuViewModel.kt index efd183454..0662ee529 100644 --- a/app/src/main/java/info/plateaukao/einkbro/viewmodel/ActionModeMenuViewModel.kt +++ b/app/src/main/java/info/plateaukao/einkbro/viewmodel/ActionModeMenuViewModel.kt @@ -16,6 +16,7 @@ import info.plateaukao.einkbro.activity.toMenuInfo import info.plateaukao.einkbro.preference.ConfigManager import info.plateaukao.einkbro.unit.ShareUtil import info.plateaukao.einkbro.util.Constants.Companion.ACTION_GPT +import info.plateaukao.einkbro.util.Constants.Companion.ACTION_GTRANSLATE import info.plateaukao.einkbro.view.dialog.compose.ActionModeDialogFragment import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -88,13 +89,14 @@ class ActionModeMenuViewModel : ViewModel(), KoinComponent { menuInfos.add( 0, MenuInfo( - context.getString(android.R.string.copy), - icon = ContextCompat.getDrawable(context, R.drawable.ic_copy), - action = { - ShareUtil.copyToClipboard(context, selectedText.value) + context.getString(R.string.google_translate), + icon = ContextCompat.getDrawable(context, R.drawable.ic_translate), + intent = Intent(context, BrowserActivity::class.java).apply { + action = ACTION_GTRANSLATE } ) ) + if (configManager.gptApiKey.isNotEmpty()) { menuInfos.add( 0, @@ -108,6 +110,17 @@ class ActionModeMenuViewModel : ViewModel(), KoinComponent { ) } + menuInfos.add( + 0, + MenuInfo( + context.getString(android.R.string.copy), + icon = ContextCompat.getDrawable(context, R.drawable.ic_copy), + action = { + ShareUtil.copyToClipboard(context, selectedText.value) + } + ) + ) + return menuInfos } diff --git a/app/src/main/java/info/plateaukao/einkbro/viewmodel/GptViewModel.kt b/app/src/main/java/info/plateaukao/einkbro/viewmodel/GptViewModel.kt index 789876ca3..d6abaae33 100644 --- a/app/src/main/java/info/plateaukao/einkbro/viewmodel/GptViewModel.kt +++ b/app/src/main/java/info/plateaukao/einkbro/viewmodel/GptViewModel.kt @@ -8,13 +8,13 @@ import com.aallam.openai.api.chat.ChatMessage import com.aallam.openai.api.chat.ChatRole import com.aallam.openai.api.model.ModelId import com.aallam.openai.client.OpenAI +import info.plateaukao.einkbro.BuildConfig import info.plateaukao.einkbro.preference.ConfigManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import org.koin.android.BuildConfig import org.koin.core.component.KoinComponent import org.koin.core.component.inject diff --git a/app/src/main/java/info/plateaukao/einkbro/viewmodel/TranslationViewModel.kt b/app/src/main/java/info/plateaukao/einkbro/viewmodel/TranslationViewModel.kt new file mode 100644 index 000000000..e3aaff399 --- /dev/null +++ b/app/src/main/java/info/plateaukao/einkbro/viewmodel/TranslationViewModel.kt @@ -0,0 +1,55 @@ +package info.plateaukao.einkbro.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import info.plateaukao.einkbro.preference.ConfigManager +import info.plateaukao.einkbro.service.TranslateRepository +import info.plateaukao.einkbro.util.TranslationLanguage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.apache.commons.text.StringEscapeUtils +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +class TranslationViewModel : ViewModel(), KoinComponent { + private val translateRepository = TranslateRepository() + private val config: ConfigManager by inject() + + private val _responseMessage = MutableStateFlow("") + val responseMessage: StateFlow = _responseMessage.asStateFlow() + + private val _inputMessage = MutableStateFlow("") + val inputMessage: StateFlow = _inputMessage.asStateFlow() + + private val _translationLanguage = MutableStateFlow(config.translationLanguage) + val translationLanguage: StateFlow = _translationLanguage.asStateFlow() + + fun updateInputMessage(userMessage: String) { + _inputMessage.value = StringEscapeUtils.unescapeJava(userMessage) + _responseMessage.value = "..." + } + + fun updateTranslationLanguage(language: TranslationLanguage) { + _translationLanguage.value = language + _responseMessage.value = "..." + query(_inputMessage.value) + } + + fun query(userMessage: String? = null) { + if (userMessage != null) { + _inputMessage.value = userMessage + } + + viewModelScope.launch(Dispatchers.IO) { + _responseMessage.value = + translateRepository.gTranslate( + _inputMessage.value, + targetLanguage = config.translationLanguage.value + ) + ?: "Something went wrong." + } + } +} \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index dae54dab6..1bc196afb 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -438,4 +438,5 @@ Enable touch pagination Show Default Selected Text Menu After selecting text, show system default action menu. + Google Translate diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 7fe404e30..698ae4a09 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -358,7 +358,7 @@ ChatGPT Integration Input OpenAI Key Input key in order to use this feature. - ChatGPT Prompt + System Prompt Input prompt like: translate into English Edit user prompt prefix User Prompt Prefix @@ -366,4 +366,5 @@ Enable touch pagination Show Default Selected Text Menu After selecting text, show system default action menu. + Google Translate diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3c2c8970d..676e198b4 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -434,4 +434,5 @@ Enable touch pagination Show Default Selected Text Menu After selecting text, show system default action menu. + Google Translate