diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8b6c42e85a..09ee947209 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -579,6 +579,8 @@ dependencies { // ML Kit implementation("com.google.mlkit:entity-extraction:$entityExtractionVersion") + implementation("com.google.mlkit:translate:17.0.1") + implementation("com.google.mlkit:language-id:17.0.4") testImplementation("com.google.protobuf:protobuf-javalite") { version { diff --git a/app/src/main/java/one/mixin/android/Constants.kt b/app/src/main/java/one/mixin/android/Constants.kt index 9f49328bb1..5aba393cce 100644 --- a/app/src/main/java/one/mixin/android/Constants.kt +++ b/app/src/main/java/one/mixin/android/Constants.kt @@ -94,6 +94,8 @@ object Constants { const val PREF_CLEANUP_THUMB = "pref_cleanup_thumb" const val PREF_CLEANUP_QUOTE_CONTENT = "pref_cleanup_quote_content" const val PREF_TRANSFER_SCENE = "pref_transfer_scene" + const val PREF_SHOW_TRANSLATE_BUTTON = "pref_show_translate_button" + const val PREF_TRANSLATE_TARGET_LANG = "pref_translate_target_lang" const val PREF_LOGIN_VERIFY = "pref_login_verify" const val PREF_LOGIN_OR_SIGN_UP = "pref_login_or_sign_up" const val PREF_NOTIFY_ENABLE_BIOMETRIC = "pref_notify_enable_biometric" diff --git a/app/src/main/java/one/mixin/android/ui/conversation/ConversationFragment.kt b/app/src/main/java/one/mixin/android/ui/conversation/ConversationFragment.kt index ab840c0a4a..5ef528a3dd 100644 --- a/app/src/main/java/one/mixin/android/ui/conversation/ConversationFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/conversation/ConversationFragment.kt @@ -6,6 +6,7 @@ import android.Manifest import android.annotation.SuppressLint import android.annotation.TargetApi import android.app.Activity +import android.app.ProgressDialog import android.content.ActivityNotFoundException import android.content.ClipData import android.content.ContentResolver @@ -120,6 +121,7 @@ import one.mixin.android.extension.getParcelableExtraCompat import one.mixin.android.extension.getUriForFile import one.mixin.android.extension.hideKeyboard import one.mixin.android.extension.inTransaction +import one.mixin.android.extension.indeterminateProgressDialog import one.mixin.android.extension.isAuto import one.mixin.android.extension.isBluetoothHeadsetOrWiredHeadset import one.mixin.android.extension.isGif @@ -216,6 +218,7 @@ import one.mixin.android.util.ErrorHandler.Companion.FORBIDDEN import one.mixin.android.util.GsonHelper import one.mixin.android.util.MusicPlayer import one.mixin.android.util.SINGLE_DB_THREAD +import one.mixin.android.util.TranslateManager import one.mixin.android.util.analytics.AnalyticsTracker import one.mixin.android.util.debug.debugLongClick import one.mixin.android.util.markdown.MarkwonUtil.Companion.getMiniMarkwon @@ -471,26 +474,30 @@ class ConversationFragment() : when { messageAdapter.selectSet.isEmpty() -> binding.toolView.fadeOut() messageAdapter.selectSet.size == 1 -> { + val firstItem = messageAdapter.selectSet.valueAt(0) try { - if (messageAdapter.selectSet.valueAt(0).isText()) { + if (firstItem.isText()) { binding.toolView.copyIv.visibility = VISIBLE + binding.toolView.translateIv.isVisible = shouldShowTranslate(firstItem.messageId) } else { binding.toolView.copyIv.visibility = GONE + binding.toolView.translateIv.visibility = GONE } } catch (e: ArrayIndexOutOfBoundsException) { binding.toolView.copyIv.visibility = GONE + binding.toolView.translateIv.visibility = GONE } - if (messageAdapter.selectSet.valueAt(0).isData()) { + if (firstItem.isData()) { binding.toolView.shareIv.visibility = VISIBLE } else { binding.toolView.shareIv.visibility = GONE } - if (messageAdapter.selectSet.valueAt(0).supportSticker()) { + if (firstItem.supportSticker()) { binding.toolView.addStickerIv.visibility = VISIBLE } else { binding.toolView.addStickerIv.visibility = GONE } - if (messageAdapter.selectSet.valueAt(0).canNotReply()) { + if (firstItem.canNotReply()) { binding.toolView.replyIv.visibility = GONE } else { binding.toolView.replyIv.visibility = VISIBLE @@ -501,6 +508,7 @@ class ConversationFragment() : binding.toolView.forwardIv.visibility = VISIBLE binding.toolView.replyIv.visibility = GONE binding.toolView.copyIv.visibility = GONE + binding.toolView.translateIv.visibility = GONE binding.toolView.addStickerIv.visibility = GONE binding.toolView.shareIv.visibility = GONE binding.toolView.pinIv.visibility = GONE @@ -524,8 +532,10 @@ class ConversationFragment() : if (b) { if (messageItem.isText()) { binding.toolView.copyIv.visibility = VISIBLE + binding.toolView.translateIv.isVisible = shouldShowTranslate(messageItem.messageId) } else { binding.toolView.copyIv.visibility = GONE + binding.toolView.translateIv.visibility = GONE } if (messageItem.isData()) { binding.toolView.shareIv.visibility = VISIBLE @@ -1353,6 +1363,7 @@ class ConversationFragment() : AudioPlayer.pause() AudioPlayer.setStatusListener(null) AudioPlayer.release() + translateManager?.release() context?.let { if (!anyCallServiceRunning(it)) { audioSwitch.safeStop() @@ -1633,6 +1644,33 @@ class ConversationFragment() : } closeTool() } + binding.toolView.translateIv.setOnClickListener { + if (messageAdapter.selectSet.isEmpty()) return@setOnClickListener + val messageItem = messageAdapter.selectSet.valueAt(0) + val content = messageItem.content ?: return@setOnClickListener + if (translateManager == null) { + translateManager = TranslateManager() + } + lifecycleScope.launch { + val translated = + withContext(Dispatchers.IO) { + translateManager?.translate(requireContext(), content) { complete -> + lifecycleScope.launch { + if (complete) { + translateDownloadModelDialog?.dismiss() + } else { + translateDownloadModelDialog = + indeterminateProgressDialog(message = R.string.Please_wait_a_bit) + } + } + } + } + if (translated != null) { + messageAdapter.updateTranslated(messageItem.messageId, translated) + } + } + closeTool() + } binding.toolView.forwardIv.setOnClickListener { showForwardDialog() } @@ -1859,6 +1897,13 @@ class ConversationFragment() : } } + private var translateManager: TranslateManager? = null + private var translateDownloadModelDialog: ProgressDialog? = null + + private fun shouldShowTranslate(messageId: String): Boolean = + defaultSharedPreferences.getBoolean(Constants.Account.PREF_SHOW_TRANSLATE_BUTTON, false) && + messageAdapter.notTranslated(messageId) + private var deleteDialog: AlertDialog? = null private fun deleteMessage(messages: List) { diff --git a/app/src/main/java/one/mixin/android/ui/conversation/adapter/MessageAdapter.kt b/app/src/main/java/one/mixin/android/ui/conversation/adapter/MessageAdapter.kt index ff769ad47d..56a47ac4e0 100644 --- a/app/src/main/java/one/mixin/android/ui/conversation/adapter/MessageAdapter.kt +++ b/app/src/main/java/one/mixin/android/ui/conversation/adapter/MessageAdapter.kt @@ -126,6 +126,18 @@ class MessageAdapter( private val isSecret: Boolean = true, var keyword: String? = null, ) : RecyclerView.Adapter(), MixinStickyRecyclerHeadersAdapter { + private val translatedMap = mutableMapOf() + + fun updateTranslated( + messageId: String, + translated: String, + ) { + translatedMap[messageId] = translated + notifyDataSetChanged() + } + + fun notTranslated(messageId: String): Boolean = translatedMap[messageId] == null + override fun onCreateViewHolder( parent: ViewGroup, viewType: Int, @@ -282,6 +294,7 @@ class MessageAdapter( (holder as TextHolder).bind( it, keyword, + translatedMap[it.messageId], isLast(position), isFirst(position), selectSet.size > 0, diff --git a/app/src/main/java/one/mixin/android/ui/conversation/holder/TextHolder.kt b/app/src/main/java/one/mixin/android/ui/conversation/holder/TextHolder.kt index 4f9b186a26..02f6676dcc 100644 --- a/app/src/main/java/one/mixin/android/ui/conversation/holder/TextHolder.kt +++ b/app/src/main/java/one/mixin/android/ui/conversation/holder/TextHolder.kt @@ -1,11 +1,13 @@ package one.mixin.android.ui.conversation.holder +import android.content.ClipData import android.graphics.Color import android.util.TypedValue import android.view.GestureDetector import android.view.MotionEvent import android.view.View import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.isVisible import one.mixin.android.Constants import one.mixin.android.Constants.Colors.LINK_COLOR import one.mixin.android.Constants.Colors.SELECT_COLOR @@ -15,6 +17,7 @@ import one.mixin.android.databinding.ItemChatTextBinding import one.mixin.android.event.MentionReadEvent import one.mixin.android.extension.defaultSharedPreferences import one.mixin.android.extension.doubleClickVibrate +import one.mixin.android.extension.getClipboardManager import one.mixin.android.extension.initChatMode import one.mixin.android.extension.maxItemWidth import one.mixin.android.extension.renderMessage @@ -34,11 +37,13 @@ class TextHolder constructor(val binding: ItemChatTextBinding) : BaseMentionHold binding.chatTime.changeSize(textSize - 4f) binding.chatName.setTextSize(TypedValue.COMPLEX_UNIT_DIP, textSize) binding.chatTv.setTextSize(TypedValue.COMPLEX_UNIT_DIP, textSize) + binding.translateTv.setTextSize(TypedValue.COMPLEX_UNIT_DIP, textSize) } } binding.chatTv.initChatMode(LINK_COLOR) binding.chatTv.setSelectedStateColor(SELECT_COLOR) binding.chatLayout.setMaxWidth(itemView.context.maxItemWidth()) + binding.translateRl.setMaxWidth(itemView.context.maxItemWidth()) } override fun chatLayout( @@ -81,11 +86,31 @@ class TextHolder constructor(val binding: ItemChatTextBinding) : BaseMentionHold } } + private fun translatedLayout(isMe: Boolean) { + val lp = binding.translateRl.layoutParams as ConstraintLayout.LayoutParams + if (isMe) { + lp.horizontalBias = 1f + setItemBackgroundResource( + binding.translateRl, + R.drawable.chat_bubble_me, + R.drawable.chat_bubble_me_night, + ) + } else { + lp.horizontalBias = 0f + setItemBackgroundResource( + binding.translateRl, + R.drawable.chat_bubble_other, + R.drawable.chat_bubble_other_night, + ) + } + } + private var onItemListener: MessageAdapter.OnItemListener? = null fun bind( messageItem: MessageItem, keyword: String?, + translated: String? = null, isLast: Boolean, isFirst: Boolean = false, hasSelect: Boolean, @@ -141,6 +166,13 @@ class TextHolder constructor(val binding: ItemChatTextBinding) : BaseMentionHold } } + binding.translateTv.setOnLongClickListener { + binding.root.context.getClipboardManager().setPrimaryClip( + ClipData.newPlainText(null, binding.translateTv.text), + ) + true + } + itemView.setOnClickListener { if (hasSelect) { onItemListener.onSelect(!isSelect, messageItem, absoluteAdapterPosition) @@ -179,6 +211,14 @@ class TextHolder constructor(val binding: ItemChatTextBinding) : BaseMentionHold binding.chatTv.renderMessage(messageItem.content, keyword) } + if (translated.isNullOrBlank()) { + binding.translateTv.text = null + binding.translateRl.isVisible = false + } else { + binding.translateTv.text = translated + binding.translateRl.isVisible = true + } + val isMe = meId == messageItem.userId if (isFirst && !isMe) { binding.chatName.visibility = View.VISIBLE @@ -201,6 +241,7 @@ class TextHolder constructor(val binding: ItemChatTextBinding) : BaseMentionHold chatJumpLayout(binding.chatJump, isMe, messageItem.expireIn, messageItem.expireAt, R.id.chat_layout) chatLayout(isMe, isLast) + translatedLayout(isMe) attachAction = if (messageItem.mentionRead == false) { diff --git a/app/src/main/java/one/mixin/android/ui/setting/AppearanceFragment.kt b/app/src/main/java/one/mixin/android/ui/setting/AppearanceFragment.kt index 21c3e751d0..fc1e6fd88f 100644 --- a/app/src/main/java/one/mixin/android/ui/setting/AppearanceFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/setting/AppearanceFragment.kt @@ -6,19 +6,25 @@ import android.os.Build import android.os.Bundle import android.view.Gravity import android.view.View +import android.view.ViewGroup.MarginLayoutParams import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.widget.ListPopupWindow import androidx.core.content.ContextCompat import androidx.core.os.LocaleListCompat +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams import androidx.fragment.app.viewModels import dagger.hilt.android.AndroidEntryPoint import one.mixin.android.Constants +import one.mixin.android.Constants.Account.PREF_SHOW_TRANSLATE_BUTTON +import one.mixin.android.Constants.Account.PREF_TRANSLATE_TARGET_LANG import one.mixin.android.R import one.mixin.android.RxBus import one.mixin.android.databinding.FragmentAppearanceBinding import one.mixin.android.event.QuoteColorEvent import one.mixin.android.extension.alertDialogBuilder import one.mixin.android.extension.defaultSharedPreferences +import one.mixin.android.extension.dp import one.mixin.android.extension.dpToPx import one.mixin.android.extension.navTo import one.mixin.android.extension.putBoolean @@ -27,7 +33,9 @@ import one.mixin.android.extension.singleChoice import one.mixin.android.session.Session import one.mixin.android.ui.common.BaseFragment import one.mixin.android.util.TimeCache +import one.mixin.android.util.TranslateManager import one.mixin.android.util.getLanguage +import one.mixin.android.util.getLanguageOrDefault import one.mixin.android.util.getLocaleString import one.mixin.android.util.isFollowSystem import one.mixin.android.util.viewBinding @@ -130,6 +138,34 @@ class AppearanceFragment : BaseFragment(R.layout.fragment_appearance) { textSizeRl.setOnClickListener { navTo(SettingSizeFragment.newInstance(), SettingSizeFragment.TAG) } + translateSv.setContent(R.string.Show_Translate_Button) + val showTranslateButton = defaultSharedPreferences.getBoolean(PREF_SHOW_TRANSLATE_BUTTON, false) + translateSv.isChecked = showTranslateButton + targetRl.isVisible = showTranslateButton + translateSv.updateLayoutParams { + topMargin = if (showTranslateButton) 0 else 20.dp + } + translateSv.setOnCheckedChangeListener { _, isChecked -> + defaultSharedPreferences.putBoolean(PREF_SHOW_TRANSLATE_BUTTON, isChecked) + targetRl.isVisible = isChecked + translateSv.updateLayoutParams { + topMargin = if (isChecked) 0 else 20.dp + } + } + targetLangTv.text = + defaultSharedPreferences.getString(PREF_TRANSLATE_TARGET_LANG, getLanguageOrDefault())?.let { + TranslateManager.Language(it).nameInCurrentLanguage + } + targetRl.setOnClickListener { + val languageBottom = TranslateLanguageBottomSheetDialogFragment.newInstance() + languageBottom.callback = + object : TranslateLanguageBottomSheetDialogFragment.Callback { + override fun onLanguageClick(language: TranslateManager.Language) { + targetLangTv.text = language.nameInCurrentLanguage + } + } + languageBottom.showNow(parentFragmentManager, TranslateLanguageBottomSheetDialogFragment.TAG) + } val quoteColor = requireContext().defaultSharedPreferences.getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) quoteColorDescTv.setText( if (quoteColor) { diff --git a/app/src/main/java/one/mixin/android/ui/setting/CurrencyBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/setting/CurrencyBottomSheetDialogFragment.kt index 9c03bdc0f0..c2e88cf0c3 100644 --- a/app/src/main/java/one/mixin/android/ui/setting/CurrencyBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/setting/CurrencyBottomSheetDialogFragment.kt @@ -10,7 +10,7 @@ import one.mixin.android.R import one.mixin.android.RxBus import one.mixin.android.api.handleMixinResponse import one.mixin.android.api.request.AccountUpdateRequest -import one.mixin.android.databinding.FragmentCurrencyBottomSheetBinding +import one.mixin.android.databinding.FragmentSearchListBottomSheetBinding import one.mixin.android.event.WalletOperationType import one.mixin.android.event.WalletRefreshedEvent import one.mixin.android.extension.appCompatActionBarHeight @@ -38,7 +38,7 @@ class CurrencyBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { private val currencyAdapter = CurrencyAdapter() private val currencies = arrayListOf() - private val binding by viewBinding(FragmentCurrencyBottomSheetBinding::inflate) + private val binding by viewBinding(FragmentSearchListBottomSheetBinding::inflate) @SuppressLint("RestrictedApi") override fun setupDialog( @@ -70,7 +70,7 @@ class CurrencyBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { savePreference(currency) } } - currencyRv.adapter = currencyAdapter + rv.adapter = currencyAdapter } currencies.clear() currencies.addAll(getCurrencyData(requireContext().resources)) diff --git a/app/src/main/java/one/mixin/android/ui/setting/TranslateLanguageBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/setting/TranslateLanguageBottomSheetDialogFragment.kt new file mode 100644 index 0000000000..7940f8f1da --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/setting/TranslateLanguageBottomSheetDialogFragment.kt @@ -0,0 +1,133 @@ +package one.mixin.android.ui.setting + +import android.annotation.SuppressLint +import android.app.Dialog +import android.text.Editable +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.google.mlkit.nl.translate.TranslateLanguage +import dagger.hilt.android.AndroidEntryPoint +import one.mixin.android.Constants +import one.mixin.android.databinding.FragmentSearchListBottomSheetBinding +import one.mixin.android.databinding.ItemLanguageBinding +import one.mixin.android.extension.appCompatActionBarHeight +import one.mixin.android.extension.containsIgnoreCase +import one.mixin.android.extension.defaultSharedPreferences +import one.mixin.android.extension.equalsIgnoreCase +import one.mixin.android.extension.putString +import one.mixin.android.extension.statusBarHeight +import one.mixin.android.ui.common.MixinBottomSheetDialogFragment +import one.mixin.android.util.TranslateManager +import one.mixin.android.util.getLanguageOrDefault +import one.mixin.android.util.viewBinding +import one.mixin.android.widget.BottomSheet +import one.mixin.android.widget.SearchView + +@AndroidEntryPoint +class TranslateLanguageBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { + companion object { + const val TAG = "TranslateLanguageBottomSheetDialogFragment" + + fun newInstance() = TranslateLanguageBottomSheetDialogFragment() + } + + private val binding by viewBinding(FragmentSearchListBottomSheetBinding::inflate) + + private val adapter = + LanguageAdapter { language -> + defaultSharedPreferences.putString(Constants.Account.PREF_TRANSLATE_TARGET_LANG, language.code) + callback?.onLanguageClick(language) + dismiss() + } + private val languages = TranslateLanguage.getAllLanguages().map { TranslateManager.Language(it) } + + @SuppressLint("RestrictedApi") + override fun setupDialog( + dialog: Dialog, + style: Int, + ) { + super.setupDialog(dialog, style) + context?.let { c -> + val topOffset = c.statusBarHeight() + c.appCompatActionBarHeight() + binding.root.heightOffset = topOffset + } + contentView = binding.root + (dialog as BottomSheet).setCustomView(contentView) + + binding.apply { + closeIv.setOnClickListener { dismiss() } + searchEt.listener = + object : SearchView.OnSearchViewListener { + override fun afterTextChanged(s: Editable?) { + filter(s.toString()) + } + + override fun onSearch() { + } + } + binding.rv.adapter = adapter + adapter.submitList(languages) + } + } + + private fun filter(s: String) { + adapter.submitList( + if (s.isNotBlank()) { + languages + .filter { + it.nameInCurrentLanguage.containsIgnoreCase(s) || it.nameInSelfLanguage.containsIgnoreCase(s) + }.sortedByDescending { + it.nameInCurrentLanguage.equalsIgnoreCase(s) || it.nameInSelfLanguage.containsIgnoreCase(s) + } + } else { + languages + }, + ) + } + + var callback: Callback? = null + + interface Callback { + fun onLanguageClick(language: TranslateManager.Language) + } + + class LanguageAdapter( + private val callback: (TranslateManager.Language) -> Unit, + ) : ListAdapter(TranslateManager.Language.DIFF_CALLBACK) { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ) = LanguageHolder(ItemLanguageBinding.inflate(LayoutInflater.from(parent.context), parent, false)) + + override fun onBindViewHolder( + holder: LanguageHolder, + position: Int, + ) { + getItem(position)?.let { holder.bind(it, callback) } + } + } + + class LanguageHolder( + private val itemBinding: ItemLanguageBinding, + ) : RecyclerView.ViewHolder(itemBinding.root) { + fun bind( + language: TranslateManager.Language, + callback: (TranslateManager.Language) -> Unit, + ) { + itemBinding.apply { + if (language.code == root.context.defaultSharedPreferences.getString(Constants.Account.PREF_TRANSLATE_TARGET_LANG, getLanguageOrDefault())) { + checkIv.isVisible = true + } else { + checkIv.isInvisible = true + } + name.text = language.nameInCurrentLanguage + desc.text = language.nameInSelfLanguage + } + itemView.setOnClickListener { callback(language) } + } + } +} diff --git a/app/src/main/java/one/mixin/android/ui/setting/ui/page/AppearancePage.kt b/app/src/main/java/one/mixin/android/ui/setting/ui/page/AppearancePage.kt index 1027d123c3..075f0c892a 100644 --- a/app/src/main/java/one/mixin/android/ui/setting/ui/page/AppearancePage.kt +++ b/app/src/main/java/one/mixin/android/ui/setting/ui/page/AppearancePage.kt @@ -17,10 +17,14 @@ import androidx.compose.foundation.selection.selectable import androidx.compose.material.RadioButton import androidx.compose.material.RadioButtonDefaults import androidx.compose.material.Scaffold +import androidx.compose.material.Switch +import androidx.compose.material.SwitchDefaults import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -37,6 +41,8 @@ import one.mixin.android.R import one.mixin.android.compose.MixinAlertDialog import one.mixin.android.compose.MixinBackButton import one.mixin.android.compose.MixinTopAppBar +import one.mixin.android.compose.SettingTile +import one.mixin.android.compose.booleanValueAsState import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.extension.defaultSharedPreferences import one.mixin.android.extension.findFragmentActivityOrNull @@ -48,8 +54,11 @@ import one.mixin.android.ui.setting.Currency import one.mixin.android.ui.setting.CurrencyBottomSheetDialogFragment import one.mixin.android.ui.setting.LocalSettingNav import one.mixin.android.ui.setting.SettingDestination +import one.mixin.android.ui.setting.TranslateLanguageBottomSheetDialogFragment import one.mixin.android.ui.setting.getLanguagePos import one.mixin.android.util.TimeCache +import one.mixin.android.util.TranslateManager +import one.mixin.android.util.getLanguageOrDefault import one.mixin.android.util.isFollowSystem import one.mixin.android.vo.Fiats import java.util.Locale @@ -101,6 +110,10 @@ fun AppearancePage() { color = MixinAppTheme.colors.textPrimary, ) } + + Box(modifier = Modifier.height(20.dp)) + + TranslateItem() } } } @@ -379,6 +392,62 @@ private fun CurrencyItem() { } } +@Composable +private fun TranslateItem() { + val context = LocalContext.current + var showTranslateButton by context.defaultSharedPreferences + .booleanValueAsState( + key = Constants.Account.PREF_SHOW_TRANSLATE_BUTTON, + defaultValue = false, + ) + val targetLang = + remember { + mutableStateOf( + context.defaultSharedPreferences.getString(Constants.Account.PREF_TRANSLATE_TARGET_LANG, getLanguageOrDefault())?.let { + TranslateManager.Language(it).nameInCurrentLanguage + } ?: "", + ) + } + if (showTranslateButton) { + AppearanceItem( + label = stringResource(id = R.string.Target_Language), + value = targetLang.value, + ) { + val activity = context.findFragmentActivityOrNull() ?: return@AppearanceItem + val languageBottom = TranslateLanguageBottomSheetDialogFragment.newInstance() + languageBottom.callback = + object : TranslateLanguageBottomSheetDialogFragment.Callback { + override fun onLanguageClick(language: TranslateManager.Language) { + targetLang.value = language.nameInCurrentLanguage + } + } + languageBottom.showNow( + activity.supportFragmentManager, + TranslateLanguageBottomSheetDialogFragment.TAG, + ) + } + } + SettingTile( + title = stringResource(R.string.Show_Translate_Button), + description = stringResource(R.string.show_translate_button_hint), + trailing = { + Switch( + checked = showTranslateButton, + colors = + SwitchDefaults.colors( + checkedThumbColor = MixinAppTheme.colors.accent, + uncheckedThumbColor = MixinAppTheme.colors.unchecked, + checkedTrackColor = MixinAppTheme.colors.accent, + uncheckedTrackColor = MixinAppTheme.colors.unchecked, + ), + onCheckedChange = null, + ) + }, + ) { + showTranslateButton = !showTranslateButton + } +} + @Composable @Preview fun AppearanceItemPreview() { diff --git a/app/src/main/java/one/mixin/android/util/LanguageUtil.kt b/app/src/main/java/one/mixin/android/util/LanguageUtil.kt index 409a9fe7e9..11aadc2ee7 100644 --- a/app/src/main/java/one/mixin/android/util/LanguageUtil.kt +++ b/app/src/main/java/one/mixin/android/util/LanguageUtil.kt @@ -10,8 +10,12 @@ import java.util.Locale fun getLanguage(): String? = AppCompatDelegate.getApplicationLocales().get(0)?.language +fun getLanguageOrDefault(): String = AppCompatDelegate.getApplicationLocales().get(0)?.language ?: Locale.getDefault().language + fun getCountry(): String? = AppCompatDelegate.getApplicationLocales().get(0)?.country +fun getLocale(): Locale = AppCompatDelegate.getApplicationLocales().get(0) ?: Locale.getDefault() + fun getLocaleString(): String = AppCompatDelegate.getApplicationLocales().get(0).toString() fun isCurrChinese(): Boolean = (getLanguage() ?: Locale.getDefault().language) == Locale.SIMPLIFIED_CHINESE.language diff --git a/app/src/main/java/one/mixin/android/util/TranslateManager.kt b/app/src/main/java/one/mixin/android/util/TranslateManager.kt new file mode 100644 index 0000000000..fbeb4dc59e --- /dev/null +++ b/app/src/main/java/one/mixin/android/util/TranslateManager.kt @@ -0,0 +1,206 @@ +package one.mixin.android.util + +import android.content.Context +import android.util.LruCache +import androidx.recyclerview.widget.DiffUtil +import com.google.android.gms.tasks.Task +import com.google.android.gms.tasks.Tasks +import com.google.mlkit.common.model.DownloadConditions +import com.google.mlkit.common.model.RemoteModelManager +import com.google.mlkit.nl.languageid.LanguageIdentification +import com.google.mlkit.nl.languageid.LanguageIdentificationOptions +import com.google.mlkit.nl.translate.TranslateLanguage +import com.google.mlkit.nl.translate.TranslateRemoteModel +import com.google.mlkit.nl.translate.Translation +import com.google.mlkit.nl.translate.Translator +import com.google.mlkit.nl.translate.TranslatorOptions +import one.mixin.android.Constants.Account.PREF_TRANSLATE_TARGET_LANG +import one.mixin.android.extension.defaultSharedPreferences +import timber.log.Timber +import java.util.Locale +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +class TranslateManager { + private val executor = Executors.newCachedThreadPool() + private val languageIdentifier = + LanguageIdentification.getClient( + LanguageIdentificationOptions + .Builder() + .setExecutor(executor) + .setConfidenceThreshold(0.5f) + .build(), + ) + private val translators = + object : LruCache(3) { + override fun create(options: TranslatorOptions): Translator = Translation.getClient(options) + + override fun entryRemoved( + evicted: Boolean, + key: TranslatorOptions, + oldValue: Translator, + newValue: Translator?, + ) { + oldValue.close() + } + } + private val modelManager = RemoteModelManager.getInstance() + + fun translate( + context: Context, + text: String, + targetLang: String? = context.defaultSharedPreferences.getString(PREF_TRANSLATE_TARGET_LANG, getLanguageOrDefault()), + doWhenDownloadModel: ((Boolean) -> Unit)? = null, // false/true represents download start/end + ): String? { + if (targetLang.isNullOrBlank()) { + Timber.d("$TAG targetLang is $targetLang") + return null + } + var latch = CountDownLatch(1) + val identifierTask = + languageIdentifier.identifyLanguage(text).addOnCompleteListener { + latch.countDown() + } + val identifierDone = latch.await(TASK_TIMEOUT, TimeUnit.SECONDS) + if (!identifierDone) { + Timber.d("$TAG languageIdentifier timeout ${identifierTask.exception?.stackTraceToString()}") + return null + } + val sourceLangCode = TranslateLanguage.fromLanguageTag(identifierTask.result) + val targetLangCode = TranslateLanguage.fromLanguageTag(targetLang) + if (sourceLangCode == null || targetLangCode == null) { + Timber.d("$TAG sourceLangCode: $sourceLangCode, targetLangCode: $targetLangCode") + return null + } + + val options = + TranslatorOptions + .Builder() + .setSourceLanguage(sourceLangCode) + .setTargetLanguage(targetLangCode) + .setExecutor(executor) + .build() + val translator = translators[options] + + val needDownload: Boolean + var checkDownloadTask = isModelDownload(sourceLangCode) + latch = CountDownLatch(1) + var checkDownloadResult = + checkDownloadTask.addOnCompleteListener { + latch.countDown() + } + var checkResult = latch.await(TASK_TIMEOUT, TimeUnit.SECONDS) + if (!checkResult || !checkDownloadResult.isSuccessful) { + Timber.d("$TAG check source download timeout or failed") + return null + } + // if source model downloaded continue check target model, otherwise go download. + if (checkDownloadResult.result) { + checkDownloadTask = isModelDownload(targetLangCode) + latch = CountDownLatch(1) + checkDownloadResult = + checkDownloadTask.addOnCompleteListener { + latch.countDown() + } + checkResult = latch.await(TASK_TIMEOUT, TimeUnit.SECONDS) + if (!checkResult || !checkDownloadResult.isSuccessful) { + Timber.d("$TAG check target download timeout or failed") + return null + } + needDownload = !checkDownloadResult.result + } else { + needDownload = true + } + + if (needDownload) { + doWhenDownloadModel?.invoke(false) + val conditions = + DownloadConditions + .Builder() + .requireWifi() + .build() + latch = CountDownLatch(1) + val downloadTask = + translator.downloadModelIfNeeded(conditions).addOnCompleteListener { + latch.countDown() + } + val downloaded = latch.await(TASK_TIMEOUT, TimeUnit.SECONDS) + doWhenDownloadModel?.invoke(true) + if (!downloaded || !downloadTask.isSuccessful) { + Timber.d("$TAG download model timeout or failed") + return null + } + } + + latch = CountDownLatch(1) + val task = + translator.translate(text).addOnCompleteListener { + latch.countDown() + } + val translated = latch.await(TASK_TIMEOUT, TimeUnit.SECONDS) + if (!translated || !task.isSuccessful) { + Timber.d("$TAG translate timeout or failed") + return null + } + return task.result + } + + private fun isModelDownload(code: String): Task { + val translateLanguage = TranslateLanguage.fromLanguageTag(code) ?: return Tasks.forResult(false) + return modelManager.isModelDownloaded(TranslateRemoteModel.Builder(translateLanguage).build()) + } + + fun release() { + languageIdentifier.close() + translators.evictAll() + } + + class Language( + val code: String, + ) : Comparable { + private val locale = Locale(code) + val nameInCurrentLanguage: String = locale.getDisplayName(getLocale()) + val nameInSelfLanguage: String = locale.getDisplayLanguage(locale) + + override fun equals(other: Any?): Boolean { + if (other === this) { + return true + } + + if (other !is Language) { + return false + } + + val otherLang = other as Language? + return otherLang!!.code == code + } + + override fun toString(): String = nameInCurrentLanguage + + override fun compareTo(other: Language): Int = this.nameInCurrentLanguage.compareTo(other.nameInCurrentLanguage) + + override fun hashCode(): Int = code.hashCode() + + companion object { + val DIFF_CALLBACK = + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: Language, + newItem: Language, + ): Boolean = oldItem.code == newItem.code + + override fun areContentsTheSame( + oldItem: Language, + newItem: Language, + ): Boolean = oldItem == newItem + } + } + } + + companion object { + const val TAG = "TranslateManager" + + private const val TASK_TIMEOUT = 5L + } +} diff --git a/app/src/main/java/one/mixin/android/widget/MaxWidthRelativeLayout.kt b/app/src/main/java/one/mixin/android/widget/MaxWidthRelativeLayout.kt new file mode 100644 index 0000000000..75727499a7 --- /dev/null +++ b/app/src/main/java/one/mixin/android/widget/MaxWidthRelativeLayout.kt @@ -0,0 +1,45 @@ +package one.mixin.android.widget + +import android.content.Context +import android.util.AttributeSet +import android.widget.RelativeLayout +import one.mixin.android.R +import one.mixin.android.extension.dp + +class MaxWidthRelativeLayout : RelativeLayout { + private var maxWidth: Int = 0 + + constructor(context: Context) : this(context, null) + + constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) + + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super( + context, + attrs, + defStyleAttr, + ) { + val ta = context.obtainStyledAttributes(attrs, R.styleable.MaxWidthRelativeLayout, defStyleAttr, 0) + maxWidth = ta.getDimensionPixelSize(R.styleable.MaxWidthRelativeLayout_mr_max_width, 300.dp) + ta.recycle() + } + + fun setMaxWidth(maxWidth: Int) { + if (this.maxWidth != maxWidth) { + this.maxWidth = maxWidth + requestLayout() + } + } + + override fun onMeasure( + widthMeasureSpec: Int, + heightMeasureSpec: Int, + ) { + val measuredWidth = MeasureSpec.getSize(widthMeasureSpec) + var wms = widthMeasureSpec + if (maxWidth in 1 until measuredWidth) { + val measureMode = MeasureSpec.getMode(widthMeasureSpec) + wms = MeasureSpec.makeMeasureSpec(maxWidth, measureMode) + } + super.onMeasure(wms, heightMeasureSpec) + } +} diff --git a/app/src/main/java/one/mixin/android/widget/ToolView.kt b/app/src/main/java/one/mixin/android/widget/ToolView.kt index 24db045f2d..e2ed78c66a 100644 --- a/app/src/main/java/one/mixin/android/widget/ToolView.kt +++ b/app/src/main/java/one/mixin/android/widget/ToolView.kt @@ -30,6 +30,7 @@ class ToolView constructor(context: Context, attrs: AttributeSet) : RelativeLayo val forwardIv = binding.forwardIv val shareIv = binding.shareIv val pinIv = binding.pinIv + val translateIv = binding.translateIv init { closeIv.setOnLongClickListener { @@ -71,6 +72,10 @@ class ToolView constructor(context: Context, attrs: AttributeSet) : RelativeLayo ) true } + translateIv.setOnLongClickListener { + showTip(it, R.string.Translate) + true + } } private val tipView by lazy { diff --git a/app/src/main/res/drawable/ic_baseline_translate_24.xml b/app/src/main/res/drawable/ic_baseline_translate_24.xml new file mode 100644 index 0000000000..9cc75def24 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_translate_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/layout/fragment_appearance.xml b/app/src/main/res/layout/fragment_appearance.xml index 1a8a27475c..1dee4b514d 100644 --- a/app/src/main/res/layout/fragment_appearance.xml +++ b/app/src/main/res/layout/fragment_appearance.xml @@ -172,5 +172,52 @@ tools:ignore="RelativeOverlap" /> + + + + + + + + + + + - \ No newline at end of file + diff --git a/app/src/main/res/layout/fragment_currency_bottom_sheet.xml b/app/src/main/res/layout/fragment_search_list_bottom_sheet.xml similarity index 98% rename from app/src/main/res/layout/fragment_currency_bottom_sheet.xml rename to app/src/main/res/layout/fragment_search_list_bottom_sheet.xml index 299613b9c5..fe85d0788a 100644 --- a/app/src/main/res/layout/fragment_currency_bottom_sheet.xml +++ b/app/src/main/res/layout/fragment_search_list_bottom_sheet.xml @@ -48,7 +48,7 @@ android:background="?attr/bg_window" /> + + + + + + + + diff --git a/app/src/main/res/layout/item_language.xml b/app/src/main/res/layout/item_language.xml new file mode 100644 index 0000000000..4efdcf9fe5 --- /dev/null +++ b/app/src/main/res/layout/item_language.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_tool.xml b/app/src/main/res/layout/view_tool.xml index 0e97cf62a5..24ae6dfed9 100644 --- a/app/src/main/res/layout/view_tool.xml +++ b/app/src/main/res/layout/view_tool.xml @@ -113,4 +113,16 @@ android:src="@drawable/ic_share" android:layout_marginEnd="8dp" tools:ignore="ContentDescription" /> + + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 2af59c3fe5..1657bf2949 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2596,4 +2596,9 @@ 不必输入复杂地址,彻底告别转账错误 拦截恶意授权,杜绝应用偷转资产 没有代币 + 翻译 + 翻译完成 + 显示翻译按钮 + \'翻译\'会在你长按一条文字消息时出现。\n\nGoogle 可能会获取到这条你要翻译的文字消息的内容。 + 目标语言 diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index df253a9ada..9792579d73 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -83,6 +83,9 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bfd44e3754..c7db8de484 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2673,4 +2673,9 @@ Avoid transfer errors caused by complex wallet addresses. Block malicious apps from gaining unsafe permissions. No Tokens + Translate + translation done + Show Translate Button + The \'Translate\' button will appear when you make a long press on a text message.\n\nGoogle may have access to the text of messages you translate. + Target Language