diff --git a/README-translation.md b/README-translation.md deleted file mode 100644 index 4745aafb..00000000 --- a/README-translation.md +++ /dev/null @@ -1,141 +0,0 @@ -# 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 74e2f0d2..ab24b77e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -116,9 +116,7 @@ android { ) } - // 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" + namespace = project.property("APP_ID").toString() lint { checkReleaseBuilds = false @@ -144,7 +142,6 @@ 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 1f2db669..fdf98661 100644 --- a/app/src/main/kotlin/org/fossify/messages/activities/MainActivity.kt +++ b/app/src/main/kotlin/org/fossify/messages/activities/MainActivity.kt @@ -101,12 +101,9 @@ class MainActivity : SimpleActivity() { loadMessages() } - // [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 - // } + if (checkAppSideloading()) { + return + } } override fun onResume() { @@ -218,11 +215,28 @@ class MainActivity : SimpleActivity() { } private fun loadMessages() { - // [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() + 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) + } + } } // 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 d1a2e35c..cfed8f45 100644 --- a/app/src/main/kotlin/org/fossify/messages/activities/SettingsActivity.kt +++ b/app/src/main/kotlin/org/fossify/messages/activities/SettingsActivity.kt @@ -106,7 +106,6 @@ class SettingsActivity : SimpleActivity() { setupLanguage() setupManageBlockedNumbers() setupManageBlockedKeywords() - setupTranslation() setupChangeDateTimeFormat() setupFontSize() setupShowCharacterCounter() @@ -231,12 +230,6 @@ 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 490ccbb5..2d5c0857 100644 --- a/app/src/main/kotlin/org/fossify/messages/adapters/BaseConversationsAdapter.kt +++ b/app/src/main/kotlin/org/fossify/messages/adapters/BaseConversationsAdapter.kt @@ -19,9 +19,6 @@ 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 @@ -47,7 +44,6 @@ 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 @@ -164,20 +160,8 @@ abstract class BaseConversationsAdapter( } conversationBodyShort.apply { - val original = smsDraft ?: conversation.snippet - text = original + text = smsDraft ?: conversation.snippet 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 5e580e1a..1151bc5d 100644 --- a/app/src/main/kotlin/org/fossify/messages/adapters/ThreadAdapter.kt +++ b/app/src/main/kotlin/org/fossify/messages/adapters/ThreadAdapter.kt @@ -41,7 +41,6 @@ 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 @@ -88,7 +87,6 @@ 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( @@ -103,7 +101,6 @@ 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 @@ -133,8 +130,6 @@ 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 } @@ -151,7 +146,6 @@ 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() @@ -232,15 +226,13 @@ class ThreadAdapter( if (selectedMessages.isEmpty()) return val textToCopy = if (selectedMessages.size == 1) { - val msg = selectedMessages.first() - translationBinder.visibleText(msg.id, msg.body).toString() + selectedMessages.first().body } 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) - val visible = translationBinder.visibleText(message.id, message.body) - "[$dateTime] $sender: $visible" + "[$dateTime] $sender: ${message.body}" } } @@ -263,34 +255,13 @@ class ThreadAdapter( private fun shareText() { val firstItem = getSelectedItems().firstOrNull() as? Message ?: return - activity.shareTextIntent(translationBinder.visibleText(firstItem.id, firstItem.body).toString()) + activity.shareTextIntent(firstItem.body) } private fun selectText() { val firstItem = getSelectedItems().firstOrNull() as? Message ?: return - 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() + if (firstItem.body.trim().isNotEmpty()) { + SelectTextDialog(activity, firstItem.body) } } @@ -460,20 +431,6 @@ 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 0d26783b..40ec728d 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -113,21 +113,6 @@ - - - - - - - - diff --git a/app/src/main/res/menu/cab_thread.xml b/app/src/main/res/menu/cab_thread.xml index 83bc19ce..2b81f67b 100644 --- a/app/src/main/res/menu/cab_thread.xml +++ b/app/src/main/res/menu/cab_thread.xml @@ -37,11 +37,6 @@ 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 deleted file mode 100644 index 8e211917..00000000 --- a/translate/src/main/aidl/dev/davidv/translator/ErrorType.aidl +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 659b8d93..00000000 --- a/translate/src/main/aidl/dev/davidv/translator/ITranslationCallback.aidl +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 5692099f..00000000 --- a/translate/src/main/aidl/dev/davidv/translator/ITranslationService.aidl +++ /dev/null @@ -1,7 +0,0 @@ -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 deleted file mode 100644 index cee284b9..00000000 --- a/translate/src/main/aidl/dev/davidv/translator/TranslationError.aidl +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 8e1937d7..00000000 --- a/translate/src/main/kotlin/org/fossify/messages/translate/MlKitLanguageDetector.kt +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index 7c3f2c2c..00000000 --- a/translate/src/main/kotlin/org/fossify/messages/translate/TranslateConfig.kt +++ /dev/null @@ -1,44 +0,0 @@ -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 deleted file mode 100644 index b1c8e4dc..00000000 --- a/translate/src/main/kotlin/org/fossify/messages/translate/TranslationBubbleBinder.kt +++ /dev/null @@ -1,165 +0,0 @@ -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 deleted file mode 100644 index 4f4dc72d..00000000 --- a/translate/src/main/kotlin/org/fossify/messages/translate/TranslationSettingsActivity.kt +++ /dev/null @@ -1,170 +0,0 @@ -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 deleted file mode 100644 index 6bc01f98..00000000 --- a/translate/src/main/kotlin/org/fossify/messages/translate/Translator.kt +++ /dev/null @@ -1,231 +0,0 @@ -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 deleted file mode 100644 index f4c957d3..00000000 --- a/translate/src/main/res/drawable/ic_translate_vector.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - diff --git a/translate/src/main/res/layout/activity_translation_settings.xml b/translate/src/main/res/layout/activity_translation_settings.xml deleted file mode 100644 index 7636c991..00000000 --- a/translate/src/main/res/layout/activity_translation_settings.xml +++ /dev/null @@ -1,141 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/translate/src/main/res/values/strings.xml b/translate/src/main/res/values/strings.xml deleted file mode 100644 index 8a720c89..00000000 --- a/translate/src/main/res/values/strings.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - 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 -