feat: on-the-fly SMS translation via offline-translator AIDL
Adds a small `:translate` Gradle module that translates received SMS / MMS bubbles and conversation-list snippets on the fly by binding to the offline-translator app on F-Droid (`dev.davidv.translator`). The actual translation runs there — Mozilla Bergamot/Marian on-device — so this patch ships no model, no inference, and no permissions beyond an Android 11+ <queries> block for package visibility. Behavior: - User-defined source-language allowlist + target language in Settings → Translation (right after Language). Off by default. - Auto-translate fires on RecyclerView bind for received bubbles and conversation snippets. Detection uses ML Kit Language Identification (CLD3, on-device). Once dev.davidv.translator exposes a detectLanguage() AIDL method we'll route through that and drop ML Kit. - AIDL latency is sub-second, so the bubble just quietly swaps from the original to the translation — no loading spinner. - Tap the translate icon next to a bubble to flip it back to the original; tap again to flip to the translation. Cached in process memory. - Long-press → ⋮ → Translate forces a one-off translation regardless of the allowlist (useful for messages in non-allowlisted languages), with a toast surfacing AIDL errors like 'language pack not installed'. - Copy / Share / Select on a translated bubble captures what the user sees, not the underlying source body. - Silently no-ops when offline-translator isn't installed; settings screen shows an F-Droid install banner. - Translation Settings is a regular Fossify sub-screen with MyAppBarLayout + MaterialToolbar + NestedScrollView, matching Manage Blocked Numbers / Keywords / SettingsActivity. No new database, no service, no boot receiver, no foreground service. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ffabeb2730
commit
99649c54d7
22 changed files with 1015 additions and 6 deletions
37
translate/build.gradle.kts
Normal file
37
translate/build.gradle.kts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
plugins {
|
||||
id("com.android.library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "org.fossify.messages.translate"
|
||||
compileSdk = project.libs.versions.app.build.compileSDKVersion.get().toInt()
|
||||
|
||||
defaultConfig {
|
||||
minSdk = project.libs.versions.app.build.minimumSDK.get().toInt()
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
aidl = true
|
||||
viewBinding = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
val javaVersion = JavaVersion.valueOf(libs.versions.app.build.javaVersion.get())
|
||||
sourceCompatibility = javaVersion
|
||||
targetCompatibility = javaVersion
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("main").java.srcDirs("src/main/kotlin")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Fossify base for SimpleActivity / MyTextView / theming so this
|
||||
// settings screen looks and behaves like the rest of the app.
|
||||
implementation(libs.fossify.commons)
|
||||
|
||||
// Bundled CLD3 language identification — transitional; will be replaced
|
||||
// by an AIDL detectLanguage() call once dev.davidv.translator exposes it.
|
||||
implementation(libs.mlkit.language.id)
|
||||
}
|
||||
19
translate/src/main/AndroidManifest.xml
Normal file
19
translate/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- Android 11+ package visibility: needed to bind to the
|
||||
offline-translator AIDL service. -->
|
||||
<queries>
|
||||
<package android:name="dev.davidv.translator" />
|
||||
<intent>
|
||||
<action android:name="dev.davidv.translator.ITranslationService" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application>
|
||||
<activity
|
||||
android:name=".TranslationSettingsActivity"
|
||||
android:exported="false"
|
||||
android:label="@string/translation_settings" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package dev.davidv.translator;
|
||||
|
||||
enum ErrorType {
|
||||
COULD_NOT_DETECT_LANGUAGE,
|
||||
DETECTED_BUT_UNAVAILABLE,
|
||||
UNEXPECTED,
|
||||
}
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package dev.davidv.translator;
|
||||
|
||||
import dev.davidv.translator.ErrorType;
|
||||
|
||||
parcelable TranslationError {
|
||||
ErrorType type;
|
||||
@nullable String language;
|
||||
@nullable String message;
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
package org.fossify.messages.translate
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* SharedPreferences-backed settings for the translation feature.
|
||||
* Lives in the [:translate] module so the main app doesn't need to know
|
||||
* about its keys.
|
||||
*/
|
||||
class TranslateConfig(context: Context) {
|
||||
|
||||
private val prefs: SharedPreferences =
|
||||
context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
/** Master switch. When false, no auto-translation happens at all. */
|
||||
var enabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_ENABLED, false)
|
||||
set(value) = prefs.edit { putBoolean(KEY_ENABLED, value) }
|
||||
|
||||
/** ISO 639-1 codes of source languages that should be auto-translated. */
|
||||
var autoTranslateSources: Set<String>
|
||||
get() = prefs.getStringSet(KEY_SOURCES, emptySet()) ?: emptySet()
|
||||
set(value) = prefs.edit { putStringSet(KEY_SOURCES, value) }
|
||||
|
||||
/** ISO 639-1 code to translate into. Defaults to the device language. */
|
||||
var targetLanguage: String
|
||||
get() = prefs.getString(KEY_TARGET, defaultTargetLang()) ?: defaultTargetLang()
|
||||
set(value) = prefs.edit { putString(KEY_TARGET, value) }
|
||||
|
||||
private fun defaultTargetLang(): String = Locale.getDefault().language.ifEmpty { "en" }
|
||||
|
||||
fun shouldAutoTranslate(detectedSource: String): Boolean =
|
||||
enabled && detectedSource != targetLanguage && detectedSource in autoTranslateSources
|
||||
|
||||
companion object {
|
||||
private const val PREFS_NAME = "fossify_messages_translate"
|
||||
private const val KEY_ENABLED = "enabled"
|
||||
private const val KEY_SOURCES = "sources"
|
||||
private const val KEY_TARGET = "target"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
package org.fossify.messages.translate
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
|
||||
/**
|
||||
* Per-thread (or per-conversations-list) helper that wires up message
|
||||
* bubbles to the [Translator]. Hides the icon/toggle/spinner book-keeping
|
||||
* away from the host adapter so its diff stays small.
|
||||
*
|
||||
* Maintains in-memory state for the current Activity:
|
||||
* - `translations[messageId]` — the latest translation we've fetched.
|
||||
* - `showingOriginal[messageId]` — toggle state per bubble.
|
||||
* - `displayed[messageId]` — what text is currently on screen, used by
|
||||
* the host adapter so copy/share/select capture what the user sees.
|
||||
*
|
||||
* RecyclerView reuse is handled via a view tag: when an AIDL callback
|
||||
* arrives we only touch the views if their tag still matches the message
|
||||
* id we started with.
|
||||
*/
|
||||
class TranslationBubbleBinder(private val context: Context) {
|
||||
|
||||
private val config = TranslateConfig(context)
|
||||
private val translations = HashMap<Long, String>()
|
||||
private val showingOriginal = HashSet<Long>()
|
||||
private val displayed = HashMap<Long, CharSequence>()
|
||||
|
||||
/**
|
||||
* Bind a received-message bubble to the translator. Idempotent — safe
|
||||
* to call from `onBindViewHolder`. Hides the icon if no translation
|
||||
* happens; spins the icon while a translation is in flight; on result
|
||||
* shows the translated text and a clickable toggle.
|
||||
*/
|
||||
fun bind(messageId: Long, body: String, bodyView: TextView, iconView: ImageView) {
|
||||
bodyView.setTag(TAG_KEY, messageId)
|
||||
iconView.setTag(TAG_KEY, messageId)
|
||||
iconView.visibility = View.GONE
|
||||
iconView.setOnClickListener(null)
|
||||
|
||||
val cached = translations[messageId]
|
||||
if (cached != null) {
|
||||
applyToggle(messageId, body, cached, bodyView, iconView)
|
||||
return
|
||||
}
|
||||
|
||||
// Show the body as-is until / unless a translation arrives.
|
||||
// No loading spinner — the AIDL round-trip is sub-second on
|
||||
// typical SMS-length inputs so the bubble just quietly swaps.
|
||||
bodyView.text = body
|
||||
displayed[messageId] = body
|
||||
|
||||
Translator.maybeAutoTranslate(body, context, config) { result ->
|
||||
if (iconView.getTag(TAG_KEY) != messageId) return@maybeAutoTranslate // stale
|
||||
handleResult(messageId, body, result, bodyView, iconView)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually translate a single message, bypassing the auto-translate
|
||||
* allowlist. Used by the bubble action-mode "Translate" item.
|
||||
* Updates the bubble in place when [bodyView]/[iconView] are still
|
||||
* showing this message.
|
||||
*/
|
||||
fun translateManually(messageId: Long, body: String, bodyView: TextView, iconView: ImageView) {
|
||||
val cached = translations[messageId]
|
||||
if (cached != null) {
|
||||
showingOriginal.remove(messageId)
|
||||
applyToggle(messageId, body, cached, bodyView, iconView)
|
||||
return
|
||||
}
|
||||
bodyView.setTag(TAG_KEY, messageId)
|
||||
iconView.setTag(TAG_KEY, messageId)
|
||||
|
||||
Translator.translate(body, fromLang = null, toLang = config.targetLanguage, context) { result ->
|
||||
if (iconView.getTag(TAG_KEY) != messageId) return@translate // stale
|
||||
showingOriginal.remove(messageId)
|
||||
handleResult(messageId, body, result, bodyView, iconView)
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns what's currently shown for this message (translation or original). */
|
||||
fun visibleText(messageId: Long, body: String): CharSequence = displayed[messageId] ?: body
|
||||
|
||||
/**
|
||||
* For CAB-driven manual translate when the bubble may not currently be
|
||||
* visible in the RecyclerView. Fires the AIDL call, stashes the result
|
||||
* in the cache, and invokes [onDone] (on the main thread) — the caller
|
||||
* then `notifyItemChanged` to repaint.
|
||||
*/
|
||||
fun preloadTranslation(messageId: Long, body: String, onDone: (Translator.Result) -> Unit) {
|
||||
val cached = translations[messageId]
|
||||
if (cached != null) {
|
||||
showingOriginal.remove(messageId)
|
||||
onDone(Translator.Result.Success(cached, sourceLang = null))
|
||||
return
|
||||
}
|
||||
Translator.translate(body, fromLang = null, toLang = config.targetLanguage, context) { result ->
|
||||
if (result is Translator.Result.Success) {
|
||||
translations[messageId] = result.translated
|
||||
showingOriginal.remove(messageId)
|
||||
}
|
||||
onDone(result)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleResult(
|
||||
messageId: Long,
|
||||
body: String,
|
||||
result: Translator.Result,
|
||||
bodyView: TextView,
|
||||
iconView: ImageView,
|
||||
) {
|
||||
when (result) {
|
||||
is Translator.Result.Success -> {
|
||||
translations[messageId] = result.translated
|
||||
applyToggle(messageId, body, result.translated, bodyView, iconView)
|
||||
}
|
||||
|
||||
Translator.Result.Skipped -> {
|
||||
iconView.visibility = View.GONE
|
||||
bodyView.text = body
|
||||
displayed[messageId] = body
|
||||
}
|
||||
|
||||
is Translator.Result.Failed -> {
|
||||
iconView.visibility = View.GONE
|
||||
bodyView.text = body
|
||||
displayed[messageId] = body
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyToggle(
|
||||
messageId: Long,
|
||||
original: String,
|
||||
translated: String,
|
||||
bodyView: TextView,
|
||||
iconView: ImageView,
|
||||
) {
|
||||
iconView.visibility = View.VISIBLE
|
||||
if (messageId in showingOriginal) {
|
||||
bodyView.text = original
|
||||
displayed[messageId] = original
|
||||
iconView.alpha = 0.5f
|
||||
} else {
|
||||
bodyView.text = translated
|
||||
displayed[messageId] = translated
|
||||
iconView.alpha = 1.0f
|
||||
}
|
||||
iconView.setOnClickListener {
|
||||
if (messageId in showingOriginal) showingOriginal.remove(messageId)
|
||||
else showingOriginal.add(messageId)
|
||||
applyToggle(messageId, original, translated, bodyView, iconView)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
// Unique-per-feature view tag id. View.setTag(int, Object) requires
|
||||
// an id from a resource; we use a synthetic constant chosen to not
|
||||
// collide with R.id values (which are positive ints from aapt).
|
||||
private const val TAG_KEY = 0x7f200001
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
package org.fossify.messages.translate
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.CheckBox
|
||||
import android.widget.Spinner
|
||||
import android.widget.TextView
|
||||
import org.fossify.commons.activities.BaseSimpleActivity
|
||||
import org.fossify.commons.extensions.toast
|
||||
import org.fossify.commons.extensions.viewBinding
|
||||
import org.fossify.commons.helpers.NavigationIcon
|
||||
import org.fossify.messages.translate.databinding.ActivityTranslationSettingsBinding
|
||||
|
||||
class TranslationSettingsActivity : BaseSimpleActivity() {
|
||||
|
||||
private val binding by viewBinding(ActivityTranslationSettingsBinding::inflate)
|
||||
private val config by lazy { TranslateConfig(this) }
|
||||
|
||||
override fun getAppIconIDs(): ArrayList<Int> = arrayListOf()
|
||||
override fun getAppLauncherName(): String = getString(R.string.translation_settings)
|
||||
override fun getRepositoryName(): String? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(binding.root)
|
||||
|
||||
setupEdgeToEdge(padBottomImeAndSystem = listOf(binding.translationSettingsNestedScrollview))
|
||||
setupMaterialScrollListener(
|
||||
scrollingView = binding.translationSettingsNestedScrollview,
|
||||
topAppBar = binding.translationSettingsAppbar,
|
||||
)
|
||||
|
||||
setupBanner()
|
||||
setupEnabledSwitch()
|
||||
setupTargetSpinner()
|
||||
setupSourcesList()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
setupTopAppBar(binding.translationSettingsAppbar, NavigationIcon.Arrow)
|
||||
}
|
||||
|
||||
private fun setupBanner() {
|
||||
if (Translator.isPackageAvailable(this)) {
|
||||
binding.installBanner.visibility = View.GONE
|
||||
} else {
|
||||
binding.installBanner.visibility = View.VISIBLE
|
||||
binding.installBannerSummary.text = getString(
|
||||
R.string.install_offline_translator_summary,
|
||||
"dev.davidv.translator",
|
||||
)
|
||||
binding.installBanner.setOnClickListener {
|
||||
openOfflineTranslatorPage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openOfflineTranslatorPage() {
|
||||
val tries = listOf(
|
||||
"fdroid://app/dev.davidv.translator",
|
||||
"https://f-droid.org/packages/dev.davidv.translator/",
|
||||
)
|
||||
for (uri in tries) {
|
||||
try {
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(uri)))
|
||||
return
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
// try next fallback
|
||||
}
|
||||
}
|
||||
toast("No browser or F-Droid client available")
|
||||
}
|
||||
|
||||
private fun setupEnabledSwitch() {
|
||||
binding.enabledSwitch.isChecked = config.enabled
|
||||
binding.enabledSwitch.setOnCheckedChangeListener { _, isChecked ->
|
||||
config.enabled = isChecked
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupTargetSpinner() {
|
||||
val codes = SUPPORTED_LANGUAGES.keys.toList()
|
||||
val labels = codes.map { "${SUPPORTED_LANGUAGES[it]} ($it)" }
|
||||
val adapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, labels)
|
||||
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||
binding.targetSpinner.adapter = adapter
|
||||
|
||||
val current = codes.indexOf(config.targetLanguage)
|
||||
if (current >= 0) binding.targetSpinner.setSelection(current)
|
||||
|
||||
binding.targetSpinner.onItemSelectedListener = object : android.widget.AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(parent: android.widget.AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||
config.targetLanguage = codes[position]
|
||||
}
|
||||
override fun onNothingSelected(parent: android.widget.AdapterView<*>?) {}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupSourcesList() {
|
||||
val current = config.autoTranslateSources.toMutableSet()
|
||||
binding.sourcesContainer.removeAllViews()
|
||||
for ((code, name) in SUPPORTED_LANGUAGES) {
|
||||
val row = CheckBox(this).apply {
|
||||
text = "$name ($code)"
|
||||
isChecked = code in current
|
||||
setOnCheckedChangeListener { _, isChecked ->
|
||||
if (isChecked) current.add(code) else current.remove(code)
|
||||
config.autoTranslateSources = current.toSet()
|
||||
}
|
||||
}
|
||||
binding.sourcesContainer.addView(row)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Languages exposed in the auto-translate allowlist + target picker.
|
||||
* Hardcoded from the Bergamot-supported set as of this writing.
|
||||
* If a chosen language pack isn't installed in offline-translator,
|
||||
* the first translation attempt returns DETECTED_BUT_UNAVAILABLE
|
||||
* and we surface a toast pointing the user at that app.
|
||||
*/
|
||||
private val SUPPORTED_LANGUAGES = linkedMapOf(
|
||||
"ar" to "Arabic",
|
||||
"bg" to "Bulgarian",
|
||||
"bn" to "Bengali",
|
||||
"cs" to "Czech",
|
||||
"da" to "Danish",
|
||||
"de" to "German",
|
||||
"el" to "Greek",
|
||||
"en" to "English",
|
||||
"es" to "Spanish",
|
||||
"et" to "Estonian",
|
||||
"fa" to "Persian",
|
||||
"fi" to "Finnish",
|
||||
"fr" to "French",
|
||||
"he" to "Hebrew",
|
||||
"hi" to "Hindi",
|
||||
"hu" to "Hungarian",
|
||||
"id" to "Indonesian",
|
||||
"is" to "Icelandic",
|
||||
"it" to "Italian",
|
||||
"ja" to "Japanese",
|
||||
"ko" to "Korean",
|
||||
"lt" to "Lithuanian",
|
||||
"lv" to "Latvian",
|
||||
"nb" to "Norwegian Bokmål",
|
||||
"nl" to "Dutch",
|
||||
"nn" to "Norwegian Nynorsk",
|
||||
"pl" to "Polish",
|
||||
"pt" to "Portuguese",
|
||||
"ro" to "Romanian",
|
||||
"ru" to "Russian",
|
||||
"sk" to "Slovak",
|
||||
"sl" to "Slovenian",
|
||||
"sv" to "Swedish",
|
||||
"th" to "Thai",
|
||||
"tr" to "Turkish",
|
||||
"uk" to "Ukrainian",
|
||||
"ur" to "Urdu",
|
||||
"vi" to "Vietnamese",
|
||||
"zh" to "Chinese",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,231 @@
|
|||
package org.fossify.messages.translate
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import dev.davidv.translator.ITranslationCallback
|
||||
import dev.davidv.translator.ITranslationService
|
||||
import dev.davidv.translator.TranslationError
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* Singleton client for the offline-translator AIDL service
|
||||
* (`dev.davidv.translator` on F-Droid). Translates SMS / MMS bodies via
|
||||
* Mozilla Bergamot/Marian on the user's device.
|
||||
*
|
||||
* Lazily binds on first use. Holds an in-memory [LruCache] of translations
|
||||
* keyed by `(bodyHash, targetLang)` so repeat views of the same message
|
||||
* don't re-translate. Cache is process-lifetime — no persistence.
|
||||
*
|
||||
* Detection currently uses [MlKitLanguageDetector] (CLD3 bundled). Once
|
||||
* davidv exposes a `detectLanguage()` AIDL method we'll delete that class
|
||||
* and route detection through this same connection.
|
||||
*/
|
||||
object Translator {
|
||||
|
||||
private const val TAG = "Translator"
|
||||
private const val PACKAGE = "dev.davidv.translator"
|
||||
private const val ACTION = "dev.davidv.translator.ITranslationService"
|
||||
private const val CACHE_CAPACITY = 200
|
||||
|
||||
sealed class Result {
|
||||
/** Translation succeeded. */
|
||||
data class Success(val translated: String, val sourceLang: String?) : Result()
|
||||
/** Translation was skipped — no detection, source not in allowlist, or source equals target. */
|
||||
data object Skipped : Result()
|
||||
/** AIDL call failed (package missing, model not installed, network/IO error). */
|
||||
data class Failed(val reason: String) : Result()
|
||||
}
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val cache = LruCache<String, String>(CACHE_CAPACITY)
|
||||
private val pending = ConcurrentLinkedQueue<() -> Unit>()
|
||||
private val binding = AtomicBoolean(false)
|
||||
|
||||
@Volatile
|
||||
private var service: ITranslationService? = null
|
||||
|
||||
@Volatile
|
||||
private var connection: ServiceConnection? = null
|
||||
|
||||
/**
|
||||
* Returns true if `dev.davidv.translator` is installed.
|
||||
* Requires the `<queries>` block in our manifest to be effective on Android 11+.
|
||||
*/
|
||||
fun isPackageAvailable(context: Context): Boolean = try {
|
||||
context.packageManager.getPackageInfo(PACKAGE, 0)
|
||||
true
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to auto-translate [text]. Reads [config] for enabled state,
|
||||
* source allowlist, and target language. Result is delivered on the
|
||||
* main thread via [onResult].
|
||||
*
|
||||
* Returns true if a translation attempt was started (UI may show a
|
||||
* loading indicator); false if the call is a no-op (cache miss
|
||||
* skipped, package not installed, or text empty).
|
||||
*/
|
||||
fun maybeAutoTranslate(text: String, context: Context, config: TranslateConfig, onResult: (Result) -> Unit): Boolean {
|
||||
if (!config.enabled || text.isBlank()) {
|
||||
postMain { onResult(Result.Skipped) }
|
||||
return false
|
||||
}
|
||||
if (!isPackageAvailable(context)) {
|
||||
postMain { onResult(Result.Failed("offline-translator not installed")) }
|
||||
return false
|
||||
}
|
||||
// Cache lookup is keyed on target only — we cache after detection
|
||||
// succeeds, so a hit means we already verified source ∈ allowlist
|
||||
// for that body+target.
|
||||
cacheGet(text, config.targetLanguage)?.let { hit ->
|
||||
postMain { onResult(Result.Success(hit, sourceLang = null)) }
|
||||
return true
|
||||
}
|
||||
MlKitLanguageDetector.detect(text) { detected ->
|
||||
if (detected == null || !config.shouldAutoTranslate(detected)) {
|
||||
postMain { onResult(Result.Skipped) }
|
||||
return@detect
|
||||
}
|
||||
translate(text, detected, config.targetLanguage, context) { result ->
|
||||
onResult(result)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual translate path (used by the CAB Translate menu). Bypasses the
|
||||
* allowlist — translates regardless of whether the source is opted-in.
|
||||
* `fromLang = null` lets davidv's service auto-detect.
|
||||
*/
|
||||
fun translate(
|
||||
text: String,
|
||||
fromLang: String?,
|
||||
toLang: String,
|
||||
context: Context,
|
||||
onResult: (Result) -> Unit,
|
||||
) {
|
||||
if (text.isBlank()) {
|
||||
postMain { onResult(Result.Skipped) }
|
||||
return
|
||||
}
|
||||
cacheGet(text, toLang)?.let { hit ->
|
||||
postMain { onResult(Result.Success(hit, fromLang)) }
|
||||
return
|
||||
}
|
||||
runWhenBound(context) { svc ->
|
||||
if (svc == null) {
|
||||
postMain { onResult(Result.Failed("could not bind to $PACKAGE")) }
|
||||
return@runWhenBound
|
||||
}
|
||||
val cb = object : ITranslationCallback.Stub() {
|
||||
override fun onTranslationResult(translatedText: String?) {
|
||||
if (translatedText != null) {
|
||||
cachePut(text, toLang, translatedText)
|
||||
postMain { onResult(Result.Success(translatedText, fromLang)) }
|
||||
} else {
|
||||
postMain { onResult(Result.Failed("empty result")) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTranslationError(error: TranslationError?) {
|
||||
val reason = "${error?.type} ${error?.language ?: ""} ${error?.message ?: ""}".trim()
|
||||
Log.w(TAG, "Translation error: $reason")
|
||||
postMain { onResult(Result.Failed(reason)) }
|
||||
}
|
||||
}
|
||||
try {
|
||||
svc.translate(text, fromLang.orEmpty(), toLang, cb)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "AIDL translate threw", e)
|
||||
postMain { onResult(Result.Failed(e.javaClass.simpleName)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Releases the binding. Safe to call multiple times. */
|
||||
fun unbind(context: Context) {
|
||||
connection?.let {
|
||||
try {
|
||||
context.applicationContext.unbindService(it)
|
||||
} catch (_: Exception) {
|
||||
// Already unbound or never bound; ignore.
|
||||
}
|
||||
}
|
||||
connection = null
|
||||
service = null
|
||||
}
|
||||
|
||||
// --- internals ------------------------------------------------------
|
||||
|
||||
private fun runWhenBound(context: Context, action: (ITranslationService?) -> Unit) {
|
||||
val existing = service
|
||||
if (existing != null) {
|
||||
action(existing)
|
||||
return
|
||||
}
|
||||
pending.add { action(service) }
|
||||
ensureBinding(context.applicationContext)
|
||||
}
|
||||
|
||||
private fun ensureBinding(appContext: Context) {
|
||||
if (!binding.compareAndSet(false, true)) return
|
||||
val conn = object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName, binder: IBinder) {
|
||||
service = ITranslationService.Stub.asInterface(binder)
|
||||
Log.i(TAG, "Bound to $name")
|
||||
drainPending()
|
||||
binding.set(false)
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName) {
|
||||
Log.w(TAG, "Disconnected from $name")
|
||||
service = null
|
||||
}
|
||||
}
|
||||
connection = conn
|
||||
val intent = Intent(ACTION).setPackage(PACKAGE)
|
||||
val ok = try {
|
||||
appContext.bindService(intent, conn, Context.BIND_AUTO_CREATE)
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "bindService SecurityException", e)
|
||||
false
|
||||
}
|
||||
if (!ok) {
|
||||
Log.e(TAG, "bindService returned false (is $PACKAGE installed?)")
|
||||
connection = null
|
||||
drainPending()
|
||||
binding.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun drainPending() {
|
||||
while (true) {
|
||||
val next = pending.poll() ?: break
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
private fun cacheKey(text: String, target: String): String =
|
||||
"${text.hashCode().toLong() and 0xffffffffL}:$target:${text.length}"
|
||||
|
||||
private fun cacheGet(text: String, target: String): String? = cache.get(cacheKey(text, target))
|
||||
|
||||
private fun cachePut(text: String, target: String, translation: String) {
|
||||
cache.put(cacheKey(text, target), translation)
|
||||
}
|
||||
|
||||
private fun postMain(block: () -> Unit) {
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) block() else mainHandler.post(block)
|
||||
}
|
||||
}
|
||||
11
translate/src/main/res/drawable/ic_translate_vector.xml
Normal file
11
translate/src/main/res/drawable/ic_translate_vector.xml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M12.87,15.07l-2.54,-2.51 0.03,-0.03A17.52,17.52 0,0 0,14.07 6H17V4h-7V2H8v2H1v1.99h11.17C11.5,7.92 10.44,9.75 9,11.35 8.07,10.32 7.3,9.19 6.69,8h-2c0.73,1.63 1.73,3.17 2.98,4.56l-5.09,5.02L4,19l5,-5 3.11,3.11 0.76,-2.04zM18.5,10h-2L12,22h2l1.12,-3h4.75L21,22h2l-4.5,-12zM15.88,17l1.62,-4.33L19.12,17h-3.24z" />
|
||||
</vector>
|
||||
141
translate/src/main/res/layout/activity_translation_settings.xml
Normal file
141
translate/src/main/res/layout/activity_translation_settings.xml
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/translation_settings_coordinator"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<org.fossify.commons.views.MyAppBarLayout
|
||||
android:id="@+id/translation_settings_appbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/translation_settings_toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
android:background="@color/color_primary"
|
||||
app:title="@string/translation_settings"
|
||||
app:titleTextAppearance="@style/AppTheme.ActionBar.TitleTextStyle" />
|
||||
|
||||
</org.fossify.commons.views.MyAppBarLayout>
|
||||
|
||||
<androidx.core.widget.NestedScrollView
|
||||
android:id="@+id/translation_settings_nested_scrollview"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true"
|
||||
android:scrollbars="none"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingVertical="12dp">
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/install_banner"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?android:attr/selectableItemBackground"
|
||||
android:padding="12dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/install_offline_translator_title"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/install_banner_summary"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:paddingVertical="8dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/translate_received_messages"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:text="@string/translate_received_messages_summary"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.materialswitch.MaterialSwitch
|
||||
android:id="@+id/enabled_switch"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_vertical" />
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:layout_marginVertical="8dp"
|
||||
android:background="?android:attr/listDivider" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingVertical="8dp"
|
||||
android:text="@string/translate_to"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/target_spinner"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:layout_marginVertical="8dp"
|
||||
android:background="?android:attr/listDivider" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingVertical="8dp"
|
||||
android:text="@string/auto_translate_languages"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/sources_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical" />
|
||||
</LinearLayout>
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
15
translate/src/main/res/values/strings.xml
Normal file
15
translate/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="translation_settings">Translation</string>
|
||||
<string name="translate_received_messages">Translate received messages</string>
|
||||
<string name="translate_received_messages_summary">Auto-translate matching SMS as you scroll</string>
|
||||
<string name="auto_translate_languages">Auto-translate from</string>
|
||||
<string name="translate_to">Translate into</string>
|
||||
<string name="install_offline_translator_title">offline-translator not installed</string>
|
||||
<string name="install_offline_translator_summary">This feature uses %1$s from F-Droid to do the actual translation on-device. Tap to install.</string>
|
||||
<string name="translate">Translate</string>
|
||||
<string name="show_original">Show original</string>
|
||||
<string name="show_translation">Show translation</string>
|
||||
<string name="translation_in_progress">Translating…</string>
|
||||
<string name="translation_failed">Translation failed</string>
|
||||
</resources>
|
||||
Loading…
Add table
Add a link
Reference in a new issue