Compare commits

..

No commits in common. "feature/upstream-translation-patch" and "main" have entirely different histories.

32 changed files with 33 additions and 1173 deletions

View file

@ -1,141 +0,0 @@
# SMS translation fork of Fossify Messages
A small fork of [Fossify Messages](https://github.com/FossifyOrg/Messages) that
auto-translates received SMS / MMS on-the-fly, on-device, with no cloud and
no API keys.
## Why this might be interesting
If you live somewhere where you get important SMS in a language you don't
read fluently (bank alerts, delivery notifications, OTPs, government
messages…), this lets you keep using a clean FOSS messaging app and still
understand them.
The translation itself is sub-second per message, runs entirely on-device
(Mozilla Bergamot / Marian NMT models), and preserves numbers and URLs
verbatim — so OTP codes, account numbers, tracking links, etc. don't get
mangled.
- **Auto-translate**: each received bubble swaps to its translation as it
scrolls into view; conversation-list snippets do the same.
- **Per-language opt-in**: you choose which source languages get auto-
translated (e.g. Korean → English) and which are left alone (e.g.
Swedish, German, anything else you already read).
- **Tap to flip**: a small icon next to each translated bubble toggles it
back to the original.
- **Manual translate**: long-press a bubble → ⋮ → **Translate** to force a
one-off translation even for languages not on your auto-translate list.
- **Copy / Share / Select** capture what you see on screen — if the bubble
is showing English, your clipboard gets English.
## Screenshots
| Conversation list | Thread (translated) | Thread (original toggled back) |
|---|---|---|
| ![Conversation list with translated snippets](docs/conversation-list.jpg) | ![Korean SMS auto-translated to English, numbers and URLs preserved](docs/thread-translated.jpg) | ![Same thread flipped back to the Korean originals](docs/thread-original.jpg) |
| Snippets are translated as you scroll, opted-in source languages only. | Auto-translate fires on view; the small icon below each bubble is the toggle. Numbers (145cm, 40kg, 112) and URLs (vo.la/1Pa9m) survive verbatim. | Tap the icon to flip a bubble back to its original. Tap again to flip back. |
| Translation settings | Manual translate (CAB ⋮) |
|---|---|
| ![Translation settings screen — master toggle, target language, source allowlist](docs/settings.jpg) | ![Long-press menu on a bubble showing the Translate item](docs/cab-translate.jpg) |
| Pick which source languages auto-translate and your target. Off by default. | Long-press → ⋮ → Translate to force a one-off translation for non-allowlisted languages. |
## Status — please read
This is an **experiment**, not a maintained project.
- No commitment to keep it up to date with upstream Fossify or with Android.
- No commitment to fix bugs or ship security updates.
- No releases on F-Droid or Play. Built and signed locally by the author.
- I'd like to propose the core feature upstream to Fossify Messages
eventually; if they accept it, this fork's reason to exist mostly goes
away. If they don't, I'll probably keep using my own build, but you
should not depend on it.
If you want a clean, maintained build of Fossify Messages, install the
official one from F-Droid. This fork is for tinkerers and for people who
specifically want the translation behavior described above.
## Dependencies
1. **`dev.davidv.translator`** — the [offline-translator](https://github.com/DavidVentura/offline-translator)
app on F-Droid. This is the engine. Install it, open it, download the
language pack(s) for the languages you want to translate (e.g. Korean,
the source side, plus the target language pack if it asks). It runs
Mozilla's Bergamot translation models on-device.
Without this app installed, the translation feature silently does
nothing — the messaging app still works as a plain SMS client.
2. **ML Kit Language Identification** — bundled inside this APK
(`com.google.mlkit:language-id`, Apache-2.0). Used only for detecting
what language an incoming SMS is in, so we can decide whether to
translate it. About 4 MB of model data baked in. This is transitional:
if davidv accepts a small AIDL addition upstream
([offline-translator issue](https://github.com/DavidVentura/offline-translator))
we'll drop ML Kit and use his detection directly.
3. **Android 8.0+** (same as upstream Fossify).
## How to use
1. Install **offline-translator** from F-Droid. Open it and download the
language packs for the source language(s) you want translated (e.g.
Korean) and confirm your target language pack is installed (usually
English).
2. Install this fork's APK (build it yourself — see below).
3. Open this app → top-right ⋮ → **Settings****Translation**:
- Turn on **Translate received messages**.
- Pick your **Translate into** target (defaults to your device language).
- Check the **Auto-translate from** languages you want auto-translated.
4. Open any conversation. Bubbles in opted-in languages translate as you
scroll. Tap the small translate icon below a bubble to flip between
original and translation.
## How it differs from upstream Fossify Messages
The fork-only changes (not for upstream):
- **Different package ID** (`net.jeena.smstranslate`) so it installs
alongside any official Fossify Messages.
- **No "make me default SMS app" prompt** on first launch. You can still
make it the default via Android Settings → Default apps → SMS app if you
want — this just doesn't pester you about it.
- **Sideload warning suppressed**. Fossify's built-in
"this APK looks corrupted, go get the official one" dialog is silenced
because of course this APK isn't the official one — it's a fork.
The translation feature itself lives in a separate `:translate` Gradle
module and is structured so it can be cherry-picked onto upstream cleanly
as a possible PR.
## Building from source
You need JDK 17 and the Android SDK.
```bash
export JAVA_HOME=/path/to/jdk17
export ANDROID_HOME=/path/to/android-sdk
git clone <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.

View file

@ -116,9 +116,7 @@ android {
) )
} }
// Source-package namespace stays decoupled from applicationId so a fork namespace = project.property("APP_ID").toString()
// can ship under its own ID without breaking thousands of `R` imports.
namespace = "org.fossify.messages"
lint { lint {
checkReleaseBuilds = false checkReleaseBuilds = false
@ -144,7 +142,6 @@ detekt {
dependencies { dependencies {
implementation(libs.fossify.commons) implementation(libs.fossify.commons)
implementation(project(":translate"))
implementation(libs.eventbus) implementation(libs.eventbus)
implementation(libs.indicator.fast.scroll) implementation(libs.indicator.fast.scroll)
implementation(libs.mmslib) implementation(libs.mmslib)

View file

@ -101,12 +101,9 @@ class MainActivity : SimpleActivity() {
loadMessages() loadMessages()
} }
// [fork] checkAppSideloading() shows a "this app is corrupted, get the if (checkAppSideloading()) {
// original" dialog when the applicationId differs from the official return
// Fossify one. Suppressed here because this is intentionally a fork. }
// if (checkAppSideloading()) {
// return
// }
} }
override fun onResume() { override fun onResume() {
@ -218,11 +215,28 @@ class MainActivity : SimpleActivity() {
} }
private fun loadMessages() { private fun loadMessages() {
// [fork] Don't force the user to make this app the default SMS app if (isQPlus()) {
// on first launch. Read-only access via permissions is enough to val roleManager = getSystemService(RoleManager::class.java)
// browse and translate; the user can opt in to be the default if (roleManager!!.isRoleAvailable(RoleManager.ROLE_SMS)) {
// through the system settings later if they want. if (roleManager.isRoleHeld(RoleManager.ROLE_SMS)) {
askPermissions() 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. // while SEND_SMS and READ_SMS permissions are mandatory, READ_CONTACTS is optional.

View file

@ -106,7 +106,6 @@ class SettingsActivity : SimpleActivity() {
setupLanguage() setupLanguage()
setupManageBlockedNumbers() setupManageBlockedNumbers()
setupManageBlockedKeywords() setupManageBlockedKeywords()
setupTranslation()
setupChangeDateTimeFormat() setupChangeDateTimeFormat()
setupFontSize() setupFontSize()
setupShowCharacterCounter() 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 { private fun setupChangeDateTimeFormat() = binding.apply {
settingsChangeDateTimeFormatHolder.setOnClickListener { settingsChangeDateTimeFormatHolder.setOnClickListener {
ChangeDateTimeFormatDialog(this@SettingsActivity) { ChangeDateTimeFormatDialog(this@SettingsActivity) {

View file

@ -19,9 +19,6 @@ import org.fossify.commons.extensions.getContrastColor
import org.fossify.commons.extensions.getTextSize import org.fossify.commons.extensions.getTextSize
import org.fossify.commons.extensions.setupViewBackground import org.fossify.commons.extensions.setupViewBackground
import org.fossify.commons.helpers.FontHelper 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.SimpleContactsHelper
import org.fossify.commons.helpers.ensureBackgroundThread import org.fossify.commons.helpers.ensureBackgroundThread
import org.fossify.commons.views.MyRecyclerView import org.fossify.commons.views.MyRecyclerView
@ -47,7 +44,6 @@ abstract class BaseConversationsAdapter(
RecyclerViewFastScroller.OnPopupTextUpdate { RecyclerViewFastScroller.OnPopupTextUpdate {
private var fontSize = activity.getTextSize() private var fontSize = activity.getTextSize()
private var drafts = HashMap<Long, String>() private var drafts = HashMap<Long, String>()
private val translateConfig by lazy { TranslateConfig(activity) }
private var recyclerViewState: Parcelable? = null private var recyclerViewState: Parcelable? = null
@ -164,20 +160,8 @@ abstract class BaseConversationsAdapter(
} }
conversationBodyShort.apply { conversationBodyShort.apply {
val original = smsDraft ?: conversation.snippet text = smsDraft ?: conversation.snippet
text = original
setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize * 0.9f) 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 { conversationDate.apply {

View file

@ -41,7 +41,6 @@ import org.fossify.commons.extensions.getTextSize
import org.fossify.commons.extensions.getTimeFormat import org.fossify.commons.extensions.getTimeFormat
import org.fossify.commons.extensions.shareTextIntent import org.fossify.commons.extensions.shareTextIntent
import org.fossify.commons.extensions.showErrorToast import org.fossify.commons.extensions.showErrorToast
import org.fossify.commons.extensions.toast
import org.fossify.commons.extensions.usableScreenSize import org.fossify.commons.extensions.usableScreenSize
import org.fossify.commons.helpers.FontHelper import org.fossify.commons.helpers.FontHelper
import org.fossify.commons.helpers.SimpleContactsHelper 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.ThreadError
import org.fossify.messages.models.ThreadItem.ThreadSending import org.fossify.messages.models.ThreadItem.ThreadSending
import org.fossify.messages.models.ThreadItem.ThreadSent import org.fossify.messages.models.ThreadItem.ThreadSent
import org.fossify.messages.translate.TranslationBubbleBinder
import org.joda.time.DateTime import org.joda.time.DateTime
class ThreadAdapter( class ThreadAdapter(
@ -103,7 +101,6 @@ class ThreadAdapter(
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
private val hasMultipleSIMCards = (activity.subscriptionManagerCompat().activeSubscriptionInfoList?.size ?: 0) > 1 private val hasMultipleSIMCards = (activity.subscriptionManagerCompat().activeSubscriptionInfoList?.size ?: 0) > 1
private val maxChatBubbleWidth = (activity.usableScreenSize.x * 0.8f).toInt() private val maxChatBubbleWidth = (activity.usableScreenSize.x * 0.8f).toInt()
private val translationBinder = TranslationBubbleBinder(activity)
companion object { companion object {
private const val MAX_MEDIA_HEIGHT_RATIO = 3 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_share).isVisible = isOneItemSelected && hasText
findItem(R.id.cab_forward_message).isVisible = isOneItemSelected findItem(R.id.cab_forward_message).isVisible = isOneItemSelected
findItem(R.id.cab_select_text).isVisible = isOneItemSelected && hasText 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_properties).isVisible = isOneItemSelected
findItem(R.id.cab_restore).isVisible = isRecycleBin findItem(R.id.cab_restore).isVisible = isRecycleBin
} }
@ -151,7 +146,6 @@ class ThreadAdapter(
R.id.cab_share -> shareText() R.id.cab_share -> shareText()
R.id.cab_forward_message -> forwardMessage() R.id.cab_forward_message -> forwardMessage()
R.id.cab_select_text -> selectText() R.id.cab_select_text -> selectText()
R.id.cab_translate -> translateSelectedMessage()
R.id.cab_delete -> askConfirmDelete() R.id.cab_delete -> askConfirmDelete()
R.id.cab_restore -> askConfirmRestore() R.id.cab_restore -> askConfirmRestore()
R.id.cab_select_all -> selectAll() R.id.cab_select_all -> selectAll()
@ -232,15 +226,13 @@ class ThreadAdapter(
if (selectedMessages.isEmpty()) return if (selectedMessages.isEmpty()) return
val textToCopy = if (selectedMessages.size == 1) { val textToCopy = if (selectedMessages.size == 1) {
val msg = selectedMessages.first() selectedMessages.first().body
translationBinder.visibleText(msg.id, msg.body).toString()
} else { } else {
selectedMessages.filter { it.body.isNotEmpty() }.joinToString("\n\n") { message -> selectedMessages.filter { it.body.isNotEmpty() }.joinToString("\n\n") { message ->
val format = "${activity.config.dateFormat}, ${activity.getTimeFormat()}" val format = "${activity.config.dateFormat}, ${activity.getTimeFormat()}"
val dateTime = DateTime(message.millis()).toString(format) val dateTime = DateTime(message.millis()).toString(format)
val sender = if (message.isReceivedMessage()) message.senderName else activity.getString(R.string.me) val sender = if (message.isReceivedMessage()) message.senderName else activity.getString(R.string.me)
val visible = translationBinder.visibleText(message.id, message.body) "[$dateTime] $sender: ${message.body}"
"[$dateTime] $sender: $visible"
} }
} }
@ -263,34 +255,13 @@ class ThreadAdapter(
private fun shareText() { private fun shareText() {
val firstItem = getSelectedItems().firstOrNull() as? Message ?: return val firstItem = getSelectedItems().firstOrNull() as? Message ?: return
activity.shareTextIntent(translationBinder.visibleText(firstItem.id, firstItem.body).toString()) activity.shareTextIntent(firstItem.body)
} }
private fun selectText() { private fun selectText() {
val firstItem = getSelectedItems().firstOrNull() as? Message ?: return val firstItem = getSelectedItems().firstOrNull() as? Message ?: return
val visible = translationBinder.visibleText(firstItem.id, firstItem.body).toString() if (firstItem.body.trim().isNotEmpty()) {
if (visible.trim().isNotEmpty()) { SelectTextDialog(activity, firstItem.body)
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()
} }
} }
@ -460,20 +431,6 @@ class ThreadAdapter(
setLinkTextColor(activity.getProperPrimaryColor()) 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) { if (!activity.isFinishing && !activity.isDestroyed) {
val contactLetterIcon = SimpleContactsHelper(activity).getContactLetterIcon(message.senderName) val contactLetterIcon = SimpleContactsHelper(activity).getContactLetterIcon(message.senderName)
val placeholder = contactLetterIcon.toDrawable(activity.resources) val placeholder = contactLetterIcon.toDrawable(activity.resources)

View file

@ -113,21 +113,6 @@
</RelativeLayout> </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 <RelativeLayout
android:id="@+id/settings_change_date_time_format_holder" android:id="@+id/settings_change_date_time_format_holder"
style="@style/SettingsHolderTextViewOneLinerStyle" style="@style/SettingsHolderTextViewOneLinerStyle"

View file

@ -65,18 +65,5 @@
android:textSize="@dimen/normal_text_size" android:textSize="@dimen/normal_text_size"
tools:drawableEndCompat="@drawable/scheduled_message_icon" tools:drawableEndCompat="@drawable/scheduled_message_icon"
tools:text="Message content" /> 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> </RelativeLayout>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -37,11 +37,6 @@
android:showAsAction="never" android:showAsAction="never"
android:title="@string/select_text" android:title="@string/select_text"
app:showAsAction="never" /> app:showAsAction="never" />
<item
android:id="@+id/cab_translate"
android:showAsAction="never"
android:title="@string/translate"
app:showAsAction="never" />
<item <item
android:id="@+id/cab_select_all" android:id="@+id/cab_select_all"
android:icon="@drawable/ic_select_all_vector" 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

View file

@ -5,4 +5,4 @@ org.gradle.jvmargs=-Xmx8192m
# Versioning # Versioning
VERSION_NAME=1.8.0 VERSION_NAME=1.8.0
VERSION_CODE=20 VERSION_CODE=20
APP_ID=net.jeena.smstranslate APP_ID=org.fossify.messages

View file

@ -30,8 +30,6 @@ app-build-javaVersion = "VERSION_17"
app-build-kotlinJVMTarget = "17" app-build-kotlinJVMTarget = "17"
#Helpers #Helpers
ez-vcard = "0.12.2" 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] [libraries]
#AndroidX #AndroidX
androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "androidx-constraintlayout" } 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" } eventbus = { module = "org.greenrobot:eventbus", version.ref = "eventbus" }
#Helpers #Helpers
ez-vcard = { module = "com.googlecode.ez-vcard:ez-vcard", version.ref = "ez-vcard" } 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 #Kotlin
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
[bundles] [bundles]

View file

@ -15,4 +15,3 @@ dependencyResolutionManagement {
} }
} }
include(":app") include(":app")
include(":translate")

View file

@ -1 +0,0 @@
/build

View file

@ -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)
}

View file

@ -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>

View file

@ -1,8 +0,0 @@
package dev.davidv.translator;
enum ErrorType {
COULD_NOT_DETECT_LANGUAGE,
DETECTED_BUT_UNAVAILABLE,
UNEXPECTED,
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -1,9 +0,0 @@
package dev.davidv.translator;
import dev.davidv.translator.ErrorType;
parcelable TranslationError {
ErrorType type;
@nullable String language;
@nullable String message;
}

View file

@ -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)
}
}
}

View file

@ -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"
}
}

View file

@ -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
}
}

View file

@ -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",
)
}
}

View file

@ -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)
}
}

View file

@ -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>

View file

@ -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>

View file

@ -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>