Compare commits
No commits in common. "feature/upstream-translation-patch" and "main" have entirely different histories.
feature/up
...
main
32 changed files with 33 additions and 1173 deletions
|
|
@ -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) |
|
||||
|---|---|---|
|
||||
|  |  |  |
|
||||
| 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 ⋮) |
|
||||
|---|---|
|
||||
|  |  |
|
||||
| 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 <this repo>
|
||||
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.
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<Long, String>()
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -113,21 +113,6 @@
|
|||
|
||||
</RelativeLayout>
|
||||
|
||||
<RelativeLayout
|
||||
android:id="@+id/settings_translation_holder"
|
||||
style="@style/SettingsHolderTextViewOneLinerStyle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<org.fossify.commons.views.MyTextView
|
||||
android:id="@+id/settings_translation"
|
||||
style="@style/SettingsTextLabelStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/translation_settings" />
|
||||
|
||||
</RelativeLayout>
|
||||
|
||||
<RelativeLayout
|
||||
android:id="@+id/settings_change_date_time_format_holder"
|
||||
style="@style/SettingsHolderTextViewOneLinerStyle"
|
||||
|
|
|
|||
|
|
@ -65,18 +65,5 @@
|
|||
android:textSize="@dimen/normal_text_size"
|
||||
tools:drawableEndCompat="@drawable/scheduled_message_icon"
|
||||
tools:text="Message content" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/thread_message_translate_icon"
|
||||
android:layout_width="20dp"
|
||||
android:layout_height="20dp"
|
||||
android:layout_below="@+id/thread_message_body"
|
||||
android:layout_alignStart="@+id/thread_message_body"
|
||||
android:layout_marginStart="@dimen/normal_margin"
|
||||
android:layout_marginTop="-2dp"
|
||||
android:padding="2dp"
|
||||
android:src="@drawable/ic_translate_vector"
|
||||
android:visibility="gone"
|
||||
tools:ignore="ContentDescription" />
|
||||
</RelativeLayout>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
|
|
|||
|
|
@ -37,11 +37,6 @@
|
|||
android:showAsAction="never"
|
||||
android:title="@string/select_text"
|
||||
app:showAsAction="never" />
|
||||
<item
|
||||
android:id="@+id/cab_translate"
|
||||
android:showAsAction="never"
|
||||
android:title="@string/translate"
|
||||
app:showAsAction="never" />
|
||||
<item
|
||||
android:id="@+id/cab_select_all"
|
||||
android:icon="@drawable/ic_select_all_vector"
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 86 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 87 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 66 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 100 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 108 KiB |
|
|
@ -5,4 +5,4 @@ org.gradle.jvmargs=-Xmx8192m
|
|||
# Versioning
|
||||
VERSION_NAME=1.8.0
|
||||
VERSION_CODE=20
|
||||
APP_ID=net.jeena.smstranslate
|
||||
APP_ID=org.fossify.messages
|
||||
|
|
|
|||
|
|
@ -30,8 +30,6 @@ app-build-javaVersion = "VERSION_17"
|
|||
app-build-kotlinJVMTarget = "17"
|
||||
#Helpers
|
||||
ez-vcard = "0.12.2"
|
||||
#ML Kit (transitional language identification — removed once dev.davidv.translator exposes detectLanguage via AIDL)
|
||||
mlkit-language-id = "17.0.6"
|
||||
[libraries]
|
||||
#AndroidX
|
||||
androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "androidx-constraintlayout" }
|
||||
|
|
@ -52,8 +50,6 @@ mmslib = { module = "org.fossify:mmslib", version.ref = "mmslib" }
|
|||
eventbus = { module = "org.greenrobot:eventbus", version.ref = "eventbus" }
|
||||
#Helpers
|
||||
ez-vcard = { module = "com.googlecode.ez-vcard:ez-vcard", version.ref = "ez-vcard" }
|
||||
#ML Kit
|
||||
mlkit-language-id = { module = "com.google.mlkit:language-id", version.ref = "mlkit-language-id" }
|
||||
#Kotlin
|
||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
|
||||
[bundles]
|
||||
|
|
|
|||
|
|
@ -15,4 +15,3 @@ dependencyResolutionManagement {
|
|||
}
|
||||
}
|
||||
include(":app")
|
||||
include(":translate")
|
||||
|
|
|
|||
1
translate/.gitignore
vendored
1
translate/.gitignore
vendored
|
|
@ -1 +0,0 @@
|
|||
/build
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
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)
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
package dev.davidv.translator;
|
||||
|
||||
enum ErrorType {
|
||||
COULD_NOT_DETECT_LANGUAGE,
|
||||
DETECTED_BUT_UNAVAILABLE,
|
||||
UNEXPECTED,
|
||||
}
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
package dev.davidv.translator;
|
||||
|
||||
import dev.davidv.translator.ErrorType;
|
||||
|
||||
parcelable TranslationError {
|
||||
ErrorType type;
|
||||
@nullable String language;
|
||||
@nullable String message;
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
<?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>
|
||||
|
|
@ -1,141 +0,0 @@
|
|||
<?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>
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
<?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