commit 61f19a0a6b04d3bc4f9638e9ee10303ce96ea7c3
Author: JabJab <noreply@xmpp.tel>
Date: Thu Jul 16 23:09:23 2026 +0300
stickers bug fix, improved translation, added private translator with fallback to google translate
---
src/main/AndroidManifest.xml | 28 +++
.../processor/MessageAcknowledgedProcessor.java | 15 ++
src/main/java/tel/xmpp/jabjab/Config.java | 6 +
.../persistance/TranslationCacheDatabase.java | 78 +++++++-
.../jabjab/services/XmppConnectionService.java | 129 ++++++++-----
src/main/java/tel/xmpp/jabjab/ui/BlogActivity.java | 5 +-
.../java/tel/xmpp/jabjab/ui/BlogPostActivity.java | 54 +++++-
.../tel/xmpp/jabjab/ui/ConversationFragment.java | 179 ++++++++++++++++--
.../tel/xmpp/jabjab/ui/MultiShareActivity.java | 26 ++-
.../tel/xmpp/jabjab/ui/StickerPickerActivity.java | 29 +--
.../java/tel/xmpp/jabjab/ui/StoriesActivity.java | 21 ++-
.../tel/xmpp/jabjab/ui/StoryComposerActivity.java | 15 +-
.../jabjab/ui/TelegramStickerImportActivity.java | 108 +++++++++++
.../tel/xmpp/jabjab/ui/WebStickerPackActivity.java | 99 +++++++++-
.../tel/xmpp/jabjab/ui/service/AudioPlayer.java | 5 +-
.../ui/util/OutgoingTranslateLanguagePicker.java | 89 +++++++++
.../jabjab/ui/util/TelegramStickerImporter.java | 4 +-
.../jabjab/ui/util/TranslationConsentDialog.java | 45 +++++
.../xmpp/jabjab/ui/util/TranslationDisclaimer.java | 39 ++++
.../xmpp/jabjab/ui/util/TranslationService.java | 206 ++++++++++++++++++---
.../xmpp/jabjab/ui/util/TranslationSettings.java | 38 ++++
.../xmpp/jabjab/ui/widget/VideoTrimmerView.java | 7 +-
.../java/tel/xmpp/jabjab/xmpp/XmppConnection.java | 10 +-
src/main/res/drawable/ic_translate_24dp.xml | 10 +
src/main/res/layout/activity_story_composer.xml | 1 +
src/main/res/layout/activity_story_view.xml | 1 +
.../layout/activity_telegram_sticker_import.xml | 42 +++++
.../res/layout/dialog_translation_fallback.xml | 25 +++
src/main/res/layout/fragment_conversation.xml | 41 +++-
src/main/res/values/strings.xml | 12 +-
30 files changed, 1218 insertions(+), 149 deletions(-)
diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml
index 0ee5257..7a0c903 100644
--- a/src/main/AndroidManifest.xml
+++ b/src/main/AndroidManifest.xml
@@ -482,6 +482,34 @@
<data android:scheme="https"
android:host="stickers.xmpp.tel" />
</intent-filter>
+ <intent-filter android:label="Import Sticker Pack">
+ <action android:name="android.intent.action.VIEW" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <category android:name="android.intent.category.BROWSABLE" />
+ <data android:scheme="sgnl" />
+ <data android:pathPrefix="/addstickers/" />
+ </intent-filter>
+ <intent-filter android:label="Import Sticker Pack">
+ <action android:name="android.intent.action.VIEW" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <category android:name="android.intent.category.BROWSABLE" />
+ <data android:scheme="https"
+ android:host="signal.art"
+ android:pathPrefix="/addstickers/" />
+ </intent-filter>
+ </activity>
+
+ <activity
+ android:name=".ui.TelegramStickerImportActivity"
+ android:exported="true">
+ <intent-filter android:label="Import Sticker Pack">
+ <action android:name="android.intent.action.VIEW" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <category android:name="android.intent.category.BROWSABLE" />
+ <data android:scheme="https"
+ android:host="t.me"
+ android:pathPrefix="/addstickers/" />
+ </intent-filter>
</activity>
<provider
diff --git a/src/main/java/im/conversations/android/xmpp/processor/MessageAcknowledgedProcessor.java b/src/main/java/im/conversations/android/xmpp/processor/MessageAcknowledgedProcessor.java
index 1a3c81e..c0c19c9 100644
--- a/src/main/java/im/conversations/android/xmpp/processor/MessageAcknowledgedProcessor.java
+++ b/src/main/java/im/conversations/android/xmpp/processor/MessageAcknowledgedProcessor.java
@@ -1,5 +1,7 @@
package im.conversations.android.xmpp.processor;
+import android.util.Log;
+import tel.xmpp.jabjab.Config;
import tel.xmpp.jabjab.entities.Conversation;
import tel.xmpp.jabjab.entities.Message;
import tel.xmpp.jabjab.services.XmppConnectionService;
@@ -34,18 +36,31 @@ public class MessageAcknowledgedProcessor extends XmppConnection.Delegate
final Jid bare = to.asBareJid();
+ boolean matchedConversation = false;
for (final Conversation conversation : service.getConversations()) {
if (conversation.getAccount() == getAccount()
&& conversation.getAddress().asBareJid().equals(bare)) {
+ matchedConversation = true;
final Message message = conversation.findUnsentMessageWithUuid(id);
if (message != null) {
+ Log.d(Config.LOGTAG, "STICKER MessageAcknowledgedProcessor: matched uuid="
+ + id + " in conversation=" + bare + " — marking STATUS_SEND");
message.setStatus(Message.STATUS_SEND);
message.setErrorMessage(null);
getDatabase().updateMessage(message, false);
return true;
+ } else {
+ Log.w(Config.LOGTAG, "STICKER MessageAcknowledgedProcessor: conversation="
+ + bare + " found but findUnsentMessageWithUuid(" + id
+ + ") returned null — ack will NOT trigger a UI refresh");
}
}
}
+ if (!matchedConversation) {
+ Log.w(Config.LOGTAG, "STICKER MessageAcknowledgedProcessor: no open conversation "
+ + "matched to=" + to + " bare=" + bare + " for acked id=" + id
+ + " — ack will NOT trigger a UI refresh");
+ }
return false;
}
}
diff --git a/src/main/java/tel/xmpp/jabjab/Config.java b/src/main/java/tel/xmpp/jabjab/Config.java
index 5d42184..3250075 100644
--- a/src/main/java/tel/xmpp/jabjab/Config.java
+++ b/src/main/java/tel/xmpp/jabjab/Config.java
@@ -111,6 +111,12 @@ public final class Config {
// The XMPP domain whose accounts use our direct upload bypass for stories/stickers.
public static final String UPLOAD_BYPASS_DOMAIN = "xmpp.tel";
+ // Private AI translation proxy (see TranslationService). Fixed infrastructure —
+ // intentionally not user-configurable, this is a privacy feature (opt-out of
+ // Google Translate by default), not a power-user "bring your own server" setting.
+ // The server proxies to an internal Ollama instance not reachable from the app directly.
+ public static final String TRANSLATION_BASE_URL = "https://translate.xmpp.tel";
+
public static final boolean X509_VERIFICATION =
false; // use x509 certificates to verify OMEMO keys
public static final boolean REQUIRE_RTP_VERIFICATION =
diff --git a/src/main/java/tel/xmpp/jabjab/persistance/TranslationCacheDatabase.java b/src/main/java/tel/xmpp/jabjab/persistance/TranslationCacheDatabase.java
index c8321bd..71e7c0c 100644
--- a/src/main/java/tel/xmpp/jabjab/persistance/TranslationCacheDatabase.java
+++ b/src/main/java/tel/xmpp/jabjab/persistance/TranslationCacheDatabase.java
@@ -18,17 +18,34 @@ import java.nio.charset.StandardCharsets;
public class TranslationCacheDatabase extends SQLiteOpenHelper {
private static final String DB_NAME = "translations.db";
- private static final int DB_VERSION = 1;
+ private static final int DB_VERSION = 2;
private static final String TABLE = "translations";
private static final String COLUMN_HASH = "text_hash";
private static final String COLUMN_TARGET_LANG = "target_lang";
+ private static final String COLUMN_BACKEND = "backend";
private static final String COLUMN_SOURCE_LANG = "source_lang";
private static final String COLUMN_TRANSLATED = "translated_text";
private static final String COLUMN_CREATED_AT = "created_at";
private static TranslationCacheDatabase instance;
+ /** Which service produced a cached translation — a private-server row and a
+ * Google-fallback row for the identical text/lang are different artifacts
+ * (different quality, different privacy provenance) and must never collide. */
+ public enum Backend {
+ PRIVATE,
+ GOOGLE;
+
+ String dbValue() {
+ return name().toLowerCase(java.util.Locale.ROOT);
+ }
+
+ static Backend fromDbValue(final String value) {
+ return "private".equals(value) ? PRIVATE : GOOGLE;
+ }
+ }
+
private TranslationCacheDatabase(final Context context) {
super(context.getApplicationContext(), DB_NAME, null, DB_VERSION);
}
@@ -50,6 +67,8 @@ public class TranslationCacheDatabase extends SQLiteOpenHelper {
+ " TEXT NOT NULL, "
+ COLUMN_TARGET_LANG
+ " TEXT NOT NULL, "
+ + COLUMN_BACKEND
+ + " TEXT NOT NULL, "
+ COLUMN_SOURCE_LANG
+ " TEXT, "
+ COLUMN_TRANSLATED
@@ -60,12 +79,50 @@ public class TranslationCacheDatabase extends SQLiteOpenHelper {
+ COLUMN_HASH
+ ", "
+ COLUMN_TARGET_LANG
+ + ", "
+ + COLUMN_BACKEND
+ "))");
}
@Override
public void onUpgrade(final SQLiteDatabase db, final int oldVersion, final int newVersion) {
- // no prior versions yet
+ if (oldVersion < 2) {
+ // v1 had no `backend` column and a 2-column PK. SQLite can't ALTER a PK
+ // in place, so rebuild: rename old table, create the v2 schema under the
+ // real name, copy rows in (every v1 row came from Google — that was the
+ // only backend that existed), then drop the renamed table.
+ db.execSQL("ALTER TABLE " + TABLE + " RENAME TO " + TABLE + "_old");
+ onCreate(db);
+ db.execSQL(
+ "INSERT INTO "
+ + TABLE
+ + " ("
+ + COLUMN_HASH
+ + ", "
+ + COLUMN_TARGET_LANG
+ + ", "
+ + COLUMN_BACKEND
+ + ", "
+ + COLUMN_SOURCE_LANG
+ + ", "
+ + COLUMN_TRANSLATED
+ + ", "
+ + COLUMN_CREATED_AT
+ + ") SELECT "
+ + COLUMN_HASH
+ + ", "
+ + COLUMN_TARGET_LANG
+ + ", 'google', "
+ + COLUMN_SOURCE_LANG
+ + ", "
+ + COLUMN_TRANSLATED
+ + ", "
+ + COLUMN_CREATED_AT
+ + " FROM "
+ + TABLE
+ + "_old");
+ db.execSQL("DROP TABLE " + TABLE + "_old");
+ }
}
public static String hash(final String text) {
@@ -85,19 +142,24 @@ public class TranslationCacheDatabase extends SQLiteOpenHelper {
}
@Nullable
- public Result get(final String textHash, final String targetLang) {
+ public Result get(final String textHash, final String targetLang, final Backend backend) {
try (final SQLiteDatabase db = getReadableDatabase();
final Cursor cursor =
db.query(
TABLE,
new String[]{COLUMN_TRANSLATED, COLUMN_SOURCE_LANG},
- COLUMN_HASH + "=? AND " + COLUMN_TARGET_LANG + "=?",
- new String[]{textHash, targetLang},
+ COLUMN_HASH
+ + "=? AND "
+ + COLUMN_TARGET_LANG
+ + "=? AND "
+ + COLUMN_BACKEND
+ + "=?",
+ new String[]{textHash, targetLang, backend.dbValue()},
null,
null,
null)) {
if (cursor.moveToFirst()) {
- return new Result(cursor.getString(0), cursor.getString(1));
+ return new Result(cursor.getString(0), cursor.getString(1), backend);
}
}
return null;
@@ -106,11 +168,13 @@ public class TranslationCacheDatabase extends SQLiteOpenHelper {
public void put(
final String textHash,
final String targetLang,
+ final Backend backend,
@Nullable final String sourceLang,
final String translatedText) {
final ContentValues values = new ContentValues();
values.put(COLUMN_HASH, textHash);
values.put(COLUMN_TARGET_LANG, targetLang);
+ values.put(COLUMN_BACKEND, backend.dbValue());
values.put(COLUMN_SOURCE_LANG, sourceLang);
values.put(COLUMN_TRANSLATED, translatedText);
values.put(COLUMN_CREATED_AT, System.currentTimeMillis());
@@ -119,5 +183,5 @@ public class TranslationCacheDatabase extends SQLiteOpenHelper {
}
}
- public record Result(String translatedText, @Nullable String sourceLang) {}
+ public record Result(String translatedText, @Nullable String sourceLang, Backend backend) {}
}
diff --git a/src/main/java/tel/xmpp/jabjab/services/XmppConnectionService.java b/src/main/java/tel/xmpp/jabjab/services/XmppConnectionService.java
index 5b26519..65bf51c 100644
--- a/src/main/java/tel/xmpp/jabjab/services/XmppConnectionService.java
+++ b/src/main/java/tel/xmpp/jabjab/services/XmppConnectionService.java
@@ -458,7 +458,14 @@ public class XmppConnectionService extends Service {
message = new Message(conversation, "", conversation.getNextEncryption());
}
if (!Message.configurePrivateFileMessage(message)) {
- message.setCounterpart(conversation.getNextCounterpart());
+ // conversation.getNextCounterpart() is only populated by the multi-device
+ // PresenceSelector dialog, which stickers never go through (they always use
+ // HTTP upload). Relying on it left the counterpart null/stale whenever that
+ // dialog hadn't just run, which in turn left the outgoing stanza's "to"
+ // attribute null — the ack could never be matched back to this message, so
+ // it would sit forever without being marked as sent. Use the conversation's
+ // own address instead, same as sendFileMessageExistingEndpoint does.
+ message.setCounterpart(conversation.getAddress().asBareJid());
message.setType(Message.TYPE_FILE);
}
message.setStickerPackNode(stickerPackNode);
@@ -470,58 +477,61 @@ public class XmppConnectionService extends Service {
+ " packNode=" + stickerPackNode
+ " filePath=" + stickerFilePath);
- // Check for a cached upload URL to avoid re-uploading the same sticker image
- if (stickerFilePath != null) {
+ // packFromDir()/getStickerUploadUrl() read meta.json synchronously — for large
+ // packs (dozens of stickers, one uploadUrls entry per account) this is enough
+ // disk I/O to stall the caller, so do the lookup off-thread before branching.
+ final var cachedUrlFuture = Futures.submit(() -> {
+ if (stickerFilePath == null) return null;
final java.io.File stickerFile = new java.io.File(stickerFilePath);
Log.d(Config.LOGTAG, "STICKER stickerFile exists=" + stickerFile.exists()
+ " path=" + stickerFile.getAbsolutePath());
- if (stickerFile.exists()) {
- final tel.xmpp.jabjab.ui.util.StickerStore.Pack pack =
- tel.xmpp.jabjab.ui.util.StickerStore.packFromDir(stickerFile.getParentFile());
- final String cachedUrl =
- tel.xmpp.jabjab.ui.util.StickerStore.getStickerUploadUrl(pack, stickerFile,
- conversation.getAccount().getJid().asBareJid().toString());
- Log.d(Config.LOGTAG, "STICKER cachedUrl=" + cachedUrl);
- if (cachedUrl != null) {
- // Copy the sticker into private storage and build the full
- // url|size|width|height body so the sender's message has a
- // local file and renders correctly as a sticker image.
- final String finalCachedUrl = cachedUrl;
- final var copyFuture = Futures.submit(
- () -> {
- try {
- getFileBackend().copyFileToPrivateStorage(
- message, uri, "image/webp");
- Log.d(Config.LOGTAG, "STICKER file copy OK, updating params with cachedUrl");
- } catch (final tel.xmpp.jabjab.persistance.FileBackend.FileCopyException e) {
- Log.w(Config.LOGTAG,
- "STICKER cached sticker: file copy failed, sending bare url: " + e.getMessage());
- }
- getFileBackend().updateFileParams(message, finalCachedUrl);
- Log.d(Config.LOGTAG, "STICKER message body after updateFileParams=" + message.getBody());
- },
- FILE_ATTACHMENT_EXECUTOR);
- return Futures.transformAsync(
- copyFuture,
- v -> {
- Log.d(Config.LOGTAG, "STICKER cached path: calling encryptIfNeededAndSend");
- return encryptIfNeededAndSend(message);
- },
- MoreExecutors.directExecutor());
- }
+ if (!stickerFile.exists()) return null;
+ final tel.xmpp.jabjab.ui.util.StickerStore.Pack pack =
+ tel.xmpp.jabjab.ui.util.StickerStore.packFromDir(stickerFile.getParentFile());
+ return tel.xmpp.jabjab.ui.util.StickerStore.getStickerUploadUrl(pack, stickerFile,
+ conversation.getAccount().getJid().asBareJid().toString());
+ }, FILE_ATTACHMENT_EXECUTOR);
+
+ return Futures.transformAsync(cachedUrlFuture, cachedUrl -> {
+ Log.d(Config.LOGTAG, "STICKER cachedUrl=" + cachedUrl);
+ if (cachedUrl != null) {
+ // Copy the sticker into private storage and build the full
+ // url|size|width|height body so the sender's message has a
+ // local file and renders correctly as a sticker image.
+ final var copyFuture = Futures.submit(
+ () -> {
+ try {
+ getFileBackend().copyFileToPrivateStorage(
+ message, uri, "image/webp");
+ Log.d(Config.LOGTAG, "STICKER file copy OK, updating params with cachedUrl");
+ } catch (final tel.xmpp.jabjab.persistance.FileBackend.FileCopyException e) {
+ Log.w(Config.LOGTAG,
+ "STICKER cached sticker: file copy failed, sending bare url: " + e.getMessage());
+ }
+ getFileBackend().updateFileParams(message, cachedUrl);
+ Log.d(Config.LOGTAG, "STICKER message body after updateFileParams=" + message.getBody());
+ },
+ FILE_ATTACHMENT_EXECUTOR);
+ return Futures.transformAsync(
+ copyFuture,
+ v -> {
+ Log.d(Config.LOGTAG, "STICKER cached path: calling encryptIfNeededAndSend");
+ return encryptIfNeededAndSend(message);
+ },
+ MoreExecutors.directExecutor());
}
- }
- // No cached URL — copy+attach file; HttpUploadConnection will cache the URL after upload
- Log.d(Config.LOGTAG, "STICKER no cached URL, submitting fresh upload");
- final var future = submitAttachToConversation(uri, "image/webp", message);
- return Futures.transformAsync(
- future,
- v -> {
- Log.d(Config.LOGTAG, "STICKER upload path: calling encryptIfNeededAndSend, body=" + message.getBody());
- return encryptIfNeededAndSend(message);
- },
- MoreExecutors.directExecutor());
+ // No cached URL — copy+attach file; HttpUploadConnection will cache the URL after upload
+ Log.d(Config.LOGTAG, "STICKER no cached URL, submitting fresh upload");
+ final var future = submitAttachToConversation(uri, "image/webp", message);
+ return Futures.transformAsync(
+ future,
+ v -> {
+ Log.d(Config.LOGTAG, "STICKER upload path: calling encryptIfNeededAndSend, body=" + message.getBody());
+ return encryptIfNeededAndSend(message);
+ },
+ MoreExecutors.directExecutor());
+ }, MoreExecutors.directExecutor());
}
private ListenableFuture<Void> submitAttachToConversation(
@@ -1956,6 +1966,12 @@ public class XmppConnectionService extends Service {
.getManager(MultiUserChatManager.class)
.isJoinInProgress(conversation);
+ if (message.getStickerFilePath() != null) {
+ Log.d(Config.LOGTAG, "STICKER sendMessage uuid=" + message.getUuid()
+ + " isOnlineAndConnected=" + account.isOnlineAndConnected()
+ + " inProgressJoin=" + inProgressJoin);
+ }
+
if (account.isOnlineAndConnected() && !inProgressJoin) {
switch (message.getEncryption()) {
case Message.ENCRYPTION_NONE:
@@ -2009,6 +2025,11 @@ public class XmppConnectionService extends Service {
break;
case Message.ENCRYPTION_AXOLOTL:
message.setFingerprint(account.getAxolotlService().getOwnFingerprint());
+ if (message.getStickerFilePath() != null) {
+ Log.d(Config.LOGTAG, "STICKER sendMessage(AXOLOTL) uuid=" + message.getUuid()
+ + " needsUploading=" + message.needsUploading()
+ + " fileParamsUrl=" + (message.getFileParams() == null ? null : message.getFileParams().url));
+ }
if (message.needsUploading()) {
if (account.httpUploadAvailable(
fileBackend.getFile(message, false).length())
@@ -2022,13 +2043,25 @@ public class XmppConnectionService extends Service {
XmppAxolotlMessage axolotlMessage =
account.getAxolotlService().fetchAxolotlMessageFromCache(message);
if (axolotlMessage == null) {
+ if (message.getStickerFilePath() != null) {
+ Log.d(Config.LOGTAG, "STICKER sendMessage(AXOLOTL) uuid=" + message.getUuid()
+ + " no cached axolotl message, calling preparePayloadMessage");
+ }
account.getAxolotlService().preparePayloadMessage(message, delay);
} else {
+ if (message.getStickerFilePath() != null) {
+ Log.d(Config.LOGTAG, "STICKER sendMessage(AXOLOTL) uuid=" + message.getUuid()
+ + " got cached axolotl message, generating packet");
+ }
packet = mMessageGenerator.generateAxolotlChat(message, axolotlMessage);
}
}
break;
}
+ if (message.getStickerFilePath() != null) {
+ Log.d(Config.LOGTAG, "STICKER sendMessage uuid=" + message.getUuid()
+ + " packet=" + (packet == null ? "null (no immediate send)" : "built"));
+ }
if (packet != null) {
if (account.getXmppConnection().getFeatures().sm()
|| (conversation.getMode() == Conversation.MODE_MULTI
diff --git a/src/main/java/tel/xmpp/jabjab/ui/BlogActivity.java b/src/main/java/tel/xmpp/jabjab/ui/BlogActivity.java
index 3b72ce3..ea66f09 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/BlogActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/BlogActivity.java
@@ -839,8 +839,9 @@ public class BlogActivity extends XmppActivity
final String menuTitle = titleCs != null ? titleCs.toString() : "";
if (menuTitle.equals(getString(R.string.blog_share))) {
- final String shareUrl = "https://blog.xmpp.tel/"
- + (itemId != null ? itemId.substring(0, Math.min(8, itemId.length())) : "");
+ final String shareUrl = itemId != null
+ ? "https://blog.xmpp.tel/" + itemId.substring(0, 8)
+ : "https://blog.xmpp.tel/";
final Intent shareIntent = new Intent(Intent.ACTION_SEND);
shareIntent.setType("text/plain");
shareIntent.putExtra(Intent.EXTRA_TEXT, shareUrl);
diff --git a/src/main/java/tel/xmpp/jabjab/ui/BlogPostActivity.java b/src/main/java/tel/xmpp/jabjab/ui/BlogPostActivity.java
index e199eb7..5ef681e 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/BlogPostActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/BlogPostActivity.java
@@ -352,8 +352,17 @@ public class BlogPostActivity extends XmppActivity {
.show();
}
+ private String currentAccountJid() {
+ if (xmppConnectionService == null) return "";
+ for (final var acc : xmppConnectionService.getAccounts()) {
+ return acc.getJid().asBareJid().toString();
+ }
+ return "";
+ }
+
private void translatePost(final String text) {
- final android.app.AlertDialog progress = new AlertDialog.Builder(this)
+ final AlertDialog[] progressHolder = new AlertDialog[1];
+ progressHolder[0] = new AlertDialog.Builder(this)
.setTitle(R.string.translation_title)
.setMessage(R.string.translation_in_progress)
.setCancelable(true)
@@ -362,17 +371,26 @@ public class BlogPostActivity extends XmppActivity {
Log.d("BlogPostActivity", "translating post, length=" + text.length() + " targetLang=" + targetLang);
tel.xmpp.jabjab.ui.util.TranslationService.translate(
this,
+ currentAccountJid(),
text,
targetLang,
new tel.xmpp.jabjab.ui.util.TranslationService.Callback() {
@Override
public void onResult(final String translatedText,
- @androidx.annotation.Nullable final String detectedSourceLang) {
- progress.dismiss();
+ @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;
new AlertDialog.Builder(BlogPostActivity.this)
.setTitle(R.string.translation_title)
- .setMessage(translatedText + "\n\n"
- + getString(R.string.translation_disclaimer))
+ .setMessage(
+ tel.xmpp.jabjab.ui.util.TranslationDisclaimer.build(
+ findViewById(android.R.id.content),
+ translatedText,
+ getString(disclaimerRes)))
.setPositiveButton(R.string.close, null)
.setNeutralButton(R.string.copy_to_clipboard, (d2, w) -> {
final ClipboardManager cm =
@@ -385,11 +403,35 @@ public class BlogPostActivity extends XmppActivity {
@Override
public void onError(final Exception e) {
- progress.dismiss();
+ progressHolder[0].dismiss();
+ if (e
+ instanceof
+ tel.xmpp.jabjab.ui.util.TranslationService
+ .UserCancelledFallbackException) {
+ return;
+ }
Log.w("BlogPostActivity", "translation failed", e);
Toast.makeText(BlogPostActivity.this,
R.string.translation_failed, Toast.LENGTH_SHORT).show();
}
+
+ @Override
+ public void onNeedsFallbackConsent(
+ final Runnable proceedWithGoogle, final Runnable cancel) {
+ progressHolder[0].dismiss();
+ tel.xmpp.jabjab.ui.util.TranslationConsentDialog.show(
+ BlogPostActivity.this,
+ getLayoutInflater(),
+ () -> {
+ progressHolder[0] = new AlertDialog.Builder(BlogPostActivity.this)
+ .setTitle(R.string.translation_title)
+ .setMessage(R.string.translation_in_progress)
+ .setCancelable(true)
+ .show();
+ proceedWithGoogle.run();
+ },
+ cancel);
+ }
});
}
diff --git a/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java b/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java
index 7398f52..90ef1cc 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java
@@ -490,8 +490,9 @@ public class ConversationFragment extends XmppFragment
} else {
fireReadEvent();
}
- } else if (scrollState == AbsListView.OnScrollListener.SCROLL_STATE_TOUCH_SCROLL) {
- mProgrammaticScroll = false; // user took over
+ } else if (scrollState == AbsListView.OnScrollListener.SCROLL_STATE_TOUCH_SCROLL
+ || scrollState == AbsListView.OnScrollListener.SCROLL_STATE_FLING) {
+ mProgrammaticScroll = false; // user took over (touch or fling)
pinToBottom = false;
pinToBottomHandler.removeCallbacks(cancelPinToBottom);
view.getViewTreeObserver().removeOnGlobalLayoutListener(pinToBottomListener);
@@ -1499,6 +1500,7 @@ public class ConversationFragment extends XmppFragment
}
binding.attachButton.setToggleCheckedStateOnClick(false);
binding.attachButton.setOnClickListener((v) -> toggleAttachmentChoicesVisibility());
+ binding.translateButton.setOnClickListener((v) -> showOutgoingTranslateLanguagePicker());
binding.attachmentChoicesFlow.setReferencedIds(Ints.toArray(viewIdBuilder.build()));
binding.getRoot().setOnClickListener(null); // TODO why the fuck did we do this?
binding.toolbar.addMenuProvider(
@@ -1594,7 +1596,11 @@ public class ConversationFragment extends XmppFragment
if (Strings.isNullOrEmpty(body)) return;
}
final String textToTranslate = body;
- final var progress =
+ final String authorJid =
+ message.getConversation().getAccount().getJid().asBareJid().toString();
+ final androidx.appcompat.app.AlertDialog[] progressHolder =
+ new androidx.appcompat.app.AlertDialog[1];
+ progressHolder[0] =
new MaterialAlertDialogBuilder(requireActivity())
.setTitle(R.string.translation_title)
.setMessage(R.string.translation_in_progress)
@@ -1603,34 +1609,72 @@ public class ConversationFragment extends XmppFragment
final String targetLang = java.util.Locale.getDefault().getLanguage();
tel.xmpp.jabjab.ui.util.TranslationService.translate(
requireContext(),
+ authorJid,
textToTranslate,
targetLang,
new tel.xmpp.jabjab.ui.util.TranslationService.Callback() {
@Override
public void onResult(
- final String translatedText, @Nullable final String detectedSourceLang) {
+ final String translatedText,
+ @Nullable final String detectedSourceLang,
+ final tel.xmpp.jabjab.persistance.TranslationCacheDatabase.Backend backend) {
if (!isAdded()) return;
- progress.dismiss();
- showTranslationResult(textToTranslate, translatedText);
+ progressHolder[0].dismiss();
+ showTranslationResult(textToTranslate, translatedText, backend);
}
@Override
public void onError(final Exception e) {
if (!isAdded()) return;
- progress.dismiss();
+ progressHolder[0].dismiss();
+ if (e
+ instanceof
+ tel.xmpp.jabjab.ui.util.TranslationService
+ .UserCancelledFallbackException) {
+ return;
+ }
Toast.makeText(
requireContext(),
R.string.translation_failed,
Toast.LENGTH_SHORT)
.show();
}
+
+ @Override
+ public void onNeedsFallbackConsent(
+ final Runnable proceedWithGoogle, final Runnable cancel) {
+ if (!isAdded()) return;
+ progressHolder[0].dismiss();
+ tel.xmpp.jabjab.ui.util.TranslationConsentDialog.show(
+ requireActivity(),
+ requireActivity().getLayoutInflater(),
+ () -> {
+ progressHolder[0] =
+ new MaterialAlertDialogBuilder(requireActivity())
+ .setTitle(R.string.translation_title)
+ .setMessage(R.string.translation_in_progress)
+ .setCancelable(true)
+ .show();
+ proceedWithGoogle.run();
+ },
+ cancel);
+ }
});
}
- private void showTranslationResult(final String original, final String translated) {
+ private void showTranslationResult(
+ final String original,
+ 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;
new MaterialAlertDialogBuilder(requireActivity())
.setTitle(R.string.translation_title)
- .setMessage(translated + "\n\n" + getString(R.string.translation_disclaimer))
+ .setMessage(
+ tel.xmpp.jabjab.ui.util.TranslationDisclaimer.build(
+ binding.getRoot(), translated, getString(disclaimerRes)))
.setPositiveButton(R.string.close, null)
.setNeutralButton(
R.string.copy_to_clipboard,
@@ -1645,6 +1689,84 @@ public class ConversationFragment extends XmppFragment
.show();
}
+ private void showOutgoingTranslateLanguagePicker() {
+ final String draft = CharSequences.nullToEmpty(binding.textInput.getText());
+ if (draft.trim().isEmpty()) {
+ return;
+ }
+ tel.xmpp.jabjab.ui.util.OutgoingTranslateLanguagePicker.show(
+ requireActivity(), (targetLang) -> translateOutgoingDraft(draft, targetLang));
+ }
+
+ private void translateOutgoingDraft(final String draft, final String targetLang) {
+ final String authorJid =
+ conversation.getAccount().getJid().asBareJid().toString();
+ binding.textInput.setEnabled(false);
+ binding.translateButton.setEnabled(false);
+ // Also block Send for the duration: translation can take a few seconds with no
+ // other visible feedback, and without this a user who taps Send while waiting
+ // ends up sending the original text, then the translated text lands and gets
+ // sent right after it — two messages instead of one edited draft.
+ binding.textSendButton.setEnabled(false);
+ Toast.makeText(requireContext(), R.string.translation_in_progress, Toast.LENGTH_SHORT).show();
+ tel.xmpp.jabjab.ui.util.TranslationService.translate(
+ requireContext(),
+ authorJid,
+ draft,
+ targetLang,
+ new tel.xmpp.jabjab.ui.util.TranslationService.Callback() {
+ @Override
+ public void onResult(
+ final String translatedText,
+ @Nullable final String detectedSourceLang,
+ final tel.xmpp.jabjab.persistance.TranslationCacheDatabase.Backend backend) {
+ if (!isAdded()) return;
+ binding.textInput.setEnabled(true);
+ updateTranslateButton();
+ updateEditablity();
+ binding.textInput.setText(translatedText);
+ binding.textInput.setSelection(translatedText.length());
+ // Without this, the translated text only lives in the EditText's
+ // in-memory state: conversation.getNextMessage() still holds the
+ // pre-translation draft, and the next reInit() (e.g. triggered by
+ // an incoming stanza/ack while the user is still composing) restores
+ // the EditText from that stale value, silently reverting the
+ // translation right back to the original text.
+ storeNextMessage(translatedText);
+ }
+
+ @Override
+ public void onError(final Exception e) {
+ if (!isAdded()) return;
+ binding.textInput.setEnabled(true);
+ updateTranslateButton();
+ updateEditablity();
+ if (e
+ instanceof
+ tel.xmpp.jabjab.ui.util.TranslationService
+ .UserCancelledFallbackException) {
+ return;
+ }
+ Toast.makeText(
+ requireContext(),
+ R.string.translation_failed,
+ Toast.LENGTH_SHORT)
+ .show();
+ }
+
+ @Override
+ public void onNeedsFallbackConsent(
+ final Runnable proceedWithGoogle, final Runnable cancel) {
+ if (!isAdded()) return;
+ tel.xmpp.jabjab.ui.util.TranslationConsentDialog.show(
+ requireActivity(),
+ requireActivity().getLayoutInflater(),
+ proceedWithGoogle,
+ cancel);
+ }
+ });
+ }
+
@Override
public void onCreateContextMenu(@NonNull ContextMenu menu, View v, ContextMenuInfo menuInfo) {
// This should cancel any remaining click events that would otherwise trigger links
@@ -3154,7 +3276,10 @@ public class ConversationFragment extends XmppFragment
@Override
public void onFailure(final Throwable t) {
+ Log.w(Config.LOGTAG, "STICKER attachStickerToConversation failed", t);
prepareFileToast.cancel();
+ Toast.makeText(getActivity(), R.string.attach_sticker_failed, Toast.LENGTH_SHORT)
+ .show();
}
},
requireActivity().getMainExecutor());
@@ -4192,6 +4317,21 @@ public class ConversationFragment extends XmppFragment
setAttachmentChoicesVisibility(false);
}
}
+ updateTranslateButton();
+ }
+
+ // Only takes up space once there is draft text to translate — mirrors how
+ // attach_button itself disappears once typing starts. Keeping it GONE rather
+ // than just disabled/dimmed while empty means the empty-state hint text gets
+ // its full width back instead of being squeezed by an icon nobody can use yet.
+ // Also restores enabled=true: translateOutgoingDraft() disables this button for
+ // the duration of a translate call, and this is the method that re-enables it
+ // afterwards — without it the button stays permanently disabled after first use.
+ private void updateTranslateButton() {
+ final boolean hasText =
+ !CharSequences.nullToEmpty(this.binding.textInput.getText()).isEmpty();
+ this.binding.translateButton.setVisibility(hasText ? View.VISIBLE : View.GONE);
+ this.binding.translateButton.setEnabled(hasText);
}
public void updateSendButton() {
@@ -4231,27 +4371,34 @@ public class ConversationFragment extends XmppFragment
/**
* Lightweight fingerprint of the current messageList content.
* Captures: size, last real message UUID+status, trailing status-message body, active
- * transferables, total reaction count across all messages.
- * Changes on: new messages, delivery/read receipts, composing state, downloads, reactions.
+ * transferables, total reaction count, and a sum of every message's status across all
+ * messages (not just the last one).
+ * Changes on: new messages, delivery/read receipts on ANY message, composing state,
+ * downloads, reactions.
* Stable for: roster updates, avatar loads, presence changes that don't affect messages.
*/
private String messageListFingerprint() {
final int size = messageList.size();
if (size == 0) return "0";
// Walk backwards to find last real message and last status-message body.
- // Also count active transferables in the same pass — their presence/absence
- // is not reflected in UUID or status, so without this the fingerprint stays
- // identical when a download completes and notifyDataSetChanged() is skipped,
- // leaving the download-button binding stale until the item scrolls off-screen.
+ // Also count active transferables and sum every message's status in the same pass.
+ // The status sum matters because an ack can land on a message that is no longer
+ // the last one (e.g. several stickers sent in a burst) — its status flips from
+ // UNSEND to SEND without changing size, lastRealUuid, or lastRealStatus, so without
+ // summing every status the fingerprint stays identical and that message's checkmark
+ // never gets rebound. Same reasoning applies to activeTransferables: a download
+ // completing doesn't change UUID or status either.
String lastRealUuid = "";
int lastRealStatus = -1;
String trailingStatusBody = "";
int activeTransferables = 0;
int totalReactions = 0;
+ int statusSum = 0;
for (int i = size - 1; i >= 0; i--) {
final Message m = messageList.get(i);
if (m.getTransferable() != null) activeTransferables++;
totalReactions += m.getReactions().size();
+ statusSum += m.getStatus();
if (m.getType() == Message.TYPE_STATUS) {
if (trailingStatusBody.isEmpty()) trailingStatusBody = String.valueOf(m.getBody());
} else if (lastRealUuid.isEmpty()) {
@@ -4259,7 +4406,7 @@ public class ConversationFragment extends XmppFragment
lastRealStatus = m.getStatus();
}
}
- return size + "/" + lastRealUuid + "/" + lastRealStatus + "/" + trailingStatusBody + "/" + activeTransferables + "/" + totalReactions;
+ return size + "/" + lastRealUuid + "/" + lastRealStatus + "/" + trailingStatusBody + "/" + activeTransferables + "/" + totalReactions + "/" + statusSum;
}
private void updateToolbar() {
diff --git a/src/main/java/tel/xmpp/jabjab/ui/MultiShareActivity.java b/src/main/java/tel/xmpp/jabjab/ui/MultiShareActivity.java
index 784ed2c..5cc4a88 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/MultiShareActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/MultiShareActivity.java
@@ -35,6 +35,7 @@ import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
+import java.util.concurrent.atomic.AtomicInteger;
/**
* Share a file or text to multiple contacts at once.
@@ -207,6 +208,10 @@ public class MultiShareActivity extends XmppActivity {
private void populateTargets(final List<Account> onlineAccounts) {
allTargets.clear();
+ // New ForwardTarget instances are created each time; clear selection so
+ // selected.contains() doesn't silently compare against stale objects.
+ selected.clear();
+ updateFab();
final var seen = new java.util.HashSet<String>();
// Own accounts first — "Me (jid)" entries for self-send
@@ -284,7 +289,7 @@ public class MultiShareActivity extends XmppActivity {
sendFilesToSelected(targets);
} else if (text != null && !text.isEmpty()) {
final int total = targets.size();
- final int[] done = {0};
+ final AtomicInteger done = new AtomicInteger(0);
for (final ForwardTarget target : targets) {
final Conversation conv = xmppConnectionService.findOrCreateConversation(
target.account, target.jid, target.isMuc, true);
@@ -304,7 +309,7 @@ public class MultiShareActivity extends XmppActivity {
*/
private void sendForwardItems(final List<ForwardTarget> targets) {
final int total = forwardItems.size() * targets.size();
- final int[] done = {0};
+ final AtomicInteger done = new AtomicInteger(0);
for (final String item : forwardItems) {
for (final ForwardTarget target : targets) {
final Conversation conv = xmppConnectionService.findOrCreateConversation(
@@ -354,7 +359,7 @@ public class MultiShareActivity extends XmppActivity {
*/
private void sendFilesToSelected(final List<ForwardTarget> targets) {
final int totalOps = uris.size() * targets.size();
- final int[] done = {0};
+ final AtomicInteger done = new AtomicInteger(0);
for (final Uri uri : uris) {
final String scheme = uri.getScheme();
@@ -401,7 +406,7 @@ public class MultiShareActivity extends XmppActivity {
private void forwardFileBodyToTargets(
final String fileBody,
final List<ForwardTarget> targets,
- final int[] done,
+ final AtomicInteger done,
final int total) {
for (final ForwardTarget target : targets) {
final Conversation conv = xmppConnectionService.findOrCreateConversation(
@@ -413,9 +418,8 @@ public class MultiShareActivity extends XmppActivity {
}
}
- private void checkDone(final int[] done, final int total) {
- done[0]++;
- if (done[0] >= total) {
+ private void checkDone(final AtomicInteger done, final int total) {
+ if (done.incrementAndGet() >= total) {
runOnUiThread(() -> {
Toast.makeText(this, getString(R.string.multi_share_done, total),
Toast.LENGTH_SHORT).show();
@@ -444,6 +448,14 @@ public class MultiShareActivity extends XmppActivity {
t.jid.asBareJid().toString()));
h.jid.setVisibility(View.GONE);
AvatarWorkerTask.loadAvatar(t.selfAccount, h.avatar, R.dimen.avatar);
+ } else if (t.isMuc) {
+ h.name.setText(t.displayName);
+ h.jid.setVisibility(View.VISIBLE);
+ h.jid.setText(t.jid.asBareJid().toString());
+ // Cancel any in-flight avatar fetch from a previously bound contact row
+ final AvatarWorkerTask stale = AvatarWorkerTask.getBitmapWorkerTask(h.avatar);
+ if (stale != null) stale.cancel(true);
+ h.avatar.setImageResource(R.drawable.ic_group_24dp);
} else {
h.name.setText(t.displayName);
h.jid.setVisibility(View.VISIBLE);
diff --git a/src/main/java/tel/xmpp/jabjab/ui/StickerPickerActivity.java b/src/main/java/tel/xmpp/jabjab/ui/StickerPickerActivity.java
index 74d73a6..ff8a5f3 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/StickerPickerActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/StickerPickerActivity.java
@@ -327,17 +327,24 @@ public class StickerPickerActivity extends BaseActivity {
}
void onStickerChosen(final File sticker) {
- final StickerStore.Pack pack = StickerStore.packFromDir(sticker.getParentFile());
- final String packNode = StickerStore.getOrCreateNodeId(this, pack);
- final Uri uri = FileProvider.getUriForFile(
- this, getPackageName() + ".files", sticker);
- final Intent result = new Intent();
- result.setData(uri);
- result.putExtra("sticker_pack_node", packNode);
- result.putExtra("sticker_file_path", sticker.getAbsolutePath());
- result.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
- setResult(Activity.RESULT_OK, result);
- finish();
+ // packFromDir()/getOrCreateNodeId() read (and sometimes write) meta.json
+ // synchronously — for large packs this is enough disk I/O to visibly stall
+ // the tap, so keep it off the UI thread.
+ executor.execute(() -> {
+ final StickerStore.Pack pack = StickerStore.packFromDir(sticker.getParentFile());
+ final String packNode = StickerStore.getOrCreateNodeId(this, pack);
+ final Uri uri = FileProvider.getUriForFile(
+ this, getPackageName() + ".files", sticker);
+ runOnUiThread(() -> {
+ final Intent result = new Intent();
+ result.setData(uri);
+ result.putExtra("sticker_pack_node", packNode);
+ result.putExtra("sticker_file_path", sticker.getAbsolutePath());
+ result.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+ setResult(Activity.RESULT_OK, result);
+ finish();
+ });
+ });
}
StickerStore.Pack packAt(final int position) {
diff --git a/src/main/java/tel/xmpp/jabjab/ui/StoriesActivity.java b/src/main/java/tel/xmpp/jabjab/ui/StoriesActivity.java
index 4be2013..a8f5127 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/StoriesActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/StoriesActivity.java
@@ -90,6 +90,9 @@ public class StoriesActivity extends XmppActivity
private String filterJid = null;
// Set from a jabjab://story?jid=…&item=… deep link; consumed once data is rendered.
private String pendingHighlightItem = null;
+ // True once the deep-link extra has been read. Saved across rotation so we don't
+ // re-read the original intent (Android re-delivers it after activity recreation).
+ private boolean highlightConsumed = false;
private EditText searchInput;
private View emptyView;
private SwipeRefreshLayout swipeRefresh;
@@ -99,6 +102,9 @@ public class StoriesActivity extends XmppActivity
@Override
protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
+ if (savedInstanceState != null) {
+ highlightConsumed = savedInstanceState.getBoolean("highlight_consumed", false);
+ }
setContentView(R.layout.activity_stories);
bottomNav = findViewById(R.id.bottom_nav);
BottomNavHelper.wire(bottomNav, R.id.bottom_stories, this);
@@ -202,6 +208,12 @@ public class StoriesActivity extends XmppActivity
BottomNavHelper.updateBadges(bottomNav, xmppConnectionService);
}
+ @Override
+ protected void onSaveInstanceState(final android.os.Bundle outState) {
+ super.onSaveInstanceState(outState);
+ outState.putBoolean("highlight_consumed", highlightConsumed);
+ }
+
@Override
protected void onDestroy() {
super.onDestroy();
@@ -235,10 +247,13 @@ public class StoriesActivity extends XmppActivity
if (a.isOnlineAndConnected() && !onlineAccounts.contains(a)) onlineAccounts.add(a);
}
Log.d(Config.LOGTAG, "STORIES onBackendConnected: onlineAccounts=" + onlineAccounts.size());
- // Read once and remove — onBackendConnected fires on every onStart(), so leaving the
- // extra in the intent would re-open StoryViewActivity every time the user comes back.
- if (getIntent().hasExtra("highlight_item")) {
+ // Read once — guarded by highlightConsumed so rotation/recreation doesn't re-open
+ // StoryViewActivity. removeExtra() only mutates the in-process Intent; Android
+ // re-delivers the original system-side Intent after activity recreation, so we also
+ // persist highlightConsumed in onSaveInstanceState.
+ if (!highlightConsumed && getIntent().hasExtra("highlight_item")) {
pendingHighlightItem = getIntent().getStringExtra("highlight_item");
+ highlightConsumed = true;
getIntent().removeExtra("highlight_item");
}
updateComposeFabVisibility();
diff --git a/src/main/java/tel/xmpp/jabjab/ui/StoryComposerActivity.java b/src/main/java/tel/xmpp/jabjab/ui/StoryComposerActivity.java
index e033945..a6311c0 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/StoryComposerActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/StoryComposerActivity.java
@@ -34,6 +34,7 @@ import com.otaliastudios.transcoder.source.UriDataSource;
import com.otaliastudios.transcoder.strategy.DefaultAudioStrategy;
import com.otaliastudios.transcoder.strategy.DefaultVideoStrategy;
import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import tel.xmpp.jabjab.xmpp.manager.StoriesManager;
@@ -279,9 +280,11 @@ public class StoryComposerActivity extends XmppActivity
final long fileSize = querySize(mediaUri);
final String finalMime = mime;
- // Auto-decide processing: trim if seekbar moved or video exceeds max; compress unless small.
+ // Auto-decide processing: trim if the user actually moved a trim boundary (not just the
+ // playhead), or if the video is longer than the max allowed duration.
final long maxMs = (long) StoriesManager.MAX_VIDEO_DURATION_SECONDS * 1000;
- final boolean needsTrim = isVideo && seekbarMoved;
+ final boolean trimBoundsChanged = trimStartMs != 0 || (trimEndMs > 0 && trimEndMs != videoDurationMs);
+ final boolean needsTrim = isVideo && (trimBoundsChanged || videoDurationMs > maxMs);
// Small video: short clip (< 2 MB) → skip compression, just upload as-is
final boolean needsCompress = isVideo && fileSize > SMALL_VIDEO_BYTES;
Log.d(Config.LOGTAG, "STORIES post: mime=" + finalMime
@@ -367,7 +370,9 @@ public class StoryComposerActivity extends XmppActivity
})
.transcode();
- latch.await();
+ if (!latch.await(10, TimeUnit.MINUTES)) {
+ throw new IOException("Transcode timed out after 10 minutes");
+ }
if (txError.get() != null) throw new IOException("Transcode failed", txError.get());
Log.d(Config.LOGTAG, "STORIES transcoded: result=" + result.get()
@@ -482,7 +487,9 @@ public class StoryComposerActivity extends XmppActivity
if (mediaPlayer.isPlaying()) mediaPlayer.pause();
// Play from wherever the playhead sits (within the trim window) to the end marker.
- final long previewStartMs = trimPlayheadMs > 0 ? trimPlayheadMs : trimStartMs;
+ // If the playhead is at or past the end marker, fall back to the start of the window.
+ final long previewStartMs = (trimPlayheadMs > trimStartMs && trimPlayheadMs < trimEndMs)
+ ? trimPlayheadMs : trimStartMs;
final long previewEndMs = trimEndMs;
mediaPlayer.setOnSeekCompleteListener(mp -> {
diff --git a/src/main/java/tel/xmpp/jabjab/ui/TelegramStickerImportActivity.java b/src/main/java/tel/xmpp/jabjab/ui/TelegramStickerImportActivity.java
new file mode 100644
index 0000000..0d98fd2
--- /dev/null
+++ b/src/main/java/tel/xmpp/jabjab/ui/TelegramStickerImportActivity.java
@@ -0,0 +1,108 @@
+package tel.xmpp.jabjab.ui;
+
+import android.net.Uri;
+import android.os.Bundle;
+import android.util.Log;
+import android.widget.TextView;
+import android.widget.Toast;
+import androidx.appcompat.app.AlertDialog;
+import androidx.appcompat.widget.Toolbar;
+import tel.xmpp.jabjab.Config;
+import tel.xmpp.jabjab.R;
+import tel.xmpp.jabjab.entities.Account;
+import tel.xmpp.jabjab.ui.util.StickerStore;
+import tel.xmpp.jabjab.ui.util.TelegramStickerImporter;
+
+/**
+ * Handles https://t.me/addstickers/{name} links shared from Telegram.
+ *
+ * Telegram sticker sets can only be resolved through the Bot API (getStickerSet/getFile),
+ * which requires a bot token configured in Settings (see TelegramStickerImporter). Until a
+ * tokenless/native Telegram import lands, this shows a friendly prompt when no token is set
+ * rather than silently failing.
+ *
+ * After local install, publishes the pack to PubSub (XEP-0449) for all online accounts.
+ */
+public class TelegramStickerImportActivity extends XmppActivity {
+
+ private TextView statusView;
+
+ @Override
+ protected void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_telegram_sticker_import);
+ final Toolbar toolbar = findViewById(R.id.toolbar);
+ setSupportActionBar(toolbar);
+ if (getSupportActionBar() != null) getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+ statusView = findViewById(R.id.status);
+ }
+
+ @Override
+ public boolean onSupportNavigateUp() {
+ finish();
+ return true;
+ }
+
+ @Override
+ protected void onBackendConnected() {
+ final String packName = resolvePackName(getIntent().getData());
+ if (packName == null) {
+ Toast.makeText(this, R.string.sticker_pack_fetch_failed, Toast.LENGTH_SHORT).show();
+ finish();
+ return;
+ }
+
+ if (!TelegramStickerImporter.hasToken(this)) {
+ new AlertDialog.Builder(this)
+ .setTitle(R.string.import_sticker_pack)
+ .setMessage(R.string.telegram_import_needs_token)
+ .setPositiveButton(R.string.ok, (d, w) -> finish())
+ .setOnCancelListener(d -> finish())
+ .show();
+ return;
+ }
+
+ statusView.setText(getString(R.string.fetching_sticker_pack));
+ TelegramStickerImporter.importPack(this, packName, new TelegramStickerImporter.Callback() {
+ @Override
+ public void onDone(final StickerStore.Pack pack, final int imported) {
+ Log.d(Config.LOGTAG, "TelegramStickerImport: imported " + imported
+ + " stickers into pack=" + pack.displayName());
+ if (xmppConnectionService != null) {
+ for (final Account account : xmppConnectionService.getAccounts()) {
+ if (account.isOnlineAndConnected()) {
+ xmppConnectionService.publishStickerPack(account, pack);
+ }
+ }
+ }
+ Toast.makeText(TelegramStickerImportActivity.this,
+ getResources().getQuantityString(R.plurals.stickers_imported, imported, imported),
+ Toast.LENGTH_SHORT).show();
+ finish();
+ }
+
+ @Override
+ public void onError(final TelegramStickerImporter.Error reason) {
+ Log.w(Config.LOGTAG, "TelegramStickerImport: failed for pack=" + packName
+ + " reason=" + reason);
+ Toast.makeText(TelegramStickerImportActivity.this,
+ R.string.sticker_pack_fetch_failed, Toast.LENGTH_SHORT).show();
+ finish();
+ }
+ });
+ }
+
+ @Override
+ public void refreshUiReal() {}
+
+ // https://t.me/addstickers/{name} -> name
+ private static String resolvePackName(final Uri data) {
+ if (data == null) return null;
+ final String path = data.getPath();
+ final String prefix = "/addstickers/";
+ if (path == null || !path.startsWith(prefix) || path.length() <= prefix.length()) {
+ return null;
+ }
+ return path.substring(prefix.length());
+ }
+}
diff --git a/src/main/java/tel/xmpp/jabjab/ui/WebStickerPackActivity.java b/src/main/java/tel/xmpp/jabjab/ui/WebStickerPackActivity.java
index e354e60..5f3f5ef 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/WebStickerPackActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/WebStickerPackActivity.java
@@ -32,17 +32,25 @@ import org.json.JSONArray;
import org.json.JSONObject;
/**
- * Shows a sticker pack fetched from stickers.xmpp.tel and lets the user install it.
+ * Shows a sticker pack fetched from stickers.xmpp.tel, or converted from a Signal sticker
+ * pack link, and lets the user install it.
*
* Accepts:
* jabjab://sticker?id={slug}
* https://stickers.xmpp.tel/{slug}
+ * sgnl://addstickers/?pack_id={id}&pack_key={key} (Signal, in-app share)
+ * https://signal.art/addstickers/#pack_id={id}&pack_key={key} (Signal, universal link)
+ *
+ * Signal packs are protobuf + AES-encrypted on Signal's own CDN, so the pack_id/pack_key are
+ * handed off to stickers.cheogram.com, which resolves them into a plain JSON array of image
+ * URLs (same convention used by monocles chat / cheogram-android).
*
* After local install, publishes the pack to PubSub (XEP-0449) for all online accounts.
*/
public class WebStickerPackActivity extends XmppActivity {
private static final String STICKERS_BASE = "https://stickers.xmpp.tel";
+ private static final String CHEOGRAM_SIGNAL_BASE = "https://stickers.cheogram.com/signal/";
private final ExecutorService executor = Executors.newCachedThreadPool();
@@ -90,6 +98,13 @@ public class WebStickerPackActivity extends XmppActivity {
btnCancel.setOnClickListener(v -> finish());
btnInstall.setOnClickListener(v -> installPack());
+ final Uri data = getIntent().getData();
+ final String cheogramUrl = buildCheogramSignalUrl(data);
+ if (cheogramUrl != null) {
+ fetchCheogramPack(cheogramUrl);
+ return;
+ }
+
final String slug = resolveSlug(getIntent());
if (slug == null) {
showError(getString(R.string.sticker_pack_fetch_failed));
@@ -98,6 +113,66 @@ public class WebStickerPackActivity extends XmppActivity {
fetchPack(slug);
}
+ /** Returns null if {@code data} is not a recognized Signal sticker-pack link. */
+ private static String buildCheogramSignalUrl(final Uri data) {
+ if (data == null) return null;
+ final String packId;
+ final String packKey;
+ if ("sgnl".equals(data.getScheme())) {
+ packId = data.getQueryParameter("pack_id");
+ packKey = data.getQueryParameter("pack_key");
+ } else if ("https".equals(data.getScheme()) && "signal.art".equals(data.getHost())) {
+ // pack_id/pack_key travel in the URL fragment, e.g. #pack_id=X&pack_key=Y
+ final android.net.UrlQuerySanitizer sanitizer = new android.net.UrlQuerySanitizer();
+ sanitizer.setAllowUnregisteredParamaters(true);
+ sanitizer.parseQuery(data.getFragment());
+ packId = sanitizer.getValue("pack_id");
+ packKey = sanitizer.getValue("pack_key");
+ } else {
+ return null;
+ }
+ if (packId == null || packId.isEmpty() || packKey == null || packKey.isEmpty()) {
+ return null;
+ }
+ return CHEOGRAM_SIGNAL_BASE + packId + "," + packKey;
+ }
+
+ private void fetchCheogramPack(final String url) {
+ executor.execute(() -> {
+ try {
+ final HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
+ conn.setConnectTimeout(10_000);
+ conn.setReadTimeout(15_000);
+ if (conn.getResponseCode() != 200)
+ throw new java.io.IOException("HTTP " + conn.getResponseCode());
+ final StringBuilder sb = new StringBuilder();
+ try (final InputStream is = conn.getInputStream()) {
+ final byte[] buf = new byte[4096]; int n;
+ while ((n = is.read(buf)) != -1) sb.append(new String(buf, 0, n));
+ }
+ final JSONArray arr = new JSONArray(sb.toString());
+ if (arr.length() == 0) throw new java.io.IOException("empty pack");
+ final List<StickerItem> items = new ArrayList<>();
+ String name = "Signal Pack";
+ for (int i = 0; i < arr.length(); i++) {
+ final JSONObject s = arr.getJSONObject(i);
+ // Cheogram hands out http:// URLs that 301-redirect to https://, but
+ // HttpURLConnection never follows a redirect that crosses protocols — every
+ // sticker would silently download the redirect page instead of the image.
+ final String stickerUrl = s.getString("url").replaceFirst("^http://", "https://");
+ items.add(new StickerItem(stickerUrl, ""));
+ if (i == 0) name = s.optString("pack", name);
+ }
+ resolvedPackName = name;
+ final String finalName = name;
+ runOnUiThread(() -> showPack(finalName, "", items));
+ } catch (final Exception e) {
+ Log.d(Config.LOGTAG, "WebStickerPack: failed to fetch Signal pack via Cheogram", e);
+ runOnUiThread(() -> showError(getString(R.string.sticker_pack_fetch_failed)));
+ }
+ });
+ }
+
@Override
public boolean onSupportNavigateUp() {
finish();
@@ -177,6 +252,16 @@ public class WebStickerPackActivity extends XmppActivity {
errorView.setVisibility(View.VISIBLE);
}
+ /** Returns a filename with a recognized sticker extension for a downloaded sticker. */
+ private static String stickerFilename(final String url, final String contentType, final int index) {
+ final String last = url.replaceAll(".*/", "");
+ final int dot = last.lastIndexOf('.');
+ if (dot > 0) return last; // URL already ends in a real filename, e.g. abc123.webp
+ final String mime = contentType != null ? contentType.split(";")[0].trim() : null;
+ final String ext = android.webkit.MimeTypeMap.getSingleton().getExtensionFromMimeType(mime);
+ return (last.isEmpty() ? "sticker_" + index : last) + "." + (ext != null ? ext : "webp");
+ }
+
private void installPack() {
btnInstall.setEnabled(false);
btnCancel.setEnabled(false);
@@ -192,13 +277,16 @@ public class WebStickerPackActivity extends XmppActivity {
final StickerStore.Pack pack = StickerStore.createPack(this, packName);
for (int i = 0; i < stickers.size(); i++) {
final StickerItem item = stickers.get(i);
- final String filename = item.url().replaceAll(".*/", "");
Log.d(Config.LOGTAG, "WebStickerPack: downloading " + (i+1) + "/" + stickers.size() + " url=" + item.url());
final HttpURLConnection sc =
(HttpURLConnection) new URL(item.url()).openConnection();
sc.setConnectTimeout(10_000);
sc.setReadTimeout(30_000);
try (final InputStream is = sc.getInputStream()) {
+ // Cheogram-resolved Signal stickers have no extension in the URL path
+ // (e.g. .../58) — fall back to the response's Content-Type in that case,
+ // since StickerStore only recognizes files with a known image extension.
+ final String filename = stickerFilename(item.url(), sc.getContentType(), i);
final java.io.File f =
StickerStore.importStickerIfNew(pack, filename, is);
if (f != null) {
@@ -234,6 +322,7 @@ public class WebStickerPackActivity extends XmppActivity {
finish();
});
} catch (final Exception e) {
+ Log.w(Config.LOGTAG, "WebStickerPack: install failed", e);
runOnUiThread(() -> {
installProgressContainer.setVisibility(View.GONE);
btnInstall.setEnabled(true);
@@ -289,8 +378,12 @@ public class WebStickerPackActivity extends XmppActivity {
runOnUiThread(() -> {
if (item.url().equals(holder.iv.getTag())) holder.iv.setImageBitmap(bm);
});
+ } else {
+ Log.w(Config.LOGTAG, "WebStickerPack: preview decode returned null for " + item.url());
}
- } catch (final Exception ignored) {}
+ } catch (final Exception e) {
+ Log.w(Config.LOGTAG, "WebStickerPack: preview fetch failed for " + item.url(), e);
+ }
});
}
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 86ebf6a..6d6df16 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/service/AudioPlayer.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/service/AudioPlayer.java
@@ -44,10 +44,10 @@ public class AudioPlayer
private static final int REFRESH_INTERVAL = 250;
private static final Object LOCK = new Object();
- private static final float[] SPEEDS = {1.0f, 1.5f, 2.0f};
+ private static final float[] SPEEDS = {1.0f, 1.25f, 1.5f, 2.0f};
private static MediaPlayer player = null;
private static Message currentlyPlayingMessage = null;
- private static float playbackSpeed = 1.0f;
+ private static float playbackSpeed = 1.25f;
private static PowerManager.WakeLock wakeLock;
private final MessageAdapter messageAdapter;
private final WeakReferenceSet<RelativeLayout> audioPlayerLayouts = new WeakReferenceSet<>();
@@ -183,6 +183,7 @@ public class AudioPlayer
private static String formatSpeed(float speed) {
if (speed == 1.0f) return "1×";
+ if (speed == 1.25f) return "1¼×";
if (speed == 1.5f) return "1.5×";
return "2×";
}
diff --git a/src/main/java/tel/xmpp/jabjab/ui/util/OutgoingTranslateLanguagePicker.java b/src/main/java/tel/xmpp/jabjab/ui/util/OutgoingTranslateLanguagePicker.java
new file mode 100644
index 0000000..cda4961
--- /dev/null
+++ b/src/main/java/tel/xmpp/jabjab/ui/util/OutgoingTranslateLanguagePicker.java
@@ -0,0 +1,89 @@
+package tel.xmpp.jabjab.ui.util;
+
+import android.app.Activity;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.concurrent.atomic.AtomicInteger;
+import tel.xmpp.jabjab.R;
+
+/**
+ * Target-language picker for the compose-bar outgoing-translate icon. Shows the
+ * confirmed-good-quality languages up front, with a "More…" row that expands into
+ * the full ISO-639-1 language list rather than a hand-maintained one.
+ */
+public final class OutgoingTranslateLanguagePicker {
+
+ private record LangEntry(String code, String displayName) {}
+
+ private static final List<LangEntry> PRIMARY =
+ List.of(
+ new LangEntry("en", "English"),
+ new LangEntry("de", "Deutsch"),
+ new LangEntry("tr", "Türkçe"),
+ new LangEntry("ar", "العربية"),
+ new LangEntry("ru", "Русский"),
+ new LangEntry("ja", "日本語"),
+ new LangEntry("ko", "한국어"),
+ new LangEntry("zh", "中文"),
+ new LangEntry("nl", "Nederlands"),
+ new LangEntry("fr", "Français"));
+
+ public interface OnLanguageSelected {
+ void onLanguageSelected(String langCode);
+ }
+
+ private OutgoingTranslateLanguagePicker() {}
+
+ public static void show(final Activity activity, final OnLanguageSelected callback) {
+ final String[] names = new String[PRIMARY.size() + 1];
+ for (int i = 0; i < PRIMARY.size(); i++) {
+ names[i] = PRIMARY.get(i).displayName();
+ }
+ names[PRIMARY.size()] = activity.getString(R.string.translate_more_languages);
+ final AtomicInteger selected = new AtomicInteger(0);
+ new MaterialAlertDialogBuilder(activity)
+ .setTitle(R.string.translate_choose_language)
+ .setSingleChoiceItems(names, 0, (dialog, which) -> selected.set(which))
+ .setNegativeButton(R.string.cancel, null)
+ .setPositiveButton(
+ R.string.ok,
+ (dialog, which) -> {
+ final int idx = selected.get();
+ if (idx < PRIMARY.size()) {
+ callback.onLanguageSelected(PRIMARY.get(idx).code());
+ } else {
+ showExpandedPicker(activity, callback);
+ }
+ })
+ .create()
+ .show();
+ }
+
+ private static void showExpandedPicker(final Activity activity, final OnLanguageSelected callback) {
+ final List<LangEntry> all = new ArrayList<>();
+ for (final String code : Locale.getISOLanguages()) {
+ final Locale locale = new Locale(code);
+ final String displayName = locale.getDisplayLanguage(Locale.getDefault());
+ if (!displayName.isEmpty()) {
+ all.add(new LangEntry(code, displayName));
+ }
+ }
+ all.sort((a, b) -> a.displayName().compareToIgnoreCase(b.displayName()));
+ final String[] names = new String[all.size()];
+ for (int i = 0; i < all.size(); i++) {
+ names[i] = all.get(i).displayName();
+ }
+ final AtomicInteger selected = new AtomicInteger(0);
+ new MaterialAlertDialogBuilder(activity)
+ .setTitle(R.string.translate_choose_language)
+ .setSingleChoiceItems(names, 0, (dialog, which) -> selected.set(which))
+ .setNegativeButton(R.string.cancel, null)
+ .setPositiveButton(
+ R.string.ok,
+ (dialog, which) -> callback.onLanguageSelected(all.get(selected.get()).code()))
+ .create()
+ .show();
+ }
+}
diff --git a/src/main/java/tel/xmpp/jabjab/ui/util/TelegramStickerImporter.java b/src/main/java/tel/xmpp/jabjab/ui/util/TelegramStickerImporter.java
index 66c5813..b10b560 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/util/TelegramStickerImporter.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/util/TelegramStickerImporter.java
@@ -27,7 +27,7 @@ public final class TelegramStickerImporter {
public enum Error { NO_TOKEN, NOT_FOUND, NETWORK }
public interface Callback {
- void onDone(int imported);
+ void onDone(StickerStore.Pack pack, int imported);
void onError(Error reason);
}
@@ -103,7 +103,7 @@ public final class TelegramStickerImporter {
}
final int finalCount = count;
if (finalCount > 0) {
- mainHandler.post(() -> cb.onDone(finalCount));
+ mainHandler.post(() -> cb.onDone(pack, finalCount));
} else {
mainHandler.post(() -> cb.onError(Error.NOT_FOUND));
}
diff --git a/src/main/java/tel/xmpp/jabjab/ui/util/TranslationConsentDialog.java b/src/main/java/tel/xmpp/jabjab/ui/util/TranslationConsentDialog.java
new file mode 100644
index 0000000..4d5446d
--- /dev/null
+++ b/src/main/java/tel/xmpp/jabjab/ui/util/TranslationConsentDialog.java
@@ -0,0 +1,45 @@
+package tel.xmpp.jabjab.ui.util;
+
+import android.content.Context;
+import android.view.LayoutInflater;
+import androidx.databinding.DataBindingUtil;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import tel.xmpp.jabjab.R;
+import tel.xmpp.jabjab.databinding.DialogTranslationFallbackBinding;
+
+/**
+ * Shared "use Google Translate?" consent prompt for every caller of
+ * {@link TranslationService} (incoming long-press translate, the outgoing compose-bar
+ * translate icon, blog post translate, …) — one dialog, one persisted preference
+ * ({@link TranslationSettings}), so the user is never asked the same privacy question
+ * twice in slightly different words depending on where translation was triggered from.
+ */
+public final class TranslationConsentDialog {
+
+ private TranslationConsentDialog() {}
+
+ public static void show(
+ final Context context,
+ final LayoutInflater inflater,
+ final Runnable proceedWithGoogle,
+ final Runnable cancel) {
+ final DialogTranslationFallbackBinding viewBinding =
+ DataBindingUtil.inflate(inflater, R.layout.dialog_translation_fallback, null, false);
+ new MaterialAlertDialogBuilder(context)
+ .setTitle(R.string.translation_fallback_title)
+ .setView(viewBinding.getRoot())
+ .setPositiveButton(
+ R.string.translation_fallback_allow,
+ (dialog, which) -> {
+ if (viewBinding.doNotAskAgain.isChecked()) {
+ TranslationSettings.getInstance(context)
+ .setAutoFallbackToGoogleEnabled(true);
+ }
+ proceedWithGoogle.run();
+ })
+ .setNegativeButton(R.string.cancel, (dialog, which) -> cancel.run())
+ .setOnCancelListener(dialog -> cancel.run())
+ .create()
+ .show();
+ }
+}
diff --git a/src/main/java/tel/xmpp/jabjab/ui/util/TranslationDisclaimer.java b/src/main/java/tel/xmpp/jabjab/ui/util/TranslationDisclaimer.java
new file mode 100644
index 0000000..f7c74cb
--- /dev/null
+++ b/src/main/java/tel/xmpp/jabjab/ui/util/TranslationDisclaimer.java
@@ -0,0 +1,39 @@
+package tel.xmpp.jabjab.ui.util;
+
+import android.text.Spannable;
+import android.text.SpannableString;
+import android.text.style.ForegroundColorSpan;
+import android.text.style.RelativeSizeSpan;
+import android.view.View;
+import androidx.core.graphics.ColorUtils;
+import com.google.android.material.color.MaterialColors;
+
+/** Renders the "via private Qwen3.6 translation" / "via public Google Translate" line
+ * dimmer and smaller than the translated text above it, so it reads as a footnote. */
+public final class TranslationDisclaimer {
+
+ private static final float RELATIVE_SIZE = 0.8f;
+ // Alpha applied to the resolved base text color rather than picking a separate
+ // theme color token (e.g. colorOnSurfaceVariant): on a dynamic-color theme that
+ // token can resolve too close to the base text color to read as "dimmer" at all.
+ // Blending the actual base color down is visible regardless of theme/palette.
+ private static final int DIM_ALPHA = (int) (255 * 0.6f);
+
+ private TranslationDisclaimer() {}
+
+ public static CharSequence build(
+ final View themedView, final String translatedText, final String disclaimerText) {
+ final String full = translatedText + "\n\n" + disclaimerText;
+ final SpannableString spannable = new SpannableString(full);
+ final int start = translatedText.length() + 2; // skip the blank line
+ final int end = full.length();
+ final int baseColor =
+ MaterialColors.getColor(themedView, com.google.android.material.R.attr.colorOnSurface);
+ final int dimColor = ColorUtils.setAlphaComponent(baseColor, DIM_ALPHA);
+ spannable.setSpan(
+ new ForegroundColorSpan(dimColor), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ spannable.setSpan(
+ new RelativeSizeSpan(RELATIVE_SIZE), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ return spannable;
+ }
+}
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 52b931f..9a21d2f 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/util/TranslationService.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/util/TranslationService.java
@@ -9,40 +9,82 @@ import com.google.common.base.Strings;
import java.io.IOException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Pattern;
import okhttp3.HttpUrl;
+import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
+import okhttp3.RequestBody;
import okhttp3.Response;
import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
import tel.xmpp.jabjab.Config;
import tel.xmpp.jabjab.persistance.TranslationCacheDatabase;
+import tel.xmpp.jabjab.persistance.TranslationCacheDatabase.Backend;
/**
- * Telegram-style "press and hold to translate" backend.
+ * Telegram-style "press and hold to translate" backend, and the backend for the
+ * compose-bar outgoing-translate icon.
*
- * The xmpp.tel server-side translation API (POST text, pull translation) is not
- * live yet. Until it is, this falls back to the free/unofficial Google Translate
- * endpoint used by many open-source clients for the same purpose. Results are
- * cached in {@link TranslationCacheDatabase} keyed by a hash of the source text
- * and the target language, so repeat lookups (including after the real API
- * lands) don't re-hit the network.
+ * 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.
+ *
+ * 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
+ * decides that label itself, only reports the fact.
*/
public class TranslationService {
private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor();
private static final Handler MAIN = new Handler(Looper.getMainLooper());
private static final OkHttpClient HTTP = new OkHttpClient();
+ // Shorter timeouts than the Google client: this is a reachability probe as much as
+ // a translation call, and long-press UX shouldn't hang indefinitely before offering
+ // the fallback-consent dialog. The server itself gives Ollama more time (see
+ // translate_ollama_timeout server config) — a late private-tier response after
+ // Android has already fallen back to Google is simply discarded.
+ private static final OkHttpClient PRIVATE_HTTP =
+ new OkHttpClient.Builder()
+ .connectTimeout(3, TimeUnit.SECONDS)
+ .readTimeout(8, TimeUnit.SECONDS)
+ .build();
+ private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8");
+
+ // Defensive client-side cleanup of private-tier output — a second, independently
+ // deployed layer alongside the server's own cleanup, in case the two drift.
+ private static final Pattern PREAMBLE =
+ Pattern.compile(
+ "^(?:sure[,!]?\\s+)?(?:here(?:'s|’s|\\s+is)\\s+(?:the\\s+)?translation:?"
+ + "|translation:?)\\s*",
+ Pattern.CASE_INSENSITIVE);
public interface Callback {
- void onResult(String translatedText, @Nullable String detectedSourceLang);
+ void onResult(String translatedText, @Nullable String detectedSourceLang, Backend backend);
void onError(Exception e);
+
+ /**
+ * The private tier failed/was unreachable and no persisted auto-fallback
+ * preference exists yet. Caller (UI layer) must show consent UI and invoke
+ * exactly one of these — both are safe to call from any thread.
+ */
+ void onNeedsFallbackConsent(Runnable proceedWithGoogle, Runnable cancel);
}
+ /** Distinguishes "user declined the fallback" from a real error, so the UI can
+ * dismiss silently instead of showing a failure toast. */
+ public static final class UserCancelledFallbackException extends Exception {}
+
private TranslationService() {}
public static void translate(
final Context context,
+ final String authorJid,
final String text,
final String targetLang,
final Callback callback) {
@@ -50,27 +92,147 @@ public class TranslationService {
callback.onError(new IllegalArgumentException("empty text"));
return;
}
- final var db = TranslationCacheDatabase.getInstance(context);
+ final Context appContext = context.getApplicationContext();
final String hash = TranslationCacheDatabase.hash(text);
EXECUTOR.execute(() -> {
- final var cached = db.get(hash, targetLang);
+ final var db = TranslationCacheDatabase.getInstance(appContext);
+ final var cached = db.get(hash, targetLang, Backend.PRIVATE);
if (cached != null) {
- MAIN.post(() -> callback.onResult(cached.translatedText(), cached.sourceLang()));
+ postResult(callback, cached);
return;
}
- try {
- final var result = fetchFromGoogleTranslate(text, targetLang);
- db.put(hash, targetLang, result.sourceLang(), result.translatedText());
- MAIN.post(() -> callback.onResult(result.translatedText(), result.sourceLang()));
- } catch (final Exception e) {
- Log.d(Config.LOGTAG, "TRANSLATE translation request failed", e);
- MAIN.post(() -> callback.onError(e));
- }
+ attemptPrivateTranslation(appContext, authorJid, text, targetLang, hash, callback);
});
}
- // Unofficial endpoint, intended as a stopgap only — see class javadoc.
- private static TranslationCacheDatabase.Result fetchFromGoogleTranslate(
+ private static void attemptPrivateTranslation(
+ final Context context,
+ final String authorJid,
+ final String text,
+ final String targetLang,
+ final String hash,
+ final Callback callback) {
+ final Request request;
+ try {
+ final JSONObject body = new JSONObject();
+ body.put("text", text);
+ body.put("target_lang", targetLang);
+ request =
+ new Request.Builder()
+ .url(Config.TRANSLATION_BASE_URL + "/api/translate")
+ .header("User-Agent", "JabJab")
+ .header("X-Author-Jid", authorJid)
+ .post(RequestBody.create(body.toString(), JSON))
+ .build();
+ } catch (final JSONException e) {
+ MAIN.post(() -> callback.onError(e));
+ return;
+ }
+
+ final Response response;
+ try {
+ response = PRIVATE_HTTP.newCall(request).execute();
+ } catch (final IOException e) {
+ // 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);
+ return;
+ }
+ try (response) {
+ final int code = response.code();
+ if (code == 502 || code == 504) {
+ fallback(context, authorJid, text, targetLang, hash, callback);
+ return;
+ }
+ if (code != 200 || response.body() == null) {
+ MAIN.post(
+ () ->
+ callback.onError(
+ new IOException("translation endpoint returned " + code)));
+ return;
+ }
+ final JSONObject json = new JSONObject(response.body().string());
+ final String translated = stripPreamble(json.getString("translated_text"));
+ final String detectedSourceLang =
+ json.isNull("detected_source_lang")
+ ? null
+ : json.optString("detected_source_lang", null);
+ final var db = TranslationCacheDatabase.getInstance(context);
+ db.put(hash, targetLang, Backend.PRIVATE, detectedSourceLang, translated);
+ MAIN.post(() -> callback.onResult(translated, detectedSourceLang, Backend.PRIVATE));
+ } catch (final IOException | JSONException e) {
+ MAIN.post(() -> callback.onError(e));
+ }
+ }
+
+ private static void fallback(
+ 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);
+ return;
+ }
+ if (TranslationSettings.getInstance(context).isAutoFallbackToGoogleEnabled()) {
+ fetchFromGoogleTranslateAndCache(context, text, targetLang, hash, callback);
+ return;
+ }
+ final Runnable proceed =
+ () ->
+ EXECUTOR.execute(
+ () ->
+ fetchFromGoogleTranslateAndCache(
+ context, text, targetLang, hash, callback));
+ final Runnable cancel =
+ () -> MAIN.post(() -> callback.onError(new UserCancelledFallbackException()));
+ MAIN.post(() -> callback.onNeedsFallbackConsent(proceed, cancel));
+ }
+
+ private static void fetchFromGoogleTranslateAndCache(
+ final Context context,
+ final String text,
+ final String targetLang,
+ final String hash,
+ final Callback callback) {
+ try {
+ final var result = fetchFromGoogleTranslate(text, targetLang);
+ final var db = TranslationCacheDatabase.getInstance(context);
+ db.put(hash, targetLang, Backend.GOOGLE, result.sourceLang(), result.translatedText());
+ MAIN.post(() -> callback.onResult(result.translatedText(), result.sourceLang(), Backend.GOOGLE));
+ } catch (final Exception e) {
+ Log.d(Config.LOGTAG, "TRANSLATE google fallback failed", e);
+ MAIN.post(() -> callback.onError(e));
+ }
+ }
+
+ private static void postResult(final Callback callback, final TranslationCacheDatabase.Result cached) {
+ MAIN.post(() -> callback.onResult(cached.translatedText(), cached.sourceLang(), cached.backend()));
+ }
+
+ private static String stripPreamble(final String raw) {
+ String text = raw.strip();
+ text = PREAMBLE.matcher(text).replaceFirst("").strip();
+ if (text.length() >= 2) {
+ final char first = text.charAt(0);
+ final char last = text.charAt(text.length() - 1);
+ if (first == last && "\"'“”".indexOf(first) >= 0) {
+ text = text.substring(1, text.length() - 1).strip();
+ }
+ }
+ return text;
+ }
+
+ private record GoogleTranslateResult(String translatedText, @Nullable String sourceLang) {}
+
+ // Unofficial endpoint, intended as a stopgap only — used solely once the user has
+ // consented to a Google fallback for this message (or permanently).
+ private static GoogleTranslateResult fetchFromGoogleTranslate(
final String text, final String targetLang) throws IOException {
final HttpUrl url = HttpUrl.parse("https://translate.googleapis.com/translate_a/single")
.newBuilder()
@@ -98,7 +260,7 @@ public class TranslationService {
} catch (final Exception ignored) {
// not all responses include the detected source language at index 2
}
- return new TranslationCacheDatabase.Result(translated.toString(), detectedSourceLang);
+ return new GoogleTranslateResult(translated.toString(), detectedSourceLang);
} catch (final org.json.JSONException e) {
throw new IOException("could not parse translation response", e);
}
diff --git a/src/main/java/tel/xmpp/jabjab/ui/util/TranslationSettings.java b/src/main/java/tel/xmpp/jabjab/ui/util/TranslationSettings.java
new file mode 100644
index 0000000..0273f9f
--- /dev/null
+++ b/src/main/java/tel/xmpp/jabjab/ui/util/TranslationSettings.java
@@ -0,0 +1,38 @@
+package tel.xmpp.jabjab.ui.util;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+
+/**
+ * Shared by both the incoming (long-press) and outgoing (compose bar) translate
+ * flows: one privacy decision, not two. Defaults to false so every fallback to
+ * Google Translate requires explicit per-instance consent until the user opts in.
+ */
+public class TranslationSettings {
+
+ private static final String PREFS_NAME = "jabjab_translation";
+ private static final String KEY_AUTO_FALLBACK_GOOGLE = "auto_fallback_google";
+ private static TranslationSettings instance;
+ private final SharedPreferences prefs;
+
+ private TranslationSettings(final Context context) {
+ this.prefs =
+ context.getApplicationContext()
+ .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
+ }
+
+ public static synchronized TranslationSettings getInstance(final Context context) {
+ if (instance == null) {
+ instance = new TranslationSettings(context);
+ }
+ return instance;
+ }
+
+ public boolean isAutoFallbackToGoogleEnabled() {
+ return prefs.getBoolean(KEY_AUTO_FALLBACK_GOOGLE, false);
+ }
+
+ public void setAutoFallbackToGoogleEnabled(final boolean enabled) {
+ prefs.edit().putBoolean(KEY_AUTO_FALLBACK_GOOGLE, enabled).apply();
+ }
+}
diff --git a/src/main/java/tel/xmpp/jabjab/ui/widget/VideoTrimmerView.java b/src/main/java/tel/xmpp/jabjab/ui/widget/VideoTrimmerView.java
index 2ae8cc5..cb61363 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/widget/VideoTrimmerView.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/widget/VideoTrimmerView.java
@@ -130,8 +130,9 @@ public class VideoTrimmerView extends View {
/** Programmatic setter — does not fire the listener. */
public void setTrim(long startMs, long endMs, long playheadMs) {
- this.startMs = startMs;
- this.endMs = endMs;
+ if (startMs > endMs) { long t = startMs; startMs = endMs; endMs = t; }
+ this.startMs = clamp(startMs, 0, durationMs);
+ this.endMs = clamp(endMs, 0, durationMs);
this.playheadMs = clampPlayhead(playheadMs);
centerViewportOn((startMs + endMs) / 2);
invalidate();
@@ -259,7 +260,7 @@ public class VideoTrimmerView extends View {
final float dx = x - touchDownX;
final float dy = e.getY() - touchDownY;
if (!didDrag && (Math.abs(dx) > 8 * dp || Math.abs(dy) > 8 * dp)) didDrag = true;
- final long dMs = (long) (dx / w * viewportWidthMs);
+ final long dMs = (long) ((double) dx / w * viewportWidthMs);
switch (touchMode) {
case TOUCH_LEFT: {
diff --git a/src/main/java/tel/xmpp/jabjab/xmpp/XmppConnection.java b/src/main/java/tel/xmpp/jabjab/xmpp/XmppConnection.java
index 0ab49e7..4c74c61 100644
--- a/src/main/java/tel/xmpp/jabjab/xmpp/XmppConnection.java
+++ b/src/main/java/tel/xmpp/jabjab/xmpp/XmppConnection.java
@@ -1318,13 +1318,17 @@ public class XmppConnection implements Runnable {
+ mStanzaQueue.keyAt(i));
}
final Stanza stanza = mStanzaQueue.valueAt(i);
- if (stanza instanceof im.conversations.android.xmpp.model.stanza.Message packet
- && messageAcknowledgedProcessor != null) {
+ if (stanza instanceof im.conversations.android.xmpp.model.stanza.Message packet) {
final String id = packet.getId();
final Jid to = packet.getTo();
- if (id != null && to != null) {
+ Log.d(Config.LOGTAG, "STICKER acknowledgeStanzaUpTo: Message stanza id=" + id
+ + " to=" + to + " processorNull=" + (messageAcknowledgedProcessor == null));
+ if (messageAcknowledgedProcessor != null && id != null && to != null) {
acknowledgedMessages |= messageAcknowledgedProcessor.apply(to, id);
}
+ } else if (Config.EXTENDED_SM_LOGGING) {
+ Log.d(Config.LOGTAG, "STICKER acknowledgeStanzaUpTo: non-Message stanza type="
+ + stanza.getClass().getSimpleName());
}
mStanzaQueue.removeAt(i);
i--;
diff --git a/src/main/res/drawable/ic_translate_24dp.xml b/src/main/res/drawable/ic_translate_24dp.xml
new file mode 100644
index 0000000..43a135e
--- /dev/null
+++ b/src/main/res/drawable/ic_translate_24dp.xml
@@ -0,0 +1,10 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:tint="?colorControlNormal"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="@android:color/white"
+ android:pathData="M12.87,15.07l-2.54,-2.51 0.03,-0.03c1.74,-1.94 2.98,-4.17 3.71,-6.53H17V4h-7V2H8v2H1v1.99h11.17C11.5,7.92 10.44,9.75 9,11.35 8.07,10.32 7.3,9.19 6.69,8h-2c0.73,1.63 1.73,3.17 2.98,4.56l-5.09,5.02L4,19l5,-5 3.11,3.11 0.76,-2.04zM18.5,10h-2L12,22h2l1.12,-3h4.75L21,22h2l-4.5,-12zM15.88,17l1.62,-4.33L19.12,17h-3.24z" />
+</vector>
diff --git a/src/main/res/layout/activity_story_composer.xml b/src/main/res/layout/activity_story_composer.xml
index 7621e92..59ad5f9 100644
--- a/src/main/res/layout/activity_story_composer.xml
+++ b/src/main/res/layout/activity_story_composer.xml
@@ -3,6 +3,7 @@
android:id="@+id/composer_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
+ android:fitsSystemWindows="true"
android:background="#000">
<ImageView
diff --git a/src/main/res/layout/activity_story_view.xml b/src/main/res/layout/activity_story_view.xml
index 576b09c..0bc000b 100644
--- a/src/main/res/layout/activity_story_view.xml
+++ b/src/main/res/layout/activity_story_view.xml
@@ -3,6 +3,7 @@
android:id="@+id/story_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
+ android:fitsSystemWindows="true"
android:background="#000">
<ImageView
diff --git a/src/main/res/layout/activity_telegram_sticker_import.xml b/src/main/res/layout/activity_telegram_sticker_import.xml
new file mode 100644
index 0000000..fe9d34e
--- /dev/null
+++ b/src/main/res/layout/activity_telegram_sticker_import.xml
@@ -0,0 +1,42 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:fitsSystemWindows="true"
+ android:orientation="vertical">
+
+ <com.google.android.material.appbar.AppBarLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content">
+
+ <com.google.android.material.appbar.MaterialToolbar
+ android:id="@+id/toolbar"
+ android:layout_width="match_parent"
+ android:layout_height="?actionBarSize"
+ android:title="@string/import_sticker_pack" />
+
+ </com.google.android.material.appbar.AppBarLayout>
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="0dp"
+ android:layout_weight="1"
+ android:gravity="center"
+ android:orientation="vertical"
+ android:padding="32dp">
+
+ <ProgressBar
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="16dp" />
+
+ <TextView
+ android:id="@+id/status"
+ style="?textAppearanceBodyMedium"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:gravity="center" />
+
+ </LinearLayout>
+
+</LinearLayout>
diff --git a/src/main/res/layout/dialog_translation_fallback.xml b/src/main/res/layout/dialog_translation_fallback.xml
new file mode 100644
index 0000000..ba4e07f
--- /dev/null
+++ b/src/main/res/layout/dialog_translation_fallback.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layout xmlns:android="http://schemas.android.com/apk/res/android">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:orientation="vertical"
+ android:padding="?dialogPreferredPadding">
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@string/translation_fallback_explained"
+ android:textAppearance="?textAppearanceBodyMedium" />
+
+ <CheckBox
+ android:layout_marginTop="16sp"
+ android:id="@+id/do_not_ask_again"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:checked="false"
+ android:text="@string/translation_do_not_ask_again" />
+
+ </LinearLayout>
+</layout>
diff --git a/src/main/res/layout/fragment_conversation.xml b/src/main/res/layout/fragment_conversation.xml
index 6f15d12..b8496dc 100644
--- a/src/main/res/layout/fragment_conversation.xml
+++ b/src/main/res/layout/fragment_conversation.xml
@@ -118,7 +118,7 @@
android:layout_centerVertical="true"
android:layout_marginVertical="8sp"
android:layout_marginHorizontal="12dp"
- android:layout_toStartOf="@+id/attach_button"
+ android:layout_toStartOf="@+id/compose_bar_icons"
android:orientation="vertical">
<TextView
@@ -165,19 +165,42 @@
</LinearLayout>
- <com.google.android.material.button.MaterialButton
- android:id="@+id/attach_button"
- style="?attr/materialIconButtonStyle"
+ <!-- Grouped in their own LinearLayout rather than two RelativeLayout
+ siblings chained via layout_toStartOf: a GONE sibling referenced by
+ toStartOf/toEndOf resolves unreliably in RelativeLayout (can collapse
+ to a negative-width remainder), which is exactly what attach_button
+ going GONE while typing would trigger. LinearLayout collapses GONE
+ children cleanly instead. -->
+ <LinearLayout
+ android:id="@+id/compose_bar_icons"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
- android:checkable="true"
- android:checked="false"
- app:icon="@drawable/ic_attach_file_24dp"
- app:iconSize="24dp"
- app:iconTint="?colorOnTertiaryContainer" />
+ android:orientation="horizontal">
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/translate_button"
+ style="?attr/materialIconButtonStyle"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:contentDescription="@string/translate_message"
+ app:icon="@drawable/ic_translate_24dp"
+ app:iconSize="24dp"
+ app:iconTint="?colorOnTertiaryContainer" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/attach_button"
+ style="?attr/materialIconButtonStyle"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:checkable="true"
+ android:checked="false"
+ app:icon="@drawable/ic_attach_file_24dp"
+ app:iconSize="24dp"
+ app:iconTint="?colorOnTertiaryContainer" />
+
+ </LinearLayout>
</RelativeLayout>
diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml
index 2f1770e..5e4cab0 100644
--- a/src/main/res/values/strings.xml
+++ b/src/main/res/values/strings.xml
@@ -118,6 +118,9 @@
<string name="sticker_pack_installed">Sticker pack installed</string>
<string name="sticker_pack_fetch_failed">Failed to install sticker pack</string>
<string name="fetching_sticker_pack">Downloading sticker pack…</string>
+ <string name="import_sticker_pack">Import sticker pack</string>
+ <string name="telegram_import_needs_token">Importing Telegram sticker packs requires a bot token. Add one in Settings, then tap the link again.</string>
+ <string name="attach_sticker_failed">Failed to send sticker</string>
<string name="export_selected_stickers">Export %d stickers</string>
<string name="select_stickers">Select stickers</string>
<string name="ok">OK</string>
@@ -1499,7 +1502,14 @@
<string name="translation_title">Translation</string>
<string name="translation_failed">Translation failed</string>
<string name="translation_in_progress">Translating…</string>
- <string name="translation_disclaimer">via Google Translate</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_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>
+ <string name="translation_do_not_ask_again">Always allow Google Translate fallback</string>
+ <string name="translate_choose_language">Translate to…</string>
+ <string name="translate_more_languages">More…</string>
<string name="story_caption_hint">Add a caption…</string>
<string name="story_post_button">Post</string>
<string name="share_to_story_label">JabJab Stories</string>
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.