diff --git a/README-translation.md b/README-translation.md new file mode 100644 index 00000000..4745aafb --- /dev/null +++ b/README-translation.md @@ -0,0 +1,141 @@ +# SMS translation fork of Fossify Messages + +A small fork of [Fossify Messages](https://github.com/FossifyOrg/Messages) that +auto-translates received SMS / MMS on-the-fly, on-device, with no cloud and +no API keys. + +## Why this might be interesting + +If you live somewhere where you get important SMS in a language you don't +read fluently (bank alerts, delivery notifications, OTPs, government +messages…), this lets you keep using a clean FOSS messaging app and still +understand them. + +The translation itself is sub-second per message, runs entirely on-device +(Mozilla Bergamot / Marian NMT models), and preserves numbers and URLs +verbatim — so OTP codes, account numbers, tracking links, etc. don't get +mangled. + +- **Auto-translate**: each received bubble swaps to its translation as it + scrolls into view; conversation-list snippets do the same. +- **Per-language opt-in**: you choose which source languages get auto- + translated (e.g. Korean → English) and which are left alone (e.g. + Swedish, German, anything else you already read). +- **Tap to flip**: a small icon next to each translated bubble toggles it + back to the original. +- **Manual translate**: long-press a bubble → ⋮ → **Translate** to force a + one-off translation even for languages not on your auto-translate list. +- **Copy / Share / Select** capture what you see on screen — if the bubble + is showing English, your clipboard gets English. + +## Screenshots + +| Conversation list | Thread (translated) | Thread (original toggled back) | +|---|---|---| +| ![Conversation list with translated snippets](docs/conversation-list.jpg) | ![Korean SMS auto-translated to English, numbers and URLs preserved](docs/thread-translated.jpg) | ![Same thread flipped back to the Korean originals](docs/thread-original.jpg) | +| Snippets are translated as you scroll, opted-in source languages only. | Auto-translate fires on view; the small icon below each bubble is the toggle. Numbers (145cm, 40kg, 112) and URLs (vo.la/1Pa9m) survive verbatim. | Tap the icon to flip a bubble back to its original. Tap again to flip back. | + +| Translation settings | Manual translate (CAB ⋮) | +|---|---| +| ![Translation settings screen — master toggle, target language, source allowlist](docs/settings.jpg) | ![Long-press menu on a bubble showing the Translate item](docs/cab-translate.jpg) | +| Pick which source languages auto-translate and your target. Off by default. | Long-press → ⋮ → Translate to force a one-off translation for non-allowlisted languages. | + +## Status — please read + +This is an **experiment**, not a maintained project. + +- No commitment to keep it up to date with upstream Fossify or with Android. +- No commitment to fix bugs or ship security updates. +- No releases on F-Droid or Play. Built and signed locally by the author. +- I'd like to propose the core feature upstream to Fossify Messages + eventually; if they accept it, this fork's reason to exist mostly goes + away. If they don't, I'll probably keep using my own build, but you + should not depend on it. + +If you want a clean, maintained build of Fossify Messages, install the +official one from F-Droid. This fork is for tinkerers and for people who +specifically want the translation behavior described above. + +## Dependencies + +1. **`dev.davidv.translator`** — the [offline-translator](https://github.com/DavidVentura/offline-translator) + app on F-Droid. This is the engine. Install it, open it, download the + language pack(s) for the languages you want to translate (e.g. Korean, + the source side, plus the target language pack if it asks). It runs + Mozilla's Bergamot translation models on-device. + + Without this app installed, the translation feature silently does + nothing — the messaging app still works as a plain SMS client. + +2. **ML Kit Language Identification** — bundled inside this APK + (`com.google.mlkit:language-id`, Apache-2.0). Used only for detecting + what language an incoming SMS is in, so we can decide whether to + translate it. About 4 MB of model data baked in. This is transitional: + if davidv accepts a small AIDL addition upstream + ([offline-translator issue](https://github.com/DavidVentura/offline-translator)) + we'll drop ML Kit and use his detection directly. + +3. **Android 8.0+** (same as upstream Fossify). + +## How to use + +1. Install **offline-translator** from F-Droid. Open it and download the + language packs for the source language(s) you want translated (e.g. + Korean) and confirm your target language pack is installed (usually + English). +2. Install this fork's APK (build it yourself — see below). +3. Open this app → top-right ⋮ → **Settings** → **Translation**: + - Turn on **Translate received messages**. + - Pick your **Translate into** target (defaults to your device language). + - Check the **Auto-translate from** languages you want auto-translated. +4. Open any conversation. Bubbles in opted-in languages translate as you + scroll. Tap the small translate icon below a bubble to flip between + original and translation. + +## How it differs from upstream Fossify Messages + +The fork-only changes (not for upstream): + +- **Different package ID** (`net.jeena.smstranslate`) so it installs + alongside any official Fossify Messages. +- **No "make me default SMS app" prompt** on first launch. You can still + make it the default via Android Settings → Default apps → SMS app if you + want — this just doesn't pester you about it. +- **Sideload warning suppressed**. Fossify's built-in + "this APK looks corrupted, go get the official one" dialog is silenced + because of course this APK isn't the official one — it's a fork. + +The translation feature itself lives in a separate `:translate` Gradle +module and is structured so it can be cherry-picked onto upstream cleanly +as a possible PR. + +## Building from source + +You need JDK 17 and the Android SDK. + +```bash +export JAVA_HOME=/path/to/jdk17 +export ANDROID_HOME=/path/to/android-sdk +git clone +cd sms-translate +./gradlew :app:assembleFossDebug +# APK at app/build/outputs/apk/foss/debug/messages-*-foss-debug.apk +adb install -r app/build/outputs/apk/foss/debug/messages-*-foss-debug.apk +``` + +If you want a release build, set up a `keystore.properties` per upstream +Fossify's instructions and `./gradlew :app:assembleFossRelease`. + +## License + +Same as upstream Fossify Messages: **GPL-3.0**. The AIDL stubs from +offline-translator are also GPL-3.0. ML Kit is Apache-2.0. + +## Credits + +- [Fossify Messages](https://github.com/FossifyOrg/Messages) — the + excellent FOSS messaging app this is a fork of. +- [David Ventura](https://github.com/DavidVentura) — author of + [offline-translator](https://github.com/DavidVentura/offline-translator), + which does all the actual translation. +- [Mozilla Bergamot](https://browser.mt/) — the underlying NMT models. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ab24b77e..74e2f0d2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -116,7 +116,9 @@ android { ) } - namespace = project.property("APP_ID").toString() + // Source-package namespace stays decoupled from applicationId so a fork + // can ship under its own ID without breaking thousands of `R` imports. + namespace = "org.fossify.messages" lint { checkReleaseBuilds = false @@ -142,6 +144,7 @@ detekt { dependencies { implementation(libs.fossify.commons) + implementation(project(":translate")) implementation(libs.eventbus) implementation(libs.indicator.fast.scroll) implementation(libs.mmslib) diff --git a/app/src/main/kotlin/org/fossify/messages/activities/MainActivity.kt b/app/src/main/kotlin/org/fossify/messages/activities/MainActivity.kt index fdf98661..1f2db669 100644 --- a/app/src/main/kotlin/org/fossify/messages/activities/MainActivity.kt +++ b/app/src/main/kotlin/org/fossify/messages/activities/MainActivity.kt @@ -101,9 +101,12 @@ class MainActivity : SimpleActivity() { loadMessages() } - if (checkAppSideloading()) { - return - } + // [fork] checkAppSideloading() shows a "this app is corrupted, get the + // original" dialog when the applicationId differs from the official + // Fossify one. Suppressed here because this is intentionally a fork. + // if (checkAppSideloading()) { + // return + // } } override fun onResume() { @@ -215,28 +218,11 @@ class MainActivity : SimpleActivity() { } private fun loadMessages() { - if (isQPlus()) { - val roleManager = getSystemService(RoleManager::class.java) - if (roleManager!!.isRoleAvailable(RoleManager.ROLE_SMS)) { - if (roleManager.isRoleHeld(RoleManager.ROLE_SMS)) { - askPermissions() - } else { - val intent = roleManager.createRequestRoleIntent(RoleManager.ROLE_SMS) - startActivityForResult(intent, MAKE_DEFAULT_APP_REQUEST) - } - } else { - toast(org.fossify.commons.R.string.unknown_error_occurred) - finish() - } - } else { - if (Telephony.Sms.getDefaultSmsPackage(this) == packageName) { - askPermissions() - } else { - val intent = Intent(Telephony.Sms.Intents.ACTION_CHANGE_DEFAULT) - intent.putExtra(Telephony.Sms.Intents.EXTRA_PACKAGE_NAME, packageName) - startActivityForResult(intent, MAKE_DEFAULT_APP_REQUEST) - } - } + // [fork] Don't force the user to make this app the default SMS app + // on first launch. Read-only access via permissions is enough to + // browse and translate; the user can opt in to be the default + // through the system settings later if they want. + askPermissions() } // while SEND_SMS and READ_SMS permissions are mandatory, READ_CONTACTS is optional. 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 +