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"

View file

@ -30,6 +30,8 @@ app-build-javaVersion = "VERSION_17"
app-build-kotlinJVMTarget = "17"
#Helpers
ez-vcard = "0.12.2"
#ML Kit (transitional language identification — removed once dev.davidv.translator exposes detectLanguage via AIDL)
mlkit-language-id = "17.0.6"
[libraries]
#AndroidX
androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "androidx-constraintlayout" }
@ -50,6 +52,8 @@ mmslib = { module = "org.fossify:mmslib", version.ref = "mmslib" }
eventbus = { module = "org.greenrobot:eventbus", version.ref = "eventbus" }
#Helpers
ez-vcard = { module = "com.googlecode.ez-vcard:ez-vcard", version.ref = "ez-vcard" }
#ML Kit
mlkit-language-id = { module = "com.google.mlkit:language-id", version.ref = "mlkit-language-id" }
#Kotlin
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
[bundles]

View file

@ -15,3 +15,4 @@ dependencyResolutionManagement {
}
}
include(":app")
include(":translate")

View file

@ -0,0 +1,37 @@
plugins {
id("com.android.library")
}
android {
namespace = "org.fossify.messages.translate"
compileSdk = project.libs.versions.app.build.compileSDKVersion.get().toInt()
defaultConfig {
minSdk = project.libs.versions.app.build.minimumSDK.get().toInt()
}
buildFeatures {
aidl = true
viewBinding = true
}
compileOptions {
val javaVersion = JavaVersion.valueOf(libs.versions.app.build.javaVersion.get())
sourceCompatibility = javaVersion
targetCompatibility = javaVersion
}
sourceSets {
getByName("main").java.srcDirs("src/main/kotlin")
}
}
dependencies {
// Fossify base for SimpleActivity / MyTextView / theming so this
// settings screen looks and behaves like the rest of the app.
implementation(libs.fossify.commons)
// Bundled CLD3 language identification — transitional; will be replaced
// by an AIDL detectLanguage() call once dev.davidv.translator exposes it.
implementation(libs.mlkit.language.id)
}

View file

@ -0,0 +1,19 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Android 11+ package visibility: needed to bind to the
offline-translator AIDL service. -->
<queries>
<package android:name="dev.davidv.translator" />
<intent>
<action android:name="dev.davidv.translator.ITranslationService" />
</intent>
</queries>
<application>
<activity
android:name=".TranslationSettingsActivity"
android:exported="false"
android:label="@string/translation_settings" />
</application>
</manifest>

View file

@ -0,0 +1,8 @@
package dev.davidv.translator;
enum ErrorType {
COULD_NOT_DETECT_LANGUAGE,
DETECTED_BUT_UNAVAILABLE,
UNEXPECTED,
}

View file

@ -0,0 +1,8 @@
package dev.davidv.translator;
import dev.davidv.translator.TranslationError;
oneway interface ITranslationCallback {
void onTranslationResult(String translatedText);
void onTranslationError(in TranslationError error);
}

View file

@ -0,0 +1,7 @@
package dev.davidv.translator;
import dev.davidv.translator.ITranslationCallback;
interface ITranslationService {
void translate(String textToTranslate, String fromLanguage, String toLanguage, ITranslationCallback callback);
}

View file

@ -0,0 +1,9 @@
package dev.davidv.translator;
import dev.davidv.translator.ErrorType;
parcelable TranslationError {
ErrorType type;
@nullable String language;
@nullable String message;
}

View file

@ -0,0 +1,40 @@
package org.fossify.messages.translate
import android.util.Log
import com.google.mlkit.nl.languageid.LanguageIdentification
import com.google.mlkit.nl.languageid.LanguageIdentifier
/**
* On-device language identification via ML Kit (CLD3 under the hood).
*
* Transitional: this is here only until `dev.davidv.translator` exposes
* a `detectLanguage()` AIDL method. Once that lands we delete this class
* and route detection through Translator's existing AIDL connection.
*/
internal object MlKitLanguageDetector {
private const val TAG = "MlKitLanguageDetector"
private val identifier: LanguageIdentifier by lazy {
LanguageIdentification.getClient()
}
/**
* Calls back with the detected ISO 639-1 code, or null if the language
* could not be identified with confidence.
*/
fun detect(text: String, callback: (String?) -> Unit) {
if (text.isBlank()) {
callback(null)
return
}
identifier.identifyLanguage(text)
.addOnSuccessListener { code ->
// ML Kit returns "und" (undetermined) when it can't decide.
callback(if (code == "und") null else code)
}
.addOnFailureListener { e ->
Log.w(TAG, "Language identification failed", e)
callback(null)
}
}
}

