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