commit e354e5d3e68850b2e65b0e261c7fcaa855fd3bd8
Author: JabJab <noreply@xmpp.tel>
Date: Wed Sep 30 08:58:26 2026 +0300
Remember scroll position in unread chats, GIF search and crash fixes
---
build.gradle | 10 ++-
.../tel/xmpp/jabjab/ui/BotCommandsFragment.java | 5 +-
.../tel/xmpp/jabjab/ui/ConversationFragment.java | 64 +++++++++++++--
.../java/tel/xmpp/jabjab/ui/GifSearchActivity.java | 29 +++++--
.../tel/xmpp/jabjab/ui/util/ScrollAnchorStore.java | 91 ++++++++++++++++++++++
src/main/res/values/strings.xml | 2 +
6 files changed, 183 insertions(+), 18 deletions(-)
diff --git a/build.gradle b/build.gradle
index 68f685b..f9d81f7 100644
--- a/build.gradle
+++ b/build.gradle
@@ -105,15 +105,19 @@ android {
defaultConfig {
minSdk = 23
- versionCode = 42316
+ versionCode = 42317
versionName = '1.0.8'
applicationId = 'tel.xmpp.jabjab'
resValue('string', 'applicationId', applicationId)
def appName = "JabJab"
resValue('string', 'app_name', appName)
buildConfigField('String', 'APP_NAME', "\"${appName}\"")
- // Giphy GIF search — override with GIPHY_API_KEY gradle property
- def giphyKey = localProps.getProperty('GIPHY_API_KEY') ?: project.findProperty('GIPHY_API_KEY') ?: ""
+ // GIF search — override with GIPHY_API_KEY (or its documented alias, TENOR_API_KEY)
+ // gradle property. Only GIPHY_API_KEY was ever actually wired here despite CLAUDE.md
+ // documenting TENOR_API_KEY as the primary name — a build using the documented flag
+ // silently got no key at all, leaving GIF search permanently in its "no API key" state.
+ def giphyKey = localProps.getProperty('GIPHY_API_KEY') ?: project.findProperty('GIPHY_API_KEY')
+ ?: localProps.getProperty('TENOR_API_KEY') ?: project.findProperty('TENOR_API_KEY') ?: ""
buildConfigField('String', 'GIPHY_API_KEY', "\"${giphyKey}\"")
def updateUrl = project.findProperty('UPDATE_SERVER_URL') ?: ""
buildConfigField('String', 'UPDATE_SERVER_URL', "\"${updateUrl}\"")
diff --git a/src/main/java/tel/xmpp/jabjab/ui/BotCommandsFragment.java b/src/main/java/tel/xmpp/jabjab/ui/BotCommandsFragment.java
index 67ebda8..e5649e7 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/BotCommandsFragment.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/BotCommandsFragment.java
@@ -195,7 +195,10 @@ public class BotCommandsFragment extends Fragment {
return null;
}
final var activity = (XmppActivity) getActivity();
- if (activity == null) {
+ // xmppConnectionService can still be null here on a cold app start — the service
+ // binds asynchronously and this fragment's view can be created (e.g. restored inside
+ // an already-open conversation) before that bind callback fires.
+ if (activity == null || activity.xmppConnectionService == null) {
return null;
}
return activity.xmppConnectionService.findAccountByUuid(uuid);
diff --git a/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java b/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java
index 55552fe..c685d1d 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java
@@ -126,6 +126,7 @@ import tel.xmpp.jabjab.ui.util.MucDetailsContextMenuHelper;
import tel.xmpp.jabjab.ui.util.PendingItem;
import tel.xmpp.jabjab.ui.util.PresenceSelector;
import tel.xmpp.jabjab.xml.Namespace;
+import tel.xmpp.jabjab.ui.util.ScrollAnchorStore;
import tel.xmpp.jabjab.ui.util.ScrollState;
import tel.xmpp.jabjab.ui.util.SendButtonAction;
import tel.xmpp.jabjab.ui.util.SendButtonTool;
@@ -4290,10 +4291,40 @@ public class ConversationFragment extends XmppFragment
.xmppConnectionService
.getNotificationService()
.setOpenConversation(null);
+ persistScrollAnchor();
}
this.reInitRequiredOnStart = true;
}
+ /** Remembers exactly where the user left off in this conversation, but only while it
+ * still has unread messages below that spot — a fully-read conversation, or one the
+ * user scrolled all the way down in, has nothing worth restoring on reopen. See
+ * ScrollAnchorStore and the matching restore logic in reInit(). */
+ private void persistScrollAnchor() {
+ final ScrollState scrollState = getScrollPosition();
+ if (scrollState == null || this.conversation.isRead()) {
+ Log.d(Config.LOGTAG, "persistScrollAnchor: clearing (atBottom="
+ + (scrollState == null) + " isRead=" + this.conversation.isRead()
+ + ") conversation=" + this.conversation.getUuid());
+ ScrollAnchorStore.clear(requireContext(), this.conversation.getUuid());
+ return;
+ }
+ synchronized (this.messageList) {
+ if (scrollState.position >= 0 && scrollState.position < this.messageList.size()) {
+ final String uuid = this.messageList.get(scrollState.position).getUuid();
+ Log.d(Config.LOGTAG, "persistScrollAnchor: saving uuid=" + uuid
+ + " pos=" + scrollState.position + " offset=" + scrollState.offset
+ + " conversation=" + this.conversation.getUuid());
+ ScrollAnchorStore.save(
+ requireContext(), this.conversation.getUuid(), uuid, scrollState.offset);
+ } else {
+ Log.d(Config.LOGTAG, "persistScrollAnchor: skipped, position "
+ + scrollState.position + " out of range (size="
+ + this.messageList.size() + ")");
+ }
+ }
+ }
+
private void updateChatState(final Conversation conversation, final String msg) {
final var state = msg.isEmpty() ? Config.DEFAULT_CHAT_STATE : Paused.class;
ChatStateManager.send(conversation, state);
@@ -4408,9 +4439,30 @@ public class ConversationFragment extends XmppFragment
this.conversation.messagesLoaded.set(true);
Log.d(Config.LOGTAG, "scrolledToBottomAndNoPending=" + scrolledToBottomAndNoPending);
- if (hasExtras || scrolledToBottomAndNoPending) {
- resetUnreadMessagesCount();
- synchronized (this.messageList) {
+ // A remembered mid-scroll spot (see ScrollAnchorStore / persistScrollAnchor()) takes
+ // priority over the firstUnread-or-bottom heuristic below, but never over an explicit
+ // target (hasExtras — e.g. a notification tap or reply jump).
+ final ScrollAnchorStore.ScrollAnchor anchor =
+ hasExtras ? null : ScrollAnchorStore.load(requireContext(), conversation.getUuid());
+
+ synchronized (this.messageList) {
+ final int anchorPos =
+ anchor == null ? -1 : getIndexOf(anchor.messageUuid, this.messageList);
+ if (anchor != null && anchorPos >= 0) {
+ deactivatePinToBottom();
+ resetUnreadMessagesCount();
+ Log.d(Config.LOGTAG, "reInit scroll: restoring remembered position uuid="
+ + anchor.messageUuid + " pos=" + anchorPos + " offset=" + anchor.offset);
+ setScrollPosition(new ScrollState(anchorPos, anchor.offset), anchor.messageUuid);
+ this.binding.messagesView.post(this::fireReadEvent);
+ } else if (anchor != null) {
+ // Not currently loaded (e.g. process restart past the initial page of
+ // messages) — reuse the same DB-aware fallback scrollToMessage() already has.
+ Log.d(Config.LOGTAG, "reInit scroll: remembered uuid=" + anchor.messageUuid
+ + " not loaded — falling back to DB lookup");
+ scrollToMessage(anchor.messageUuid);
+ } else if (hasExtras || scrolledToBottomAndNoPending) {
+ resetUnreadMessagesCount();
final Message first = conversation.getFirstUnreadMessage();
final int bottom = Math.max(0, this.messageList.size() - 1);
final int pos;
@@ -4433,10 +4485,10 @@ public class ConversationFragment extends XmppFragment
if (jumpToBottom) {
activatePinToBottom();
}
+ } else {
+ Log.d(Config.LOGTAG, "reInit scroll: skipped (hasExtras=" + hasExtras
+ + " atBottom=" + scrolledToBottomAndNoPending + ") — keeping user position");
}
- } else {
- Log.d(Config.LOGTAG, "reInit scroll: skipped (hasExtras=" + hasExtras
- + " atBottom=" + scrolledToBottomAndNoPending + ") — keeping user position");
}
this.binding.messagesView.post(this::fireReadEvent);
diff --git a/src/main/java/tel/xmpp/jabjab/ui/GifSearchActivity.java b/src/main/java/tel/xmpp/jabjab/ui/GifSearchActivity.java
index 61c544a..b8b8f3b 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/GifSearchActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/GifSearchActivity.java
@@ -21,6 +21,7 @@ import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.google.android.material.tabs.TabLayout;
import tel.xmpp.jabjab.BuildConfig;
+import tel.xmpp.jabjab.Config;
import tel.xmpp.jabjab.R;
import tel.xmpp.jabjab.persistance.FileBackend;
import tel.xmpp.jabjab.ui.util.GifStore;
@@ -108,6 +109,7 @@ public class GifSearchActivity extends BaseActivity {
});
final String apiKey = resolveApiKey();
+ Log.d(Config.LOGTAG, "GifSearchActivity.onCreate: apiKey " + (apiKey.isEmpty() ? "MISSING" : "present"));
if (apiKey.isEmpty()) {
emptyView.setText(R.string.gif_no_api_key);
emptyView.setVisibility(View.VISIBLE);
@@ -176,7 +178,7 @@ public class GifSearchActivity extends BaseActivity {
showLoading(false);
adapter.setResults(results);
emptyView.setVisibility(results.isEmpty() ? View.VISIBLE : View.GONE);
- if (results.isEmpty()) emptyView.setText(R.string.gif_search_trending);
+ if (results.isEmpty()) emptyView.setText(R.string.gif_search_no_results);
});
});
}
@@ -191,7 +193,7 @@ public class GifSearchActivity extends BaseActivity {
showLoading(false);
adapter.setResults(results);
emptyView.setVisibility(results.isEmpty() ? View.VISIBLE : View.GONE);
- if (results.isEmpty()) emptyView.setText(android.R.string.cancel);
+ if (results.isEmpty()) emptyView.setText(R.string.gif_search_no_results);
});
});
}
@@ -202,7 +204,12 @@ public class GifSearchActivity extends BaseActivity {
final HttpURLConnection conn = (HttpURLConnection) new URL(urlString).openConnection();
conn.setConnectTimeout(10_000);
conn.setReadTimeout(10_000);
- if (conn.getResponseCode() != 200) return results;
+ final int responseCode = conn.getResponseCode();
+ if (responseCode != 200) {
+ Log.w(Config.LOGTAG, "GifSearchActivity.fetchGifs: HTTP " + responseCode
+ + " from " + urlString.replaceAll("api_key=[^&]+", "api_key=REDACTED"));
+ return results;
+ }
final StringBuilder sb = new StringBuilder();
try (final InputStream is = conn.getInputStream()) {
final byte[] buf = new byte[4096];
@@ -225,7 +232,7 @@ public class GifSearchActivity extends BaseActivity {
results.add(new GifResult(gifUrl, sendUrl, previewUrl));
}
} catch (Exception e) {
- // network or parse error — return empty list
+ Log.w(Config.LOGTAG, "GifSearchActivity.fetchGifs: request failed", e);
}
return results;
}
@@ -247,7 +254,7 @@ public class GifSearchActivity extends BaseActivity {
finish();
});
} catch (Exception e) {
- Log.e("GifSearch", "onGifSelected failed", e);
+ Log.e(Config.LOGTAG, "GifSearchActivity.onGifSelected failed", e);
runOnUiThread(() -> {
showLoading(false);
Toast.makeText(this, R.string.no_application_found_to_open_file, Toast.LENGTH_SHORT).show();
@@ -275,6 +282,7 @@ public class GifSearchActivity extends BaseActivity {
alreadyHad ? R.string.gif_already_saved : R.string.gif_saved_to_favorites,
Toast.LENGTH_SHORT).show());
} catch (Exception e) {
+ Log.e(Config.LOGTAG, "GifSearchActivity.onGifLongPress: save failed", e);
runOnUiThread(() -> Toast.makeText(this,
R.string.sticker_export_failed, Toast.LENGTH_SHORT).show());
}
@@ -290,13 +298,14 @@ public class GifSearchActivity extends BaseActivity {
setResult(Activity.RESULT_OK, intent);
finish();
} catch (Exception e) {
+ Log.e(Config.LOGTAG, "GifSearchActivity.onLocalGifSelected failed", e);
Toast.makeText(this, R.string.no_application_found_to_open_file, Toast.LENGTH_SHORT).show();
}
}
private void onLocalGifLongPress(final File gifFile) {
new android.app.AlertDialog.Builder(this)
- .setMessage(getString(R.string.gif_deleted) + "?")
+ .setMessage(R.string.gif_remove_confirm)
.setPositiveButton(android.R.string.ok, (d, w) -> {
GifStore.delete(gifFile);
showMyGifs();
@@ -467,7 +476,9 @@ public class GifSearchActivity extends BaseActivity {
while ((n = is.read(buf)) != -1) baos.write(buf, 0, n);
}
decodeAndShow(baos.toByteArray(), url, imageView);
- } catch (Exception ignored) {}
+ } catch (Exception e) {
+ Log.w(Config.LOGTAG, "GifSearchActivity.loadAnimatedInto: failed for " + url, e);
+ }
}
static void decodeAndShow(final byte[] bytes, final String tag, final ImageView imageView) {
@@ -485,7 +496,9 @@ public class GifSearchActivity extends BaseActivity {
((android.graphics.drawable.AnimatedImageDrawable) drawable).start();
}
});
- } catch (Exception ignored) {}
+ } catch (Exception e) {
+ Log.w(Config.LOGTAG, "GifSearchActivity.decodeAndShow: ImageDecoder failed for " + tag, e);
+ }
} else {
final Bitmap bm = android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
if (bm != null) {
diff --git a/src/main/java/tel/xmpp/jabjab/ui/util/ScrollAnchorStore.java b/src/main/java/tel/xmpp/jabjab/ui/util/ScrollAnchorStore.java
new file mode 100644
index 0000000..fd6833d
--- /dev/null
+++ b/src/main/java/tel/xmpp/jabjab/ui/util/ScrollAnchorStore.java
@@ -0,0 +1,91 @@
+package tel.xmpp.jabjab.ui.util;
+
+import android.content.Context;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+/**
+ * Remembers, per conversation, the message the user was last scrolled to (see
+ * ConversationFragment#onStop / #reInit) — durable across leaving and reopening a
+ * conversation, unlike the Bundle-based ScrollState/pendingScrollState mechanism, which
+ * only survives OS-level instance-state retention. Only meant to be written to while a
+ * conversation still has unread messages below the remembered spot; a fully-read
+ * conversation's entry should be cleared, not written.
+ */
+public class ScrollAnchorStore {
+
+ private static final String PREFS = "jabjab_scroll_anchors";
+
+ private ScrollAnchorStore() {}
+
+ public static ScrollAnchor load(final Context context, final String conversationUuid) {
+ if (conversationUuid == null) {
+ return null;
+ }
+ final var prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
+ final String raw = prefs.getString(conversationUuid, null);
+ if (raw == null) {
+ return null;
+ }
+ return ScrollAnchor.fromJson(raw);
+ }
+
+ public static void save(
+ final Context context,
+ final String conversationUuid,
+ final String messageUuid,
+ final int offset) {
+ if (conversationUuid == null || messageUuid == null) {
+ return;
+ }
+ final var prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
+ prefs.edit()
+ .putString(conversationUuid, new ScrollAnchor(messageUuid, offset).toJson())
+ .apply();
+ }
+
+ public static void clear(final Context context, final String conversationUuid) {
+ if (conversationUuid == null) {
+ return;
+ }
+ context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ .edit()
+ .remove(conversationUuid)
+ .apply();
+ }
+
+ public static final class ScrollAnchor {
+ public final String messageUuid;
+ public final int offset;
+
+ public ScrollAnchor(final String messageUuid, final int offset) {
+ this.messageUuid = messageUuid;
+ this.offset = offset;
+ }
+
+ String toJson() {
+ try {
+ final var j = new JSONObject();
+ j.put("messageUuid", messageUuid);
+ j.put("offset", offset);
+ return j.toString();
+ } catch (final JSONException e) {
+ return "{}";
+ }
+ }
+
+ static ScrollAnchor fromJson(final String raw) {
+ try {
+ final var j = new JSONObject(raw);
+ final String uuid = j.optString("messageUuid", null);
+ if (uuid == null) {
+ return null;
+ }
+ return new ScrollAnchor(uuid, j.optInt("offset", 0));
+ } catch (final JSONException e) {
+ return null;
+ }
+ }
+ }
+}
diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml
index b0d6a96..4d9fe7a 100644
--- a/src/main/res/values/strings.xml
+++ b/src/main/res/values/strings.xml
@@ -1456,7 +1456,9 @@
<string name="attachment_choice_gif">GIF</string>
<string name="gif_search_hint">Search GIFs…</string>
<string name="gif_search_trending">Trending</string>
+ <string name="gif_search_no_results">No GIFs found</string>
<string name="gif_no_api_key">Set a GIPHY API key in settings to use GIF search</string>
+ <string name="gif_remove_confirm">Remove this GIF?</string>
<string name="pref_giphy_api_key">Giphy API key</string>
<string name="pref_giphy_api_key_summary">Override the built-in Giphy key for GIF search</string>
<string name="sticker_pack_title">Sticker Pack: %s</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.