View file

@ -0,0 +1,44 @@
package org.fossify.messages.translate
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import java.util.Locale
/**
* SharedPreferences-backed settings for the translation feature.
* Lives in the [:translate] module so the main app doesn't need to know
* about its keys.
*/
class TranslateConfig(context: Context) {
private val prefs: SharedPreferences =
context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
/** Master switch. When false, no auto-translation happens at all. */
var enabled: Boolean
get() = prefs.getBoolean(KEY_ENABLED, false)
set(value) = prefs.edit { putBoolean(KEY_ENABLED, value) }
/** ISO 639-1 codes of source languages that should be auto-translated. */
var autoTranslateSources: Set<String>
get() = prefs.getStringSet(KEY_SOURCES, emptySet()) ?: emptySet()
set(value) = prefs.edit { putStringSet(KEY_SOURCES, value) }
/** ISO 639-1 code to translate into. Defaults to the device language. */
var targetLanguage: String
get() = prefs.getString(KEY_TARGET, defaultTargetLang()) ?: defaultTargetLang()
set(value) = prefs.edit { putString(KEY_TARGET, value) }
private fun defaultTargetLang(): String = Locale.getDefault().language.ifEmpty { "en" }
fun shouldAutoTranslate(detectedSource: String): Boolean =
enabled && detectedSource != targetLanguage && detectedSource in autoTranslateSources
companion object {
private const val PREFS_NAME = "fossify_messages_translate"
private const val KEY_ENABLED = "enabled"
private const val KEY_SOURCES = "sources"
private const val KEY_TARGET = "target"
}
}

View file

