commit 2ee396bf7ab450c891947e75620859c93b94693d
Author: JabJab <noreply@xmpp.tel>
Date: Fri Aug 7 07:06:14 2026 +0300
On-Device Offline Translation added, fixed issue with pinned messages
---
build.gradle | 5 +-
src/main/AndroidManifest.xml | 1 +
src/main/aidl/dev/davidv/translator/ErrorType.aidl | 12 +
.../davidv/translator/ITranslationCallback.aidl | 12 +
.../dev/davidv/translator/ITranslationService.aidl | 11 +
.../dev/davidv/translator/TranslationError.aidl | 13 +
.../persistance/TranslationCacheDatabase.java | 5 +-
src/main/java/tel/xmpp/jabjab/ui/BlogActivity.java | 19 ++
.../java/tel/xmpp/jabjab/ui/BlogPostActivity.java | 10 +-
.../xmpp/jabjab/ui/ChannelDiscoveryActivity.java | 15 +
.../tel/xmpp/jabjab/ui/ConversationFragment.java | 135 +++++++-
.../jabjab/ui/ConversationsOverviewFragment.java | 17 +
.../java/tel/xmpp/jabjab/ui/StoriesActivity.java | 18 ++
src/main/java/tel/xmpp/jabjab/ui/XmppActivity.java | 12 +-
.../xmpp/jabjab/ui/activity/SettingsActivity.java | 20 ++
.../tel/xmpp/jabjab/ui/adapter/MessageAdapter.java | 337 +++++++++++++++-----
.../fragment/settings/PrivacySettingsFragment.java | 345 +++++++++++++++++++++
.../tel/xmpp/jabjab/ui/service/AudioPlayer.java | 9 +-
.../xmpp/jabjab/ui/util/LinkPreviewFetcher.java | 44 ++-
.../jabjab/ui/util/OfflineTranslatorBridge.java | 140 +++++++++
.../xmpp/jabjab/ui/util/TranslationService.java | 167 ++++++++--
.../xmpp/jabjab/ui/util/TranslationSettings.java | 43 +++
.../xmpp/jabjab/ui/widget/FeatureBannerView.java | 227 ++++++++++++++
src/main/res/layout/activity_about.xml | 6 +-
src/main/res/layout/activity_blog.xml | 7 +
src/main/res/layout/activity_channel_discovery.xml | 7 +
src/main/res/layout/activity_stories.xml | 7 +
src/main/res/layout/fragment_conversation.xml | 16 +
.../res/layout/fragment_conversations_overview.xml | 10 +
src/main/res/layout/item_message_content.xml | 6 +-
src/main/res/layout/view_feature_banner.xml | 60 ++++
src/main/res/menu/message_context.xml | 5 +
src/main/res/values-ar/strings.xml | 16 +
src/main/res/values-de/strings.xml | 16 +
src/main/res/values-es/strings.xml | 16 +
src/main/res/values-fr/strings.xml | 16 +
src/main/res/values-it/strings.xml | 16 +
src/main/res/values-ja/strings.xml | 16 +
src/main/res/values-pt-rBR/strings.xml | 16 +
src/main/res/values-ru/strings.xml | 16 +
src/main/res/values-tr-rTR/strings.xml | 16 +
src/main/res/values-tr/strings.xml | 16 +
src/main/res/values-zh-rCN/strings.xml | 16 +
src/main/res/values/strings.xml | 19 ++
src/main/res/xml/preferences_privacy.xml | 13 +
45 files changed, 1826 insertions(+), 123 deletions(-)
diff --git a/build.gradle b/build.gradle
index b897948..5ffbf5a 100644
--- a/build.gradle
+++ b/build.gradle
@@ -113,8 +113,8 @@ android {
defaultConfig {
minSdkVersion 23
- versionCode 42305
- versionName "1.0.5"
+ versionCode 42306
+ versionName "1.0.6"
applicationId "tel.xmpp.jabjab"
resValue "string", "applicationId", applicationId
def appName = "JabJab"
@@ -291,6 +291,7 @@ android {
buildFeatures {
buildConfig true
resValues = true
+ aidl true
}
}
diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml
index 7ebf0a4..b1ff795 100644
--- a/src/main/AndroidManifest.xml
+++ b/src/main/AndroidManifest.xml
@@ -65,6 +65,7 @@
<queries>
<package android:name="org.sufficientlysecure.keychain" />
<package android:name="org.torproject.android" />
+ <package android:name="dev.davidv.translator" />
<intent>
<action android:name="tel.xmpp.jabjab.location.request" />
diff --git a/src/main/aidl/dev/davidv/translator/ErrorType.aidl b/src/main/aidl/dev/davidv/translator/ErrorType.aidl
new file mode 100644
index 0000000..fd7d4da
--- /dev/null
+++ b/src/main/aidl/dev/davidv/translator/ErrorType.aidl
@@ -0,0 +1,12 @@
+// Copied verbatim from the offline-translator project (GPLv3), so this process
+// can bind to its exported ITranslationService: https://github.com/DavidVentura/offline-translator
+// Not modified — must match the remote service's interface exactly.
+
+package dev.davidv.translator;
+
+enum ErrorType {
+ COULD_NOT_DETECT_LANGUAGE,
+ DETECTED_BUT_UNAVAILABLE,
+ UNEXPECTED,
+}
+
diff --git a/src/main/aidl/dev/davidv/translator/ITranslationCallback.aidl b/src/main/aidl/dev/davidv/translator/ITranslationCallback.aidl
new file mode 100644
index 0000000..759e719
--- /dev/null
+++ b/src/main/aidl/dev/davidv/translator/ITranslationCallback.aidl
@@ -0,0 +1,12 @@
+// Copied verbatim from the offline-translator project (GPLv3), so this process
+// can bind to its exported ITranslationService: https://github.com/DavidVentura/offline-translator
+// Not modified — must match the remote service's interface exactly.
+
+package dev.davidv.translator;
+
+import dev.davidv.translator.TranslationError;
+
+oneway interface ITranslationCallback {
+ void onTranslationResult(String translatedText);
+ void onTranslationError(in TranslationError error);
+}
diff --git a/src/main/aidl/dev/davidv/translator/ITranslationService.aidl b/src/main/aidl/dev/davidv/translator/ITranslationService.aidl
new file mode 100644
index 0000000..7fb8261
--- /dev/null
+++ b/src/main/aidl/dev/davidv/translator/ITranslationService.aidl
@@ -0,0 +1,11 @@
+// Copied verbatim from the offline-translator project (GPLv3), so this process
+// can bind to its exported ITranslationService: https://github.com/DavidVentura/offline-translator
+// Not modified — must match the remote service's interface exactly.
+
+package dev.davidv.translator;
+
+import dev.davidv.translator.ITranslationCallback;
+
+interface ITranslationService {
+ void translate(String textToTranslate, String fromLanguage, String toLanguage, ITranslationCallback callback);
+}
diff --git a/src/main/aidl/dev/davidv/translator/TranslationError.aidl b/src/main/aidl/dev/davidv/translator/TranslationError.aidl
new file mode 100644
index 0000000..188baf3
--- /dev/null
+++ b/src/main/aidl/dev/davidv/translator/TranslationError.aidl
@@ -0,0 +1,13 @@
+// Copied verbatim from the offline-translator project (GPLv3), so this process
+// can bind to its exported ITranslationService: https://github.com/DavidVentura/offline-translator
+// Not modified — must match the remote service's interface exactly.
+
+package dev.davidv.translator;
+
+import dev.davidv.translator.ErrorType;
+
+parcelable TranslationError {
+ ErrorType type;
+ @nullable String language;
+ @nullable String message;
+}
diff --git a/src/main/java/tel/xmpp/jabjab/persistance/TranslationCacheDatabase.java b/src/main/java/tel/xmpp/jabjab/persistance/TranslationCacheDatabase.java
index 71e7c0c..0442dad 100644
--- a/src/main/java/tel/xmpp/jabjab/persistance/TranslationCacheDatabase.java
+++ b/src/main/java/tel/xmpp/jabjab/persistance/TranslationCacheDatabase.java
@@ -35,6 +35,7 @@ public class TranslationCacheDatabase extends SQLiteOpenHelper {
* (different quality, different privacy provenance) and must never collide. */
public enum Backend {
PRIVATE,
+ OFFLINE_DEVICE,
GOOGLE;
String dbValue() {
@@ -42,7 +43,9 @@ public class TranslationCacheDatabase extends SQLiteOpenHelper {
}
static Backend fromDbValue(final String value) {
- return "private".equals(value) ? PRIVATE : GOOGLE;
+ if ("private".equals(value)) return PRIVATE;
+ if ("offline_device".equals(value)) return OFFLINE_DEVICE;
+ return GOOGLE;
}
}
diff --git a/src/main/java/tel/xmpp/jabjab/ui/BlogActivity.java b/src/main/java/tel/xmpp/jabjab/ui/BlogActivity.java
index aea6cee..81aa378 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/BlogActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/BlogActivity.java
@@ -83,6 +83,25 @@ public class BlogActivity extends XmppActivity
BottomNavHelper.wire(bottomNav, R.id.bottom_blog, this);
draftStore = new BlogDraftStore(this);
+ final tel.xmpp.jabjab.ui.widget.FeatureBannerView featureBanner =
+ findViewById(R.id.feature_banner);
+ featureBanner.registerBanner(
+ "translation_feature",
+ R.drawable.ic_translate_24dp,
+ getString(R.string.translation_feature_banner_text),
+ () -> {
+ final android.content.Intent intent =
+ new android.content.Intent(
+ this, tel.xmpp.jabjab.ui.activity.SettingsActivity.class);
+ intent.putExtra(
+ tel.xmpp.jabjab.ui.activity.SettingsActivity.EXTRA_OPEN_FRAGMENT,
+ tel.xmpp.jabjab.ui.activity.SettingsActivity.FRAGMENT_PRIVACY);
+ intent.putExtra(
+ tel.xmpp.jabjab.ui.activity.SettingsActivity.EXTRA_SCROLL_TO_PREFERENCE,
+ "translation_backend_priority");
+ startActivity(intent);
+ });
+
searchInput = findViewById(R.id.blog_search_input);
searchInput.addTextChangedListener(new TextWatcher() {
@Override public void beforeTextChanged(CharSequence s, int st, int c, int a) {}
diff --git a/src/main/java/tel/xmpp/jabjab/ui/BlogPostActivity.java b/src/main/java/tel/xmpp/jabjab/ui/BlogPostActivity.java
index f56770e..0f2795e 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/BlogPostActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/BlogPostActivity.java
@@ -515,10 +515,12 @@ public class BlogPostActivity extends XmppActivity {
@androidx.annotation.Nullable final String detectedSourceLang,
final tel.xmpp.jabjab.persistance.TranslationCacheDatabase.Backend backend) {
progressHolder[0].dismiss();
- final int disclaimerRes = backend
- == tel.xmpp.jabjab.persistance.TranslationCacheDatabase.Backend.PRIVATE
- ? R.string.translation_disclaimer_private
- : R.string.translation_disclaimer_google;
+ final int disclaimerRes =
+ switch (backend) {
+ case PRIVATE -> R.string.translation_disclaimer_private;
+ case OFFLINE_DEVICE -> R.string.translation_disclaimer_offline_device;
+ case GOOGLE -> R.string.translation_disclaimer_google;
+ };
new AlertDialog.Builder(BlogPostActivity.this)
.setTitle(R.string.translation_title)
.setMessage(
diff --git a/src/main/java/tel/xmpp/jabjab/ui/ChannelDiscoveryActivity.java b/src/main/java/tel/xmpp/jabjab/ui/ChannelDiscoveryActivity.java
index 8b59b9e..5d32fa0 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/ChannelDiscoveryActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/ChannelDiscoveryActivity.java
@@ -116,6 +116,21 @@ public class ChannelDiscoveryActivity extends XmppActivity
binding = DataBindingUtil.setContentView(this, R.layout.activity_channel_discovery);
Activities.setStatusAndNavigationBarColors(this, binding.getRoot());
BottomNavHelper.wire(binding.bottomNav, R.id.bottom_channels, this);
+ binding.featureBanner.registerBanner(
+ "translation_feature",
+ R.drawable.ic_translate_24dp,
+ getString(R.string.translation_feature_banner_text),
+ () -> {
+ final Intent intent =
+ new Intent(this, tel.xmpp.jabjab.ui.activity.SettingsActivity.class);
+ intent.putExtra(
+ tel.xmpp.jabjab.ui.activity.SettingsActivity.EXTRA_OPEN_FRAGMENT,
+ tel.xmpp.jabjab.ui.activity.SettingsActivity.FRAGMENT_PRIVACY);
+ intent.putExtra(
+ tel.xmpp.jabjab.ui.activity.SettingsActivity.EXTRA_SCROLL_TO_PREFERENCE,
+ "translation_backend_priority");
+ startActivity(intent);
+ });
// SearchBar — menu with discover / accounts / settings
binding.searchBar.setNavigationOnClickListener(null);
diff --git a/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java b/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java
index e33187d..3dcde8b 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java
@@ -313,6 +313,9 @@ public class ConversationFragment extends XmppFragment
private final android.view.ViewTreeObserver.OnGlobalLayoutListener pinToBottomListener = this::onPinToBottomLayout;
public Uri mPendingEditorContent = null;
protected MessageAdapter messageListAdapter;
+ // Positions to return to after tapping a reply card or "jump to reply" — each jump pushes
+ // where you were, so the floating back button can retrace them one at a time.
+ private final java.util.ArrayDeque<Integer> replyJumpBackStack = new java.util.ArrayDeque<>();
private String lastMessageUuid = null;
private Conversation conversation;
private FragmentConversationBinding binding;
@@ -1556,6 +1559,7 @@ public class ConversationFragment extends XmppFragment
binding.textSendButton.setOnClickListener(this.mSendButtonListener);
binding.scrollToBottomButton.setOnClickListener(this.mScrollButtonListener);
+ binding.replyJumpBackButton.setOnClickListener(v -> jumpBack());
binding.pinnedMessagesBanner.setOnClickListener(v -> onPinnedMessagesBannerTapped());
binding.pinnedMessagesBanner.setOnLongClickListener(
v -> {
@@ -1572,8 +1576,14 @@ public class ConversationFragment extends XmppFragment
messageListAdapter = new MessageAdapter((XmppActivity) getActivity(), this.messageList);
messageListAdapter.setOnContactPictureClicked(this);
messageListAdapter.setOnContactPictureLongClicked(this);
- messageListAdapter.setOnScrollToReplyListener(pos ->
- binding.messagesView.setSelectionFromTop(pos, 0));
+ messageListAdapter.setOnScrollToReplyListener((targetPosition, fromPosition) -> {
+ if (fromPosition >= 0) {
+ replyJumpBackStack.push(fromPosition);
+ binding.replyJumpBackButton.setVisibility(View.VISIBLE);
+ }
+ binding.messagesView.setSelectionFromTop(targetPosition, 0);
+ highlightMessageAt(targetPosition);
+ });
binding.messagesView.setAdapter(messageListAdapter);
// Native ContextMenu (registerForContextMenu) never renders MenuItem icons — a
@@ -1915,7 +1925,85 @@ public class ConversationFragment extends XmppFragment
quoteText(MessageUtils.prepareQuote(message));
}
+ /**
+ * Briefly flashes the message row at {@code pos} so jumping to a reply's original message
+ * (tapping the reply-preview card) is visually obvious even when the scroll distance is
+ * small or zero (e.g. the target was already on screen).
+ */
+ private void highlightMessageAt(final int pos) {
+ binding.messagesView.post(() -> {
+ final int index = pos - binding.messagesView.getFirstVisiblePosition();
+ final View child = binding.messagesView.getChildAt(index);
+ if (child == null) return;
+ final int highlightColor =
+ com.google.android.material.color.MaterialColors.getColor(
+ child, androidx.appcompat.R.attr.colorPrimary);
+ final ColorDrawable overlay =
+ new ColorDrawable(highlightColor);
+ overlay.setAlpha(0);
+ child.setForeground(overlay);
+ final android.animation.ValueAnimator animator =
+ android.animation.ValueAnimator.ofInt(0, 90, 90, 0);
+ animator.setDuration(2200);
+ animator.setInterpolator(new android.view.animation.AccelerateDecelerateInterpolator());
+ animator.addUpdateListener(a -> overlay.setAlpha((int) a.getAnimatedValue()));
+ animator.addListener(
+ new android.animation.AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(final android.animation.Animator animation) {
+ child.setForeground(null);
+ }
+ });
+ animator.start();
+ });
+ }
+
+ /**
+ * Returns to wherever a reply-card tap or "jump to reply" jumped away from. Falls back to
+ * scrolling to the end of chat if there's nothing left on the stack, or if the remembered
+ * position no longer exists (e.g. the list shrank).
+ */
+ private void jumpBack() {
+ final Integer pos = replyJumpBackStack.poll();
+ if (pos == null || pos < 0 || pos >= messageListAdapter.getCount()) {
+ mScrollButtonListener.onClick(binding.scrollToBottomButton);
+ } else {
+ binding.messagesView.setSelectionFromTop(pos, 0);
+ highlightMessageAt(pos);
+ }
+ binding.replyJumpBackButton.setVisibility(
+ replyJumpBackStack.isEmpty() ? View.GONE : View.VISIBLE);
+ }
+
+ /**
+ * Jumps forward from an original message to the most recent message that replied to it
+ * (the reverse of tapping a reply-preview card). Shows a toast if none is currently loaded.
+ */
+ private void jumpToReplyOf(final Message message) {
+ final Integer targetPosition = messageListAdapter.findMostRecentReplyPosition(message);
+ if (targetPosition == null) {
+ Toast.makeText(requireContext(), R.string.no_reply_found, Toast.LENGTH_SHORT).show();
+ return;
+ }
+ final int fromPosition = messageListAdapter.getPosition(message);
+ if (fromPosition >= 0) {
+ replyJumpBackStack.push(fromPosition);
+ binding.replyJumpBackButton.setVisibility(View.VISIBLE);
+ }
+ binding.messagesView.setSelectionFromTop(targetPosition, 0);
+ highlightMessageAt(targetPosition);
+ }
+
private void translateMessage(final Message message) {
+ translateMessage(message, null);
+ }
+
+ /** @param forcedTargetLang null means "use the system language, and if the
+ * message turns out to already be in that language, ask the user to pick a
+ * different target instead of showing a no-op translation." Non-null means
+ * the user already picked a target explicitly (from that same picker), so
+ * the same-language check is skipped — there's nothing left to ask. */
+ private void translateMessage(final Message message, @Nullable final String forcedTargetLang) {
String body = message.getBody();
if (Strings.isNullOrEmpty(body)) {
return;
@@ -1937,7 +2025,8 @@ public class ConversationFragment extends XmppFragment
.setMessage(R.string.translation_in_progress)
.setCancelable(true)
.show();
- final String targetLang = java.util.Locale.getDefault().getLanguage();
+ final String targetLang =
+ forcedTargetLang != null ? forcedTargetLang : java.util.Locale.getDefault().getLanguage();
tel.xmpp.jabjab.ui.util.TranslationService.translate(
requireContext(),
authorJid,
@@ -1950,6 +2039,24 @@ public class ConversationFragment extends XmppFragment
@Nullable final String detectedSourceLang,
final tel.xmpp.jabjab.persistance.TranslationCacheDatabase.Backend backend) {
if (!isAdded()) return;
+ // Only backends that report a detected source language (private,
+ // Google) can trigger this — the on-device tier's AIDL callback
+ // has no such field, so a same-language message routed there
+ // just gets translated (into itself) as before.
+ if (forcedTargetLang == null
+ && detectedSourceLang != null
+ && detectedSourceLang.equalsIgnoreCase(targetLang)) {
+ progressHolder[0].dismiss();
+ Toast.makeText(
+ requireContext(),
+ R.string.translation_already_system_language,
+ Toast.LENGTH_SHORT)
+ .show();
+ tel.xmpp.jabjab.ui.util.OutgoingTranslateLanguagePicker.show(
+ requireActivity(),
+ pickedLang -> translateMessage(message, pickedLang));
+ return;
+ }
progressHolder[0].dismiss();
showTranslationResult(textToTranslate, translatedText, backend);
}
@@ -1998,9 +2105,11 @@ public class ConversationFragment extends XmppFragment
final String translated,
final tel.xmpp.jabjab.persistance.TranslationCacheDatabase.Backend backend) {
final int disclaimerRes =
- backend == tel.xmpp.jabjab.persistance.TranslationCacheDatabase.Backend.PRIVATE
- ? R.string.translation_disclaimer_private
- : R.string.translation_disclaimer_google;
+ switch (backend) {
+ case PRIVATE -> R.string.translation_disclaimer_private;
+ case OFFLINE_DEVICE -> R.string.translation_disclaimer_offline_device;
+ case GOOGLE -> R.string.translation_disclaimer_google;
+ };
new MaterialAlertDialogBuilder(requireActivity())
.setTitle(R.string.translation_title)
.setMessage(
@@ -2295,6 +2404,7 @@ public class ConversationFragment extends XmppFragment
final MenuItem translateMessage = menu.findItem(R.id.translate_message);
final MenuItem copyLink = menu.findItem(R.id.copy_link);
final MenuItem quoteMessage = menu.findItem(R.id.quote_message);
+ final MenuItem jumpToReply = menu.findItem(R.id.jump_to_reply);
final MenuItem retryDecryption = menu.findItem(R.id.retry_decryption);
final MenuItem correctMessage = menu.findItem(R.id.correct_message);
final MenuItem retractMessage = menu.findItem(R.id.retract_message);
@@ -2397,6 +2507,12 @@ public class ConversationFragment extends XmppFragment
quoteMessage.setVisible(!MessageUtils.prepareQuote(m).isEmpty());
}
}
+ // "Jump to reply" is only meaningful once something could have replied to this
+ // message — whether one actually did is resolved lazily on tap (see
+ // jumpToReplyOf()), not here, since that requires scanning the loaded messages.
+ if (!showError && !deleted && !encrypted && m.getBody() != null) {
+ jumpToReply.setVisible(true);
+ }
if (!m.isFileOrImage()
&& !encrypted
&& !m.isGeoUri()
@@ -2557,6 +2673,10 @@ public class ConversationFragment extends XmppFragment
quoteMessage(selectedMessage);
yield true;
}
+ case R.id.jump_to_reply -> {
+ jumpToReplyOf(selectedMessage);
+ yield true;
+ }
case R.id.send_again -> {
resendMessage(selectedMessage, false);
yield true;
@@ -4270,6 +4390,9 @@ public class ConversationFragment extends XmppFragment
updateBotCommandTabVisibility();
currentPinnedIndex = 0;
refreshPinnedMessagesBanner();
+ // Positions from the previous conversation are meaningless here.
+ replyJumpBackStack.clear();
+ binding.replyJumpBackButton.setVisibility(View.GONE);
stopScrolling();
Log.d(Config.LOGTAG, "reInit(hasExtras=" + hasExtras + ")");
diff --git a/src/main/java/tel/xmpp/jabjab/ui/ConversationsOverviewFragment.java b/src/main/java/tel/xmpp/jabjab/ui/ConversationsOverviewFragment.java
index ac12746..9cd97cc 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/ConversationsOverviewFragment.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/ConversationsOverviewFragment.java
@@ -411,6 +411,7 @@ public class ConversationsOverviewFragment extends XmppFragment {
});
this.binding.fab.setOnClickListener(
(view) -> StartConversationActivity.launch(getActivity()));
+ showTranslationFeatureBanner();
this.conversationsAdapter =
new ConversationAdapter(requireXmppActivity(), this.conversations);
@@ -435,6 +436,22 @@ public class ConversationsOverviewFragment extends XmppFragment {
return binding.getRoot();
}
+ private void showTranslationFeatureBanner() {
+ this.binding.featureBanner.registerBanner(
+ "translation_feature",
+ R.drawable.ic_translate_24dp,
+ getString(R.string.translation_feature_banner_text),
+ () -> {
+ final var intent = new Intent(requireContext(), SettingsActivity.class);
+ intent.putExtra(
+ SettingsActivity.EXTRA_OPEN_FRAGMENT, SettingsActivity.FRAGMENT_PRIVACY);
+ intent.putExtra(
+ SettingsActivity.EXTRA_SCROLL_TO_PREFERENCE,
+ "translation_backend_priority");
+ startActivity(intent);
+ });
+ }
+
private void startSearch(final String term) {
final var intent = new Intent(requireContext(), SearchActivity.class);
intent.putExtra(SearchActivity.EXTRA_SEARCH_TERM, term);
diff --git a/src/main/java/tel/xmpp/jabjab/ui/StoriesActivity.java b/src/main/java/tel/xmpp/jabjab/ui/StoriesActivity.java
index 819121b..25f0dd7 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/StoriesActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/StoriesActivity.java
@@ -110,6 +110,24 @@ public class StoriesActivity extends XmppActivity
bottomNav = findViewById(R.id.bottom_nav);
BottomNavHelper.wire(bottomNav, R.id.bottom_stories, this);
+ final tel.xmpp.jabjab.ui.widget.FeatureBannerView featureBanner =
+ findViewById(R.id.feature_banner);
+ featureBanner.registerBanner(
+ "translation_feature",
+ R.drawable.ic_translate_24dp,
+ getString(R.string.translation_feature_banner_text),
+ () -> {
+ final Intent intent =
+ new Intent(this, tel.xmpp.jabjab.ui.activity.SettingsActivity.class);
+ intent.putExtra(
+ tel.xmpp.jabjab.ui.activity.SettingsActivity.EXTRA_OPEN_FRAGMENT,
+ tel.xmpp.jabjab.ui.activity.SettingsActivity.FRAGMENT_PRIVACY);
+ intent.putExtra(
+ tel.xmpp.jabjab.ui.activity.SettingsActivity.EXTRA_SCROLL_TO_PREFERENCE,
+ "translation_backend_priority");
+ startActivity(intent);
+ });
+
final ImageButton storiesMenuBtn = findViewById(R.id.stories_menu_btn);
storiesMenuBtn.setOnClickListener(v -> {
final PopupMenu popup = new PopupMenu(this, v);
diff --git a/src/main/java/tel/xmpp/jabjab/ui/XmppActivity.java b/src/main/java/tel/xmpp/jabjab/ui/XmppActivity.java
index f88712e..bfaaa43 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/XmppActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/XmppActivity.java
@@ -1175,7 +1175,10 @@ public abstract class XmppActivity extends ActionBarActivity {
bm =
xmppConnectionService
.getFileBackend()
- .getThumbnail(message, (int) (metrics.density * 288), true);
+ .getThumbnail(
+ message,
+ (int) getResources().getDimension(R.dimen.image_preview_width),
+ true);
} catch (IOException e) {
bm = null;
}
@@ -1260,7 +1263,12 @@ public abstract class XmppActivity extends ActionBarActivity {
if (activity != null && activity.xmppConnectionService != null) {
return activity.xmppConnectionService
.getFileBackend()
- .getThumbnail(message, (int) (activity.metrics.density * 288), false);
+ .getThumbnail(
+ message,
+ (int)
+ activity.getResources()
+ .getDimension(R.dimen.image_preview_width),
+ false);
} else {
return null;
}
diff --git a/src/main/java/tel/xmpp/jabjab/ui/activity/SettingsActivity.java b/src/main/java/tel/xmpp/jabjab/ui/activity/SettingsActivity.java
index 08533d6..94dcbae 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/activity/SettingsActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/activity/SettingsActivity.java
@@ -14,12 +14,21 @@ import tel.xmpp.jabjab.ui.Activities;
import tel.xmpp.jabjab.ui.XmppActivity;
import tel.xmpp.jabjab.ui.fragment.settings.MainSettingsFragment;
import tel.xmpp.jabjab.ui.fragment.settings.NotificationsSettingsFragment;
+import tel.xmpp.jabjab.ui.fragment.settings.PrivacySettingsFragment;
import tel.xmpp.jabjab.ui.fragment.settings.XmppPreferenceFragment;
import java.util.Collections;
public class SettingsActivity extends XmppActivity {
+ // Deep-links straight to a sub-screen instead of the top-level settings list — e.g. from
+ // the "new translation feature" announcement banner on the conversations overview.
+ public static final String EXTRA_OPEN_FRAGMENT = "open_fragment";
+ public static final String FRAGMENT_PRIVACY = "privacy";
+ // Optional: once landed on that sub-screen, scroll straight to a specific preference key
+ // (e.g. "translation_backend_priority") instead of leaving the user at the top of the list.
+ public static final String EXTRA_SCROLL_TO_PREFERENCE = "scroll_to_preference";
+
@Override
protected void refreshUiReal() {}
@@ -46,6 +55,17 @@ public class SettingsActivity extends XmppActivity {
if (ImmutableSet.of(Notification.INTENT_CATEGORY_NOTIFICATION_PREFERENCES)
.equals(categories)) {
preferenceFragment = new NotificationsSettingsFragment();
+ } else if (FRAGMENT_PRIVACY.equals(
+ intent == null ? null : intent.getStringExtra(EXTRA_OPEN_FRAGMENT))) {
+ final var fragment = new PrivacySettingsFragment();
+ final String scrollTo =
+ intent == null ? null : intent.getStringExtra(EXTRA_SCROLL_TO_PREFERENCE);
+ if (scrollTo != null) {
+ final Bundle args = new Bundle();
+ args.putString(EXTRA_SCROLL_TO_PREFERENCE, scrollTo);
+ fragment.setArguments(args);
+ }
+ preferenceFragment = fragment;
} else {
preferenceFragment = new MainSettingsFragment();
}
diff --git a/src/main/java/tel/xmpp/jabjab/ui/adapter/MessageAdapter.java b/src/main/java/tel/xmpp/jabjab/ui/adapter/MessageAdapter.java
index 189cd87..9cfb0de 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/adapter/MessageAdapter.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/adapter/MessageAdapter.java
@@ -565,7 +565,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
}
final String nick = UIHelper.getMessageDisplayName(message);
final boolean hasMeCommand = message.hasMeCommand();
- final String processedRaw = hasMeCommand ? rawBody : applyReplyPreview(viewHolder, rawBody.trim());
+ final String processedRaw = hasMeCommand ? rawBody : applyReplyPreview(viewHolder, rawBody.trim(), message);
final var trimmedBody = hasMeCommand ? rawBody.trim() : processedRaw;
final SpannableStringBuilder body;
if (trimmedBody.length() > Config.MAX_DISPLAY_MESSAGE_CHARS) {
@@ -817,10 +817,10 @@ public class MessageAdapter extends ArrayAdapter<Message> {
if (bm != null) {
// Bitmap already decoded — set synchronously, no placeholder flash.
viewHolder.linkPreviewImageFrame().setVisibility(View.VISIBLE);
- viewHolder.linkPreviewImagePlaceholder().setVisibility(View.GONE);
viewHolder.linkPreviewImage().setImageBitmap(bm);
viewHolder.linkPreviewImage().setTag(cached.imageUrl());
viewHolder.linkPreviewImage().setVisibility(View.VISIBLE);
+ applyLinkPreviewPlaceholder(viewHolder);
} else {
android.util.Log.d(Config.LOGTAG, "PREVIEW bitmap-not-in-mem imageUrl=" + cached.imageUrl());
// Bitmap not in memory yet (e.g. process restart) — show placeholder, load async.
@@ -830,8 +830,8 @@ public class MessageAdapter extends ArrayAdapter<Message> {
tel.xmpp.jabjab.ui.util.LinkPreviewFetcher.loadImage(
cached.imageUrl(), viewHolder.linkPreviewImage(), () -> {
if (!previewUrl.equals(card.getTag())) return;
- viewHolder.linkPreviewImagePlaceholder().setVisibility(View.GONE);
viewHolder.linkPreviewImage().setVisibility(View.VISIBLE);
+ applyLinkPreviewPlaceholder(viewHolder);
});
}
// Show play overlay for YouTube thumbnails.
@@ -859,9 +859,9 @@ public class MessageAdapter extends ArrayAdapter<Message> {
tel.xmpp.jabjab.ui.util.LinkPreviewFetcher.loadImage(thumbUrl,
viewHolder.linkPreviewImage(), () -> {
if (!previewUrl.equals(card.getTag())) return;
- viewHolder.linkPreviewImagePlaceholder().setVisibility(View.GONE);
viewHolder.linkPreviewImage().setVisibility(View.VISIBLE);
viewHolder.linkPreviewPlay().setVisibility(View.VISIBLE);
+ applyLinkPreviewPlaceholder(viewHolder);
});
fetchYouTubeTitle(activity, previewUrl, videoId, thumbUrl,
card, viewHolder.linkPreviewTextPlaceholder(), viewHolder.linkPreviewTitle());
@@ -900,8 +900,8 @@ public class MessageAdapter extends ArrayAdapter<Message> {
tel.xmpp.jabjab.ui.util.LinkPreviewFetcher.loadImage(
data.imageUrl(), viewHolder.linkPreviewImage(), () -> {
if (!previewUrl.equals(card.getTag())) return;
- viewHolder.linkPreviewImagePlaceholder().setVisibility(View.GONE);
viewHolder.linkPreviewImage().setVisibility(View.VISIBLE);
+ applyLinkPreviewPlaceholder(viewHolder);
});
} else {
viewHolder.linkPreviewImageFrame().setVisibility(View.GONE);
@@ -1441,7 +1441,10 @@ public class MessageAdapter extends ArrayAdapter<Message> {
}
if (data.imageUrl() != null) {
tel.xmpp.jabjab.ui.util.LinkPreviewFetcher.loadImage(data.imageUrl(),
- viewHolder.linkPreviewImage());
+ viewHolder.linkPreviewImage(), () -> {
+ if (!link.equals(card.getTag())) return;
+ applyLinkPreviewPlaceholder(viewHolder);
+ });
}
});
}
@@ -1508,10 +1511,9 @@ public class MessageAdapter extends ArrayAdapter<Message> {
final float target = activity.getResources().getDimension(R.dimen.image_preview_width);
final int scaledW;
final int scaledH;
- if (Math.max(params.height, params.width) * metrics.density <= target) {
- scaledW = (int) (params.width * metrics.density);
- scaledH = (int) (params.height * metrics.density);
- } else if (Math.max(params.height, params.width) <= target) {
+ if (params.width <= 0 || params.height <= 0) {
+ scaledW = scaledH = (int) target;
+ } else if (Math.max(params.width, params.height) <= target) {
scaledW = params.width;
scaledH = params.height;
} else if (params.width <= params.height) {
@@ -1716,6 +1718,15 @@ public class MessageAdapter extends ArrayAdapter<Message> {
viewHolder.linkPreviewCard().setOnClickListener(null);
viewHolder.linkPreviewImage().setTag(null);
viewHolder.linkPreviewImage().setScaleType(android.widget.ImageView.ScaleType.CENTER_CROP);
+ viewHolder.linkPreviewImage().setAdjustViewBounds(false);
+ // A recycled row previously showing a transparent-image preview clears this
+ // placeholder's background to TRANSPARENT (see applyLinkPreviewPlaceholder) — reset
+ // it back to the default grey skeleton color so the next bind starts clean.
+ viewHolder.linkPreviewImagePlaceholder().setBackgroundColor(
+ MaterialColors.getColor(
+ viewHolder.linkPreviewImagePlaceholder(),
+ com.google.android.material.R.attr.colorSurfaceVariant));
+ viewHolder.linkPreviewImagePlaceholder().setVisibility(View.GONE);
viewHolder.linkPreviewImage().getLayoutParams().height =
(int) (160 * viewHolder.linkPreviewImage().getContext().getResources().getDisplayMetrics().density);
viewHolder.linkPreviewPlay().setVisibility(View.GONE);
@@ -2199,7 +2210,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
}
private boolean mergeIntoTop(final int position, final Message message) {
- if (position < 0) {
+ if (position <= 0) {
return false;
}
final var top = getItem(position - 1);
@@ -2370,7 +2381,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
}
public interface OnScrollToReplyListener {
- void onScrollToReply(int position);
+ void onScrollToReply(int targetPosition, int fromPosition);
}
private OnScrollToReplyListener mOnScrollToReplyListener;
@@ -2379,6 +2390,35 @@ public class MessageAdapter extends ArrayAdapter<Message> {
this.mOnScrollToReplyListener = listener;
}
+ /**
+ * Finds the most recent currently-loaded message whose quoted-reply text points back at
+ * {@code target}, for "jump forward to reply" navigation from an original message. Replies
+ * are chronologically after what they quote, so unlike the reverse (reply → original)
+ * lookup, the target is almost always still within the currently loaded page — no DB
+ * fallback here. Returns null if none of the loaded messages reply to it.
+ */
+ public Integer findMostRecentReplyPosition(final Message target) {
+ final String targetBody = target.getBody();
+ if (targetBody == null) return null;
+ final String targetBare = targetBody.contains("|")
+ ? targetBody.substring(0, targetBody.indexOf('|')) : targetBody;
+ for (int i = getCount() - 1; i >= 0; i--) {
+ final Message candidate = getItem(i);
+ if (candidate == null || candidate == target || candidate.getBody() == null) continue;
+ final java.util.regex.Matcher m = QUOTED_URL_PATTERN.matcher(candidate.getBody());
+ if (!m.matches()) continue;
+ final String rawUrl = m.group(1) + m.group(2);
+ final String candUrl = rawUrl.startsWith("aesgcm://") && rawUrl.contains("|")
+ ? rawUrl.substring(0, rawUrl.indexOf('|')) : rawUrl;
+ if (candUrl.equals(targetBare)
+ || candUrl.startsWith(targetBare)
+ || targetBare.startsWith(candUrl)) {
+ return i;
+ }
+ }
+ return null;
+ }
+
public static void setBackgroundTint(final ViewGroup view, final BubbleColor bubbleColor) {
view.setBackgroundTintList(bubbleToColorStateList(view, bubbleColor));
}
@@ -2608,6 +2648,10 @@ public class MessageAdapter extends ArrayAdapter<Message> {
"^(?:[>»] )+(https?://|aesgcm://)([^\\s]+)\\s*\\n?(.*)", java.util.regex.Pattern.DOTALL);
private static final java.util.Map<String, Integer> MIME_ICONS = new java.util.HashMap<>();
+ // Caches the DB-fallback lookup result (see applyReplyPreview's aesgcm branch) by quoted
+ // url, so repeated rebinds of the same reply-preview row don't re-run the DB scan.
+ private static final java.util.Map<String, Message> aesgcmDbFallbackCache =
+ new java.util.concurrent.ConcurrentHashMap<>();
static {
MIME_ICONS.put("image/", R.drawable.ic_image_24dp);
@@ -2624,8 +2668,70 @@ public class MessageAdapter extends ArrayAdapter<Message> {
}
}
+ /**
+ * Hides the link-preview image's grey loading-skeleton placeholder once the real image is
+ * shown — unless that image has alpha, in which case the placeholder is kept as the image's
+ * direct backdrop but made transparent, so transparent PNG/GIF/WebP og:images blend in
+ * instead of showing a solid grey fill behind them.
+ */
+ private void applyLinkPreviewPlaceholder(final BubbleMessageItemViewHolder viewHolder) {
+ final android.graphics.drawable.Drawable d = viewHolder.linkPreviewImage().getDrawable();
+ final android.graphics.Bitmap bm =
+ d instanceof android.graphics.drawable.BitmapDrawable bd ? bd.getBitmap() : null;
+ if (bm != null && tel.xmpp.jabjab.persistance.FileBackend.hasAlpha(bm)) {
+ viewHolder.linkPreviewImagePlaceholder().setBackgroundColor(android.graphics.Color.TRANSPARENT);
+ viewHolder.linkPreviewImagePlaceholder().setVisibility(View.VISIBLE);
+ } else {
+ viewHolder.linkPreviewImagePlaceholder().setVisibility(View.GONE);
+ }
+ }
+
+ /**
+ * Grabs the first frame of a local video file for use as a reply-preview thumbnail.
+ * Returns null (falls back to the video icon) if extraction fails for any reason.
+ */
+ private static android.graphics.Bitmap extractVideoFrame(final java.io.File file) {
+ final android.media.MediaMetadataRetriever retriever =
+ new android.media.MediaMetadataRetriever();
+ try {
+ retriever.setDataSource(file.getAbsolutePath());
+ return retriever.getFrameAtTime(0);
+ } catch (final Exception e) {
+ return null;
+ } finally {
+ try {
+ retriever.release();
+ } catch (final Exception ignored) {}
+ }
+ }
+
+ /**
+ * Sets the reply-preview card's background transparent when the loaded thumbnail has
+ * alpha, so transparent PNG/GIF/WebP images blend into the bubble instead of showing a
+ * solid grey card behind them; restores the default surface color otherwise.
+ */
+ private void applyReplyCardAlphaBackground(
+ final BubbleMessageItemViewHolder viewHolder, final android.graphics.Bitmap bm) {
+ if (bm != null && tel.xmpp.jabjab.persistance.FileBackend.hasAlpha(bm)) {
+ viewHolder.replyPreviewCard().setCardBackgroundColor(android.graphics.Color.TRANSPARENT);
+ } else {
+ viewHolder.replyPreviewCard().setCardBackgroundColor(
+ MaterialColors.getColor(
+ viewHolder.replyPreviewCard(),
+ com.google.android.material.R.attr.colorSurfaceVariant));
+ }
+ }
+
private String applyReplyPreview(
- final BubbleMessageItemViewHolder viewHolder, final String rawBody) {
+ final BubbleMessageItemViewHolder viewHolder, final String rawBody,
+ final Message ownerMessage) {
+ // Reset to the default surface color up front — a recycled row previously showing a
+ // transparent-image reply (which clears this to TRANSPARENT) must not leak that into
+ // the next bind if this one shows an opaque image or no thumbnail at all.
+ viewHolder.replyPreviewCard().setCardBackgroundColor(
+ MaterialColors.getColor(
+ viewHolder.replyPreviewCard(),
+ com.google.android.material.R.attr.colorSurfaceVariant));
final java.util.regex.Matcher m = QUOTED_URL_PATTERN.matcher(rawBody);
if (!m.matches()) {
viewHolder.replyPreviewCard().setVisibility(View.GONE);
@@ -2675,6 +2781,8 @@ public class MessageAdapter extends ArrayAdapter<Message> {
} else {
mimeGuess = null;
}
+ android.util.Log.d(Config.LOGTAG,
+ "REPLY_PREVIEW url=" + url + " isAesgcm=" + isAesgcm + " mimeGuess=" + mimeGuess);
// Plain web URLs (no recognizable media extension) are not file attachments — show
// the message as regular quoted text instead of a misleading attachment card.
if (mimeGuess == null && !isAesgcm) {
@@ -2686,72 +2794,129 @@ public class MessageAdapter extends ArrayAdapter<Message> {
setBubbleFullWidth(viewHolder, true);
if (isAesgcm) {
// Load thumbnail from local file (aesgcm URLs are encrypted, can't fetch directly).
- viewHolder.replyPreviewIcon().setImageResource(R.drawable.ic_image_24dp);
+ final Integer fallbackIconRes = MIME_ICONS.get(mimeGuess);
+ viewHolder.replyPreviewIcon().setImageResource(
+ fallbackIconRes != null ? fallbackIconRes : R.drawable.ic_attach_file_24dp);
viewHolder.replyPreviewIcon().setVisibility(View.VISIBLE);
viewHolder.replyPreviewThumbnail().setVisibility(View.GONE);
- final String thumbTag = "aesgcm_thumb:" + url;
- viewHolder.replyPreviewThumbnail().setTag(thumbTag);
- final ImageView thumbView = viewHolder.replyPreviewThumbnail();
- final ImageView iconView = viewHolder.replyPreviewIcon();
- // Scan in-memory message list for the original message with this aesgcm body.
- Message aesgcmSource = null;
- for (int i = 0; i < getCount(); i++) {
- final Message candidate = getItem(i);
- if (candidate != null && candidate.getBody() != null
- && candidate.getBody().startsWith(url)) {
- aesgcmSource = candidate;
- break;
+ if ("audio/".equals(mimeGuess)) {
+ // Audio has no meaningful visual thumbnail — the icon set above is enough.
+ } else {
+ final String thumbTag = "aesgcm_thumb:" + url;
+ viewHolder.replyPreviewThumbnail().setTag(thumbTag);
+ final ImageView thumbView = viewHolder.replyPreviewThumbnail();
+ final ImageView iconView = viewHolder.replyPreviewIcon();
+ // Scan in-memory message list for the original message with this aesgcm body.
+ Message aesgcmSource = null;
+ for (int i = 0; i < getCount(); i++) {
+ final Message candidate = getItem(i);
+ if (candidate != null && candidate.getBody() != null
+ && candidate.getBody().startsWith(url)) {
+ aesgcmSource = candidate;
+ break;
+ }
}
- }
- if (aesgcmSource != null) {
- final Message srcMsg = aesgcmSource;
- new android.os.AsyncTask<Void, Void, android.graphics.Bitmap>() {
- @Override
- protected android.graphics.Bitmap doInBackground(Void... v) {
- try {
- final java.io.File f = getFileBackend().getFile(srcMsg);
- if (!f.exists()) return null;
- final java.io.File cacheDir = new java.io.File(
- thumbView.getContext().getCacheDir(), "link_thumbs");
- cacheDir.mkdirs();
- final java.io.File cached = new java.io.File(cacheDir,
- java.lang.Long.toHexString(url.hashCode()) + ".jpg");
- if (cached.exists()) {
- return android.graphics.BitmapFactory.decodeFile(
- cached.getAbsolutePath());
+ android.util.Log.d(Config.LOGTAG,
+ "REPLY_PREVIEW aesgcmSource=" + (aesgcmSource != null)
+ + " mimeGuess=" + mimeGuess);
+ {
+ final Message inMemorySource = aesgcmSource;
+ final boolean isVideo = "video/".equals(mimeGuess);
+ final tel.xmpp.jabjab.entities.Conversational conversational =
+ ownerMessage.getConversation();
+ new android.os.AsyncTask<Void, Void, android.graphics.Bitmap>() {
+ @Override
+ protected android.graphics.Bitmap doInBackground(Void... v) {
+ try {
+ Message srcMsg = inMemorySource != null
+ ? inMemorySource : aesgcmDbFallbackCache.get(url);
+ if (srcMsg == null
+ && conversational
+ instanceof tel.xmpp.jabjab.entities.Conversation
+ conv) {
+ // Not currently loaded in memory — e.g. the app only
+ // keeps a recent page of messages around, so replying to
+ // an older attachment and then leaving/reopening the
+ // conversation can miss it above. Fall back to a direct
+ // DB lookup so the thumbnail still resolves. Bounded (not
+ // the whole history) and cached by url — this used to
+ // re-scan thousands of rows on every single row rebind,
+ // which serialized behind (and starved) every other
+ // image/sticker thumbnail on the shared AsyncTask queue.
+ final java.util.List<Message> history =
+ activity.xmppConnectionService.databaseBackend
+ .getMessages(conv, 3000);
+ for (final Message candidate : history) {
+ if (candidate.getBody() != null
+ && candidate.getBody().startsWith(url)) {
+ srcMsg = candidate;
+ aesgcmDbFallbackCache.put(url, srcMsg);
+ break;
+ }
+ }
+ android.util.Log.d(Config.LOGTAG,
+ "REPLY_PREVIEW db-fallback found="
+ + (srcMsg != null));
+ }
+ if (srcMsg == null) return null;
+ final java.io.File f = getFileBackend().getFile(srcMsg);
+ android.util.Log.d(Config.LOGTAG,
+ "REPLY_PREVIEW file=" + f.getAbsolutePath()
+ + " exists=" + f.exists() + " isVideo=" + isVideo);
+ if (!f.exists()) return null;
+ if (isVideo) {
+ final android.graphics.Bitmap frame = extractVideoFrame(f);
+ android.util.Log.d(Config.LOGTAG,
+ "REPLY_PREVIEW video-frame=" + (frame != null));
+ return frame;
+ }
+ final java.io.File cacheDir = new java.io.File(
+ thumbView.getContext().getCacheDir(), "link_thumbs");
+ cacheDir.mkdirs();
+ // PNG (not JPEG) — JPEG has no alpha channel and would
+ // permanently flatten transparent images on every reload
+ // after the first, baking in a solid grey/opaque fill.
+ final java.io.File cached = new java.io.File(cacheDir,
+ java.lang.Long.toHexString(url.hashCode()) + ".png");
+ if (cached.exists()) {
+ return android.graphics.BitmapFactory.decodeFile(
+ cached.getAbsolutePath());
+ }
+ final android.graphics.BitmapFactory.Options opts =
+ new android.graphics.BitmapFactory.Options();
+ opts.inJustDecodeBounds = true;
+ android.graphics.BitmapFactory.decodeFile(
+ f.getAbsolutePath(), opts);
+ opts.inSampleSize = Math.max(1,
+ Math.max(opts.outWidth, opts.outHeight) / 128);
+ opts.inJustDecodeBounds = false;
+ final android.graphics.Bitmap bm =
+ android.graphics.BitmapFactory.decodeFile(
+ f.getAbsolutePath(), opts);
+ if (bm != null) {
+ try (final java.io.FileOutputStream fos =
+ new java.io.FileOutputStream(cached)) {
+ bm.compress(android.graphics.Bitmap.CompressFormat.PNG,
+ 100, fos);
+ } catch (final Exception ignored) {}
+ }
+ return bm;
+ } catch (final Exception e) {
+ return null;
}
- final android.graphics.BitmapFactory.Options opts =
- new android.graphics.BitmapFactory.Options();
- opts.inJustDecodeBounds = true;
- android.graphics.BitmapFactory.decodeFile(f.getAbsolutePath(), opts);
- opts.inSampleSize = Math.max(1,
- Math.max(opts.outWidth, opts.outHeight) / 128);
- opts.inJustDecodeBounds = false;
- final android.graphics.Bitmap bm =
- android.graphics.BitmapFactory.decodeFile(
- f.getAbsolutePath(), opts);
+ }
+ @Override
+ protected void onPostExecute(android.graphics.Bitmap bm) {
+ if (!thumbTag.equals(thumbView.getTag())) return;
if (bm != null) {
- try (final java.io.FileOutputStream fos =
- new java.io.FileOutputStream(cached)) {
- bm.compress(android.graphics.Bitmap.CompressFormat.JPEG,
- 85, fos);
- } catch (final Exception ignored) {}
+ thumbView.setImageBitmap(bm);
+ thumbView.setVisibility(View.VISIBLE);
+ iconView.setVisibility(View.GONE);
+ if (!isVideo) applyReplyCardAlphaBackground(viewHolder, bm);
}
- return bm;
- } catch (final Exception e) {
- return null;
}
- }
- @Override
- protected void onPostExecute(android.graphics.Bitmap bm) {
- if (!thumbTag.equals(thumbView.getTag())) return;
- if (bm != null) {
- thumbView.setImageBitmap(bm);
- thumbView.setVisibility(View.VISIBLE);
- iconView.setVisibility(View.GONE);
- }
- }
- }.execute();
+ }.executeOnExecutor(android.os.AsyncTask.THREAD_POOL_EXECUTOR);
+ }
}
} else if ("image/".equals(mimeGuess) || mimeGuess == null) {
// For images (by extension) or unknown URLs, optimistically try to load the thumbnail.
@@ -2767,7 +2932,8 @@ public class MessageAdapter extends ArrayAdapter<Message> {
final String thumbTag = "reply_thumb:" + url;
viewHolder.replyPreviewThumbnail().setTag(thumbTag);
tel.xmpp.jabjab.ui.util.LinkPreviewFetcher.loadImageForReply(
- url, viewHolder.replyPreviewThumbnail(), viewHolder.replyPreviewIcon());
+ url, viewHolder.replyPreviewThumbnail(), viewHolder.replyPreviewIcon(),
+ bm -> applyReplyCardAlphaBackground(viewHolder, bm));
} else {
viewHolder.replyPreviewThumbnail().setVisibility(View.GONE);
final Integer iconRes = MIME_ICONS.get(mimeGuess);
@@ -2792,17 +2958,34 @@ public class MessageAdapter extends ArrayAdapter<Message> {
// Scroll to the original attachment when the reply card is tapped.
final String quotedUrl = url;
viewHolder.replyPreviewCard().setOnClickListener(v -> {
+ android.util.Log.d(Config.LOGTAG,
+ "REPLY_JUMP tapped quotedUrl=" + quotedUrl + " isAesgcm=" + isAesgcm
+ + " listenerNull=" + (mOnScrollToReplyListener == null)
+ + " count=" + getCount());
if (mOnScrollToReplyListener == null) return;
for (int i = 0; i < getCount(); i++) {
final Message candidate = getItem(i);
- if (candidate == null || candidate.getBody() == null) continue;
- final boolean matches = candidate.getBody().equals(quotedUrl)
- || (isAesgcm && candidate.getBody().startsWith(quotedUrl));
+ final String candidateBody = candidate == null ? null : candidate.getBody();
+ if (candidateBody == null) continue;
+ // Compare against the candidate's own pipe-metadata-stripped URL too — the
+ // quoted text and the live source message can each carry (or lack) the
+ // |filesize|width|height suffix independently, so a plain startsWith/equals
+ // check on the raw bodies alone can miss a match that both sides agree on
+ // once the transient metadata suffix is normalized away.
+ final String candidateBare = isAesgcm && candidateBody.contains("|")
+ ? candidateBody.substring(0, candidateBody.indexOf('|'))
+ : candidateBody;
+ final boolean matches = candidateBody.equals(quotedUrl)
+ || candidateBare.equals(quotedUrl)
+ || (isAesgcm && candidateBody.startsWith(quotedUrl));
if (matches) {
- mOnScrollToReplyListener.onScrollToReply(i);
+ android.util.Log.d(Config.LOGTAG,
+ "REPLY_JUMP matched at i=" + i + " body=" + candidateBody);
+ mOnScrollToReplyListener.onScrollToReply(i, getPosition(ownerMessage));
return;
}
}
+ android.util.Log.d(Config.LOGTAG, "REPLY_JUMP no match found for " + quotedUrl);
});
return rest;
}
diff --git a/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/PrivacySettingsFragment.java b/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/PrivacySettingsFragment.java
index bd5eab5..5181c50 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/PrivacySettingsFragment.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/PrivacySettingsFragment.java
@@ -1,6 +1,13 @@
package tel.xmpp.jabjab.ui.fragment.settings;
+import android.content.Intent;
+import android.graphics.Typeface;
+import android.net.Uri;
import android.os.Bundle;
+import android.view.Gravity;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.preference.Preference;
@@ -8,12 +15,20 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import tel.xmpp.jabjab.AppSettings;
import tel.xmpp.jabjab.R;
import tel.xmpp.jabjab.entities.Account;
+import tel.xmpp.jabjab.persistance.TranslationCacheDatabase.Backend;
+import tel.xmpp.jabjab.ui.util.TranslationSettings;
+import java.util.ArrayList;
+import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
public class PrivacySettingsFragment extends XmppPreferenceFragment {
+ // Scrolling is a one-shot: only do it the first time this fragment starts, not on every
+ // onStart() (e.g. returning from a picker dialog shouldn't re-jump the list).
+ private boolean didScrollToRequestedPreference = false;
+
@Override
public void onCreatePreferences(@Nullable Bundle savedInstanceState, @Nullable String rootKey) {
setPreferencesFromResource(R.xml.preferences_privacy, rootKey);
@@ -25,6 +40,336 @@ public class PrivacySettingsFragment extends XmppPreferenceFragment {
requireActivity().setTitle(R.string.pref_privacy);
refreshCrashAccountSummary();
wireCrashAccountPicker();
+ wireTranslationBackendPriority();
+ wireOfflineTranslatorInfo();
+ scrollToRequestedPreference();
+ }
+
+ private void scrollToRequestedPreference() {
+ if (didScrollToRequestedPreference) return;
+ final Bundle args = getArguments();
+ final String key = args == null
+ ? null
+ : args.getString(
+ tel.xmpp.jabjab.ui.activity.SettingsActivity.EXTRA_SCROLL_TO_PREFERENCE);
+ if (key == null) return;
+ didScrollToRequestedPreference = true;
+ scrollToPreference(key);
+ // Same smooth "blink" used for jumping to a reply's original message
+ // (ConversationFragment.highlightMessageAt) — scrolling alone can be easy to miss,
+ // this makes it obvious exactly which row the user was jumped to.
+ final androidx.recyclerview.widget.RecyclerView recyclerView = getListView();
+ if (recyclerView != null) {
+ recyclerView.post(() -> highlightPreferenceView(recyclerView, key, 2));
+ }
+ }
+
+ private void highlightPreferenceView(
+ final androidx.recyclerview.widget.RecyclerView recyclerView,
+ final String key,
+ final int retriesLeft) {
+ final var adapter = recyclerView.getAdapter();
+ if (!(adapter instanceof androidx.preference.PreferenceGroup.PreferencePositionCallback
+ callback)) {
+ return;
+ }
+ final int position = callback.getPreferenceAdapterPosition(key);
+ if (position < 0) return;
+ final var holder = recyclerView.findViewHolderForAdapterPosition(position);
+ if (holder == null) {
+ // The scrolled-to row may not be laid out on the very first frame after
+ // scrollToPreference() — give it one more frame before giving up.
+ if (retriesLeft > 0) {
+ recyclerView.post(() -> highlightPreferenceView(recyclerView, key, retriesLeft - 1));
+ }
+ return;
+ }
+ flashHighlight(holder.itemView);
+ }
+
+ private void flashHighlight(final android.view.View view) {
+ final int highlightColor =
+ com.google.android.material.color.MaterialColors.getColor(
+ view, androidx.appcompat.R.attr.colorPrimary);
+ final android.graphics.drawable.ColorDrawable overlay =
+ new android.graphics.drawable.ColorDrawable(highlightColor);
+ overlay.setAlpha(0);
+ view.setForeground(overlay);
+ final android.animation.ValueAnimator animator =
+ android.animation.ValueAnimator.ofInt(0, 90, 90, 0);
+ animator.setDuration(2200);
+ animator.setInterpolator(new android.view.animation.AccelerateDecelerateInterpolator());
+ animator.addUpdateListener(a -> overlay.setAlpha((int) a.getAnimatedValue()));
+ animator.addListener(
+ new android.animation.AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(final android.animation.Animator animation) {
+ view.setForeground(null);
+ }
+ });
+ animator.start();
+ }
+
+ private static final String OFFLINE_TRANSLATOR_RELEASES_API =
+ "https://api.github.com/repos/DavidVentura/offline-translator/releases/latest";
+ private static final String OFFLINE_TRANSLATOR_RELEASES_PAGE =
+ "https://github.com/DavidVentura/offline-translator/releases/latest";
+
+ private void wireOfflineTranslatorInfo() {
+ final Preference pref = findPreference("translation_offline_translator_info");
+ if (pref == null) return;
+ // Re-checked every onStart() (not just once) — covers returning to this screen right
+ // after installing the app, e.g. from the download flow below.
+ final boolean installed =
+ tel.xmpp.jabjab.ui.util.OfflineTranslatorBridge.isInstalled(requireContext());
+ android.util.Log.d(
+ tel.xmpp.jabjab.Config.LOGTAG, "OFFLINE_TRANSLATOR installed=" + installed);
+ if (installed) {
+ pref.setIcon(R.drawable.ic_check_circle_24dp);
+ pref.setTitle(R.string.pref_offline_translator_installed_title);
+ pref.setSummary(R.string.pref_offline_translator_installed_summary);
+ pref.setOnPreferenceClickListener(null);
+ } else {
+ pref.setIcon(R.drawable.ic_download_24dp);
+ pref.setTitle(R.string.pref_offline_translator_info_title);
+ pref.setSummary(R.string.pref_offline_translator_info_summary);
+ pref.setOnPreferenceClickListener(
+ p -> {
+ fetchAndOpenLatestOfflineTranslatorApk();
+ return true;
+ });
+ }
+ }
+
+ /**
+ * Resolves the offline-translator repo's current latest-release APK asset via the GitHub
+ * API and opens its direct download link (browsers/download managers treat this as a file
+ * download, not a page — skips having the user hunt for the .apk link on the releases page
+ * themselves). Falls back to the plain releases page on any failure (network error, no APK
+ * asset found, rate-limited, etc.) so tapping the preference always does *something* useful.
+ */
+ private void fetchAndOpenLatestOfflineTranslatorApk() {
+ android.util.Log.d(
+ tel.xmpp.jabjab.Config.LOGTAG,
+ "OFFLINE_TRANSLATOR fetching latest release from " + OFFLINE_TRANSLATOR_RELEASES_API);
+ Toast.makeText(requireContext(), R.string.offline_translator_fetching, Toast.LENGTH_SHORT)
+ .show();
+ final android.os.Handler mainHandler = new android.os.Handler(android.os.Looper.getMainLooper());
+ java.util.concurrent.Executors.newSingleThreadExecutor()
+ .execute(
+ () -> {
+ String apkUrl = null;
+ try {
+ final okhttp3.Request request =
+ new okhttp3.Request.Builder()
+ .url(OFFLINE_TRANSLATOR_RELEASES_API)
+ .header("Accept", "application/vnd.github+json")
+ // GitHub's API returns 403 for any request with
+ // no User-Agent at all — this was missing and is
+ // why every lookup fell straight through to the
+ // releases-page fallback below.
+ .header(
+ "User-Agent",
+ tel.xmpp.jabjab.http.HttpConnectionManager
+ .getUserAgent())
+ .build();
+ try (okhttp3.Response response =
+ tel.xmpp.jabjab.ui.util.LinkPreviewFetcher.client(
+ requireContext())
+ .newCall(request)
+ .execute()) {
+ if (response.isSuccessful() && response.body() != null) {
+ final org.json.JSONObject json =
+ new org.json.JSONObject(response.body().string());
+ final org.json.JSONArray assets =
+ json.getJSONArray("assets");
+ final java.util.Map<String, String> apkAssets =
+ new java.util.LinkedHashMap<>();
+ for (int i = 0; i < assets.length(); i++) {
+ final org.json.JSONObject asset =
+ assets.getJSONObject(i);
+ final String name = asset.optString("name", "");
+ if (name.toLowerCase(java.util.Locale.ROOT)
+ .endsWith(".apk")) {
+ apkAssets.put(
+ name,
+ asset.optString(
+ "browser_download_url", null));
+ }
+ }
+ android.util.Log.d(
+ tel.xmpp.jabjab.Config.LOGTAG,
+ "OFFLINE_TRANSLATOR apk assets found="
+ + apkAssets.keySet());
+ apkUrl = pickApkForDeviceAbi(apkAssets);
+ } else {
+ android.util.Log.w(
+ tel.xmpp.jabjab.Config.LOGTAG,
+ "OFFLINE_TRANSLATOR release lookup http="
+ + response.code());
+ }
+ }
+ } catch (final Exception e) {
+ android.util.Log.w(
+ tel.xmpp.jabjab.Config.LOGTAG,
+ "OFFLINE_TRANSLATOR release lookup failed",
+ e);
+ }
+ final String resolvedApkUrl = apkUrl;
+ mainHandler.post(
+ () -> {
+ if (!isAdded()) return;
+ android.util.Log.d(
+ tel.xmpp.jabjab.Config.LOGTAG,
+ "OFFLINE_TRANSLATOR resolved apkUrl="
+ + resolvedApkUrl);
+ final String target;
+ if (resolvedApkUrl != null) {
+ target = resolvedApkUrl;
+ } else {
+ target = OFFLINE_TRANSLATOR_RELEASES_PAGE;
+ Toast.makeText(
+ requireContext(),
+ R.string.offline_translator_apk_not_found,
+ Toast.LENGTH_SHORT)
+ .show();
+ }
+ startActivity(
+ new Intent(Intent.ACTION_VIEW, Uri.parse(target)));
+ });
+ });
+ }
+
+ /**
+ * The offline-translator repo ships per-ABI APKs (e.g. one built for arm64-v8a, one for
+ * armeabi-v7a) rather than a single universal one — picking "whichever asset comes first"
+ * would download the wrong architecture's build on roughly half of devices. Matches the
+ * device's supported ABIs (in the device's own preference order) against a marker string in
+ * each asset's filename; falls back to any single APK asset if there's only one, or to null
+ * (triggering the releases-page fallback) if there are multiple and none can be matched.
+ */
+ @Nullable
+ private static String pickApkForDeviceAbi(final java.util.Map<String, String> apkAssetsByName) {
+ if (apkAssetsByName.isEmpty()) return null;
+ if (apkAssetsByName.size() == 1) return apkAssetsByName.values().iterator().next();
+ for (final String abi : android.os.Build.SUPPORTED_ABIS) {
+ final String marker =
+ switch (abi) {
+ case "arm64-v8a" -> "arm64";
+ case "armeabi-v7a", "armeabi" -> "armv7";
+ case "x86_64" -> "x86_64";
+ case "x86" -> "x86";
+ default -> null;
+ };
+ if (marker == null) continue;
+ for (final var entry : apkAssetsByName.entrySet()) {
+ if (entry.getKey().toLowerCase(java.util.Locale.ROOT).contains(marker)) {
+ android.util.Log.d(
+ tel.xmpp.jabjab.Config.LOGTAG,
+ "OFFLINE_TRANSLATOR matched abi=" + abi + " asset=" + entry.getKey());
+ return entry.getValue();
+ }
+ }
+ }
+ android.util.Log.w(
+ tel.xmpp.jabjab.Config.LOGTAG,
+ "OFFLINE_TRANSLATOR no asset matched device ABIs="
+ + java.util.Arrays.toString(android.os.Build.SUPPORTED_ABIS));
+ return null;
+ }
+
+ private static int backendLabel(final Backend backend) {
+ return switch (backend) {
+ case PRIVATE -> R.string.translation_backend_private;
+ case OFFLINE_DEVICE -> R.string.translation_backend_offline_device;
+ case GOOGLE -> R.string.translation_backend_google;
+ };
+ }
+
+ /** Reorder dialog for {@link TranslationSettings#getBackendPriority()}: plain
+ * up/down rows rather than a drag-reorder RecyclerView — three items never
+ * warrants that much machinery. Each tap persists immediately, so there's no
+ * separate save step to forget. */
+ private void wireTranslationBackendPriority() {
+ final Preference pref = findPreference("translation_backend_priority");
+ if (pref == null) return;
+ pref.setOnPreferenceClickListener(
+ p -> {
+ showBackendPriorityDialog();
+ return true;
+ });
+ }
+
+ private void showBackendPriorityDialog() {
+ final TranslationSettings settings = TranslationSettings.getInstance(requireContext());
+ final List<Backend> order = new ArrayList<>(settings.getBackendPriority());
+
+ final LinearLayout container = new LinearLayout(requireContext());
+ container.setOrientation(LinearLayout.VERTICAL);
+ final int pad = (int) (16 * getResources().getDisplayMetrics().density);
+ container.setPadding(pad, pad, pad, pad);
+
+ final MaterialAlertDialogBuilder builder =
+ new MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.pref_translation_backend_priority_title)
+ .setView(container)
+ .setPositiveButton(android.R.string.ok, null);
+ final androidx.appcompat.app.AlertDialog dialog = builder.create();
+
+ rebuildBackendPriorityRows(container, order, settings);
+ dialog.show();
+ }
+
+ private void rebuildBackendPriorityRows(
+ final LinearLayout container, final List<Backend> order, final TranslationSettings settings) {
+ container.removeAllViews();
+ final int pad = (int) (12 * getResources().getDisplayMetrics().density);
+ for (int i = 0; i < order.size(); i++) {
+ final int index = i;
+ final LinearLayout row = new LinearLayout(requireContext());
+ row.setOrientation(LinearLayout.HORIZONTAL);
+ row.setGravity(Gravity.CENTER_VERTICAL);
+ row.setPadding(0, pad / 2, 0, pad / 2);
+
+ final TextView label = new TextView(requireContext());
+ label.setText((index + 1) + ". " + getString(backendLabel(order.get(index))));
+ label.setLayoutParams(
+ new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f));
+
+ final TextView up = new TextView(requireContext());
+ up.setText("▲");
+ up.setTypeface(Typeface.DEFAULT_BOLD);
+ up.setPadding(pad, 0, pad, 0);
+ up.setAlpha(index == 0 ? 0.3f : 1f);
+ up.setEnabled(index != 0);
+ up.setOnClickListener(
+ v -> {
+ if (index == 0) return;
+ Collections.swap(order, index, index - 1);
+ settings.setBackendPriority(order);
+ rebuildBackendPriorityRows(container, order, settings);
+ });
+
+ final TextView down = new TextView(requireContext());
+ down.setText("▼");
+ down.setTypeface(Typeface.DEFAULT_BOLD);
+ down.setPadding(pad, 0, pad, 0);
+ final boolean isLast = index == order.size() - 1;
+ down.setAlpha(isLast ? 0.3f : 1f);
+ down.setEnabled(!isLast);
+ down.setOnClickListener(
+ v -> {
+ if (index == order.size() - 1) return;
+ Collections.swap(order, index, index + 1);
+ settings.setBackendPriority(order);
+ rebuildBackendPriorityRows(container, order, settings);
+ });
+
+ row.addView(label);
+ row.addView(up);
+ row.addView(down);
+ container.addView(row);
+ }
}
private void refreshCrashAccountSummary() {
diff --git a/src/main/java/tel/xmpp/jabjab/ui/service/AudioPlayer.java b/src/main/java/tel/xmpp/jabjab/ui/service/AudioPlayer.java
index 6d6df16..aeba8d2 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/service/AudioPlayer.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/service/AudioPlayer.java
@@ -172,11 +172,16 @@ public class AudioPlayer
if (player != null && player.isPlaying()) {
player.setPlaybackParams(new PlaybackParams().setSpeed(playbackSpeed));
}
- // update all visible speed buttons
+ final String label = formatSpeed(playbackSpeed);
+ // always reflect the change on the button that was actually tapped;
+ // audioPlayerLayouts may not contain it (it only tracks the currently
+ // playing row), so relying on that set alone updates the wrong bubble
+ speedToggle.setText(label);
+ // keep any other visible speed toggles (e.g. the currently playing row) in sync
for (WeakReference<RelativeLayout> audioPlayer : audioPlayerLayouts) {
final RelativeLayout layout = audioPlayer.get();
if (layout != null) {
- ViewHolder.get(layout).speedToggle.setText(formatSpeed(playbackSpeed));
+ ViewHolder.get(layout).speedToggle.setText(label);
}
}
}
diff --git a/src/main/java/tel/xmpp/jabjab/ui/util/LinkPreviewFetcher.java b/src/main/java/tel/xmpp/jabjab/ui/util/LinkPreviewFetcher.java
index aba6405..e18d33b 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/util/LinkPreviewFetcher.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/util/LinkPreviewFetcher.java
@@ -40,6 +40,11 @@ public class LinkPreviewFetcher {
// OG data cache: keyed by URL, value is fetched data or NEGATIVE_SENTINEL on failure.
// Entries expire after 1 hour so stale OG tags eventually refresh.
private static final long OG_CACHE_TTL_MS = TimeUnit.HOURS.toMillis(1);
+ // Matches MessageAdapter's fixed link-preview card footprint (256dp wide card,
+ // 160dp tall image frame) — decoding at this bound instead of full source
+ // resolution avoids needless memory/CPU cost on large og:image assets.
+ private static final int PREVIEW_TARGET_WIDTH_DP = 256;
+ private static final int PREVIEW_TARGET_HEIGHT_DP = 160;
private record CacheEntry(OgData data, long fetchedAt) {}
private static final ConcurrentHashMap<String, CacheEntry> cache = new ConcurrentHashMap<>();
// Sentinel stored on fetch failure — suppresses retries for the same session.
@@ -226,6 +231,32 @@ public class LinkPreviewFetcher {
return new File(dir, hash + ".jpg");
}
+ private static int calcSampleSize(
+ final int width, final int height, final int reqWidth, final int reqHeight) {
+ int inSampleSize = 1;
+ if (height > reqHeight || width > reqWidth) {
+ final int halfHeight = height / 2;
+ final int halfWidth = width / 2;
+ while ((halfHeight / inSampleSize) >= reqHeight
+ && (halfWidth / inSampleSize) >= reqWidth) {
+ inSampleSize *= 2;
+ }
+ }
+ return inSampleSize;
+ }
+
+ private static Bitmap decodeSampledBitmap(
+ final byte[] bytes, final float density) {
+ final int reqWidth = (int) (PREVIEW_TARGET_WIDTH_DP * density);
+ final int reqHeight = (int) (PREVIEW_TARGET_HEIGHT_DP * density);
+ final BitmapFactory.Options options = new BitmapFactory.Options();
+ options.inJustDecodeBounds = true;
+ BitmapFactory.decodeByteArray(bytes, 0, bytes.length, options);
+ options.inSampleSize = calcSampleSize(options.outWidth, options.outHeight, reqWidth, reqHeight);
+ options.inJustDecodeBounds = false;
+ return BitmapFactory.decodeByteArray(bytes, 0, bytes.length, options);
+ }
+
public static void loadImage(final String imageUrl, final ImageView imageView) {
loadImage(imageUrl, imageView, null);
}
@@ -308,7 +339,8 @@ public class LinkPreviewFetcher {
}
}
}
- final Bitmap bm = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
+ final Bitmap bm = decodeSampledBitmap(
+ bytes, imageView.getContext().getResources().getDisplayMetrics().density);
if (bm != null) {
android.util.Log.d(Config.LOGTAG, "PREVIEW LOADIMAGE decoded " + bm.getWidth() + "x" + bm.getHeight()
+ " url=" + imageUrl);
@@ -368,6 +400,12 @@ public class LinkPreviewFetcher {
*/
public static void loadImageForReply(
final String imageUrl, final ImageView thumbnailView, final ImageView iconView) {
+ loadImageForReply(imageUrl, thumbnailView, iconView, null);
+ }
+
+ public static void loadImageForReply(
+ final String imageUrl, final ImageView thumbnailView, final ImageView iconView,
+ final @androidx.annotation.Nullable java.util.function.Consumer<Bitmap> onBitmapLoaded) {
final String tag = "reply_thumb:" + imageUrl;
thumbnailView.setTag(tag);
executor.execute(() -> {
@@ -394,13 +432,15 @@ public class LinkPreviewFetcher {
} catch (Exception ignored) {}
}
}
- final Bitmap bm = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
+ final Bitmap bm = decodeSampledBitmap(
+ bytes, thumbnailView.getContext().getResources().getDisplayMetrics().density);
if (bm != null) {
mainHandler.post(() -> {
if (!tag.equals(thumbnailView.getTag())) return;
thumbnailView.setImageBitmap(bm);
thumbnailView.setVisibility(View.VISIBLE);
iconView.setVisibility(View.GONE);
+ if (onBitmapLoaded != null) onBitmapLoaded.accept(bm);
keepScrolledToBottomIfNeeded(thumbnailView);
});
} else {
diff --git a/src/main/java/tel/xmpp/jabjab/ui/util/OfflineTranslatorBridge.java b/src/main/java/tel/xmpp/jabjab/ui/util/OfflineTranslatorBridge.java
new file mode 100644
index 0000000..ab14aa3
--- /dev/null
+++ b/src/main/java/tel/xmpp/jabjab/ui/util/OfflineTranslatorBridge.java
@@ -0,0 +1,140 @@
+package tel.xmpp.jabjab.ui.util;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.content.pm.PackageManager;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Looper;
+import android.os.RemoteException;
+import android.util.Log;
+import androidx.annotation.Nullable;
+import dev.davidv.translator.ITranslationCallback;
+import dev.davidv.translator.ITranslationService;
+import dev.davidv.translator.TranslationError;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import tel.xmpp.jabjab.Config;
+
+/**
+ * Binds to the on-device "offline-translator" app (dev.davidv.translator, GPLv3,
+ * https://github.com/DavidVentura/offline-translator) over its exported
+ * ITranslationService AIDL interface. This process never touches the translation
+ * model weights or the Rust inference engine — it only sends/receives short-lived
+ * IPC calls, so the other app is a genuinely separate, independently distributed
+ * program from JabJab's point of view.
+ *
+ * A bind+call+unbind is done per request rather than keeping a long-lived
+ * connection: translation is bursty (long-press/compose-bar), not continuous, and
+ * this avoids leaking a ServiceConnection across activity lifecycles.
+ */
+public final class OfflineTranslatorBridge {
+
+ private static final String PACKAGE = "dev.davidv.translator";
+ private static final String ACTION = "dev.davidv.translator.ITranslationService";
+ private static final long BIND_TIMEOUT_SECONDS = 5;
+
+ private OfflineTranslatorBridge() {}
+
+ public static boolean isInstalled(final Context context) {
+ try {
+ context.getPackageManager().getPackageInfo(PACKAGE, 0);
+ return true;
+ } catch (final PackageManager.NameNotFoundException e) {
+ return false;
+ }
+ }
+
+ public interface Callback {
+ void onResult(String translatedText);
+
+ /** unavailable=true means the callee reported a routine miss (language
+ * pack not downloaded, couldn't detect language) — callers should fall
+ * through to the next tier quietly rather than surface it as an error. */
+ void onError(Exception e, boolean unavailable);
+ }
+
+ public static void translate(
+ final Context context,
+ final String text,
+ final String fromLanguage,
+ final String toLanguage,
+ final Callback callback) {
+ final Context appContext = context.getApplicationContext();
+ final Intent intent = new Intent(ACTION).setPackage(PACKAGE);
+ final AtomicBoolean handled = new AtomicBoolean(false);
+ final ServiceConnection[] connectionHolder = new ServiceConnection[1];
+
+ final ServiceConnection connection = new ServiceConnection() {
+ @Override
+ public void onServiceConnected(final ComponentName name, final IBinder binder) {
+ final ITranslationService service = ITranslationService.Stub.asInterface(binder);
+ try {
+ service.translate(
+ text,
+ fromLanguage,
+ toLanguage,
+ new ITranslationCallback.Stub() {
+ @Override
+ public void onTranslationResult(final String translatedText) {
+ finish(() -> callback.onResult(translatedText));
+ }
+
+ @Override
+ public void onTranslationError(final TranslationError error) {
+ final boolean unavailable =
+ error.type == dev.davidv.translator.ErrorType.DETECTED_BUT_UNAVAILABLE
+ || error.type
+ == dev.davidv.translator.ErrorType
+ .COULD_NOT_DETECT_LANGUAGE;
+ finish(
+ () ->
+ callback.onError(
+ new RuntimeException(
+ "offline-translator: " + error.type),
+ unavailable));
+ }
+ });
+ } catch (final RemoteException e) {
+ finish(() -> callback.onError(e, false));
+ }
+ }
+
+ @Override
+ public void onServiceDisconnected(final ComponentName name) {}
+
+ private void finish(final Runnable deliver) {
+ if (handled.compareAndSet(false, true)) {
+ deliver.run();
+ appContext.unbindService(connectionHolder[0]);
+ }
+ }
+ };
+ connectionHolder[0] = connection;
+
+ final boolean bound;
+ try {
+ bound = appContext.bindService(intent, connection, Context.BIND_AUTO_CREATE);
+ } catch (final SecurityException e) {
+ callback.onError(e, true);
+ return;
+ }
+ if (!bound) {
+ callback.onError(new IllegalStateException("could not bind offline-translator"), true);
+ return;
+ }
+
+ new Handler(Looper.getMainLooper())
+ .postDelayed(
+ () -> {
+ if (handled.compareAndSet(false, true)) {
+ Log.d(Config.LOGTAG, "TRANSLATE offline-translator timed out");
+ appContext.unbindService(connection);
+ callback.onError(new java.util.concurrent.TimeoutException(), true);
+ }
+ },
+ TimeUnit.SECONDS.toMillis(BIND_TIMEOUT_SECONDS));
+ }
+}
diff --git a/src/main/java/tel/xmpp/jabjab/ui/util/TranslationService.java b/src/main/java/tel/xmpp/jabjab/ui/util/TranslationService.java
index 9a21d2f..de9b72f 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/util/TranslationService.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/util/TranslationService.java
@@ -7,6 +7,7 @@ import android.util.Log;
import androidx.annotation.Nullable;
import com.google.common.base.Strings;
import java.io.IOException;
+import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
@@ -28,11 +29,14 @@ import tel.xmpp.jabjab.persistance.TranslationCacheDatabase.Backend;
* Telegram-style "press and hold to translate" backend, and the backend for the
* compose-bar outgoing-translate icon.
*
- * Privacy-tiered: every request first tries the private translation proxy
- * (Config.TRANSLATION_BASE_URL, which forwards to a self-hosted Ollama instance).
- * Only if that tier is unreachable does this fall back to Google Translate — and
- * only with the user's consent, either granted for this one request or permanently
- * via {@link TranslationSettings}. Nothing reaches Google silently.
+ * Privacy-tiered: tries backends in the order from
+ * {@link TranslationSettings#getBackendPriority()} (by default: the on-device
+ * offline-translator app if installed, then the private Ollama proxy, then Google
+ * Translate), skipping any tier that's unreachable/unavailable. Google is the only
+ * tier that ever leaves
+ * the device, so it's still gated on the user's consent — either granted for this
+ * one request or permanently via {@link TranslationSettings} — regardless of
+ * where it sits in the configured order. Nothing reaches Google silently.
*
* Results (fresh or cached) are tagged with which {@link Backend} produced them so
* callers can show an accurate "private" vs "public" label — this class never
@@ -94,24 +98,66 @@ public class TranslationService {
}
final Context appContext = context.getApplicationContext();
final String hash = TranslationCacheDatabase.hash(text);
- EXECUTOR.execute(() -> {
- final var db = TranslationCacheDatabase.getInstance(appContext);
- final var cached = db.get(hash, targetLang, Backend.PRIVATE);
- if (cached != null) {
- postResult(callback, cached);
- return;
- }
- attemptPrivateTranslation(appContext, authorJid, text, targetLang, hash, callback);
- });
+ final List<Backend> order = TranslationSettings.getInstance(appContext).getBackendPriority();
+ EXECUTOR.execute(
+ () -> attemptTier(appContext, authorJid, text, targetLang, hash, order, 0, callback));
}
- private static void attemptPrivateTranslation(
+ /** Walks {@code order} starting at {@code index}: checks the per-backend cache,
+ * then attempts a live call, then moves on to the next tier on any failure or
+ * unavailability. Runs on {@link #EXECUTOR}; each tier hops back onto it after
+ * any async work so this stays single-threaded end to end. */
+ private static void attemptTier(
final Context context,
final String authorJid,
final String text,
final String targetLang,
final String hash,
+ final List<Backend> order,
+ final int index,
final Callback callback) {
+ if (index >= order.size()) {
+ MAIN.post(
+ () ->
+ callback.onError(
+ new IOException("no translation backend available")));
+ return;
+ }
+ final Backend backend = order.get(index);
+ final var db = TranslationCacheDatabase.getInstance(context);
+ final var cached = db.get(hash, targetLang, backend);
+ if (cached != null) {
+ postResult(callback, cached);
+ return;
+ }
+ final Runnable next =
+ () ->
+ EXECUTOR.execute(
+ () ->
+ attemptTier(
+ context,
+ authorJid,
+ text,
+ targetLang,
+ hash,
+ order,
+ index + 1,
+ callback));
+ switch (backend) {
+ case PRIVATE -> attemptPrivateTranslation(context, authorJid, text, targetLang, hash, callback, next);
+ case OFFLINE_DEVICE -> attemptOfflineDeviceTranslation(context, text, targetLang, hash, callback, next);
+ case GOOGLE -> attemptGoogleTranslation(context, text, targetLang, hash, callback);
+ }
+ }
+
+ private static void attemptPrivateTranslation(
+ final Context context,
+ final String authorJid,
+ final String text,
+ final String targetLang,
+ final String hash,
+ final Callback callback,
+ final Runnable next) {
final Request request;
try {
final JSONObject body = new JSONObject();
@@ -136,13 +182,13 @@ public class TranslationService {
// Can't reach translate.xmpp.tel (or, transitively, the private Ollama
// server behind it) at all — treat exactly like a private-tier failure.
Log.d(Config.LOGTAG, "TRANSLATE private tier unreachable", e);
- fallback(context, authorJid, text, targetLang, hash, callback);
+ next.run();
return;
}
try (response) {
final int code = response.code();
if (code == 502 || code == 504) {
- fallback(context, authorJid, text, targetLang, hash, callback);
+ next.run();
return;
}
if (code != 200 || response.body() == null) {
@@ -166,19 +212,92 @@ public class TranslationService {
}
}
- private static void fallback(
+ // A cold service-bind (first request in a while) can time out once the process is still
+ // spinning up; a second attempt is often enough to hit an already-warm service. Only
+ // matters for the on-device tier — it's the one with bind/IPC overhead, unlike the plain
+ // HTTP calls the other tiers make.
+ private static final int OFFLINE_DEVICE_MAX_ATTEMPTS = 2;
+
+ private static void attemptOfflineDeviceTranslation(
final Context context,
- final String authorJid,
final String text,
final String targetLang,
final String hash,
- final Callback callback) {
- final var db = TranslationCacheDatabase.getInstance(context);
- final var cached = db.get(hash, targetLang, Backend.GOOGLE);
- if (cached != null) {
- postResult(callback, cached);
+ final Callback callback,
+ final Runnable next) {
+ attemptOfflineDeviceTranslation(context, text, targetLang, hash, callback, next, 1);
+ }
+
+ private static void attemptOfflineDeviceTranslation(
+ final Context context,
+ final String text,
+ final String targetLang,
+ final String hash,
+ final Callback callback,
+ final Runnable next,
+ final int attempt) {
+ if (!OfflineTranslatorBridge.isInstalled(context)) {
+ next.run();
return;
}
+ OfflineTranslatorBridge.translate(
+ context,
+ text,
+ "auto",
+ targetLang,
+ new OfflineTranslatorBridge.Callback() {
+ @Override
+ public void onResult(final String translatedText) {
+ final var db = TranslationCacheDatabase.getInstance(context);
+ db.put(hash, targetLang, Backend.OFFLINE_DEVICE, null, translatedText);
+ MAIN.post(
+ () ->
+ callback.onResult(
+ translatedText, null, Backend.OFFLINE_DEVICE));
+ }
+
+ @Override
+ public void onError(final Exception e, final boolean unavailable) {
+ if (attempt < OFFLINE_DEVICE_MAX_ATTEMPTS) {
+ Log.d(
+ Config.LOGTAG,
+ "TRANSLATE offline-device tier failed (attempt "
+ + attempt
+ + "/"
+ + OFFLINE_DEVICE_MAX_ATTEMPTS
+ + "), retrying",
+ e);
+ EXECUTOR.execute(
+ () ->
+ attemptOfflineDeviceTranslation(
+ context,
+ text,
+ targetLang,
+ hash,
+ callback,
+ next,
+ attempt + 1));
+ return;
+ }
+ Log.d(
+ Config.LOGTAG,
+ "TRANSLATE offline-device tier failed (attempt "
+ + attempt
+ + "/"
+ + OFFLINE_DEVICE_MAX_ATTEMPTS
+ + "), giving up",
+ e);
+ next.run();
+ }
+ });
+ }
+
+ private static void attemptGoogleTranslation(
+ final Context context,
+ final String text,
+ final String targetLang,
+ final String hash,
+ final Callback callback) {
if (TranslationSettings.getInstance(context).isAutoFallbackToGoogleEnabled()) {
fetchFromGoogleTranslateAndCache(context, text, targetLang, hash, callback);
return;
diff --git a/src/main/java/tel/xmpp/jabjab/ui/util/TranslationSettings.java b/src/main/java/tel/xmpp/jabjab/ui/util/TranslationSettings.java
index 6c9090b..6bce1ca 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/util/TranslationSettings.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/util/TranslationSettings.java
@@ -3,9 +3,13 @@ package tel.xmpp.jabjab.ui.util;
import android.content.Context;
import android.content.SharedPreferences;
import androidx.annotation.Nullable;
+import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
+import java.util.List;
import java.util.Set;
+import java.util.stream.Collectors;
+import tel.xmpp.jabjab.persistance.TranslationCacheDatabase.Backend;
/**
* Shared by both the incoming (long-press) and outgoing (compose bar) translate
@@ -18,6 +22,12 @@ public class TranslationSettings {
private static final String KEY_AUTO_FALLBACK_GOOGLE = "auto_fallback_google";
private static final String KEY_LAST_CUSTOM_LANGUAGE = "last_custom_language";
private static final String KEY_HIDDEN_PRIMARY_LANGUAGES = "hidden_primary_languages";
+ private static final String KEY_BACKEND_PRIORITY = "backend_priority";
+ /** On-device first (fully local, no network at all), then the private Ollama
+ * proxy, Google last — matches the existing default of "never hit Google
+ * unless everything else failed and the user consented." */
+ private static final List<Backend> DEFAULT_BACKEND_PRIORITY =
+ List.of(Backend.OFFLINE_DEVICE, Backend.PRIVATE, Backend.GOOGLE);
private static TranslationSettings instance;
private final SharedPreferences prefs;
@@ -66,4 +76,37 @@ public class TranslationSettings {
hidden.add(langCode);
prefs.edit().putStringSet(KEY_HIDDEN_PRIMARY_LANGUAGES, hidden).apply();
}
+
+ /** The order in which {@link TranslationService} tries backends. Always
+ * returns all three known backends exactly once, even if the stored value is
+ * stale (e.g. from a version that only knew about two of them). */
+ public List<Backend> getBackendPriority() {
+ final String stored = prefs.getString(KEY_BACKEND_PRIORITY, null);
+ if (stored == null || stored.isEmpty()) {
+ return DEFAULT_BACKEND_PRIORITY;
+ }
+ final List<Backend> parsed = new ArrayList<>();
+ for (final String name : stored.split(",")) {
+ try {
+ final Backend backend = Backend.valueOf(name);
+ if (!parsed.contains(backend)) {
+ parsed.add(backend);
+ }
+ } catch (final IllegalArgumentException ignored) {
+ // dropped enum value from an old build; skip it
+ }
+ }
+ for (final Backend backend : DEFAULT_BACKEND_PRIORITY) {
+ if (!parsed.contains(backend)) {
+ parsed.add(backend);
+ }
+ }
+ return parsed;
+ }
+
+ public void setBackendPriority(final List<Backend> order) {
+ final String joined =
+ order.stream().map(Enum::name).collect(Collectors.joining(","));
+ prefs.edit().putString(KEY_BACKEND_PRIORITY, joined).apply();
+ }
}
diff --git a/src/main/java/tel/xmpp/jabjab/ui/widget/FeatureBannerView.java b/src/main/java/tel/xmpp/jabjab/ui/widget/FeatureBannerView.java
new file mode 100644
index 0000000..ae818c4
--- /dev/null
+++ b/src/main/java/tel/xmpp/jabjab/ui/widget/FeatureBannerView.java
@@ -0,0 +1,227 @@
+package tel.xmpp.jabjab.ui.widget;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.ImageButton;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+import android.widget.Toast;
+import androidx.annotation.DrawableRes;
+import androidx.annotation.Nullable;
+import com.google.android.material.color.MaterialColors;
+import java.util.ArrayList;
+import java.util.List;
+import tel.xmpp.jabjab.BuildConfig;
+import tel.xmpp.jabjab.Config;
+import tel.xmpp.jabjab.R;
+
+/**
+ * Dismissible "new feature" announcement banner, reused across every top-level screen
+ * (conversations, channels, stories, blog). One instance holds its own queue of not-yet-dismissed
+ * announcements and cycles through them (prev/next + "1/2" counter) exactly like the
+ * in-conversation pinned-messages banner — callers just call {@link #registerBanner} once per
+ * announcement they want shown on that screen; all the layout, click-wiring and dismissed-state
+ * persistence lives here instead of being copy-pasted into every screen.
+ */
+public class FeatureBannerView extends LinearLayout {
+
+ private static final String PREFS = "jabjab_banners";
+
+ private record Banner(String id, @DrawableRes int icon, String text, Runnable onTap) {}
+
+ // Everything ever registered on this instance, dismissed or not — the "pending" queue
+ // (what's actually eligible to show right now) is always derived from this against the
+ // current SharedPreferences state, never mutated directly. That's what lets the debug
+ // long-press reset below actually bring dismissed banners back: clearing the prefs flag
+ // is enough, there's no separate "removed from memory" state to also undo.
+ private final List<Banner> allRegistered = new ArrayList<>();
+ private int currentIndex = 0;
+
+ private final ImageView iconView;
+ private final TextView textView;
+ private final ImageButton prevButton;
+ private final ImageButton nextButton;
+ private final ImageButton closeButton;
+
+ public FeatureBannerView(final Context context) {
+ this(context, null);
+ }
+
+ public FeatureBannerView(final Context context, @Nullable final AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public FeatureBannerView(
+ final Context context, @Nullable final AttributeSet attrs, final int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ LayoutInflater.from(context).inflate(R.layout.view_feature_banner, this, true);
+ setOrientation(HORIZONTAL);
+ setGravity(android.view.Gravity.CENTER_VERTICAL);
+ setClickable(true);
+ setFocusable(true);
+ setBackgroundColor(
+ MaterialColors.getColor(
+ this, com.google.android.material.R.attr.colorSurfaceContainer));
+ final int pad = (int) (12 * getResources().getDisplayMetrics().density);
+ final int padV = (int) (8 * getResources().getDisplayMetrics().density);
+ setPadding(pad, padV, pad, padV);
+ setVisibility(GONE);
+
+ this.iconView = findViewById(R.id.feature_banner_icon);
+ this.textView = findViewById(R.id.feature_banner_text);
+ this.prevButton = findViewById(R.id.feature_banner_cycle_prev);
+ this.nextButton = findViewById(R.id.feature_banner_cycle_next);
+ this.closeButton = findViewById(R.id.feature_banner_close);
+
+ // Debug-only escape hatch: tapping a banner permanently dismisses it (by design), so
+ // while testing there'd otherwise be no way to see it again short of clearing app data.
+ // Long-press clears every dismissed-flag this view knows about and recomputes the
+ // pending queue from allRegistered, which brings everything back. Registered on `this`
+ // unconditionally (not just when a banner is visible) so it still works once the last
+ // banner is dismissed — see refresh()'s debug placeholder for why there's still
+ // something on screen to long-press at that point.
+ if (BuildConfig.DEBUG) {
+ setOnLongClickListener(v -> {
+ Log.d(Config.LOGTAG, "FEATURE_BANNER debug long-press reset, prefs=" + PREFS);
+ context.getSharedPreferences(PREFS, 0).edit().clear().apply();
+ Toast.makeText(
+ context,
+ "Feature banner dismissed-state cleared (debug)",
+ Toast.LENGTH_SHORT)
+ .show();
+ refresh();
+ return true;
+ });
+ }
+ }
+
+ /**
+ * Registers an announcement for this screen. It only actually shows if it isn't currently
+ * dismissed (checked live, not just at registration time — see {@link #computePending()}),
+ * so a debug long-press reset elsewhere doesn't require calling this again.
+ */
+ public void registerBanner(
+ final String id, @DrawableRes final int icon, final String text, final Runnable onTap) {
+ if (allRegistered.stream().anyMatch(b -> b.id().equals(id))) {
+ Log.d(Config.LOGTAG, "FEATURE_BANNER skip id=" + id + " reason=already-registered");
+ return;
+ }
+ allRegistered.add(new Banner(id, icon, text, onTap));
+ Log.d(
+ Config.LOGTAG,
+ "FEATURE_BANNER registered id=" + id + " totalRegistered=" + allRegistered.size());
+ refresh();
+ }
+
+ private static String dismissedKey(final String id) {
+ return "feature_banner_" + id + "_dismissed";
+ }
+
+ private List<Banner> computePending() {
+ final SharedPreferences prefs = getContext().getSharedPreferences(PREFS, 0);
+ final List<Banner> result = new ArrayList<>();
+ for (final Banner b : allRegistered) {
+ if (!prefs.getBoolean(dismissedKey(b.id()), false)) {
+ result.add(b);
+ }
+ }
+ return result;
+ }
+
+ private void refresh() {
+ final List<Banner> pending = computePending();
+ if (pending.isEmpty()) {
+ if (BuildConfig.DEBUG) {
+ // Stay visible (as a minimal placeholder) instead of GONE — a GONE view has no
+ // size and can't be long-pressed, which would make the reset above unreachable
+ // once the last banner is dismissed.
+ Log.d(Config.LOGTAG, "FEATURE_BANNER debug-placeholder reason=no-pending-banners");
+ setVisibility(VISIBLE);
+ iconView.setImageResource(R.drawable.ic_translate_24dp);
+ textView.setText("[DEBUG] No feature banners pending — long-press to reset");
+ prevButton.setVisibility(GONE);
+ nextButton.setVisibility(GONE);
+ setOnClickListener(null);
+ closeButton.setOnClickListener(v -> setVisibility(GONE));
+ } else {
+ Log.d(Config.LOGTAG, "FEATURE_BANNER hide reason=no-pending-banners");
+ setVisibility(GONE);
+ }
+ return;
+ }
+ if (currentIndex >= pending.size()) {
+ currentIndex = 0;
+ }
+ final Banner current = pending.get(currentIndex);
+ Log.d(
+ Config.LOGTAG,
+ "FEATURE_BANNER show id="
+ + current.id()
+ + " index="
+ + currentIndex
+ + "/"
+ + pending.size());
+ setVisibility(VISIBLE);
+ // icon() == 0 means this particular announcement didn't specify one — every feature
+ // registers its own (or none), nothing here is hardcoded to any one feature; falls back
+ // to a generic announcement icon rather than leaving a blank gap.
+ iconView.setImageResource(current.icon() != 0 ? current.icon() : R.drawable.ic_announcement_24dp);
+ final boolean multiple = pending.size() > 1;
+ textView.setText(
+ multiple
+ ? current.text()
+ + " "
+ + getResources()
+ .getString(
+ R.string.pinned_messages_counter,
+ currentIndex + 1,
+ pending.size())
+ : current.text());
+ final int cycleVisibility = multiple ? VISIBLE : GONE;
+ prevButton.setVisibility(cycleVisibility);
+ nextButton.setVisibility(cycleVisibility);
+ prevButton.setOnClickListener(v -> cycle(-1));
+ nextButton.setOnClickListener(v -> cycle(1));
+ setOnClickListener(
+ v -> {
+ Log.d(Config.LOGTAG, "FEATURE_BANNER tapped id=" + current.id());
+ dismissCurrent();
+ current.onTap().run();
+ });
+ closeButton.setOnClickListener(
+ v -> {
+ Log.d(Config.LOGTAG, "FEATURE_BANNER closed id=" + current.id());
+ dismissCurrent();
+ });
+ }
+
+ private void cycle(final int direction) {
+ final List<Banner> pending = computePending();
+ if (pending.isEmpty()) {
+ return;
+ }
+ currentIndex = Math.floorMod(currentIndex + direction, pending.size());
+ Log.d(Config.LOGTAG, "FEATURE_BANNER cycle direction=" + direction + " newIndex=" + currentIndex);
+ refresh();
+ }
+
+ private void dismissCurrent() {
+ final List<Banner> pending = computePending();
+ if (currentIndex >= pending.size()) {
+ return;
+ }
+ final Banner current = pending.get(currentIndex);
+ getContext()
+ .getSharedPreferences(PREFS, 0)
+ .edit()
+ .putBoolean(dismissedKey(current.id()), true)
+ .apply();
+ Log.d(Config.LOGTAG, "FEATURE_BANNER dismissed id=" + current.id() + " key=" + dismissedKey(current.id()));
+ refresh();
+ }
+}
diff --git a/src/main/res/layout/activity_about.xml b/src/main/res/layout/activity_about.xml
index 062560a..c082012 100644
--- a/src/main/res/layout/activity_about.xml
+++ b/src/main/res/layout/activity_about.xml
@@ -91,16 +91,20 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
- android:layout_marginBottom="20dp"
android:paddingHorizontal="24dp"
android:gravity="center"
android:alpha="0.7"
android:textAppearance="?textAppearanceBodySmall"
android:visibility="gone" />
+ <!-- Fixed marginTop (not the status TextView's own margin) so the gap to this
+ divider stays consistent whether or not the status text above is showing —
+ a GONE view's margins collapse to zero, which previously left this divider
+ sitting right under the button until a status message had been shown once. -->
<View
android:layout_width="match_parent"
android:layout_height="1dp"
+ android:layout_marginTop="20dp"
android:layout_marginHorizontal="24dp"
android:background="?attr/colorOutline"
android:alpha="0.3" />
diff --git a/src/main/res/layout/activity_blog.xml b/src/main/res/layout/activity_blog.xml
index 006390d..06f582a 100644
--- a/src/main/res/layout/activity_blog.xml
+++ b/src/main/res/layout/activity_blog.xml
@@ -17,6 +17,13 @@
app:elevation="0dp"
app:liftOnScroll="false">
+ <!-- Reusable "new feature" announcement — see FeatureBannerView. -->
+ <tel.xmpp.jabjab.ui.widget.FeatureBannerView
+ android:id="@+id/feature_banner"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ app:layout_scrollFlags="snap" />
+
<!--
Replaces Material SearchBar+SearchView (overlay was broken, tap did nothing).
Styled to match SearchBar: colorSurfaceContainerHigh, 28dp corners, 16dp side margin.
diff --git a/src/main/res/layout/activity_channel_discovery.xml b/src/main/res/layout/activity_channel_discovery.xml
index 4e7a33c..9744651 100644
--- a/src/main/res/layout/activity_channel_discovery.xml
+++ b/src/main/res/layout/activity_channel_discovery.xml
@@ -19,6 +19,13 @@
app:elevation="0dp"
app:liftOnScroll="false">
+ <!-- Reusable "new feature" announcement — see FeatureBannerView. -->
+ <tel.xmpp.jabjab.ui.widget.FeatureBannerView
+ android:id="@+id/feature_banner"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ app:layout_scrollFlags="snap" />
+
<com.google.android.material.search.SearchBar
android:id="@+id/search_bar"
android:layout_width="match_parent"
diff --git a/src/main/res/layout/activity_stories.xml b/src/main/res/layout/activity_stories.xml
index 08266df..bfad405 100644
--- a/src/main/res/layout/activity_stories.xml
+++ b/src/main/res/layout/activity_stories.xml
@@ -17,6 +17,13 @@
app:elevation="0dp"
app:liftOnScroll="false">
+ <!-- Reusable "new feature" announcement — see FeatureBannerView. -->
+ <tel.xmpp.jabjab.ui.widget.FeatureBannerView
+ android:id="@+id/feature_banner"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ app:layout_scrollFlags="snap" />
+
<LinearLayout
android:layout_width="match_parent"
android:layout_height="56dp"
diff --git a/src/main/res/layout/fragment_conversation.xml b/src/main/res/layout/fragment_conversation.xml
index 9b03e3f..71f88f8 100644
--- a/src/main/res/layout/fragment_conversation.xml
+++ b/src/main/res/layout/fragment_conversation.xml
@@ -234,6 +234,22 @@
android:visibility="visible"
app:useCompatPadding="true" />
+ <!-- Appears only mid-navigation, after tapping a reply card (or "jump to reply")
+ jumps you away from where you were reading. Tapping it returns to that spot;
+ if that spot no longer resolves, it falls back to scrolling to the end of chat. -->
+ <com.google.android.material.floatingactionbutton.FloatingActionButton
+ android:id="@+id/reply_jump_back_button"
+ style="?attr/floatingActionButtonSmallSurfaceStyle"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_above="@+id/scroll_to_bottom_button"
+ android:layout_marginBottom="8dp"
+ android:layout_alignParentEnd="true"
+ android:contentDescription="@string/jump_back"
+ android:src="@drawable/ic_arrow_back_24dp"
+ android:visibility="gone"
+ app:useCompatPadding="true" />
+
<tel.xmpp.jabjab.ui.widget.UnreadCountCustomView
android:id="@+id/unread_count_custom_view"
android:layout_width="18sp"
diff --git a/src/main/res/layout/fragment_conversations_overview.xml b/src/main/res/layout/fragment_conversations_overview.xml
index 33c8424..63b4343 100644
--- a/src/main/res/layout/fragment_conversations_overview.xml
+++ b/src/main/res/layout/fragment_conversations_overview.xml
@@ -13,6 +13,16 @@
app:liftOnScroll="false">
+ <!-- Reusable "new feature" announcement — see FeatureBannerView. Sits above the
+ search bar; each screen that includes this registers its own announcements via
+ binding.featureBanner.registerBanner(...), and dismissed state is shared across
+ screens by banner id. -->
+ <tel.xmpp.jabjab.ui.widget.FeatureBannerView
+ android:id="@+id/feature_banner"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ app:layout_scrollFlags="snap" />
+
<com.google.android.material.search.SearchBar
android:id="@+id/search_bar"
android:layout_width="match_parent"
diff --git a/src/main/res/layout/item_message_content.xml b/src/main/res/layout/item_message_content.xml
index 35c8bb7..0e1c258 100644
--- a/src/main/res/layout/item_message_content.xml
+++ b/src/main/res/layout/item_message_content.xml
@@ -29,6 +29,7 @@
android:id="@+id/reply_preview_thumbnail"
android:layout_width="96dp"
android:layout_height="96dp"
+ android:layout_gravity="center_vertical"
android:scaleType="centerCrop"
android:visibility="gone" />
@@ -36,8 +37,9 @@
android:id="@+id/reply_preview_icon"
android:layout_width="96dp"
android:layout_height="96dp"
- android:padding="20dp"
- android:scaleType="center"
+ android:layout_gravity="center_vertical"
+ android:padding="24dp"
+ android:scaleType="fitCenter"
android:visibility="gone" />
<TextView
diff --git a/src/main/res/layout/view_feature_banner.xml b/src/main/res/layout/view_feature_banner.xml
new file mode 100644
index 0000000..2fa3241
--- /dev/null
+++ b/src/main/res/layout/view_feature_banner.xml
@@ -0,0 +1,60 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Inflated by tel.xmpp.jabjab.ui.widget.FeatureBannerView into itself (a LinearLayout it
+ configures programmatically) — these are its direct children, not a standalone screen. -->
+<merge xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools">
+
+ <View
+ android:layout_width="3dp"
+ android:layout_height="match_parent"
+ android:layout_marginEnd="10dp"
+ android:background="?colorPrimary" />
+
+ <ImageView
+ android:id="@+id/feature_banner_icon"
+ android:layout_width="20dp"
+ android:layout_height="20dp"
+ android:layout_marginEnd="10dp"
+ android:importantForAccessibility="no"
+ app:tint="?colorPrimary"
+ tools:src="@drawable/ic_translate_24dp" />
+
+ <TextView
+ android:id="@+id/feature_banner_text"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:textAppearance="?textAppearanceBodyMedium"
+ tools:text="New translation feature added. Tap here to find out more and how to set it up." />
+
+ <ImageButton
+ android:id="@+id/feature_banner_cycle_prev"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:background="?attr/selectableItemBackgroundBorderless"
+ android:contentDescription="@string/pinned_messages_previous"
+ android:padding="8dp"
+ android:src="@drawable/ic_expand_less_24dp"
+ android:visibility="gone" />
+
+ <ImageButton
+ android:id="@+id/feature_banner_cycle_next"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:background="?attr/selectableItemBackgroundBorderless"
+ android:contentDescription="@string/pinned_messages_next"
+ android:padding="8dp"
+ android:src="@drawable/ic_expand_more_24dp"
+ android:visibility="gone" />
+
+ <ImageButton
+ android:id="@+id/feature_banner_close"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:background="?attr/selectableItemBackgroundBorderless"
+ android:contentDescription="@string/dismiss"
+ android:padding="8dp"
+ android:src="@drawable/ic_close_24dp" />
+
+</merge>
diff --git a/src/main/res/menu/message_context.xml b/src/main/res/menu/message_context.xml
index 13f4aad..48559eb 100644
--- a/src/main/res/menu/message_context.xml
+++ b/src/main/res/menu/message_context.xml
@@ -64,6 +64,11 @@
android:icon="@drawable/ic_reply_24dp"
android:title="@string/quote"
android:visible="false" />
+ <item
+ android:id="@+id/jump_to_reply"
+ android:icon="@drawable/ic_keyboard_double_arrow_down_24dp"
+ android:title="@string/jump_to_reply"
+ android:visible="false" />
<item
android:id="@+id/action_pin_message"
diff --git a/src/main/res/values-ar/strings.xml b/src/main/res/values-ar/strings.xml
index 5495279..1aac25a 100644
--- a/src/main/res/values-ar/strings.xml
+++ b/src/main/res/values-ar/strings.xml
@@ -1593,4 +1593,20 @@
<item quantity="many">%d محادثة غير مقروءة</item>
<item quantity="other">%d محادثة غير مقروءة</item>
</plurals>
+
+ <!-- Translation feature banner + settings -->
+ <string name="pref_category_translation">الترجمة</string>
+ <string name="pref_translation_backend_priority_title">أولوية خدمة الترجمة</string>
+ <string name="pref_translation_backend_priority_summary">الترتيب الذي يتم به تجربة خدمات الترجمة</string>
+ <string name="translation_backend_private">خاص (Qwen3.6)</string>
+ <string name="translation_backend_offline_device">على الجهاز (تطبيق offline-translator)</string>
+ <string name="translation_backend_google">ترجمة Google (عامة)</string>
+ <string name="translation_feature_banner_text">تمت إضافة ميزة ترجمة جديدة. اضغط هنا لمعرفة المزيد وكيفية إعدادها.</string>
+ <string name="dismiss">إغلاق</string>
+ <string name="pref_offline_translator_info_title">احصل على الترجمة على الجهاز</string>
+ <string name="pref_offline_translator_info_summary">ثبّت تطبيق offline-translator من F-Droid أو GitHub لترجمة الرسائل دون إرسالها إلى أي مكان. اضغط لتنزيل أحدث إصدار APK مباشرة.</string>
+ <string name="offline_translator_fetching">جارٍ جلب أحدث إصدار…</string>
+ <string name="offline_translator_apk_not_found">تعذر العثور على ملف APK في أحدث إصدار — سيتم فتح صفحة الإصدارات بدلاً من ذلك</string>
+ <string name="pref_offline_translator_installed_title">تم تثبيت المترجم على الجهاز</string>
+ <string name="pref_offline_translator_installed_summary">تطبيق offline-translator مثبّت — يمكن ترجمة الرسائل بالكامل على هذا الجهاز.</string>
</resources>
diff --git a/src/main/res/values-de/strings.xml b/src/main/res/values-de/strings.xml
index 3ba5980..027cf8b 100644
--- a/src/main/res/values-de/strings.xml
+++ b/src/main/res/values-de/strings.xml
@@ -1537,4 +1537,20 @@
<item quantity="one">1 Sticker importiert</item>
<item quantity="other">%d Sticker importiert</item>
</plurals>
+
+ <!-- Translation feature banner + settings -->
+ <string name="pref_category_translation">Übersetzung</string>
+ <string name="pref_translation_backend_priority_title">Backend-Priorität</string>
+ <string name="pref_translation_backend_priority_summary">Reihenfolge, in der Übersetzungs-Backends versucht werden</string>
+ <string name="translation_backend_private">Privat (Qwen3.6)</string>
+ <string name="translation_backend_offline_device">Auf dem Gerät (offline-translator App)</string>
+ <string name="translation_backend_google">Google Übersetzer (öffentlich)</string>
+ <string name="translation_feature_banner_text">Neue Übersetzungsfunktion hinzugefügt. Tippen Sie hier, um mehr zu erfahren und sie einzurichten.</string>
+ <string name="dismiss">Schließen</string>
+ <string name="pref_offline_translator_info_title">Übersetzung auf dem Gerät einrichten</string>
+ <string name="pref_offline_translator_info_summary">Installiere die offline-translator-App von F-Droid oder GitHub, um Nachrichten zu übersetzen, ohne sie zu versenden. Tippen, um die neueste APK direkt herunterzuladen.</string>
+ <string name="offline_translator_fetching">Neueste Version wird abgerufen…</string>
+ <string name="offline_translator_apk_not_found">Keine APK in der neuesten Version gefunden – öffne stattdessen die Release-Seite</string>
+ <string name="pref_offline_translator_installed_title">Übersetzer auf dem Gerät installiert</string>
+ <string name="pref_offline_translator_installed_summary">Die offline-translator-App ist installiert – Nachrichten können vollständig auf diesem Gerät übersetzt werden.</string>
</resources>
diff --git a/src/main/res/values-es/strings.xml b/src/main/res/values-es/strings.xml
index 3964e0a..4383107 100644
--- a/src/main/res/values-es/strings.xml
+++ b/src/main/res/values-es/strings.xml
@@ -1462,4 +1462,20 @@
<string name="ephemeral_upload_always_allow">Enviar siempre sin preguntar</string>
<string name="story_caption_hint">Añade un pie de foto…</string>
<string name="story_post_button">Publicar</string>
+
+ <!-- Translation feature banner + settings -->
+ <string name="pref_category_translation">Traducción</string>
+ <string name="pref_translation_backend_priority_title">Prioridad de los servicios de traducción</string>
+ <string name="pref_translation_backend_priority_summary">Orden en el que se prueban los servicios de traducción</string>
+ <string name="translation_backend_private">Privado (Qwen3.6)</string>
+ <string name="translation_backend_offline_device">En el dispositivo (app offline-translator)</string>
+ <string name="translation_backend_google">Google Translate (público)</string>
+ <string name="translation_feature_banner_text">Nueva función de traducción añadida. Toca aquí para saber más y cómo configurarla.</string>
+ <string name="dismiss">Descartar</string>
+ <string name="pref_offline_translator_info_title">Obtener traducción en el dispositivo</string>
+ <string name="pref_offline_translator_info_summary">Instala la app offline-translator desde F-Droid o GitHub para traducir mensajes sin enviarlos a ningún sitio. Toca para descargar directamente la última versión APK.</string>
+ <string name="offline_translator_fetching">Obteniendo la última versión…</string>
+ <string name="offline_translator_apk_not_found">No se encontró ningún APK en la última versión; se abrirá la página de versiones en su lugar</string>
+ <string name="pref_offline_translator_installed_title">Traductor en el dispositivo instalado</string>
+ <string name="pref_offline_translator_installed_summary">La app offline-translator está instalada; los mensajes pueden traducirse completamente en este dispositivo.</string>
</resources>
diff --git a/src/main/res/values-fr/strings.xml b/src/main/res/values-fr/strings.xml
index 09156b8..b933433 100644
--- a/src/main/res/values-fr/strings.xml
+++ b/src/main/res/values-fr/strings.xml
@@ -1454,4 +1454,20 @@
<string name="ephemeral_upload_always_allow">Toujours envoyer sans demander</string>
<string name="story_caption_hint">Ajouter une légende…</string>
<string name="story_post_button">Publier</string>
+
+ <!-- Translation feature banner + settings -->
+ <string name="pref_category_translation">Traduction</string>
+ <string name="pref_translation_backend_priority_title">Priorité des services de traduction</string>
+ <string name="pref_translation_backend_priority_summary">Ordre dans lequel les services de traduction sont essayés</string>
+ <string name="translation_backend_private">Privé (Qwen3.6)</string>
+ <string name="translation_backend_offline_device">Sur l\'appareil (application offline-translator)</string>
+ <string name="translation_backend_google">Google Traduction (public)</string>
+ <string name="translation_feature_banner_text">Nouvelle fonctionnalité de traduction ajoutée. Appuyez ici pour en savoir plus et savoir comment la configurer.</string>
+ <string name="dismiss">Ignorer</string>
+ <string name="pref_offline_translator_info_title">Obtenir la traduction sur l\'appareil</string>
+ <string name="pref_offline_translator_info_summary">Installez l\'application offline-translator depuis F-Droid ou GitHub pour traduire les messages sans les envoyer où que ce soit. Appuyez pour télécharger directement la dernière version APK.</string>
+ <string name="offline_translator_fetching">Récupération de la dernière version…</string>
+ <string name="offline_translator_apk_not_found">Impossible de trouver un APK dans la dernière version — ouverture de la page des versions à la place</string>
+ <string name="pref_offline_translator_installed_title">Traducteur sur l\'appareil installé</string>
+ <string name="pref_offline_translator_installed_summary">L\'application offline-translator est installée — les messages peuvent être traduits entièrement sur cet appareil.</string>
</resources>
diff --git a/src/main/res/values-it/strings.xml b/src/main/res/values-it/strings.xml
index bd08b0a..d27c9d3 100644
--- a/src/main/res/values-it/strings.xml
+++ b/src/main/res/values-it/strings.xml
@@ -1464,4 +1464,20 @@
<string name="ephemeral_upload_always_allow">Invia sempre senza chiedere</string>
<string name="story_caption_hint">Aggiungi una didascalia…</string>
<string name="story_post_button">Pubblica</string>
+
+ <!-- Translation feature banner + settings -->
+ <string name="pref_category_translation">Traduzione</string>
+ <string name="pref_translation_backend_priority_title">Priorità dei servizi di traduzione</string>
+ <string name="pref_translation_backend_priority_summary">Ordine in cui vengono provati i servizi di traduzione</string>
+ <string name="translation_backend_private">Privato (Qwen3.6)</string>
+ <string name="translation_backend_offline_device">Sul dispositivo (app offline-translator)</string>
+ <string name="translation_backend_google">Google Traduttore (pubblico)</string>
+ <string name="translation_feature_banner_text">Nuova funzione di traduzione aggiunta. Tocca qui per saperne di più e scoprire come configurarla.</string>
+ <string name="dismiss">Ignora</string>
+ <string name="pref_offline_translator_info_title">Ottieni la traduzione sul dispositivo</string>
+ <string name="pref_offline_translator_info_summary">Installa l\'app offline-translator da F-Droid o GitHub per tradurre i messaggi senza inviarli da nessuna parte. Tocca per scaricare direttamente l\'ultima versione APK.</string>
+ <string name="offline_translator_fetching">Recupero dell\'ultima versione…</string>
+ <string name="offline_translator_apk_not_found">Nessun APK trovato nell\'ultima versione: verrà aperta invece la pagina delle release</string>
+ <string name="pref_offline_translator_installed_title">Traduttore sul dispositivo installato</string>
+ <string name="pref_offline_translator_installed_summary">L\'app offline-translator è installata: i messaggi possono essere tradotti interamente su questo dispositivo.</string>
</resources>
diff --git a/src/main/res/values-ja/strings.xml b/src/main/res/values-ja/strings.xml
index 6bd8711..a5448f6 100644
--- a/src/main/res/values-ja/strings.xml
+++ b/src/main/res/values-ja/strings.xml
@@ -1419,4 +1419,20 @@
<string name="ephemeral_upload_always_allow">常に確認せず送信</string>
<string name="story_caption_hint">キャプションを追加…</string>
<string name="story_post_button">投稿</string>
+
+ <!-- Translation feature banner + settings -->
+ <string name="pref_category_translation">翻訳</string>
+ <string name="pref_translation_backend_priority_title">翻訳バックエンドの優先順位</string>
+ <string name="pref_translation_backend_priority_summary">翻訳バックエンドが試される順序</string>
+ <string name="translation_backend_private">プライベート(Qwen3.6)</string>
+ <string name="translation_backend_offline_device">デバイス上(offline-translator アプリ)</string>
+ <string name="translation_backend_google">Google 翻訳(パブリック)</string>
+ <string name="translation_feature_banner_text">新しい翻訳機能が追加されました。詳細と設定方法についてはここをタップしてください。</string>
+ <string name="dismiss">閉じる</string>
+ <string name="pref_offline_translator_info_title">デバイス上翻訳を利用する</string>
+ <string name="pref_offline_translator_info_summary">offline-translator アプリを F-Droid または GitHub からインストールすると、メッセージをどこにも送信せずに翻訳できます。タップすると最新リリースの APK を直接ダウンロードします。</string>
+ <string name="offline_translator_fetching">最新リリースを取得しています…</string>
+ <string name="offline_translator_apk_not_found">最新リリースに APK が見つかりませんでした。代わりにリリースページを開きます</string>
+ <string name="pref_offline_translator_installed_title">デバイス上翻訳がインストール済みです</string>
+ <string name="pref_offline_translator_installed_summary">offline-translator アプリがインストールされています。メッセージはこの端末上で完全に翻訳できます。</string>
</resources>
diff --git a/src/main/res/values-pt-rBR/strings.xml b/src/main/res/values-pt-rBR/strings.xml
index 430cc7d..405af6c 100644
--- a/src/main/res/values-pt-rBR/strings.xml
+++ b/src/main/res/values-pt-rBR/strings.xml
@@ -1454,4 +1454,20 @@
<string name="ephemeral_upload_always_allow">Sempre enviar sem perguntar</string>
<string name="story_caption_hint">Adicionar uma legenda…</string>
<string name="story_post_button">Publicar</string>
+
+ <!-- Translation feature banner + settings -->
+ <string name="pref_category_translation">Tradução</string>
+ <string name="pref_translation_backend_priority_title">Prioridade dos serviços de tradução</string>
+ <string name="pref_translation_backend_priority_summary">Ordem em que os serviços de tradução são tentados</string>
+ <string name="translation_backend_private">Privado (Qwen3.6)</string>
+ <string name="translation_backend_offline_device">No dispositivo (app offline-translator)</string>
+ <string name="translation_backend_google">Google Tradutor (público)</string>
+ <string name="translation_feature_banner_text">Novo recurso de tradução adicionado. Toque aqui para saber mais e como configurá-lo.</string>
+ <string name="dismiss">Dispensar</string>
+ <string name="pref_offline_translator_info_title">Obter tradução no dispositivo</string>
+ <string name="pref_offline_translator_info_summary">Instale o app offline-translator pelo F-Droid ou GitHub para traduzir mensagens sem enviá-las a lugar nenhum. Toque para baixar o APK da última versão diretamente.</string>
+ <string name="offline_translator_fetching">Buscando a última versão…</string>
+ <string name="offline_translator_apk_not_found">Nenhum APK encontrado na última versão — abrindo a página de lançamentos em vez disso</string>
+ <string name="pref_offline_translator_installed_title">Tradutor no dispositivo instalado</string>
+ <string name="pref_offline_translator_installed_summary">O app offline-translator está instalado — as mensagens podem ser traduzidas totalmente neste dispositivo.</string>
</resources>
diff --git a/src/main/res/values-ru/strings.xml b/src/main/res/values-ru/strings.xml
index 82cd70f..94eceb5 100644
--- a/src/main/res/values-ru/strings.xml
+++ b/src/main/res/values-ru/strings.xml
@@ -1500,4 +1500,20 @@
<string name="ephemeral_upload_always_allow">Всегда отправлять без вопроса</string>
<string name="story_caption_hint">Добавить подпись…</string>
<string name="story_post_button">Опубликовать</string>
+
+ <!-- Translation feature banner + settings -->
+ <string name="pref_category_translation">Перевод</string>
+ <string name="pref_translation_backend_priority_title">Приоритет служб перевода</string>
+ <string name="pref_translation_backend_priority_summary">Порядок, в котором пробуются службы перевода</string>
+ <string name="translation_backend_private">Приватный (Qwen3.6)</string>
+ <string name="translation_backend_offline_device">На устройстве (приложение offline-translator)</string>
+ <string name="translation_backend_google">Google Переводчик (публичный)</string>
+ <string name="translation_feature_banner_text">Добавлена новая функция перевода. Нажмите здесь, чтобы узнать больше и как её настроить.</string>
+ <string name="dismiss">Скрыть</string>
+ <string name="pref_offline_translator_info_title">Получить перевод на устройстве</string>
+ <string name="pref_offline_translator_info_summary">Установите приложение offline-translator из F-Droid или GitHub, чтобы переводить сообщения, не отправляя их куда-либо. Нажмите, чтобы сразу скачать последний APK.</string>
+ <string name="offline_translator_fetching">Получение последней версии…</string>
+ <string name="offline_translator_apk_not_found">В последнем релизе не найден APK — вместо этого открывается страница релизов</string>
+ <string name="pref_offline_translator_installed_title">Переводчик на устройстве установлен</string>
+ <string name="pref_offline_translator_installed_summary">Приложение offline-translator установлено — сообщения можно переводить полностью на этом устройстве.</string>
</resources>
diff --git a/src/main/res/values-tr-rTR/strings.xml b/src/main/res/values-tr-rTR/strings.xml
index 7b18659..87b5187 100644
--- a/src/main/res/values-tr-rTR/strings.xml
+++ b/src/main/res/values-tr-rTR/strings.xml
@@ -1203,4 +1203,20 @@
<string name="attachment_choice_contact">Kişi</string>
<string name="note_to_self">Kendime not</string>
<string name="invalid_contact">Kişi çözümlenemedi</string>
+
+ <!-- Translation feature banner + settings -->
+ <string name="pref_category_translation">Çeviri</string>
+ <string name="pref_translation_backend_priority_title">Çeviri motoru önceliği</string>
+ <string name="pref_translation_backend_priority_summary">Çeviri motorlarının denenme sırası</string>
+ <string name="translation_backend_private">Özel (Qwen3.6)</string>
+ <string name="translation_backend_offline_device">Cihaz üzerinde (offline-translator uygulaması)</string>
+ <string name="translation_backend_google">Google Çeviri (herkese açık)</string>
+ <string name="translation_feature_banner_text">Yeni çeviri özelliği eklendi. Daha fazla bilgi almak ve nasıl kurulacağını öğrenmek için buraya dokunun.</string>
+ <string name="dismiss">Kapat</string>
+ <string name="pref_offline_translator_info_title">Cihaz üzerinde çeviri edinin</string>
+ <string name="pref_offline_translator_info_summary">Mesajları hiçbir yere göndermeden çevirmek için F-Droid veya GitHub üzerinden offline-translator uygulamasını yükleyin. En son sürümün APK dosyasını doğrudan indirmek için dokunun.</string>
+ <string name="offline_translator_fetching">En son sürüm alınıyor…</string>
+ <string name="offline_translator_apk_not_found">En son sürümde bir APK bulunamadı — bunun yerine sürümler sayfası açılıyor</string>
+ <string name="pref_offline_translator_installed_title">Cihaz üzerinde çevirmen yüklü</string>
+ <string name="pref_offline_translator_installed_summary">offline-translator uygulaması yüklü — mesajlar tamamen bu cihazda çevrilebilir.</string>
</resources>
diff --git a/src/main/res/values-tr/strings.xml b/src/main/res/values-tr/strings.xml
index d912495..e754bd6 100644
--- a/src/main/res/values-tr/strings.xml
+++ b/src/main/res/values-tr/strings.xml
@@ -188,4 +188,20 @@
<item quantity="one">%d okunmamış sohbet</item>
<item quantity="other">%d okunmamış sohbet</item>
</plurals>
+
+ <!-- Translation feature banner + settings -->
+ <string name="pref_category_translation">Çeviri</string>
+ <string name="pref_translation_backend_priority_title">Çeviri motoru önceliği</string>
+ <string name="pref_translation_backend_priority_summary">Çeviri motorlarının denenme sırası</string>
+ <string name="translation_backend_private">Özel (Qwen3.6)</string>
+ <string name="translation_backend_offline_device">Cihaz üzerinde (offline-translator uygulaması)</string>
+ <string name="translation_backend_google">Google Çeviri (herkese açık)</string>
+ <string name="translation_feature_banner_text">Yeni çeviri özelliği eklendi. Daha fazla bilgi almak ve nasıl kurulacağını öğrenmek için buraya dokunun.</string>
+ <string name="dismiss">Kapat</string>
+ <string name="pref_offline_translator_info_title">Cihaz üzerinde çeviri edinin</string>
+ <string name="pref_offline_translator_info_summary">Mesajları hiçbir yere göndermeden çevirmek için F-Droid veya GitHub üzerinden offline-translator uygulamasını yükleyin. En son sürümün APK dosyasını doğrudan indirmek için dokunun.</string>
+ <string name="offline_translator_fetching">En son sürüm alınıyor…</string>
+ <string name="offline_translator_apk_not_found">En son sürümde bir APK bulunamadı — bunun yerine sürümler sayfası açılıyor</string>
+ <string name="pref_offline_translator_installed_title">Cihaz üzerinde çevirmen yüklü</string>
+ <string name="pref_offline_translator_installed_summary">offline-translator uygulaması yüklü — mesajlar tamamen bu cihazda çevrilebilir.</string>
</resources>
diff --git a/src/main/res/values-zh-rCN/strings.xml b/src/main/res/values-zh-rCN/strings.xml
index c8b0859..acb4fb4 100644
--- a/src/main/res/values-zh-rCN/strings.xml
+++ b/src/main/res/values-zh-rCN/strings.xml
@@ -1418,4 +1418,20 @@
<string name="ephemeral_upload_always_allow">始终发送且不再询问</string>
<string name="story_caption_hint">添加说明文字…</string>
<string name="story_post_button">发布</string>
+
+ <!-- Translation feature banner + settings -->
+ <string name="pref_category_translation">翻译</string>
+ <string name="pref_translation_backend_priority_title">翻译后端优先级</string>
+ <string name="pref_translation_backend_priority_summary">尝试翻译后端的顺序</string>
+ <string name="translation_backend_private">私密(Qwen3.6)</string>
+ <string name="translation_backend_offline_device">设备端(offline-translator 应用)</string>
+ <string name="translation_backend_google">谷歌翻译(公共)</string>
+ <string name="translation_feature_banner_text">已添加新的翻译功能。点击此处了解详情及设置方法。</string>
+ <string name="dismiss">关闭</string>
+ <string name="pref_offline_translator_info_title">获取设备端翻译</string>
+ <string name="pref_offline_translator_info_summary">从 F-Droid 或 GitHub 安装 offline-translator 应用,无需发送即可翻译消息。点击可直接下载最新版本的 APK。</string>
+ <string name="offline_translator_fetching">正在获取最新版本…</string>
+ <string name="offline_translator_apk_not_found">在最新版本中未找到 APK — 将改为打开发布页面</string>
+ <string name="pref_offline_translator_installed_title">设备端翻译器已安装</string>
+ <string name="pref_offline_translator_installed_summary">offline-translator 应用已安装 — 消息可以完全在此设备上翻译。</string>
</resources>
diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml
index 18479b6..93809ff 100644
--- a/src/main/res/values/strings.xml
+++ b/src/main/res/values/strings.xml
@@ -406,6 +406,9 @@
<string name="message_options">Message options</string>
<string name="quote">Quote</string>
<string name="paste_as_quote">Paste as quote</string>
+ <string name="jump_to_reply">Jump to reply</string>
+ <string name="no_reply_found">No reply to this message found</string>
+ <string name="jump_back">Jump back</string>
<string name="copy_original_url">Copy original URL</string>
<string name="share_upload_link">Share upload link</string>
<string name="send_again">Send again</string>
@@ -1563,9 +1566,25 @@
<string name="translate_message">Translate</string>
<string name="translation_title">Translation</string>
<string name="translation_failed">Translation failed</string>
+ <string name="translation_already_system_language">This message is already in your language — pick a target</string>
<string name="translation_in_progress">Translating…</string>
<string name="translation_disclaimer_private">via private Qwen3.6 translation</string>
<string name="translation_disclaimer_google">via public Google Translate</string>
+ <string name="translation_disclaimer_offline_device">via on-device translation</string>
+ <string name="pref_category_translation">Translation</string>
+ <string name="pref_translation_backend_priority_title">Backend priority</string>
+ <string name="pref_translation_backend_priority_summary">Order in which translation backends are tried</string>
+ <string name="pref_offline_translator_info_title">Get on-device translation</string>
+ <string name="pref_offline_translator_info_summary">Install the offline-translator app from F-Droid or GitHub to translate messages without sending them anywhere. Tap to download the latest release APK directly.</string>
+ <string name="pref_offline_translator_installed_title">On-device translator installed</string>
+ <string name="pref_offline_translator_installed_summary">The offline-translator app is installed — messages can be translated entirely on this device.</string>
+ <string name="offline_translator_fetching">Fetching latest release…</string>
+ <string name="offline_translator_apk_not_found">Couldn\'t find an APK in the latest release — opening the releases page instead</string>
+ <string name="translation_feature_banner_text">New On-Device offline translation feature added. Tap here to find out more and how to set it up.</string>
+ <string name="dismiss">Dismiss</string>
+ <string name="translation_backend_private">Private (Qwen3.6)</string>
+ <string name="translation_backend_offline_device">On-device (offline-translator app)</string>
+ <string name="translation_backend_google">Google Translate (public)</string>
<string name="translation_fallback_title">Use Google Translate?</string>
<string name="translation_fallback_explained">The private translation service could not be reached. Falling back to Google Translate will send this message text to Google.</string>
<string name="translation_fallback_allow">Allow this time</string>
diff --git a/src/main/res/xml/preferences_privacy.xml b/src/main/res/xml/preferences_privacy.xml
index 385ac87..75f3305 100644
--- a/src/main/res/xml/preferences_privacy.xml
+++ b/src/main/res/xml/preferences_privacy.xml
@@ -82,4 +82,17 @@
</PreferenceCategory>
+ <PreferenceCategory android:title="@string/pref_category_translation">
+ <Preference
+ android:icon="@drawable/ic_translate_24dp"
+ android:key="translation_backend_priority"
+ android:summary="@string/pref_translation_backend_priority_summary"
+ android:title="@string/pref_translation_backend_priority_title" />
+ <Preference
+ android:icon="@drawable/ic_download_24dp"
+ android:key="translation_offline_translator_info"
+ android:summary="@string/pref_offline_translator_info_summary"
+ android:title="@string/pref_offline_translator_info_title" />
+ </PreferenceCategory>
+
</PreferenceScreen>
\ No newline at end of file
Built with passion for open-source software, XMPP, privacy, and security —
the belief that people deserve communication tools that serve them, not surveil them.
Dedicated to every brave man and woman who stands up against tyranny and oppression across the world.
And in memory of those who were silenced before they could speak freely.