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
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -15,4 +15,3 @@ dependencyResolutionManagement {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
include(":app")
|
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