@ -0,0 +1,165 @@
package org.fossify.messages.translate
import android.content.Context
import android.view.View
import android.widget.ImageView
import android.widget.TextView
/**
* Per-thread (or per-conversations-list) helper that wires up message
* bubbles to the [Translator]. Hides the icon/toggle/spinner book-keeping
* away from the host adapter so its diff stays small.
*
* Maintains in-memory state for the current Activity:
* - `translations[messageId]` the latest translation we've fetched.
* - `showingOriginal[messageId]` toggle state per bubble.
* - `displayed[messageId]` what text is currently on screen, used by
* the host adapter so copy/share/select capture what the user sees.
*
* RecyclerView reuse is handled via a view tag: when an AIDL callback
* arrives we only touch the views if their tag still matches the message
* id we started with.
*/
class TranslationBubbleBinder(private val context: Context) {
private val config = TranslateConfig(context)
private val translations = HashMap<Long, String>()
private val showingOriginal = HashSet<Long>()
private val displayed = HashMap<Long, CharSequence>()
/**
* Bind a received-message bubble to the translator. Idempotent safe
* to call from `onBindViewHolder`. Hides the icon if no translation
* happens; spins the icon while a translation is in flight; on result
* shows the translated text and a clickable toggle.
*/
fun bind(messageId: Long, body: String, bodyView: TextView, iconView: ImageView) {
bodyView.setTag(TAG_KEY, messageId)
iconView.setTag(TAG_KEY, messageId)
iconView.visibility = View.GONE
iconView.setOnClickListener(null)
val cached = translations[messageId]
if (cached != null) {
applyToggle(messageId, body, cached, bodyView, iconView)
return
}
// Show the body as-is until / unless a translation arrives.
// No loading spinner — the AIDL round-trip is sub-second on
// typical SMS-length inputs so the bubble just quietly swaps.
bodyView.text = body
displayed[messageId] = body
Translator.maybeAutoTranslate(body, context, config) { result ->
if (iconView.getTag(TAG_KEY) != messageId) return@maybeAutoTranslate // stale
handleResult(messageId, body, result, bodyView, iconView)
}
}
/**
* Manually translate a single message, bypassing the auto-translate
* allowlist. Used by the bubble action-mode "Translate" item.
* Updates the bubble in place when [bodyView]/[iconView] are still
* showing this message.
*/
fun translateManually(messageId: Long, body: String, bodyView: TextView, iconView: ImageView) {
val cached = translations[messageId]
if (cached != null) {
showingOriginal.remove(messageId)
applyToggle(messageId, body, cached, bodyView, iconView)
return
}
bodyView.setTag(TAG_KEY, messageId)
iconView.setTag(TAG_KEY, messageId)
Translator.translate(body, fromLang = null, toLang = config.targetLanguage, context) { result ->
if (iconView.getTag(TAG_KEY) != messageId) return@translate // stale
showingOriginal.remove(messageId)
handleResult(messageId, body, result, bodyView, iconView)
}
}
/** Returns what's currently shown for this message (translation or original). */
fun visibleText(messageId: Long, body: String): CharSequence = displayed[messageId] ?: body
/**
* For CAB-driven manual translate when the bubble may not currently be
* visible in the RecyclerView. Fires the AIDL call, stashes the result
* in the cache, and invokes [onDone] (on the main thread) the caller
* then `notifyItemChanged` to repaint.
*/
fun preloadTranslation(messageId: Long, body: String, onDone: (Translator.Result) -> Unit) {
val cached = translations[messageId]
if (cached != null) {
showingOriginal.remove(messageId)
onDone(Translator.Result.Success(cached, sourceLang = null))
return
}
Translator.translate(body, fromLang = null, toLang = config.targetLanguage, context) { result ->
if (result is Translator.Result.Success) {
translations[messageId] = result.translated
showingOriginal.remove(messageId)
}
onDone(result)
}
}
private fun handleResult(
messageId: Long,
body: String,
result: Translator.Result,
bodyView: TextView,
iconView: ImageView,
) {
when (result) {
is Translator.Result.Success -> {
translations[messageId] = result.translated
applyToggle(messageId, body, result.translated, bodyView, iconView)
}
Translator.Result.Skipped -> {
iconView.visibility = View.GONE
bodyView.text = body
displayed[messageId] = body
}
is Translator.Result.Failed -> {
iconView.visibility = View.GONE
bodyView.text = body
displayed[messageId] = body
}
}
}
private fun applyToggle(
messageId: Long,
original: String,
translated: String,
bodyView: TextView,
iconView: ImageView,
) {
iconView.visibility = View.VISIBLE
if (messageId in showingOriginal) {
bodyView.text = original
displayed[messageId] = original
iconView.alpha = 0.5f
} else {
bodyView.text = translated
displayed[messageId] = translated
iconView.alpha = 1.0f
}
iconView.setOnClickListener {
if (messageId in showingOriginal) showingOriginal.remove(messageId)
else showingOriginal.add(messageId)
applyToggle(messageId, original, translated, bodyView, iconView)
}
}
companion object {
// Unique-per-feature view tag id. View.setTag(int, Object) requires
// an id from a resource; we use a synthetic constant chosen to not
// collide with R.id values (which are positive ints from aapt).
private const val TAG_KEY = 0x7f200001
}
}

View file

