feat: on-the-fly SMS translation via offline-translator AIDL
Adds a small `:translate` Gradle module that translates received SMS / MMS bubbles and conversation-list snippets on the fly by binding to the offline-translator app on F-Droid (`dev.davidv.translator`). The actual translation runs there — Mozilla Bergamot/Marian on-device — so this patch ships no model, no inference, and no permissions beyond an Android 11+ <queries> block for package visibility. Behavior: - User-defined source-language allowlist + target language in Settings → Translation (right after Language). Off by default. - Auto-translate fires on RecyclerView bind for received bubbles and conversation snippets. Detection uses ML Kit Language Identification (CLD3, on-device). Once dev.davidv.translator exposes a detectLanguage() AIDL method we'll route through that and drop ML Kit. - AIDL latency is sub-second, so the bubble just quietly swaps from the original to the translation — no loading spinner. - Tap the translate icon next to a bubble to flip it back to the original; tap again to flip to the translation. Cached in process memory. - Long-press → ⋮ → Translate forces a one-off translation regardless of the allowlist (useful for messages in non-allowlisted languages), with a toast surfacing AIDL errors like 'language pack not installed'. - Copy / Share / Select on a translated bubble captures what the user sees, not the underlying source body. - Silently no-ops when offline-translator isn't installed; settings screen shows an F-Droid install banner. - Translation Settings is a regular Fossify sub-screen with MyAppBarLayout + MaterialToolbar + NestedScrollView, matching Manage Blocked Numbers / Keywords / SettingsActivity. No new database, no service, no boot receiver, no foreground service. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ffabeb2730
commit
99649c54d7
22 changed files with 1015 additions and 6 deletions
|
|
@ -106,6 +106,7 @@ class SettingsActivity : SimpleActivity() {
|
|||
setupLanguage()
|
||||
setupManageBlockedNumbers()
|
||||
setupManageBlockedKeywords()
|
||||
setupTranslation()
|
||||
setupChangeDateTimeFormat()
|
||||
setupFontSize()
|
||||
setupShowCharacterCounter()
|
||||
|
|
@ -230,6 +231,12 @@ class SettingsActivity : SimpleActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
private fun setupTranslation() = binding.apply {
|
||||
settingsTranslationHolder.setOnClickListener {
|
||||
startActivity(Intent(this@SettingsActivity, org.fossify.messages.translate.TranslationSettingsActivity::class.java))
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupChangeDateTimeFormat() = binding.apply {
|
||||
settingsChangeDateTimeFormatHolder.setOnClickListener {
|
||||
ChangeDateTimeFormatDialog(this@SettingsActivity) {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ import org.fossify.commons.extensions.getContrastColor
|
|||
import org.fossify.commons.extensions.getTextSize
|
||||
import org.fossify.commons.extensions.setupViewBackground
|
||||
import org.fossify.commons.helpers.FontHelper
|
||||
import org.fossify.messages.R
|
||||
import org.fossify.messages.translate.TranslateConfig
|
||||
import org.fossify.messages.translate.Translator
|
||||
import org.fossify.commons.helpers.SimpleContactsHelper
|
||||
import org.fossify.commons.helpers.ensureBackgroundThread
|
||||
import org.fossify.commons.views.MyRecyclerView
|
||||
|
|
@ -44,6 +47,7 @@ abstract class BaseConversationsAdapter(
|
|||
RecyclerViewFastScroller.OnPopupTextUpdate {
|
||||
private var fontSize = activity.getTextSize()
|
||||
private var drafts = HashMap<Long, String>()
|
||||
private val translateConfig by lazy { TranslateConfig(activity) }
|
||||
|
||||
private var recyclerViewState: Parcelable? = null
|
||||
|
||||
|
|
@ -160,8 +164,20 @@ abstract class BaseConversationsAdapter(
|
|||
}
|
||||
|
||||
conversationBodyShort.apply {
|
||||
text = smsDraft ?: conversation.snippet
|
||||
val original = smsDraft ?: conversation.snippet
|
||||
text = original
|
||||
setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize * 0.9f)
|
||||
if (smsDraft == null && original.isNotEmpty()) {
|
||||
val view = this
|
||||
val token = conversation.threadId
|
||||
setTag(R.id.conversation_body_short, token)
|
||||
Translator.maybeAutoTranslate(original, activity, translateConfig) { result ->
|
||||
if (view.getTag(R.id.conversation_body_short) == token &&
|
||||
result is Translator.Result.Success) {
|
||||
view.text = result.translated
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
conversationDate.apply {
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import org.fossify.commons.extensions.getTextSize
|
|||
import org.fossify.commons.extensions.getTimeFormat
|
||||
import org.fossify.commons.extensions.shareTextIntent
|
||||
import org.fossify.commons.extensions.showErrorToast
|
||||
import org.fossify.commons.extensions.toast
|
||||
import org.fossify.commons.extensions.usableScreenSize
|
||||
import org.fossify.commons.helpers.FontHelper
|
||||
import org.fossify.commons.helpers.SimpleContactsHelper
|
||||
|
|
@ -87,6 +88,7 @@ import org.fossify.messages.models.ThreadItem.ThreadDateTime
|
|||
import org.fossify.messages.models.ThreadItem.ThreadError
|
||||
import org.fossify.messages.models.ThreadItem.ThreadSending
|
||||
import org.fossify.messages.models.ThreadItem.ThreadSent
|
||||
import org.fossify.messages.translate.TranslationBubbleBinder
|
||||
import org.joda.time.DateTime
|
||||
|
||||
class ThreadAdapter(
|
||||
|
|
@ -101,6 +103,7 @@ class ThreadAdapter(
|
|||
@SuppressLint("MissingPermission")
|
||||
private val hasMultipleSIMCards = (activity.subscriptionManagerCompat().activeSubscriptionInfoList?.size ?: 0) > 1
|
||||
private val maxChatBubbleWidth = (activity.usableScreenSize.x * 0.8f).toInt()
|
||||
private val translationBinder = TranslationBubbleBinder(activity)
|
||||
|
||||
companion object {
|
||||
private const val MAX_MEDIA_HEIGHT_RATIO = 3
|
||||
|
|
@ -130,6 +133,8 @@ class ThreadAdapter(
|
|||
findItem(R.id.cab_share).isVisible = isOneItemSelected && hasText
|
||||
findItem(R.id.cab_forward_message).isVisible = isOneItemSelected
|
||||
findItem(R.id.cab_select_text).isVisible = isOneItemSelected && hasText
|
||||
findItem(R.id.cab_translate).isVisible =
|
||||
isOneItemSelected && hasText && selectedMessages.firstOrNull()?.isReceivedMessage() == true
|
||||
findItem(R.id.cab_properties).isVisible = isOneItemSelected
|
||||
findItem(R.id.cab_restore).isVisible = isRecycleBin
|
||||
}
|
||||
|
|
@ -146,6 +151,7 @@ class ThreadAdapter(
|
|||
R.id.cab_share -> shareText()
|
||||
R.id.cab_forward_message -> forwardMessage()
|
||||
R.id.cab_select_text -> selectText()
|
||||
R.id.cab_translate -> translateSelectedMessage()
|
||||
R.id.cab_delete -> askConfirmDelete()
|
||||
R.id.cab_restore -> askConfirmRestore()
|
||||
R.id.cab_select_all -> selectAll()
|
||||
|
|
@ -226,13 +232,15 @@ class ThreadAdapter(
|
|||
if (selectedMessages.isEmpty()) return
|
||||
|
||||
val textToCopy = if (selectedMessages.size == 1) {
|
||||
selectedMessages.first().body
|
||||
val msg = selectedMessages.first()
|
||||
translationBinder.visibleText(msg.id, msg.body).toString()
|
||||
} else {
|
||||
selectedMessages.filter { it.body.isNotEmpty() }.joinToString("\n\n") { message ->
|
||||
val format = "${activity.config.dateFormat}, ${activity.getTimeFormat()}"
|
||||
val dateTime = DateTime(message.millis()).toString(format)
|
||||
val sender = if (message.isReceivedMessage()) message.senderName else activity.getString(R.string.me)
|
||||
"[$dateTime] $sender: ${message.body}"
|
||||
val visible = translationBinder.visibleText(message.id, message.body)
|
||||
"[$dateTime] $sender: $visible"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -255,13 +263,34 @@ class ThreadAdapter(
|
|||
|
||||
private fun shareText() {
|
||||
val firstItem = getSelectedItems().firstOrNull() as? Message ?: return
|
||||
activity.shareTextIntent(firstItem.body)
|
||||
activity.shareTextIntent(translationBinder.visibleText(firstItem.id, firstItem.body).toString())
|
||||
}
|
||||
|
||||
private fun selectText() {
|
||||
val firstItem = getSelectedItems().firstOrNull() as? Message ?: return
|
||||
if (firstItem.body.trim().isNotEmpty()) {
|
||||
SelectTextDialog(activity, firstItem.body)
|
||||
val visible = translationBinder.visibleText(firstItem.id, firstItem.body).toString()
|
||||
if (visible.trim().isNotEmpty()) {
|
||||
SelectTextDialog(activity, visible)
|
||||
}
|
||||
}
|
||||
|
||||
private fun translateSelectedMessage() {
|
||||
val message = getSelectedItems().firstOrNull() as? Message ?: return
|
||||
if (message.body.isBlank()) return
|
||||
val position = currentList.indexOf(message)
|
||||
translationBinder.preloadTranslation(message.id, message.body) { result ->
|
||||
when (result) {
|
||||
is org.fossify.messages.translate.Translator.Result.Success -> {
|
||||
if (position >= 0) notifyItemChanged(position)
|
||||
}
|
||||
is org.fossify.messages.translate.Translator.Result.Failed -> {
|
||||
activity.toast(activity.getString(org.fossify.messages.translate.R.string.translation_failed) + ": " + result.reason)
|
||||
}
|
||||
org.fossify.messages.translate.Translator.Result.Skipped -> {
|
||||
activity.toast(org.fossify.messages.translate.R.string.translation_failed)
|
||||
}
|
||||
}
|
||||
finishActMode()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -431,6 +460,20 @@ class ThreadAdapter(
|
|||
setLinkTextColor(activity.getProperPrimaryColor())
|
||||
}
|
||||
|
||||
// Translation: auto-translate if the rule allowlist matches; the
|
||||
// binder also manages the show-original toggle on the icon.
|
||||
if (message.body.isNotEmpty()) {
|
||||
threadMessageTranslateIcon.setColorFilter(textColor)
|
||||
translationBinder.bind(
|
||||
messageId = message.id,
|
||||
body = message.body,
|
||||
bodyView = threadMessageBody,
|
||||
iconView = threadMessageTranslateIcon,
|
||||
)
|
||||
} else {
|
||||
threadMessageTranslateIcon.visibility = View.GONE
|
||||
}
|
||||
|
||||
if (!activity.isFinishing && !activity.isDestroyed) {
|
||||
val contactLetterIcon = SimpleContactsHelper(activity).getContactLetterIcon(message.senderName)
|
||||
val placeholder = contactLetterIcon.toDrawable(activity.resources)
|
||||
|
|
|
|||
|
|
@ -113,6 +113,21 @@
|
|||
|
||||
</RelativeLayout>
|
||||
|
||||
<RelativeLayout
|
||||
android:id="@+id/settings_translation_holder"
|
||||
style="@style/SettingsHolderTextViewOneLinerStyle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<org.fossify.commons.views.MyTextView
|
||||
android:id="@+id/settings_translation"
|
||||
style="@style/SettingsTextLabelStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/translation_settings" />
|
||||
|
||||
</RelativeLayout>
|
||||
|
||||
<RelativeLayout
|
||||
android:id="@+id/settings_change_date_time_format_holder"
|
||||
style="@style/SettingsHolderTextViewOneLinerStyle"
|
||||
|
|
|
|||
|
|
@ -65,5 +65,18 @@
|
|||
android:textSize="@dimen/normal_text_size"
|
||||
tools:drawableEndCompat="@drawable/scheduled_message_icon"
|
||||
tools:text="Message content" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/thread_message_translate_icon"
|
||||
android:layout_width="20dp"
|
||||
android:layout_height="20dp"
|
||||
android:layout_below="@+id/thread_message_body"
|
||||
android:layout_alignStart="@+id/thread_message_body"
|
||||
android:layout_marginStart="@dimen/normal_margin"
|
||||
android:layout_marginTop="-2dp"
|
||||
android:padding="2dp"
|
||||
android:src="@drawable/ic_translate_vector"
|
||||
android:visibility="gone"
|
||||
tools:ignore="ContentDescription" />
|
||||
</RelativeLayout>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
|
|
|||
|
|
@ -37,6 +37,11 @@
|
|||
android:showAsAction="never"
|
||||
android:title="@string/select_text"
|
||||
app:showAsAction="never" />
|
||||
<item
|
||||
android:id="@+id/cab_translate"
|
||||
android:showAsAction="never"
|
||||
android:title="@string/translate"
|
||||
app:showAsAction="never" />
|
||||
<item
|
||||
android:id="@+id/cab_select_all"
|
||||
android:icon="@drawable/ic_select_all_vector"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue