From 99649c54d7c95d79ed2a26ebd2c91a5ce1b23361 Mon Sep 17 00:00:00 2001 From: Jeena Date: Thu, 7 May 2026 04:13:43 +0000 Subject: [PATCH] feat: on-the-fly SMS translation via offline-translator AIDL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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+ 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) --- .../messages/activities/SettingsActivity.kt | 7 + .../adapters/BaseConversationsAdapter.kt | 18 +- .../messages/adapters/ThreadAdapter.kt | 53 +++- app/src/main/res/layout/activity_settings.xml | 15 ++ app/src/main/res/layout/item_message.xml | 13 + app/src/main/res/menu/cab_thread.xml | 5 + gradle/libs.versions.toml | 4 + settings.gradle.kts | 1 + translate/build.gradle.kts | 37 +++ translate/src/main/AndroidManifest.xml | 19 ++ .../aidl/dev/davidv/translator/ErrorType.aidl | 8 + .../translator/ITranslationCallback.aidl | 8 + .../translator/ITranslationService.aidl | 7 + .../davidv/translator/TranslationError.aidl | 9 + .../translate/MlKitLanguageDetector.kt | 40 +++ .../messages/translate/TranslateConfig.kt | 44 ++++ .../translate/TranslationBubbleBinder.kt | 165 +++++++++++++ .../translate/TranslationSettingsActivity.kt | 170 +++++++++++++ .../fossify/messages/translate/Translator.kt | 231 ++++++++++++++++++ .../main/res/drawable/ic_translate_vector.xml | 11 + .../layout/activity_translation_settings.xml | 141 +++++++++++ translate/src/main/res/values/strings.xml | 15 ++ 22 files changed, 1015 insertions(+), 6 deletions(-) create mode 100644 translate/build.gradle.kts create mode 100644 translate/src/main/AndroidManifest.xml create mode 100644 translate/src/main/aidl/dev/davidv/translator/ErrorType.aidl create mode 100644 translate/src/main/aidl/dev/davidv/translator/ITranslationCallback.aidl create mode 100644 translate/src/main/aidl/dev/davidv/translator/ITranslationService.aidl create mode 100644 translate/src/main/aidl/dev/davidv/translator/TranslationError.aidl create mode 100644 translate/src/main/kotlin/org/fossify/messages/translate/MlKitLanguageDetector.kt create mode 100644 translate/src/main/kotlin/org/fossify/messages/translate/TranslateConfig.kt create mode 100644 translate/src/main/kotlin/org/fossify/messages/translate/TranslationBubbleBinder.kt create mode 100644 translate/src/main/kotlin/org/fossify/messages/translate/TranslationSettingsActivity.kt create mode 100644 translate/src/main/kotlin/org/fossify/messages/translate/Translator.kt create mode 100644 translate/src/main/res/drawable/ic_translate_vector.xml create mode 100644 translate/src/main/res/layout/activity_translation_settings.xml create mode 100644 translate/src/main/res/values/strings.xml diff --git a/app/src/main/kotlin/org/fossify/messages/activities/SettingsActivity.kt b/app/src/main/kotlin/org/fossify/messages/activities/SettingsActivity.kt index cfed8f45..d1a2e35c 100644 --- a/app/src/main/kotlin/org/fossify/messages/activities/SettingsActivity.kt +++ b/app/src/main/kotlin/org/fossify/messages/activities/SettingsActivity.kt @@ -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) { diff --git a/app/src/main/kotlin/org/fossify/messages/adapters/BaseConversationsAdapter.kt b/app/src/main/kotlin/org/fossify/messages/adapters/BaseConversationsAdapter.kt index 2d5c0857..490ccbb5 100644 --- a/app/src/main/kotlin/org/fossify/messages/adapters/BaseConversationsAdapter.kt +++ b/app/src/main/kotlin/org/fossify/messages/adapters/BaseConversationsAdapter.kt @@ -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() + 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 { diff --git a/app/src/main/kotlin/org/fossify/messages/adapters/ThreadAdapter.kt b/app/src/main/kotlin/org/fossify/messages/adapters/ThreadAdapter.kt index 1151bc5d..5e580e1a 100644 --- a/app/src/main/kotlin/org/fossify/messages/adapters/ThreadAdapter.kt +++ b/app/src/main/kotlin/org/fossify/messages/adapters/ThreadAdapter.kt @@ -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) diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index 40ec728d..0d26783b 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -113,6 +113,21 @@ + + + + + + + + diff --git a/app/src/main/res/menu/cab_thread.xml b/app/src/main/res/menu/cab_thread.xml index 2b81f67b..83bc19ce 100644 --- a/app/src/main/res/menu/cab_thread.xml +++ b/app/src/main/res/menu/cab_thread.xml @@ -37,6 +37,11 @@ android:showAsAction="never" android:title="@string/select_text" app:showAsAction="never" /> + + + + + + + + + + + + + + + diff --git a/translate/src/main/aidl/dev/davidv/translator/ErrorType.aidl b/translate/src/main/aidl/dev/davidv/translator/ErrorType.aidl new file mode 100644 index 00000000..8e211917 --- /dev/null +++ b/translate/src/main/aidl/dev/davidv/translator/ErrorType.aidl @@ -0,0 +1,8 @@ +package dev.davidv.translator; + +enum ErrorType { + COULD_NOT_DETECT_LANGUAGE, + DETECTED_BUT_UNAVAILABLE, + UNEXPECTED, +} + diff --git a/translate/src/main/aidl/dev/davidv/translator/ITranslationCallback.aidl b/translate/src/main/aidl/dev/davidv/translator/ITranslationCallback.aidl new file mode 100644 index 00000000..659b8d93 --- /dev/null +++ b/translate/src/main/aidl/dev/davidv/translator/ITranslationCallback.aidl @@ -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); +} diff --git a/translate/src/main/aidl/dev/davidv/translator/ITranslationService.aidl b/translate/src/main/aidl/dev/davidv/translator/ITranslationService.aidl new file mode 100644 index 00000000..5692099f --- /dev/null +++ b/translate/src/main/aidl/dev/davidv/translator/ITranslationService.aidl @@ -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); +} diff --git a/translate/src/main/aidl/dev/davidv/translator/TranslationError.aidl b/translate/src/main/aidl/dev/davidv/translator/TranslationError.aidl new file mode 100644 index 00000000..cee284b9 --- /dev/null +++ b/translate/src/main/aidl/dev/davidv/translator/TranslationError.aidl @@ -0,0 +1,9 @@ +package dev.davidv.translator; + +import dev.davidv.translator.ErrorType; + +parcelable TranslationError { + ErrorType type; + @nullable String language; + @nullable String message; +} diff --git a/translate/src/main/kotlin/org/fossify/messages/translate/MlKitLanguageDetector.kt b/translate/src/main/kotlin/org/fossify/messages/translate/MlKitLanguageDetector.kt new file mode 100644 index 00000000..8e1937d7 --- /dev/null +++ b/translate/src/main/kotlin/org/fossify/messages/translate/MlKitLanguageDetector.kt @@ -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) + } + } +} diff --git a/translate/src/main/kotlin/org/fossify/messages/translate/TranslateConfig.kt b/translate/src/main/kotlin/org/fossify/messages/translate/TranslateConfig.kt new file mode 100644 index 00000000..7c3f2c2c --- /dev/null +++ b/translate/src/main/kotlin/org/fossify/messages/translate/TranslateConfig.kt @@ -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 + 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" + } +} diff --git a/translate/src/main/kotlin/org/fossify/messages/translate/TranslationBubbleBinder.kt b/translate/src/main/kotlin/org/fossify/messages/translate/TranslationBubbleBinder.kt new file mode 100644 index 00000000..b1c8e4dc --- /dev/null +++ b/translate/src/main/kotlin/org/fossify/messages/translate/TranslationBubbleBinder.kt @@ -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() + private val showingOriginal = HashSet() + private val displayed = HashMap() + + /** + * 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 + } +} diff --git a/translate/src/main/kotlin/org/fossify/messages/translate/TranslationSettingsActivity.kt b/translate/src/main/kotlin/org/fossify/messages/translate/TranslationSettingsActivity.kt new file mode 100644 index 00000000..4f4dc72d --- /dev/null +++ b/translate/src/main/kotlin/org/fossify/messages/translate/TranslationSettingsActivity.kt @@ -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 = 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", + ) + } +} diff --git a/translate/src/main/kotlin/org/fossify/messages/translate/Translator.kt b/translate/src/main/kotlin/org/fossify/messages/translate/Translator.kt new file mode 100644 index 00000000..6bc01f98 --- /dev/null +++ b/translate/src/main/kotlin/org/fossify/messages/translate/Translator.kt @@ -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(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 `` 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) + } +} diff --git a/translate/src/main/res/drawable/ic_translate_vector.xml b/translate/src/main/res/drawable/ic_translate_vector.xml new file mode 100644 index 00000000..f4c957d3 --- /dev/null +++ b/translate/src/main/res/drawable/ic_translate_vector.xml @@ -0,0 +1,11 @@ + + + + diff --git a/translate/src/main/res/layout/activity_translation_settings.xml b/translate/src/main/res/layout/activity_translation_settings.xml new file mode 100644 index 00000000..7636c991 --- /dev/null +++ b/translate/src/main/res/layout/activity_translation_settings.xml @@ -0,0 +1,141 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/translate/src/main/res/values/strings.xml b/translate/src/main/res/values/strings.xml new file mode 100644 index 00000000..8a720c89 --- /dev/null +++ b/translate/src/main/res/values/strings.xml @@ -0,0 +1,15 @@ + + + Translation + Translate received messages + Auto-translate matching SMS as you scroll + Auto-translate from + Translate into + offline-translator not installed + This feature uses %1$s from F-Droid to do the actual translation on-device. Tap to install. + Translate + Show original + Show translation + Translating… + Translation failed +