@ -0,0 +1,170 @@
package org.fossify.messages.translate
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.View
import android.widget.ArrayAdapter
import android.widget.CheckBox
import android.widget.Spinner
import android.widget.TextView
import org.fossify.commons.activities.BaseSimpleActivity
import org.fossify.commons.extensions.toast
import org.fossify.commons.extensions.viewBinding
import org.fossify.commons.helpers.NavigationIcon
import org.fossify.messages.translate.databinding.ActivityTranslationSettingsBinding
class TranslationSettingsActivity : BaseSimpleActivity() {
private val binding by viewBinding(ActivityTranslationSettingsBinding::inflate)
private val config by lazy { TranslateConfig(this) }
override fun getAppIconIDs(): ArrayList<Int> = arrayListOf()
override fun getAppLauncherName(): String = getString(R.string.translation_settings)
override fun getRepositoryName(): String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
setupEdgeToEdge(padBottomImeAndSystem = listOf(binding.translationSettingsNestedScrollview))
setupMaterialScrollListener(
scrollingView = binding.translationSettingsNestedScrollview,
topAppBar = binding.translationSettingsAppbar,
)
setupBanner()
setupEnabledSwitch()
setupTargetSpinner()
setupSourcesList()
}
override fun onResume() {
super.onResume()
setupTopAppBar(binding.translationSettingsAppbar, NavigationIcon.Arrow)
}
private fun setupBanner() {
if (Translator.isPackageAvailable(this)) {
binding.installBanner.visibility = View.GONE
} else {
binding.installBanner.visibility = View.VISIBLE
binding.installBannerSummary.text = getString(
R.string.install_offline_translator_summary,
"dev.davidv.translator",
)
binding.installBanner.setOnClickListener {
openOfflineTranslatorPage()
}
}
}
private fun openOfflineTranslatorPage() {
val tries = listOf(
"fdroid://app/dev.davidv.translator",
"https://f-droid.org/packages/dev.davidv.translator/",
)
for (uri in tries) {
try {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(uri)))
return
} catch (_: ActivityNotFoundException) {
// try next fallback
}
}
toast("No browser or F-Droid client available")
}
private fun setupEnabledSwitch() {
binding.enabledSwitch.isChecked = config.enabled
binding.enabledSwitch.setOnCheckedChangeListener { _, isChecked ->
config.enabled = isChecked
}
}
private fun setupTargetSpinner() {
val codes = SUPPORTED_LANGUAGES.keys.toList()
val labels = codes.map { "${SUPPORTED_LANGUAGES[it]} ($it)" }
val adapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, labels)
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
binding.targetSpinner.adapter = adapter
val current = codes.indexOf(config.targetLanguage)
if (current >= 0) binding.targetSpinner.setSelection(current)
binding.targetSpinner.onItemSelectedListener = object : android.widget.AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: android.widget.AdapterView<*>?, view: View?, position: Int, id: Long) {
config.targetLanguage = codes[position]
}
override fun onNothingSelected(parent: android.widget.AdapterView<*>?) {}
}
}
private fun setupSourcesList() {
val current = config.autoTranslateSources.toMutableSet()
binding.sourcesContainer.removeAllViews()
for ((code, name) in SUPPORTED_LANGUAGES) {
val row = CheckBox(this).apply {
text = "$name ($code)"
isChecked = code in current
setOnCheckedChangeListener { _, isChecked ->
if (isChecked) current.add(code) else current.remove(code)
config.autoTranslateSources = current.toSet()
}
}
binding.sourcesContainer.addView(row)
}
}
companion object {
/**
* Languages exposed in the auto-translate allowlist + target picker.
* Hardcoded from the Bergamot-supported set as of this writing.
* If a chosen language pack isn't installed in offline-translator,
* the first translation attempt returns DETECTED_BUT_UNAVAILABLE
* and we surface a toast pointing the user at that app.
*/
private val SUPPORTED_LANGUAGES = linkedMapOf(
"ar" to "Arabic",
"bg" to "Bulgarian",
"bn" to "Bengali",
"cs" to "Czech",
"da" to "Danish",
"de" to "German",
"el" to "Greek",
"en" to "English",
"es" to "Spanish",
"et" to "Estonian",
"fa" to "Persian",
"fi" to "Finnish",
"fr" to "French",
"he" to "Hebrew",
"hi" to "Hindi",
"hu" to "Hungarian",
"id" to "Indonesian",
"is" to "Icelandic",
"it" to "Italian",
"ja" to "Japanese",
"ko" to "Korean",
"lt" to "Lithuanian",
"lv" to "Latvian",
"nb" to "Norwegian Bokmål",
"nl" to "Dutch",
"nn" to "Norwegian Nynorsk",
"pl" to "Polish",
"pt" to "Portuguese",
"ro" to "Romanian",
"ru" to "Russian",
"sk" to "Slovak",
"sl" to "Slovenian",
"sv" to "Swedish",
"th" to "Thai",
"tr" to "Turkish",
"uk" to "Ukrainian",
"ur" to "Urdu",
"vi" to "Vietnamese",
"zh" to "Chinese",
)
}
}

View file

