commit d2d99a02218ecd66dfce7d79b640b4e8885a8759
Author: JabJab <noreply@xmpp.tel>
Date: Wed Jul 22 11:36:14 2026 +0300
See if your contact uses JabJab
---
build.gradle | 2 +-
.../tel/xmpp/jabjab/ui/ContactDetailsActivity.java | 43 ++++++++++++++++
.../tel/xmpp/jabjab/ui/ConversationFragment.java | 59 ++++++++++++++++++++--
.../xmpp/jabjab/ui/util/ClientResourceUtils.java | 45 +++++++++++++++++
.../java/tel/xmpp/jabjab/xmpp/XmppConnection.java | 30 +++++++----
src/main/res/layout/activity_contact_details.xml | 12 +++++
src/main/res/layout/fragment_conversation.xml | 51 ++++++++++++++++++-
src/main/res/values/strings.xml | 2 +
8 files changed, 228 insertions(+), 16 deletions(-)
diff --git a/build.gradle b/build.gradle
index 8469813..8993725 100644
--- a/build.gradle
+++ b/build.gradle
@@ -113,7 +113,7 @@ android {
defaultConfig {
minSdkVersion 23
- versionCode 42297
+ versionCode 42298
versionName "1.0.1"
applicationId "tel.xmpp.jabjab"
resValue "string", "applicationId", applicationId
diff --git a/src/main/java/tel/xmpp/jabjab/ui/ContactDetailsActivity.java b/src/main/java/tel/xmpp/jabjab/ui/ContactDetailsActivity.java
index bff3007..e2c7c2f 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/ContactDetailsActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/ContactDetailsActivity.java
@@ -448,6 +448,46 @@ public class ContactDetailsActivity extends OmemoActivity
return super.onCreateOptionsMenu(menu);
}
+ /**
+ * One small pill per online resource, labelled with a best-guess client name. Conversations-
+ * lineage clients (Conversations, JabJab, Monocles) put their client name directly in the
+ * resource as "ClientName.RANDOM" — this just strips the trailing ".RANDOM" part. Clients
+ * that don't follow that convention just show their raw resource string as-is; there's no
+ * XEP-0092/disco query here, this is purely a free heuristic on data we already have.
+ */
+ private void populateClientResourceBadges(final Contact contact) {
+ binding.clientResourcesBox.removeAllViews();
+ boolean added = false;
+ final float density = getResources().getDisplayMetrics().density;
+ final int hPad = (int) (10 * density);
+ final int vPad = (int) (4 * density);
+ final int marginEnd = (int) (6 * density);
+ for (final Presence presence : contact.getPresences()) {
+ final Jid from = presence.getFrom();
+ final String resource = from == null ? null : from.getResource();
+ if (Strings.isNullOrEmpty(resource)) {
+ continue;
+ }
+ final TextView chip = new TextView(this);
+ chip.setText(tel.xmpp.jabjab.ui.util.ClientResourceUtils.stripResourceSuffix(resource));
+ chip.setBackgroundResource(R.drawable.bg_search_pill);
+ chip.setPadding(hPad, vPad, hPad, vPad);
+ chip.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 12);
+ chip.setTextColor(
+ MaterialColors.getColor(
+ this, com.google.android.material.R.attr.colorOnSurfaceVariant, 0));
+ final android.widget.LinearLayout.LayoutParams lp =
+ new android.widget.LinearLayout.LayoutParams(
+ android.widget.LinearLayout.LayoutParams.WRAP_CONTENT,
+ android.widget.LinearLayout.LayoutParams.WRAP_CONTENT);
+ lp.setMarginEnd(marginEnd);
+ chip.setLayoutParams(lp);
+ binding.clientResourcesBox.addView(chip);
+ added = true;
+ }
+ binding.clientResourcesBox.setVisibility(added ? View.VISIBLE : View.GONE);
+ }
+
private void populateView() {
if (contact == null) {
return;
@@ -484,6 +524,8 @@ public class ContactDetailsActivity extends OmemoActivity
binding.statusMessage.setText(Joiner.on('\n').join(statusMessages));
}
+ populateClientResourceBadges(contact);
+
if (contact.getOption(Contact.Options.FROM)) {
binding.detailsSendPresence.setText(R.string.send_presence_updates);
binding.detailsSendPresence.setChecked(true);
@@ -517,6 +559,7 @@ public class ContactDetailsActivity extends OmemoActivity
binding.detailsSendPresence.setVisibility(View.GONE);
binding.detailsReceivePresence.setVisibility(View.GONE);
binding.statusMessage.setVisibility(View.GONE);
+ binding.clientResourcesBox.setVisibility(View.GONE);
}
binding.detailsContactXmppAddress.setText(
IrregularUnicodeDetector.style(this, contact.getAddress()));
diff --git a/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java b/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java
index cf089c2..3945732 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java
@@ -4446,9 +4446,20 @@ public class ConversationFragment extends XmppFragment
private void updateToolbar() {
final boolean isTabletView = ConversationsActivity.isTabletView(getActivity());
this.binding.toolbar.setSubtitleCentered(isTabletView);
- this.binding.toolbar.setTitleCentered(isTabletView);
+ // Title itself is now a custom view (name + "uses JabJab" badge, see
+ // fragment_conversation.xml) rather than the Toolbar's own setTitle() — so it can be
+ // tapped independently. setTitleCentered() no longer has anything to act on; mirror
+ // its intent by centering our own title box on tablet layouts instead.
+ final var titleBoxParams =
+ (androidx.appcompat.widget.Toolbar.LayoutParams)
+ this.binding.toolbarTitleBox.getLayoutParams();
+ titleBoxParams.gravity =
+ isTabletView
+ ? android.view.Gravity.CENTER
+ : android.view.Gravity.START | android.view.Gravity.CENTER_VERTICAL;
+ this.binding.toolbarTitleBox.setLayoutParams(titleBoxParams);
final var c = this.conversation;
- this.binding.toolbar.setTitle(c.getName());
+ this.binding.toolbarTitleText.setText(c.getName());
if (c.getMode() == Conversation.MODE_SINGLE) {
final var contact = conversation.getContact();
final int statusRes = switch (contact.getShownStatus()) {
@@ -4459,8 +4470,29 @@ public class ConversationFragment extends XmppFragment
case DND -> R.string.presence_dnd;
default -> R.string.presence_offline;
};
- this.binding.toolbar.setSubtitle(getString(statusRes));
+ // In-row status text, not the native Toolbar subtitle — keeps name/status/badge on
+ // one line instead of stacking status above/below the custom title row.
+ this.binding.toolbar.setSubtitle(null);
+ this.binding.toolbarStatusText.setText(getString(statusRes));
+ this.binding.toolbarStatusText.setVisibility(View.VISIBLE);
+ // Free heuristic on data already loaded (see ClientResourceUtils) — no extra query.
+ // Piggybacks on the existing refresh() -> updateToolbar() pipeline, which already
+ // runs on presence changes (PresenceManager -> updateRosterUi() -> onRosterUpdate()
+ // -> refreshUi()), so this stays live with no additional listener needed.
+ final boolean usesJabJab =
+ tel.xmpp.jabjab.ui.util.ClientResourceUtils.isOnlineUsingThisApp(
+ requireContext(), contact);
+ this.binding.jabjabBadgeIcon.setVisibility(usesJabJab ? View.VISIBLE : View.GONE);
+ this.binding.jabjabBadgeIcon.setOnClickListener(
+ v ->
+ Toast.makeText(
+ requireContext(),
+ R.string.contact_uses_jabjab_toast,
+ Toast.LENGTH_SHORT)
+ .show());
} else if (c.getMode() == Conversation.MODE_MULTI) {
+ this.binding.jabjabBadgeIcon.setVisibility(View.GONE);
+ this.binding.toolbarStatusText.setVisibility(View.GONE);
final var mucOptions = conversation.getMucOptions();
final var userCount = mucOptions.getUserCount();
if (mucOptions.isPrivateAndNonAnonymous() || userCount < 1) {
@@ -4471,10 +4503,27 @@ public class ConversationFragment extends XmppFragment
.getQuantityString(R.plurals.x_participants, userCount, userCount));
}
} else {
+ this.binding.jabjabBadgeIcon.setVisibility(View.GONE);
+ this.binding.toolbarStatusText.setVisibility(View.GONE);
this.binding.toolbar.setSubtitle(null);
}
- ToolbarUtils.setActionBarOnClickListener(
- this.binding.toolbar, v -> openConversationDetails(c));
+ // ToolbarUtils.setActionBarOnClickListener() finds the title/subtitle TextViews by
+ // matching toolbar.getTitle()/getSubtitle() text among the Toolbar's children — since
+ // the title row is now our own custom view (toolbar.setTitle() is never called for 1:1
+ // chats, and setSubtitle(null) here too), it would find nothing and silently attach to
+ // nothing. Wire the click directly onto our views instead: name, status text, and the
+ // row container itself (covers the small gaps between them and any empty space) all
+ // open the profile; the badge icon keeps its own separate listener (set above) and
+ // isn't touched here, same for the toolbar's nav/overflow icons which aren't part of
+ // this row at all.
+ final View.OnClickListener openDetails = v -> openConversationDetails(c);
+ this.binding.toolbarTitleText.setOnClickListener(openDetails);
+ this.binding.toolbarStatusText.setOnClickListener(openDetails);
+ this.binding.toolbarTitleBox.setOnClickListener(openDetails);
+ // Also the Toolbar itself, so tapping empty space beyond the title row (it's
+ // wrap_content, not full-width) still opens the profile — nav icon/overflow menu
+ // already consume their own touches so they're unaffected.
+ this.binding.toolbar.setOnClickListener(openDetails);
ToolbarUtils.adjustToolbarHeight(this.binding.toolbar, isTabletView);
this.binding.toolbar.invalidateMenu();
}
diff --git a/src/main/java/tel/xmpp/jabjab/ui/util/ClientResourceUtils.java b/src/main/java/tel/xmpp/jabjab/ui/util/ClientResourceUtils.java
new file mode 100644
index 0000000..66f21fc
--- /dev/null
+++ b/src/main/java/tel/xmpp/jabjab/ui/util/ClientResourceUtils.java
@@ -0,0 +1,45 @@
+package tel.xmpp.jabjab.ui.util;
+
+import android.content.Context;
+import com.google.common.base.Strings;
+import tel.xmpp.jabjab.R;
+import tel.xmpp.jabjab.entities.Contact;
+import tel.xmpp.jabjab.xmpp.Jid;
+import im.conversations.android.xmpp.model.stanza.Presence;
+
+/**
+ * Best-guess client name from a resource string, and whether a contact currently looks like
+ * they're on this app. Purely a heuristic on data we already have (no XEP-0092/disco query):
+ * Conversations-lineage clients (Conversations, JabJab, Monocles) put their client name directly
+ * in the resource as "ClientName.RANDOM" (see XmppConnection.createNewResource()) — this just
+ * reads that convention back out. Clients that don't follow it just show their raw resource.
+ */
+public class ClientResourceUtils {
+
+ private ClientResourceUtils() {}
+
+ public static String stripResourceSuffix(final String resource) {
+ final int dot = resource.indexOf('.');
+ if (dot > 0) {
+ return resource.substring(0, dot);
+ }
+ return resource;
+ }
+
+ /** True if any of the contact's online resources' guessed client name matches this app's
+ * own name — i.e. they're plausibly also using JabJab right now. */
+ public static boolean isOnlineUsingThisApp(final Context context, final Contact contact) {
+ final String appName = context.getString(R.string.app_name);
+ for (final Presence presence : contact.getPresences()) {
+ final Jid from = presence.getFrom();
+ final String resource = from == null ? null : from.getResource();
+ if (Strings.isNullOrEmpty(resource)) {
+ continue;
+ }
+ if (appName.equalsIgnoreCase(stripResourceSuffix(resource))) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/src/main/java/tel/xmpp/jabjab/xmpp/XmppConnection.java b/src/main/java/tel/xmpp/jabjab/xmpp/XmppConnection.java
index 4c74c61..4e53b8c 100644
--- a/src/main/java/tel/xmpp/jabjab/xmpp/XmppConnection.java
+++ b/src/main/java/tel/xmpp/jabjab/xmpp/XmppConnection.java
@@ -241,15 +241,27 @@ public class XmppConnection implements Runnable {
}
private static void fixResource(final Context context, final Account account) {
- String resource = account.getResource();
- int fixedPartLength =
- context.getString(R.string.app_name).length() + 1; // include the trailing dot
- int randomPartLength = 4; // 3 bytes
- if (resource != null && resource.length() > fixedPartLength + randomPartLength) {
- if (validBase64(
- resource.substring(fixedPartLength, fixedPartLength + randomPartLength))) {
- account.setResource(resource.substring(0, fixedPartLength + randomPartLength));
- }
+ final String resource = account.getResource();
+ final String prefix = context.getString(R.string.app_name) + "."; // includes the dot
+ final int fixedPartLength = prefix.length();
+ final int randomPartLength = 4; // 3 bytes
+ if (resource == null || !resource.startsWith(prefix)) {
+ // A resource that doesn't start with our own "<app_name>." prefix is stale (e.g.
+ // still "Conversations.XXXX" from before the tel.xmpp.jabjab package rename, or
+ // literally from a different app) — not just an oversized version of our own
+ // resource. The length-only check below used to run regardless of prefix, so a
+ // same-or-longer foreign resource could get truncated into garbage whenever the
+ // cut point happened to coincidentally land on a valid-base64 substring — e.g.
+ // "Conversations.HSjx" decoding "atio" as valid base64 and truncating to
+ // "Conversatio". Generate a fresh resource instead so it actually reads
+ // "<app_name>.XXXX" going forward.
+ account.setResource(createNewResource());
+ return;
+ }
+ if (resource.length() > fixedPartLength + randomPartLength
+ && validBase64(
+ resource.substring(fixedPartLength, fixedPartLength + randomPartLength))) {
+ account.setResource(resource.substring(0, fixedPartLength + randomPartLength));
}
}
diff --git a/src/main/res/layout/activity_contact_details.xml b/src/main/res/layout/activity_contact_details.xml
index 56b9d6d..7e0f4b7 100644
--- a/src/main/res/layout/activity_contact_details.xml
+++ b/src/main/res/layout/activity_contact_details.xml
@@ -134,6 +134,18 @@
android:textAppearance="?textAppearanceBodyMedium"
tools:text="Hey there! I’m using Conversations" />
+ <!-- One small pill per online resource, guessed from the resource
+ string convention Conversations-lineage clients (Conversations,
+ JabJab, Monocles) use: "ClientName.RANDOM". Hidden when the
+ contact has no online presences. -->
+ <LinearLayout
+ android:id="@+id/client_resources_box"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="8sp"
+ android:orientation="horizontal"
+ android:visibility="gone" />
+
<TextView
android:id="@+id/vcard_bio"
android:layout_width="wrap_content"
diff --git a/src/main/res/layout/fragment_conversation.xml b/src/main/res/layout/fragment_conversation.xml
index b8496dc..db0fa3c 100644
--- a/src/main/res/layout/fragment_conversation.xml
+++ b/src/main/res/layout/fragment_conversation.xml
@@ -18,7 +18,56 @@
android:gravity="center_vertical"
app:buttonGravity="center_vertical"
app:subtitleTextAppearance="?textAppearanceBodySmall"
- app:subtitleTextColor="?colorOnSurfaceVariant" />
+ app:subtitleTextColor="?colorOnSurfaceVariant">
+
+ <!-- Custom title row: name, then status (small+dim), then a tiny tappable
+ "uses JabJab" badge — all on one line. Replaces Toolbar's own
+ setTitle()/setSubtitle() for 1:1 chats (see updateToolbar()) so the badge
+ can sit right next to the name instead of the native title/subtitle stack,
+ whose vertical ordering doesn't mix cleanly with an added custom child. A
+ LinearLayout here mirrors automatically under RTL locales. -->
+ <LinearLayout
+ android:id="@+id/toolbar_title_box"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="start|center_vertical"
+ android:gravity="center_vertical"
+ android:orientation="horizontal">
+
+ <TextView
+ android:id="@+id/toolbar_title_text"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:ellipsize="end"
+ android:maxLines="1"
+ android:textAppearance="?attr/textAppearanceTitleLarge" />
+
+ <TextView
+ android:id="@+id/toolbar_status_text"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="6dp"
+ android:ellipsize="end"
+ android:maxLines="1"
+ android:textAppearance="?attr/textAppearanceBodySmall"
+ android:textColor="?colorOnSurfaceVariant"
+ android:visibility="gone" />
+
+ <ImageView
+ android:id="@+id/jabjab_badge_icon"
+ android:layout_width="16dp"
+ android:layout_height="16dp"
+ android:layout_marginStart="6dp"
+ android:background="?attr/selectableItemBackgroundBorderless"
+ android:clickable="true"
+ android:contentDescription="@string/contact_uses_jabjab"
+ android:focusable="true"
+ android:padding="2dp"
+ android:src="@drawable/ic_app_icon_notification"
+ android:visibility="gone"
+ app:tint="?attr/colorPrimary" />
+ </LinearLayout>
+ </com.google.android.material.appbar.MaterialToolbar>
</com.google.android.material.appbar.AppBarLayout>
diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml
index d29eb56..d94ed7b 100644
--- a/src/main/res/values/strings.xml
+++ b/src/main/res/values/strings.xml
@@ -635,6 +635,8 @@
<string name="presence_xa">Not Available</string>
<string name="presence_dnd">Do Not Disturb</string>
<string name="presence_offline">Offline</string>
+ <string name="contact_uses_jabjab">Uses JabJab</string>
+ <string name="contact_uses_jabjab_toast">Your contact uses JabJab</string>
<string name="secure_password_generated">A secure password has been generated</string>
<string name="device_does_not_support_battery_op">Your device does not support opting out of battery optimization</string>
<string name="registration_please_wait">Registration failed: Try again later</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.