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:
Jeena 2026-05-07 04:13:43 +00:00
parent ffabeb2730
commit 99649c54d7
22 changed files with 1015 additions and 6 deletions

View file

@ -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) {

View file

@ -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 {

View file

@ -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)

View file

@ -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"

View file

@ -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>

View file

@ -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"