@ -0,0 +1,231 @@
package org.fossify.messages.translate
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.util.Log
import android.util.LruCache
import dev.davidv.translator.ITranslationCallback
import dev.davidv.translator.ITranslationService
import dev.davidv.translator.TranslationError
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.atomic.AtomicBoolean
/**
* Singleton client for the offline-translator AIDL service
* (`dev.davidv.translator` on F-Droid). Translates SMS / MMS bodies via
* Mozilla Bergamot/Marian on the user's device.
*
* Lazily binds on first use. Holds an in-memory [LruCache] of translations
* keyed by `(bodyHash, targetLang)` so repeat views of the same message
* don't re-translate. Cache is process-lifetime no persistence.
*
* Detection currently uses [MlKitLanguageDetector] (CLD3 bundled). Once
* davidv exposes a `detectLanguage()` AIDL method we'll delete that class
* and route detection through this same connection.
*/
object Translator {
private const val TAG = "Translator"
private const val PACKAGE = "dev.davidv.translator"
private const val ACTION = "dev.davidv.translator.ITranslationService"
private const val CACHE_CAPACITY = 200
sealed class Result {
/** Translation succeeded. */
data class Success(val translated: String, val sourceLang: String?) : Result()
/** Translation was skipped — no detection, source not in allowlist, or source equals target. */
data object Skipped : Result()
/** AIDL call failed (package missing, model not installed, network/IO error). */
data class Failed(val reason: String) : Result()
}
private val mainHandler = Handler(Looper.getMainLooper())
private val cache = LruCache<String, String>(CACHE_CAPACITY)
private val pending = ConcurrentLinkedQueue<() -> Unit>()
private val binding = AtomicBoolean(false)
@Volatile
private var service: ITranslationService? = null
@Volatile
private var connection: ServiceConnection? = null
/**
* Returns true if `dev.davidv.translator` is installed.
* Requires the `<queries>` block in our manifest to be effective on Android 11+.
*/
fun isPackageAvailable(context: Context): Boolean = try {
context.packageManager.getPackageInfo(PACKAGE, 0)
true
} catch (_: Exception) {
false
}
/**
* Tries to auto-translate [text]. Reads [config] for enabled state,
* source allowlist, and target language. Result is delivered on the
* main thread via [onResult].
*
* Returns true if a translation attempt was started (UI may show a
* loading indicator); false if the call is a no-op (cache miss
* skipped, package not installed, or text empty).
*/
fun maybeAutoTranslate(text: String, context: Context, config: TranslateConfig, onResult: (Result) -> Unit): Boolean {
if (!config.enabled || text.isBlank()) {
postMain { onResult(Result.Skipped) }
return false
}
if (!isPackageAvailable(context)) {
postMain { onResult(Result.Failed("offline-translator not installed")) }
return false
}
// Cache lookup is keyed on target only — we cache after detection
// succeeds, so a hit means we already verified source ∈ allowlist
// for that body+target.
cacheGet(text, config.targetLanguage)?.let { hit ->
postMain { onResult(Result.Success(hit, sourceLang = null)) }
return true
}
MlKitLanguageDetector.detect(text) { detected ->
if (detected == null || !config.shouldAutoTranslate(detected)) {
postMain { onResult(Result.Skipped) }
return@detect
}
translate(text, detected, config.targetLanguage, context) { result ->
onResult(result)
}
}
return true
}
/**
* Manual translate path (used by the CAB Translate menu). Bypasses the
* allowlist translates regardless of whether the source is opted-in.
* `fromLang = null` lets davidv's service auto-detect.
*/
fun translate(
text: String,
fromLang: String?,
toLang: String,
context: Context,
onResult: (Result) -> Unit,
) {
if (text.isBlank()) {
postMain { onResult(Result.Skipped) }
return
}
cacheGet(text, toLang)?.let { hit ->
postMain { onResult(Result.Success(hit, fromLang)) }
return
}
runWhenBound(context) { svc ->
if (svc == null) {
postMain { onResult(Result.Failed("could not bind to $PACKAGE")) }
return@runWhenBound
}
val cb = object : ITranslationCallback.Stub() {
override fun onTranslationResult(translatedText: String?) {
if (translatedText != null) {
cachePut(text, toLang, translatedText)
postMain { onResult(Result.Success(translatedText, fromLang)) }
} else {
postMain { onResult(Result.Failed("empty result")) }
}
}
override fun onTranslationError(error: TranslationError?) {
val reason = "${error?.type} ${error?.language ?: ""} ${error?.message ?: ""}".trim()
Log.w(TAG, "Translation error: $reason")
postMain { onResult(Result.Failed(reason)) }
}
}
try {
svc.translate(text, fromLang.orEmpty(), toLang, cb)
} catch (e: Exception) {
Log.e(TAG, "AIDL translate threw", e)
postMain { onResult(Result.Failed(e.javaClass.simpleName)) }
}
}
}
/** Releases the binding. Safe to call multiple times. */
fun unbind(context: Context) {
connection?.let {
try {
context.applicationContext.unbindService(it)
} catch (_: Exception) {
// Already unbound or never bound; ignore.
}
}
connection = null
service = null
}
// --- internals ------------------------------------------------------
private fun runWhenBound(context: Context, action: (ITranslationService?) -> Unit) {
val existing = service
if (existing != null) {
action(existing)
return
}
pending.add { action(service) }
ensureBinding(context.applicationContext)
}
private fun ensureBinding(appContext: Context) {
if (!binding.compareAndSet(false, true)) return
val conn = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, binder: IBinder) {
service = ITranslationService.Stub.asInterface(binder)
Log.i(TAG, "Bound to $name")
drainPending()
binding.set(false)
}
override fun onServiceDisconnected(name: ComponentName) {
Log.w(TAG, "Disconnected from $name")
service = null
}
}
connection = conn
val intent = Intent(ACTION).setPackage(PACKAGE)
val ok = try {
appContext.bindService(intent, conn, Context.BIND_AUTO_CREATE)
} catch (e: SecurityException) {
Log.e(TAG, "bindService SecurityException", e)
false
}
if (!ok) {
Log.e(TAG, "bindService returned false (is $PACKAGE installed?)")
connection = null
drainPending()
binding.set(false)
}
}
private fun drainPending() {
while (true) {
val next = pending.poll() ?: break
next()
}
}
private fun cacheKey(text: String, target: String): String =
"${text.hashCode().toLong() and 0xffffffffL}:$target:${text.length}"
private fun cacheGet(text: String, target: String): String? = cache.get(cacheKey(text, target))
private fun cachePut(text: String, target: String, translation: String) {
cache.put(cacheKey(text, target), translation)
}
private fun postMain(block: () -> Unit) {
if (Looper.myLooper() == Looper.getMainLooper()) block() else mainHandler.post(block)
}
}

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12.87,15.07l-2.54,-2.51 0.03,-0.03A17.52,17.52 0,0 0,14.07 6H17V4h-7V2H8v2H1v1.99h11.17C11.5,7.92 10.44,9.75 9,11.35 8.07,10.32 7.3,9.19 6.69,8h-2c0.73,1.63 1.73,3.17 2.98,4.56l-5.09,5.02L4,19l5,-5 3.11,3.11 0.76,-2.04zM18.5,10h-2L12,22h2l1.12,-3h4.75L21,22h2l-4.5,-12zM15.88,17l1.62,-4.33L19.12,17h-3.24z" />
</vector>

View file

@ -0,0 +1,141 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/translation_settings_coordinator"
android:layout_width="match_parent"
android:layout_height="match_parent">
<org.fossify.commons.views.MyAppBarLayout
android:id="@+id/translation_settings_appbar"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/translation_settings_toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@color/color_primary"
app:title="@string/translation_settings"
app:titleTextAppearance="@style/AppTheme.ActionBar.TitleTextStyle" />
</org.fossify.commons.views.MyAppBarLayout>
<androidx.core.widget.NestedScrollView
android:id="@+id/translation_settings_nested_scrollview"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true"
android:scrollbars="none"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingHorizontal="16dp"
android:paddingVertical="12dp">
<FrameLayout
android:id="@+id/install_banner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:attr/selectableItemBackground"
android:padding="12dp"
android:visibility="gone">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/install_offline_translator_title"
android:textStyle="bold" />
<TextView
android:id="@+id/install_banner_summary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textSize="12sp" />
</LinearLayout>
</FrameLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingVertical="8dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/translate_received_messages"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text="@string/translate_received_messages_summary"
android:textSize="12sp" />
</LinearLayout>
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/enabled_switch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginVertical="8dp"
android:background="?android:attr/listDivider" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingVertical="8dp"
android:text="@string/translate_to"
android:textSize="14sp"
android:textStyle="bold" />
<Spinner
android:id="@+id/target_spinner"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginVertical="8dp"
android:background="?android:attr/listDivider" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingVertical="8dp"
android:text="@string/auto_translate_languages"
android:textSize="14sp"
android:textStyle="bold" />
<LinearLayout
android:id="@+id/sources_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="translation_settings">Translation</string>
<string name="translate_received_messages">Translate received messages</string>
<string name="translate_received_messages_summary">Auto-translate matching SMS as you scroll</string>
<string name="auto_translate_languages">Auto-translate from</string>
<string name="translate_to">Translate into</string>
<string name="install_offline_translator_title">offline-translator not installed</string>
<string name="install_offline_translator_summary">This feature uses %1$s from F-Droid to do the actual translation on-device. Tap to install.</string>
<string name="translate">Translate</string>
<string name="show_original">Show original</string>
<string name="show_translation">Show translation</string>
<string name="translation_in_progress">Translating…</string>
<string name="translation_failed">Translation failed</string>
</resources>