commit faa4a4b93dafd2a76715ed6c788163f387c3f4cf
Author: JabJab <noreply@xmpp.tel>
Date: Tue Jul 28 15:27:21 2026 +0300
Added social media style profiles with overlapping avatar and header
---
build.gradle | 4 +-
.../0e30cb2cf77a7e4ff1d6ca9c4165b3b1/results.bin | 1 -
.../annotation/annotation_dex/classes.dex | Bin 1424 -> 0 bytes
src/main/AndroidManifest.xml | 3 +
.../android/xmpp/model/banner/Banner.java | 98 ++++++
.../android/xmpp/model/banner/package-info.java | 1 +
src/main/java/tel/xmpp/jabjab/Config.java | 4 +
.../java/tel/xmpp/jabjab/entities/Account.java | 33 ++
.../java/tel/xmpp/jabjab/entities/Contact.java | 17 ++
.../tel/xmpp/jabjab/ui/ContactDetailsActivity.java | 65 +++-
.../tel/xmpp/jabjab/ui/ConversationFragment.java | 132 +++++++-
.../tel/xmpp/jabjab/ui/EditAccountActivity.java | 116 +++++++
.../tel/xmpp/jabjab/ui/EditProfileActivity.java | 104 ++++++-
.../tel/xmpp/jabjab/ui/PublishBannerActivity.java | 255 ++++++++++++++++
.../xmpp/jabjab/ui/util/ProfilePendingStore.java | 123 ++++++++
src/main/java/tel/xmpp/jabjab/xmpp/Managers.java | 2 +
.../xmpp/jabjab/xmpp/manager/BannerManager.java | 227 ++++++++++++++
.../tel/xmpp/jabjab/xmpp/manager/DiscoManager.java | 1 +
.../xmpp/jabjab/xmpp/manager/PubSubManager.java | 10 +
src/main/res/drawable/circle_solid.xml | 5 +
src/main/res/drawable/ic_circle_24dp.xml | 10 +
src/main/res/layout/activity_contact_details.xml | 332 +++++++++++++++------
src/main/res/layout/activity_edit_account.xml | 100 +++++++
src/main/res/layout/activity_edit_profile.xml | 57 +++-
src/main/res/layout/activity_publish_banner.xml | 108 +++++++
src/main/res/menu/activity_publish_banner.xml | 10 +
src/main/res/values/strings.xml | 11 +
src/main/res/values/themes.xml | 5 +
28 files changed, 1698 insertions(+), 136 deletions(-)
diff --git a/build.gradle b/build.gradle
index 25206a3..d8125f6 100644
--- a/build.gradle
+++ b/build.gradle
@@ -113,8 +113,8 @@ android {
defaultConfig {
minSdkVersion 23
- versionCode 42303
- versionName "1.0.4"
+ versionCode 42304
+ versionName "1.0.5"
applicationId "tel.xmpp.jabjab"
resValue "string", "applicationId", applicationId
def appName = "JabJab"
diff --git a/libs/annotation/build/.transforms/0e30cb2cf77a7e4ff1d6ca9c4165b3b1/results.bin b/libs/annotation/build/.transforms/0e30cb2cf77a7e4ff1d6ca9c4165b3b1/results.bin
deleted file mode 100644
index 1a31b12..0000000
--- a/libs/annotation/build/.transforms/0e30cb2cf77a7e4ff1d6ca9c4165b3b1/results.bin
+++ /dev/null
@@ -1 +0,0 @@
-o/annotation
diff --git a/libs/annotation/build/.transforms/0e30cb2cf77a7e4ff1d6ca9c4165b3b1/transformed/annotation/annotation_dex/classes.dex b/libs/annotation/build/.transforms/0e30cb2cf77a7e4ff1d6ca9c4165b3b1/transformed/annotation/annotation_dex/classes.dex
deleted file mode 100644
index 084a5d3..0000000
Binary files a/libs/annotation/build/.transforms/0e30cb2cf77a7e4ff1d6ca9c4165b3b1/transformed/annotation/annotation_dex/classes.dex and /dev/null differ
diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml
index 3c01ef5..7ebf0a4 100644
--- a/src/main/AndroidManifest.xml
+++ b/src/main/AndroidManifest.xml
@@ -345,6 +345,9 @@
<activity
android:name=".ui.PublishProfilePictureActivity"
android:label="@string/mgmt_account_publish_avatar" />
+ <activity
+ android:name=".ui.PublishBannerActivity"
+ android:label="@string/publish_banner_title" />
<activity
android:name=".ui.PublishGroupChatProfilePictureActivity"
android:label="@string/group_chat_avatar" />
diff --git a/src/main/java/im/conversations/android/xmpp/model/banner/Banner.java b/src/main/java/im/conversations/android/xmpp/model/banner/Banner.java
new file mode 100644
index 0000000..269cc62
--- /dev/null
+++ b/src/main/java/im/conversations/android/xmpp/model/banner/Banner.java
@@ -0,0 +1,98 @@
+package im.conversations.android.xmpp.model.banner;
+
+import im.conversations.android.xmpp.model.atom.Entry;
+
+/**
+ * Wraps an Atom <entry> from the urn:xmpp:jabjab:banner:0 node — a single persistent item
+ * (item id "current") holding the user's profile banner image URL. Mirrors
+ * im.conversations.android.xmpp.model.stories.Story's Entry-wrapping approach so no new
+ * ExtensionFactory-registered XML element type is needed; Entry is already registered.
+ *
+ * <entry xmlns="http://www.w3.org/2005/Atom">
+ * <published>ISO-8601</published>
+ * <link rel="enclosure" href="URL" sha1="…" width="…" height="…"/>
+ * </entry>
+ */
+public class Banner {
+
+ private final String url;
+ private final String sha1;
+ private final int width;
+ private final int height;
+ private final String published;
+
+ private Banner(
+ final String url,
+ final String sha1,
+ final int width,
+ final int height,
+ final String published) {
+ this.url = url;
+ this.sha1 = sha1;
+ this.width = width;
+ this.height = height;
+ this.published = published;
+ }
+
+ public static Banner fromEntry(final Entry entry) {
+ if (entry == null) return null;
+ final var link = entry.findChild("link");
+ if (link == null || !"enclosure".equals(link.getAttribute("rel"))) return null;
+ final String url = link.getAttribute("href");
+ if (url == null || url.isEmpty()) return null;
+ return new Banner(
+ url,
+ link.getAttribute("sha1"),
+ parseIntOrZero(link.getAttribute("width")),
+ parseIntOrZero(link.getAttribute("height")),
+ entry.getPublished());
+ }
+
+ public static Entry toEntry(
+ final String url,
+ final String sha1,
+ final int width,
+ final int height,
+ final String now) {
+ final var entry = new Entry();
+ entry.setId("urn:uuid:banner");
+ entry.setPublished(now);
+ entry.setUpdated(now);
+ final var link = entry.addChild("link");
+ link.setAttribute("rel", "enclosure");
+ link.setAttribute("href", url);
+ if (sha1 != null) link.setAttribute("sha1", sha1);
+ if (width > 0) link.setAttribute("width", String.valueOf(width));
+ if (height > 0) link.setAttribute("height", String.valueOf(height));
+ return entry;
+ }
+
+ private static int parseIntOrZero(final String s) {
+ if (s == null) return 0;
+ try {
+ return Integer.parseInt(s);
+ } catch (final NumberFormatException e) {
+ return 0;
+ }
+ }
+
+ public String getUrl() {
+ return url;
+ }
+
+ public String getSha1() {
+ return sha1;
+ }
+
+ public int getWidth() {
+ return width;
+ }
+
+ public int getHeight() {
+ return height;
+ }
+
+ public String getPublishedRaw() {
+ return published;
+ }
+}
diff --git a/src/main/java/im/conversations/android/xmpp/model/banner/package-info.java b/src/main/java/im/conversations/android/xmpp/model/banner/package-info.java
new file mode 100644
index 0000000..579fcdd
--- /dev/null
+++ b/src/main/java/im/conversations/android/xmpp/model/banner/package-info.java
@@ -0,0 +1 @@
+package im.conversations.android.xmpp.model.banner;
diff --git a/src/main/java/tel/xmpp/jabjab/Config.java b/src/main/java/tel/xmpp/jabjab/Config.java
index 853c5dc..ca34f39 100644
--- a/src/main/java/tel/xmpp/jabjab/Config.java
+++ b/src/main/java/tel/xmpp/jabjab/Config.java
@@ -116,6 +116,10 @@ public final class Config {
// Story uploads bypass Prosody for xmpp.tel accounts so they land in stories/ (7-day TTL)
// instead of files/ (30-day TTL). Accounts on other servers use their own upload server.
public static final String STORY_UPLOAD_BASE = "https://upload.xmpp.tel/stories";
+ // Profile banner uploads bypass Prosody for xmpp.tel accounts so they land in banner/
+ // (permanent, never purged — unlike files/'s 30-day TTL) at a fixed per-JID path that
+ // gets overwritten on every republish, instead of accumulating a new file each time.
+ public static final String BANNER_UPLOAD_BASE = "https://upload.xmpp.tel/banner";
// The XMPP domain whose accounts use our direct upload bypass for stories/stickers.
public static final String UPLOAD_BYPASS_DOMAIN = "xmpp.tel";
diff --git a/src/main/java/tel/xmpp/jabjab/entities/Account.java b/src/main/java/tel/xmpp/jabjab/entities/Account.java
index ec61c27..7eba3ee 100644
--- a/src/main/java/tel/xmpp/jabjab/entities/Account.java
+++ b/src/main/java/tel/xmpp/jabjab/entities/Account.java
@@ -591,6 +591,39 @@ public class Account extends AbstractEntity implements AvatarService.Avatar {
return this.avatar;
}
+ // Not persisted (unlike avatar) — see the identical note on Contact.banner.
+ private transient String banner;
+
+ public boolean setBanner(final String banner) {
+ if (this.banner == null ? banner == null : this.banner.equals(banner)) {
+ return false;
+ }
+ this.banner = banner;
+ return true;
+ }
+
+ public String getBanner() {
+ return this.banner;
+ }
+
+ // Not persisted — same reasoning as banner. Cached vCard NOTE so UI (e.g. the mini
+ // profile header on the account details screen) can show it instantly without an
+ // async vCard fetch on every open; refreshed opportunistically whenever a vCard fetch
+ // happens to run anyway (EditProfileActivity's load, etc).
+ private transient String bio;
+
+ public boolean setBio(final String bio) {
+ if (this.bio == null ? bio == null : this.bio.equals(bio)) {
+ return false;
+ }
+ this.bio = bio;
+ return true;
+ }
+
+ public String getBio() {
+ return this.bio;
+ }
+
public MiniUri.Xmpp getShareableUri() {
return new MiniUri.Xmpp(this.getJid().asBareJid(), getFingerprints());
}
diff --git a/src/main/java/tel/xmpp/jabjab/entities/Contact.java b/src/main/java/tel/xmpp/jabjab/entities/Contact.java
index f4a4847..bffab17 100644
--- a/src/main/java/tel/xmpp/jabjab/entities/Contact.java
+++ b/src/main/java/tel/xmpp/jabjab/entities/Contact.java
@@ -67,6 +67,11 @@ public class Contact implements ListItem, Blockable, MucOptions.IdentifiableUser
private JSONArray groups = new JSONArray();
protected Account account;
protected String avatar;
+ // Profile banner URL — intentionally NOT persisted to the DB (unlike avatar). It's
+ // re-populated from a live BannerManager fetch/PEP push each session; the tradeoff is a
+ // banner needs a fresh fetch after an app restart rather than loading from a local cache
+ // immediately, but avoids a schema migration for what's a lightweight, best-effort feature.
+ protected String banner;
private String mLastPresence = null;
private RtpCapability.Capability rtpCapability;
@@ -453,6 +458,18 @@ public class Contact implements ListItem, Blockable, MucOptions.IdentifiableUser
return this.avatar;
}
+ public boolean setBanner(final String banner) {
+ if (this.banner == null ? banner == null : this.banner.equals(banner)) {
+ return false;
+ }
+ this.banner = banner;
+ return true;
+ }
+
+ public String getBanner() {
+ return this.banner;
+ }
+
public boolean mutualPresenceSubscription() {
return getOption(Options.FROM) && getOption(Options.TO);
}
diff --git a/src/main/java/tel/xmpp/jabjab/ui/ContactDetailsActivity.java b/src/main/java/tel/xmpp/jabjab/ui/ContactDetailsActivity.java
index 27d8bf1..458fff1 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/ContactDetailsActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/ContactDetailsActivity.java
@@ -33,6 +33,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.google.common.base.Joiner;
import com.google.common.base.Strings;
import com.google.common.collect.Collections2;
+import im.conversations.android.model.DynamicTag;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
@@ -455,6 +456,35 @@ public class ContactDetailsActivity extends OmemoActivity
* 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 static final int[] STATUS_COLORS = {
+ 0xFF4CAF50, // CHAT
+ 0xFF4CAF50, // ONLINE
+ 0xFFFFA000, // AWAY
+ 0xFFFF7043, // XA
+ 0xFFE53935, // DND
+ 0xFF9E9E9E, // OFFLINE
+ };
+
+ private void populateStatusRow(final Contact contact) {
+ final Presence.Availability availability = contact.getShownStatus();
+ final int labelRes =
+ switch (availability) {
+ case CHAT -> R.string.presence_chat;
+ case ONLINE -> R.string.presence_online;
+ case AWAY -> R.string.presence_away;
+ case XA -> R.string.presence_xa;
+ case DND -> R.string.presence_dnd;
+ case OFFLINE -> R.string.presence_offline;
+ };
+ final var tint =
+ android.content.res.ColorStateList.valueOf(STATUS_COLORS[availability.ordinal()]);
+ // Same color shown twice: the info-list's colorful status pill, and the plain circular
+ // ring sitting behind (not stroked around) the avatar.
+ binding.detailsRowStatus.setText(labelRes);
+ binding.detailsRowStatus.getBackground().mutate().setTintList(tint);
+ binding.detailsAvatarRing.getBackground().mutate().setTintList(tint);
+ }
+
private void populateClientResourceBadges(final Contact contact) {
binding.clientResourcesBox.removeAllViews();
boolean added = false;
@@ -485,7 +515,7 @@ public class ContactDetailsActivity extends OmemoActivity
binding.clientResourcesBox.addView(chip);
added = true;
}
- binding.clientResourcesBox.setVisibility(added ? View.VISIBLE : View.GONE);
+ binding.detailsRowResources.setVisibility(added ? View.VISIBLE : View.GONE);
}
private void populateView() {
@@ -559,10 +589,10 @@ 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.detailsRowResources.setVisibility(View.GONE);
}
- binding.detailsContactXmppAddress.setText(
- IrregularUnicodeDetector.style(this, contact.getAddress()));
+ binding.detailsRowJid.setText(IrregularUnicodeDetector.style(this, contact.getAddress()));
+ populateStatusRow(contact);
final boolean isVerified =
tel.xmpp.jabjab.Config.VERIFIED_JIDS.contains(contact.getAddress().asBareJid());
binding.detailsVerifiedBadgeIcon.setVisibility(isVerified ? View.VISIBLE : View.GONE);
@@ -575,6 +605,7 @@ public class ContactDetailsActivity extends OmemoActivity
binding.detailsAccount.setText(getString(R.string.using_account, account));
AvatarWorkerTask.loadAvatar(contact, binding.detailsAvatar, R.dimen.publish_avatar_size);
binding.detailsAvatar.setOnClickListener(this::onAvatarClicked);
+ loadBanner(contact);
loadVCardBio(contact);
if (QuickConversationsService.isContactListIntegration(this)) {
if (contact.getSystemAccount() == null) {
@@ -666,9 +697,15 @@ public class ContactDetailsActivity extends OmemoActivity
}
binding.keysWrapper.setVisibility(hasKeys ? View.VISIBLE : View.GONE);
- final var tagList = contact.getTags();
+ // Contact.getTags() unconditionally includes a DynamicTag.Status("Online"/etc.) chip
+ // for any non-offline, non-blocked contact — redundant now that presence has its own
+ // colorful badge + avatar ring in the info list, so filter it out here rather than
+ // showing the same status twice in two different visual styles.
+ final var tagList =
+ Collections2.filter(
+ contact.getTags(), tag -> !(tag instanceof DynamicTag.Status));
if (Contact.isNoteworthy(tagList) || this.showDynamicTags) {
- UserAdapter.setHats(binding.tags, tagList);
+ UserAdapter.setHats(binding.tags, new ArrayList<>(tagList));
} else {
this.binding.tags.setVisibility(View.GONE);
}
@@ -701,6 +738,22 @@ public class ContactDetailsActivity extends OmemoActivity
MoreExecutors.directExecutor());
}
+ /**
+ * Banner state isn't persisted to the DB (see Contact.banner) — force a fresh fetch each
+ * time this screen opens. Shows the cached value (if any, e.g. from an earlier PEP push
+ * this session) immediately, then re-checks live.
+ */
+ private void loadBanner(final Contact contact) {
+ final var conn = contact.getAccount().getXmppConnection();
+ if (conn == null) return;
+ final var bannerManager = conn.getManager(tel.xmpp.jabjab.xmpp.manager.BannerManager.class);
+ final String cached = contact.getBanner();
+ if (cached != null) {
+ tel.xmpp.jabjab.ui.util.LinkPreviewFetcher.loadImage(cached, binding.detailsBanner);
+ }
+ bannerManager.fetchAndCache(contact.getAddress().asBareJid());
+ }
+
private void onDetailsAccountClicked(final View view) {
final var contact = this.contact;
if (contact == null) {
diff --git a/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java b/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java
index 6735b72..e33187d 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java
@@ -166,6 +166,7 @@ import tel.xmpp.jabjab.xmpp.manager.PresenceManager;
import im.conversations.android.model.AttachmentChoice;
import im.conversations.android.provider.ApplicationProvider;
import im.conversations.android.provider.VCardProvider;
+import im.conversations.android.xmpp.Entity;
import im.conversations.android.xmpp.model.muc.Role;
import im.conversations.android.xmpp.model.stanza.Presence;
import im.conversations.android.xmpp.model.state.Composing;
@@ -184,6 +185,7 @@ import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
public class ConversationFragment extends XmppFragment
@@ -1626,7 +1628,7 @@ public class ConversationFragment extends XmppFragment
if (showCommands
&& getChildFragmentManager().findFragmentByTag(BotCommandsFragment.TAG) == null
&& this.conversation != null) {
- final var botJid = findCommandBotJid(this.conversation);
+ final var botJid = resolveVisibleCommandBotJid(this.conversation);
if (botJid == null) {
return;
}
@@ -1666,7 +1668,7 @@ public class ConversationFragment extends XmppFragment
if (this.binding == null || this.conversation == null) {
return;
}
- final boolean isBotCommandCapable = findCommandBotJid(this.conversation) != null;
+ final boolean isBotCommandCapable = resolveVisibleCommandBotJid(this.conversation) != null;
this.binding.botCommandTabs.setVisibility(isBotCommandCapable ? View.VISIBLE : View.GONE);
final var childFragmentManager = getChildFragmentManager();
final var existing = childFragmentManager.findFragmentByTag(BotCommandsFragment.TAG);
@@ -1680,8 +1682,130 @@ public class ConversationFragment extends XmppFragment
showBotCommandTab(false);
}
+ // Cache of "does this JID's disco#items(node=commands) list actually have >0 entries",
+ // keyed by the exact JID we queried (bare for 1:1, full occupant JID for MUC). Entity-caps
+ // (disco#info) only tells us a bot *advertises* the ad-hoc commands feature at all — plenty
+ // of bots/components do that unconditionally while the actual per-requester command list
+ // (disco#items) comes back empty depending on permissions/session state. Showing the tab
+ // off the feature flag alone is exactly what produced the empty-tab reports: inexperienced
+ // users without special standing on the bot see a Commands tab with nothing in it. This
+ // cache lets us gate tab visibility on a real, verified non-empty item list instead.
+ private static final long COMMAND_ITEMS_CACHE_TTL_MS = TimeUnit.MINUTES.toMillis(10);
+ private record CommandItemsCacheEntry(boolean hasItems, long checkedAt) {}
+ private static final java.util.concurrent.ConcurrentHashMap<Jid, CommandItemsCacheEntry>
+ commandItemsCache = new java.util.concurrent.ConcurrentHashMap<>();
+ private static final java.util.Set<Jid> commandItemsInFlight =
+ java.util.concurrent.ConcurrentHashMap.newKeySet();
+
+ /**
+ * Wraps findCommandBotCandidate()'s feature-flag check with a verified disco#items(node=
+ * commands) lookup so the tab only ever shows for a bot that actually has >0 commands for
+ * us right now. Returns null (hide the tab) until that verification has completed at least
+ * once — never shows the tab optimistically while the query is in flight — and kicks off
+ * (or reuses an in-flight/cached) verification as a side effect.
+ */
+ @Nullable
+ private Jid resolveVisibleCommandBotJid(final Conversation conversation) {
+ final var candidate = findCommandBotCandidate(conversation);
+ if (candidate == null) {
+ return null;
+ }
+ final var cached = commandItemsCache.get(candidate);
+ final boolean isFresh =
+ cached != null
+ && System.currentTimeMillis() - cached.checkedAt() < COMMAND_ITEMS_CACHE_TTL_MS;
+ if (isFresh) {
+ Log.d(
+ Config.LOGTAG,
+ "ADHOC-ITEMS cache-hit " + candidate + " hasItems=" + cached.hasItems());
+ return cached.hasItems() ? candidate : null;
+ }
+ refreshCommandItemsCache(conversation, candidate);
+ return null;
+ }
+
+ /**
+ * XEP-0050 ad-hoc commands are frequently only advertised by the bot's specific online
+ * resource, not the bare JID — querying the bare JID gets item-not-found even though the
+ * bot is reachable (same reasoning as BotCommandsFragment#resolveTargetJid, which this
+ * mirrors). For MUC, candidate is already the occupant's full JID — nothing to resolve.
+ */
+ private static Jid resolveItemsQueryTarget(final Conversation conversation, final Jid candidate) {
+ if (conversation.getMode() != Conversation.MODE_SINGLE) {
+ return candidate;
+ }
+ final var contact = conversation.getAccount().getRoster().getContact(candidate);
+ final var fullAddresses = contact.getFullAddresses();
+ return fullAddresses.isEmpty() ? candidate : fullAddresses.get(0);
+ }
+
+ /**
+ * Fires the disco#items(node=commands) query for candidate (if not already in flight) and,
+ * on completion, caches the result and re-runs updateBotCommandTabVisibility() so the tab
+ * appears the moment we have a verified non-empty list — never before. The cache is keyed
+ * by candidate (bare for 1:1) so tab visibility survives the bot's resource churning, but
+ * the actual IQ is sent to the resolved full JID — see resolveItemsQueryTarget().
+ */
+ private void refreshCommandItemsCache(final Conversation conversation, final Jid candidate) {
+ if (!commandItemsInFlight.add(candidate)) {
+ Log.d(Config.LOGTAG, "ADHOC-ITEMS already in flight for " + candidate);
+ return;
+ }
+ final var queryTarget = resolveItemsQueryTarget(conversation, candidate);
+ Log.d(
+ Config.LOGTAG,
+ "ADHOC-ITEMS querying disco#items(node=commands) for "
+ + candidate
+ + " (resolved target="
+ + queryTarget
+ + ")");
+ final var discoManager =
+ conversation.getAccount().getXmppConnection().getManager(DiscoManager.class);
+ final var future = discoManager.commands(Entity.discoItem(queryTarget));
+ Futures.addCallback(
+ future,
+ new FutureCallback<java.util.Map<String, Jid>>() {
+ @Override
+ public void onSuccess(final java.util.Map<String, Jid> result) {
+ commandItemsInFlight.remove(candidate);
+ final boolean hasItems = result != null && !result.isEmpty();
+ Log.d(
+ Config.LOGTAG,
+ "ADHOC-ITEMS resolved "
+ + candidate
+ + " itemCount="
+ + (result == null ? 0 : result.size())
+ + " hasItems="
+ + hasItems);
+ commandItemsCache.put(
+ candidate,
+ new CommandItemsCacheEntry(hasItems, System.currentTimeMillis()));
+ runOnUiThreadQuiet(ConversationFragment.this::updateBotCommandTabVisibility);
+ }
+
+ @Override
+ public void onFailure(@NonNull final Throwable throwable) {
+ commandItemsInFlight.remove(candidate);
+ Log.d(
+ Config.LOGTAG,
+ "ADHOC-ITEMS query failed for " + candidate + ": "
+ + throwable.getMessage());
+ // Cache the failure too (short-lived via the same TTL) so a persistently
+ // unreachable/erroring bot doesn't get re-queried on every tab/getView
+ // refresh — it'll retry once the cache entry expires.
+ commandItemsCache.put(
+ candidate,
+ new CommandItemsCacheEntry(false, System.currentTimeMillis()));
+ }
+ },
+ MoreExecutors.directExecutor());
+ }
+
/**
- * Resolves which JID the Commands tab should target, or null if it shouldn't be shown.
+ * Resolves which JID the Commands tab *could* target based on advertised XEP-0050 support
+ * alone, or null if there's no candidate at all. This is the feature-flag check only — see
+ * resolveVisibleCommandBotJid() for the additional disco#items verification that gates
+ * actual tab visibility.
* For 1:1 chats: data@xmpp.tel always qualifies (Config.COMMAND_CAPABLE_JIDS override);
* otherwise the contact's presences are checked for the cached XEP-0050 disco#info feature
* (populated via entity-caps, no extra round trip).
@@ -1691,7 +1815,7 @@ public class ConversationFragment extends XmppFragment
* to target.
*/
@Nullable
- private static Jid findCommandBotJid(final Conversation conversation) {
+ private static Jid findCommandBotCandidate(final Conversation conversation) {
if (conversation.getMode() == Conversation.MODE_SINGLE) {
final var contact = conversation.getContact();
if (Config.COMMAND_CAPABLE_JIDS.contains(contact.getAddress().asBareJid())) {
diff --git a/src/main/java/tel/xmpp/jabjab/ui/EditAccountActivity.java b/src/main/java/tel/xmpp/jabjab/ui/EditAccountActivity.java
index c4a9c14..ca9b561 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/EditAccountActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/EditAccountActivity.java
@@ -164,6 +164,7 @@ public class EditAccountActivity extends OmemoActivity
};
private String messageFingerprint;
private boolean mFetchingAvatar = false;
+ private boolean mFetchedMiniProfileBio = false;
private Toast mFetchingMamPrefsToast;
private String mSavedInstanceAccount;
private boolean mSavedInstanceInit = false;
@@ -474,10 +475,115 @@ public class EditAccountActivity extends OmemoActivity
}
if (mAccount != null) {
updateAccountInformation(false);
+ updateMiniProfileHeader();
+ retryPendingProfileIfOnline();
}
updateSaveButton();
}
+ /**
+ * Live preview shown at the top of the account editor: banner/avatar/name/status/bio,
+ * refreshed on every refreshUiReal() (called from onAccountUpdate()) so it reflects
+ * changes made elsewhere — e.g. from EditProfileActivity, or a live presence change —
+ * without needing to reopen this screen.
+ */
+ private void updateMiniProfileHeader() {
+ AvatarWorkerTask.loadAvatar(
+ mAccount, binding.miniProfileAvatar, R.dimen.avatar_on_details_screen_size);
+
+ final String bannerUrl = mAccount.getBanner();
+ if (bannerUrl != null) {
+ tel.xmpp.jabjab.ui.util.LinkPreviewFetcher.loadImage(bannerUrl, binding.miniProfileBanner);
+ }
+ mAccount.getXmppConnection()
+ .getManager(tel.xmpp.jabjab.xmpp.manager.BannerManager.class)
+ .fetchAndCache(mAccount.getJid().asBareJid());
+
+ binding.miniProfileName.setText(mAccount.getDisplayName());
+
+ final String statusMessage = mAccount.getPresenceStatusMessage();
+ if (statusMessage == null || statusMessage.isEmpty()) {
+ binding.miniProfileStatus.setVisibility(View.GONE);
+ } else {
+ binding.miniProfileStatus.setText(statusMessage);
+ binding.miniProfileStatus.setVisibility(View.VISIBLE);
+ }
+
+ final String cachedBio = mAccount.getBio();
+ if (cachedBio == null || cachedBio.isEmpty()) {
+ binding.miniProfileBio.setVisibility(View.GONE);
+ } else {
+ binding.miniProfileBio.setText(cachedBio);
+ binding.miniProfileBio.setVisibility(View.VISIBLE);
+ }
+
+ // Account.bio is only ever populated as a side effect of EditProfileActivity's own
+ // vCard fetch (see its populateFields()) — nothing on this screen fetched it before,
+ // so the bio silently stayed blank here until the user happened to open and leave
+ // that other screen. Fetch it directly, once per activity instance (vCard retrieval
+ // has no local cache of its own — refreshUiReal() runs on every account update, so an
+ // unguarded fetch here would re-hit the network every time).
+ if (!mFetchedMiniProfileBio && mAccount.getXmppConnection() != null) {
+ mFetchedMiniProfileBio = true;
+ Futures.addCallback(
+ mAccount.getXmppConnection()
+ .getManager(tel.xmpp.jabjab.xmpp.manager.VCardManager.class)
+ .retrieveSelf(),
+ new FutureCallback<im.conversations.android.xmpp.model.vcard.VCard>() {
+ @Override
+ public void onSuccess(
+ final im.conversations.android.xmpp.model.vcard.VCard vCard) {
+ if (vCard == null) return;
+ final String note = vCard.getNote();
+ if (note != null && mAccount.setBio(note)) {
+ runOnUiThread(EditAccountActivity.this::updateMiniProfileHeader);
+ }
+ }
+
+ @Override
+ public void onFailure(@NonNull final Throwable t) {
+ // Bio row just stays hidden; not worth surfacing an error for this.
+ }
+ },
+ MoreExecutors.directExecutor());
+ }
+ }
+
+ /**
+ * If a profile edit was saved while offline (see ProfilePendingStore /
+ * EditProfileActivity#saveProfile), retry it now that the account is back online — same
+ * "queue locally, retry on OnAccountUpdate + State.ONLINE" pattern already used for offline
+ * blog drafts (BlogActivity/BlogDraftStore).
+ */
+ private void retryPendingProfileIfOnline() {
+ if (!mAccount.isOnlineAndConnected()) return;
+ final var store = new tel.xmpp.jabjab.ui.util.ProfilePendingStore(this);
+ final var pending = store.load(mAccount.getUuid());
+ if (pending == null) return;
+ Log.d(
+ Config.LOGTAG,
+ "PROFILE retrying queued edit for " + mAccount.getJid()
+ + " displayName=" + pending.displayName
+ + " status=" + pending.status);
+ Futures.addCallback(
+ tel.xmpp.jabjab.ui.util.ProfilePendingStore.publishNow(
+ xmppConnectionService, mAccount, pending),
+ new FutureCallback<Void>() {
+ @Override
+ public void onSuccess(final Void result) {
+ Log.d(Config.LOGTAG, "PROFILE queued edit published, clearing");
+ store.clear(mAccount.getUuid());
+ runOnUiThread(EditAccountActivity.this::updateMiniProfileHeader);
+ }
+
+ @Override
+ public void onFailure(@NonNull final Throwable t) {
+ Log.d(Config.LOGTAG, "PROFILE queued edit publish failed, left queued", t);
+ }
+ },
+ MoreExecutors.directExecutor());
+ }
+
@Override
public boolean onNavigateUp() {
deleteAccountAndReturnIfNecessary();
@@ -733,6 +839,14 @@ public class EditAccountActivity extends OmemoActivity
this.binding.accountPassword.addTextChangedListener(this.mTextWatcher);
this.binding.avater.setOnClickListener(this.mAvatarClickListener);
+ this.binding.miniProfileHeader.setOnClickListener(
+ v -> {
+ if (mAccount == null) return;
+ final Intent editProfileIntent = new Intent(this, EditProfileActivity.class);
+ editProfileIntent.putExtra(
+ EditProfileActivity.EXTRA_ACCOUNT, mAccount.getJid().asBareJid().toString());
+ startActivity(editProfileIntent);
+ });
this.binding.hostname.addTextChangedListener(mTextWatcher);
this.binding.hostname.setOnFocusChangeListener(mEditTextFocusListener);
this.binding.clearDevices.setOnClickListener(v -> showWipePepDialog());
@@ -972,6 +1086,8 @@ public class EditAccountActivity extends OmemoActivity
mPendingFingerprintVerificationUri = null;
}
updateAccountInformation(init);
+ updateMiniProfileHeader();
+ retryPendingProfileIfOnline();
}
if (Config.MAGIC_CREATE_DOMAIN == null
diff --git a/src/main/java/tel/xmpp/jabjab/ui/EditProfileActivity.java b/src/main/java/tel/xmpp/jabjab/ui/EditProfileActivity.java
index 476bf5f..0078003 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/EditProfileActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/EditProfileActivity.java
@@ -19,14 +19,16 @@ import tel.xmpp.jabjab.xmpp.Jid;
import tel.xmpp.jabjab.xmpp.manager.VCardManager;
import im.conversations.android.xmpp.model.vcard.VCard;
-public class EditProfileActivity extends XmppActivity {
+public class EditProfileActivity extends XmppActivity
+ implements tel.xmpp.jabjab.services.XmppConnectionService.OnAccountUpdate {
public static final String EXTRA_ACCOUNT = "account";
private Account account;
private android.widget.ImageView profileAvatar;
+ private android.widget.ImageView profileBanner;
private TextInputEditText fieldDisplayName;
- private TextInputEditText fieldNickname;
+ private TextInputEditText fieldStatus;
private TextInputEditText fieldBio;
private View saveButton;
@@ -42,18 +44,30 @@ public class EditProfileActivity extends XmppActivity {
}
profileAvatar = findViewById(R.id.profile_avatar);
+ profileBanner = findViewById(R.id.profile_banner);
fieldDisplayName = findViewById(R.id.profile_display_name);
- fieldNickname = findViewById(R.id.profile_nickname);
+ fieldStatus = findViewById(R.id.profile_status);
fieldBio = findViewById(R.id.profile_bio);
saveButton = findViewById(R.id.save_profile);
saveButton.setOnClickListener(v -> saveProfile());
findViewById(R.id.avatar_edit_fab).setOnClickListener(v -> launchAvatarPicker());
+ findViewById(R.id.banner_edit_fab).setOnClickListener(v -> launchBannerPicker());
}
@Override
public void refreshUiReal() {}
+ @Override
+ public void onAccountUpdate() {
+ if (account == null) return;
+ final String url = account.getBanner();
+ if (url != null) {
+ runOnUiThread(() ->
+ tel.xmpp.jabjab.ui.util.LinkPreviewFetcher.loadImage(url, profileBanner));
+ }
+ }
+
@Override
public void onBackendConnected() {
final String jidExtra = getIntent().getStringExtra(EXTRA_ACCOUNT);
@@ -75,6 +89,23 @@ public class EditProfileActivity extends XmppActivity {
final android.widget.TextView jidView = findViewById(R.id.profile_jid);
jidView.setText(account.getJid().asBareJid().toString());
AvatarWorkerTask.loadAvatar(account, profileAvatar, R.dimen.publish_avatar_size);
+ loadBanner();
+
+ final String currentStatus = account.getPresenceStatusMessage();
+ Log.d(Config.LOGTAG, "PROFILE prefill status=" + currentStatus + " for " + account.getJid());
+ if (currentStatus != null && !currentStatus.isEmpty()) {
+ fieldStatus.setText(currentStatus);
+ }
+
+ // Prefill from the account's canonical display name (Account.displayName / XEP-0172
+ // Nick — the same field EditAccountActivity's "Edit your name" sets) FIRST, since
+ // that's the name actually shown everywhere else in the app. vCard FN (below, once the
+ // fetch completes) is a separate, secondary field — only used as a fallback if the
+ // canonical name was never set, not the other way around.
+ final String currentDisplayName = account.getDisplayName();
+ if (currentDisplayName != null && !currentDisplayName.isEmpty()) {
+ fieldDisplayName.setText(currentDisplayName);
+ }
loadCurrentVCard();
}
@@ -106,38 +137,56 @@ public class EditProfileActivity extends XmppActivity {
&& fieldDisplayName.getText().toString().isEmpty()) {
fieldDisplayName.setText(fn);
}
- final String nick = vCard.getNickname();
- if (nick != null && fieldNickname.getText() != null
- && fieldNickname.getText().toString().isEmpty()) {
- fieldNickname.setText(nick);
- }
final String note = vCard.getNote();
if (note != null && fieldBio.getText() != null
&& fieldBio.getText().toString().isEmpty()) {
fieldBio.setText(note);
}
+ if (note != null) {
+ account.setBio(note);
+ }
}
private void saveProfile() {
if (account == null || account.getXmppConnection() == null) return;
final String fn = fieldDisplayName.getText() != null
? fieldDisplayName.getText().toString().trim() : "";
- final String nick = fieldNickname.getText() != null
- ? fieldNickname.getText().toString().trim() : "";
+ // No separate "Nickname" input — vCard NICKNAME is set to the same value as display
+ // name automatically. JabJab never reads it locally (that's the XEP-0172 nickname,
+ // Account.displayName, set above); this only exists for other clients that read vCard
+ // NICKNAME instead of/alongside FN, so there's no reason to expose it as a second,
+ // confusingly-similar input.
+ final String nick = fn;
+ final String status = fieldStatus.getText() != null
+ ? fieldStatus.getText().toString().trim() : "";
final String bio = fieldBio.getText() != null
? fieldBio.getText().toString().trim() : "";
saveButton.setEnabled(false);
- final var future = account.getXmppConnection()
- .getManager(VCardManager.class)
- .publishProfile(fn, nick, bio);
+ final var pending =
+ new tel.xmpp.jabjab.ui.util.ProfilePendingStore.PendingProfile(fn, nick, status, bio);
+ final var pendingStore = new tel.xmpp.jabjab.ui.util.ProfilePendingStore(this);
+ // Always persist first — if the publish below fails or we're offline, the edit isn't
+ // silently lost; EditAccountActivity retries it once the account reconnects (same
+ // "save locally, retry on OnAccountUpdate + State.ONLINE" pattern BlogDraftStore uses
+ // for offline blog posts).
+ pendingStore.save(account.getUuid(), pending);
+
+ if (!account.isOnlineAndConnected()) {
+ Toast.makeText(this, R.string.profile_saved_offline, Toast.LENGTH_SHORT).show();
+ saveButton.setEnabled(true);
+ finish();
+ return;
+ }
Futures.addCallback(
- future,
+ tel.xmpp.jabjab.ui.util.ProfilePendingStore.publishNow(
+ xmppConnectionService, account, pending),
new FutureCallback<Void>() {
@Override
public void onSuccess(final Void result) {
+ pendingStore.clear(account.getUuid());
runOnUiThread(() -> {
Toast.makeText(
EditProfileActivity.this,
@@ -150,13 +199,15 @@ public class EditProfileActivity extends XmppActivity {
@Override
public void onFailure(final Throwable t) {
- Log.e(Config.LOGTAG, "Failed to publish profile", t);
+ // Left in pendingStore — EditAccountActivity will retry on reconnect.
+ Log.e(Config.LOGTAG, "Failed to publish profile, queued for retry", t);
runOnUiThread(() -> {
Toast.makeText(
EditProfileActivity.this,
- R.string.profile_save_failed,
+ R.string.profile_saved_offline,
Toast.LENGTH_SHORT).show();
saveButton.setEnabled(true);
+ finish();
});
}
},
@@ -170,6 +221,27 @@ public class EditProfileActivity extends XmppActivity {
startActivity(intent);
}
+ private void launchBannerPicker() {
+ if (account == null) return;
+ final Intent intent = new Intent(this, PublishBannerActivity.class);
+ intent.putExtra(EXTRA_ACCOUNT, account.getJid().asBareJid().toString());
+ startActivity(intent);
+ }
+
+ /**
+ * Banner state isn't persisted to the DB (see Account.banner) — force a fresh fetch each
+ * time this screen opens so it doesn't sit permanently blank after an app restart.
+ */
+ private void loadBanner() {
+ final var bannerManager =
+ account.getXmppConnection().getManager(tel.xmpp.jabjab.xmpp.manager.BannerManager.class);
+ bannerManager.fetchAndCache(account.getJid().asBareJid());
+ final String cached = account.getBanner();
+ if (cached != null) {
+ tel.xmpp.jabjab.ui.util.LinkPreviewFetcher.loadImage(cached, profileBanner);
+ }
+ }
+
@Override
public boolean onSupportNavigateUp() {
finish();
diff --git a/src/main/java/tel/xmpp/jabjab/ui/PublishBannerActivity.java b/src/main/java/tel/xmpp/jabjab/ui/PublishBannerActivity.java
new file mode 100644
index 0000000..8717859
--- /dev/null
+++ b/src/main/java/tel/xmpp/jabjab/ui/PublishBannerActivity.java
@@ -0,0 +1,255 @@
+package tel.xmpp.jabjab.ui;
+
+import android.content.Intent;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.net.Uri;
+import android.os.Bundle;
+import android.util.Log;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.annotation.NonNull;
+import androidx.core.content.ContextCompat;
+import androidx.databinding.DataBindingUtil;
+import com.canhub.cropper.CropImageContract;
+import com.canhub.cropper.CropImageContractOptions;
+import com.canhub.cropper.CropImageOptions;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import tel.xmpp.jabjab.Config;
+import tel.xmpp.jabjab.R;
+import tel.xmpp.jabjab.databinding.ActivityPublishBannerBinding;
+import tel.xmpp.jabjab.entities.Account;
+import tel.xmpp.jabjab.xmpp.manager.BannerManager;
+
+/**
+ * Picks/crops/uploads/publishes the user's profile banner image. A deliberately separate,
+ * lean Activity rather than extending PublishProfilePictureActivity — banners are URL-only
+ * (no XEP-0084 in-band data step, see BannerManager) and this avoids touching the existing,
+ * well-tested avatar publish flow at all.
+ */
+public class PublishBannerActivity extends XmppActivity {
+
+ private ActivityPublishBannerBinding binding;
+ private Uri bannerUri;
+ private Account account;
+ private final ExecutorService executor = Executors.newSingleThreadExecutor();
+
+ final ActivityResultLauncher<CropImageContractOptions> cropImage =
+ registerForActivityResult(
+ new CropImageContract(),
+ cropResult -> {
+ if (cropResult.isSuccessful()) {
+ onBannerPicked(cropResult.getUriContent());
+ }
+ });
+
+ @Override
+ public void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ this.binding = DataBindingUtil.setContentView(this, R.layout.activity_publish_banner);
+ setSupportActionBar(binding.toolbar);
+ Activities.setStatusAndNavigationBarColors(this, binding.getRoot());
+ setTitle(R.string.publish_banner_title);
+
+ binding.bannerImageWrapper.setOnClickListener(v -> pickBanner());
+ binding.cancelButton.setOnClickListener(v -> finish());
+ binding.publishButton.setOnClickListener(v -> publish());
+
+ if (savedInstanceState != null) {
+ this.bannerUri = savedInstanceState.getParcelable("uri");
+ }
+ }
+
+ @Override
+ protected void onBackendConnected() {
+ this.account = extractAccount(getIntent());
+ if (this.bannerUri != null) {
+ loadImageIntoPreview(this.bannerUri);
+ }
+ }
+
+ @Override
+ protected void refreshUiReal() {}
+
+ @Override
+ public boolean onCreateOptionsMenu(@NonNull final Menu menu) {
+ getMenuInflater().inflate(R.menu.activity_publish_banner, menu);
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ if (item.getItemId() == R.id.action_delete_banner) {
+ deleteBanner();
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ private void deleteBanner() {
+ new MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.delete_banner)
+ .setMessage(R.string.delete_banner_message)
+ .setNegativeButton(R.string.cancel, null)
+ .setPositiveButton(
+ R.string.confirm,
+ (d, v) -> {
+ if (account == null) return;
+ Futures.addCallback(
+ account.getXmppConnection()
+ .getManager(BannerManager.class)
+ .deleteBanner(),
+ new FutureCallback<Void>() {
+ @Override
+ public void onSuccess(final Void result) {
+ runOnUiThread(PublishBannerActivity.this::finish);
+ }
+
+ @Override
+ public void onFailure(@NonNull final Throwable t) {
+ Log.d(Config.LOGTAG, "BANNER delete failed", t);
+ }
+ },
+ ContextCompat.getMainExecutor(this));
+ })
+ .create()
+ .show();
+ }
+
+ private void pickBanner() {
+ final var cropImageOptions = new CropImageOptions();
+ // Wide banner aspect ratio — matches the header's actual display proportions
+ // (full device width x 200dp; ~2:1 approximates that across common phone widths,
+ // updated from the old 3:1 which was calibrated for the header's previous 140dp
+ // height).
+ cropImageOptions.aspectRatioX = 2;
+ cropImageOptions.aspectRatioY = 1;
+ cropImageOptions.fixAspectRatio = true;
+ cropImageOptions.outputCompressFormat = Bitmap.CompressFormat.JPEG;
+ cropImageOptions.imageSourceIncludeCamera = false;
+ this.cropImage.launch(new CropImageContractOptions(null, cropImageOptions));
+ }
+
+ private void onBannerPicked(final Uri uri) {
+ this.bannerUri = uri;
+ loadImageIntoPreview(uri);
+ }
+
+ private void loadImageIntoPreview(final Uri uri) {
+ executor.execute(() -> {
+ Bitmap bm = null;
+ try (final InputStream in = getContentResolver().openInputStream(uri)) {
+ if (in != null) bm = BitmapFactory.decodeStream(in);
+ } catch (final IOException e) {
+ Log.d(Config.LOGTAG, "BANNER failed to load preview", e);
+ }
+ final Bitmap finalBm = bm;
+ runOnUiThread(() -> {
+ if (finalBm == null) {
+ binding.hintOrWarning.setVisibility(View.VISIBLE);
+ binding.hintOrWarning.setText(R.string.error_publish_banner_converting);
+ return;
+ }
+ binding.bannerImage.setImageBitmap(finalBm);
+ binding.hintOrWarning.setVisibility(View.GONE);
+ binding.publishButton.setEnabled(true);
+ });
+ });
+ }
+
+ private void publish() {
+ final var uri = this.bannerUri;
+ if (uri == null || account == null) {
+ return;
+ }
+ binding.publishButton.setEnabled(false);
+ executor.execute(() -> {
+ try {
+ final File dest = new File(getCacheDir(), "banner_" + System.currentTimeMillis() + ".jpg");
+ copyUriToFile(uri, dest);
+ if (dest.length() > MAX_BANNER_BYTES) {
+ dest.delete();
+ runOnUiThread(() -> {
+ binding.publishButton.setEnabled(true);
+ binding.hintOrWarning.setVisibility(View.VISIBLE);
+ binding.hintOrWarning.setText(R.string.error_banner_too_large);
+ });
+ return;
+ }
+ Futures.addCallback(
+ account.getXmppConnection()
+ .getManager(BannerManager.class)
+ .uploadAndPublish(dest, "image/jpeg"),
+ new FutureCallback<Void>() {
+ @Override
+ public void onSuccess(final Void result) {
+ runOnUiThread(() -> {
+ android.widget.Toast.makeText(
+ PublishBannerActivity.this,
+ R.string.banner_has_been_published,
+ android.widget.Toast.LENGTH_SHORT)
+ .show();
+ finish();
+ });
+ }
+
+ @Override
+ public void onFailure(@NonNull final Throwable t) {
+ Log.d(Config.LOGTAG, "BANNER publish failed", t);
+ runOnUiThread(() -> {
+ binding.publishButton.setEnabled(true);
+ binding.hintOrWarning.setVisibility(View.VISIBLE);
+ binding.hintOrWarning.setText(R.string.error_saving_avatar);
+ });
+ }
+ },
+ ContextCompat.getMainExecutor(this));
+ } catch (final IOException e) {
+ Log.d(Config.LOGTAG, "BANNER compress failed", e);
+ runOnUiThread(() -> {
+ binding.publishButton.setEnabled(true);
+ binding.hintOrWarning.setVisibility(View.VISIBLE);
+ binding.hintOrWarning.setText(R.string.error_publish_banner_converting);
+ });
+ }
+ });
+ }
+
+ private static final long MAX_BANNER_BYTES = 5L * 1024 * 1024;
+
+ /**
+ * Just copies the cropped image's bytes as-is — no client-side resize/recompress. The
+ * upload server re-encodes to WebP itself regardless of input format/size
+ * (_process_banner_image in upload/app.py), so the client doesn't need to match a
+ * particular format or dimension; only a plain size cap to keep the upload itself
+ * reasonably fast.
+ */
+ private void copyUriToFile(final Uri uri, final File dest) throws IOException {
+ try (final InputStream in = getContentResolver().openInputStream(uri);
+ final OutputStream out = Files.newOutputStream(dest.toPath())) {
+ if (in == null) throw new IOException("Could not open banner image URI");
+ final byte[] buf = new byte[8192];
+ int n;
+ while ((n = in.read(buf)) != -1) out.write(buf, 0, n);
+ }
+ }
+
+ @Override
+ public void onSaveInstanceState(@NonNull final Bundle outState) {
+ if (this.bannerUri != null) {
+ outState.putParcelable("uri", this.bannerUri);
+ }
+ super.onSaveInstanceState(outState);
+ }
+}
diff --git a/src/main/java/tel/xmpp/jabjab/ui/util/ProfilePendingStore.java b/src/main/java/tel/xmpp/jabjab/ui/util/ProfilePendingStore.java
new file mode 100644
index 0000000..421f2d0
--- /dev/null
+++ b/src/main/java/tel/xmpp/jabjab/ui/util/ProfilePendingStore.java
@@ -0,0 +1,123 @@
+package tel.xmpp.jabjab.ui.util;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
+import org.json.JSONObject;
+import tel.xmpp.jabjab.entities.Account;
+import tel.xmpp.jabjab.entities.PresenceTemplate;
+import tel.xmpp.jabjab.services.XmppConnectionService;
+import tel.xmpp.jabjab.xmpp.manager.VCardManager;
+
+/**
+ * Queues a profile edit (display name / nickname / status / bio) that couldn't be published
+ * immediately because the account was offline, so it isn't silently lost — mirrors
+ * BlogDraftStore's offline-draft approach, but only one pending edit per account (not a list)
+ * since a profile has no concept of multiple drafts. EditAccountActivity retries the pending
+ * edit and clears it once the account reconnects, the same "retry on OnAccountUpdate +
+ * State.ONLINE" pattern BlogActivity already uses for blog drafts.
+ */
+public class ProfilePendingStore {
+
+ private static final String PREFS = "jabjab_profile_pending";
+
+ private final SharedPreferences prefs;
+
+ public ProfilePendingStore(final Context context) {
+ this.prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
+ }
+
+ public void save(final String accountUuid, final PendingProfile pending) {
+ prefs.edit().putString(accountUuid, pending.toJson()).apply();
+ }
+
+ public PendingProfile load(final String accountUuid) {
+ final String raw = prefs.getString(accountUuid, null);
+ return raw == null ? null : PendingProfile.fromJson(raw);
+ }
+
+ public void clear(final String accountUuid) {
+ prefs.edit().remove(accountUuid).apply();
+ }
+
+ /**
+ * Actually publishes a pending edit: presence <status/> (only if it actually changed,
+ * to avoid a redundant broadcast), the account's canonical display name (Account.displayName
+ * + XEP-0172 User Nickname via XmppConnectionService#publishDisplayName — the SAME mechanism
+ * EditAccountActivity's older "Edit your name" flow uses; this is NOT the same thing as
+ * vCard FN, which is a separate, XMPP-standard field some other clients read but nothing in
+ * THIS app displays locally), plus the vCard fields (FN/nickname/NOTE). Caches the bio onto
+ * the Account so any UI showing it (the mini profile header) reflects it instantly without
+ * waiting for a fresh vCard fetch. Callers (EditProfileActivity.saveProfile,
+ * EditAccountActivity's reconnect retry) both funnel through this one place rather than
+ * duplicating the publish sequence.
+ */
+ public static ListenableFuture<Void> publishNow(
+ final XmppConnectionService service, final Account account, final PendingProfile pending) {
+ final String currentStatus = account.getPresenceStatusMessage();
+ if (!pending.status.equals(currentStatus == null ? "" : currentStatus)) {
+ final var template = new PresenceTemplate(account.getPresenceStatus(), pending.status);
+ service.changeStatus(account, template, null);
+ }
+ final String currentDisplayName = account.getDisplayName();
+ if (!pending.displayName.equals(currentDisplayName == null ? "" : currentDisplayName)) {
+ account.setDisplayName(pending.displayName);
+ service.publishDisplayName(account);
+ }
+ return Futures.transform(
+ account.getXmppConnection()
+ .getManager(VCardManager.class)
+ .publishProfile(pending.displayName, pending.nickname, pending.bio),
+ result -> {
+ account.setBio(pending.bio);
+ return result;
+ },
+ MoreExecutors.directExecutor());
+ }
+
+ public static class PendingProfile {
+ public final String displayName;
+ public final String nickname;
+ public final String status;
+ public final String bio;
+
+ public PendingProfile(
+ final String displayName,
+ final String nickname,
+ final String status,
+ final String bio) {
+ this.displayName = displayName;
+ this.nickname = nickname;
+ this.status = status;
+ this.bio = bio;
+ }
+
+ public String toJson() {
+ try {
+ final var j = new JSONObject();
+ j.put("displayName", displayName != null ? displayName : "");
+ j.put("nickname", nickname != null ? nickname : "");
+ j.put("status", status != null ? status : "");
+ j.put("bio", bio != null ? bio : "");
+ return j.toString();
+ } catch (final Exception e) {
+ return "{}";
+ }
+ }
+
+ public static PendingProfile fromJson(final String raw) {
+ try {
+ final var j = new JSONObject(raw);
+ return new PendingProfile(
+ j.optString("displayName", ""),
+ j.optString("nickname", ""),
+ j.optString("status", ""),
+ j.optString("bio", ""));
+ } catch (final Exception e) {
+ return null;
+ }
+ }
+ }
+}
diff --git a/src/main/java/tel/xmpp/jabjab/xmpp/Managers.java b/src/main/java/tel/xmpp/jabjab/xmpp/Managers.java
index 0848eec..96c0d9f 100644
--- a/src/main/java/tel/xmpp/jabjab/xmpp/Managers.java
+++ b/src/main/java/tel/xmpp/jabjab/xmpp/Managers.java
@@ -5,6 +5,7 @@ import com.google.common.collect.ImmutableClassToInstanceMap;
import tel.xmpp.jabjab.services.XmppConnectionService;
import tel.xmpp.jabjab.xmpp.manager.AbstractManager;
import tel.xmpp.jabjab.xmpp.manager.ActivityManager;
+import tel.xmpp.jabjab.xmpp.manager.BannerManager;
import tel.xmpp.jabjab.xmpp.manager.BlogManager;
import tel.xmpp.jabjab.xmpp.manager.AdHocCommandsManager;
import tel.xmpp.jabjab.xmpp.manager.AvatarManager;
@@ -57,6 +58,7 @@ public class Managers {
return new ImmutableClassToInstanceMap.Builder<AbstractManager>()
.put(ActivityManager.class, new ActivityManager(context, connection))
.put(BlogManager.class, new BlogManager(context, connection))
+ .put(BannerManager.class, new BannerManager(context, connection))
.put(AdHocCommandsManager.class, new AdHocCommandsManager(context, connection))
.put(AvatarManager.class, new AvatarManager(context, connection))
.put(AxolotlManager.class, new AxolotlManager(context, connection))
diff --git a/src/main/java/tel/xmpp/jabjab/xmpp/manager/BannerManager.java b/src/main/java/tel/xmpp/jabjab/xmpp/manager/BannerManager.java
new file mode 100644
index 0000000..93e305f
--- /dev/null
+++ b/src/main/java/tel/xmpp/jabjab/xmpp/manager/BannerManager.java
@@ -0,0 +1,227 @@
+package tel.xmpp.jabjab.xmpp.manager;
+
+import android.content.Context;
+import android.util.Log;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.common.util.concurrent.ListenableFuture;
+import im.conversations.android.xmpp.NodeConfiguration;
+import im.conversations.android.xmpp.model.atom.Entry;
+import im.conversations.android.xmpp.model.banner.Banner;
+import im.conversations.android.xmpp.model.pubsub.Items;
+import im.conversations.android.xmpp.model.upload.purpose.Purpose;
+import java.io.File;
+import java.security.SecureRandom;
+import java.time.Instant;
+import java.time.format.DateTimeFormatter;
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import tel.xmpp.jabjab.Config;
+import tel.xmpp.jabjab.services.XmppConnectionService;
+import tel.xmpp.jabjab.xmpp.Jid;
+import tel.xmpp.jabjab.xmpp.XmppConnection;
+
+/**
+ * Publishes/fetches the user's profile banner image — a single persistent item (item id
+ * "current") on a custom PEP node, following the same pattern StoriesManager/BlogManager use:
+ * upload the image via HttpUploadManager first, publish only the resulting URL. No XEP covers
+ * a "profile banner"/"cover photo" concept, so this is a JabJab-specific extension; unrecognized
+ * by other clients per normal XEP-0060 PEP semantics (they simply ignore the unknown node).
+ */
+public class BannerManager extends AbstractManager {
+
+ public static final String BANNER_NODE = "urn:xmpp:jabjab:banner:0";
+
+ private final XmppConnectionService service;
+
+ public BannerManager(final XmppConnectionService service, final XmppConnection connection) {
+ super(service, connection);
+ this.service = service;
+ }
+
+ /** Called by PubSubManager when a banner PEP event arrives from a contact (or ourselves). */
+ public void handleIncomingItems(final Jid from, final Items items) {
+ final var entry = items.getOnlyItem(Entry.class);
+ final Banner banner = Banner.fromEntry(entry);
+ Log.d(Config.LOGTAG, "BANNER push from " + from + " url=" + (banner == null ? null : banner.getUrl()));
+ applyToContact(from, banner);
+ }
+
+ /** Called by PubSubManager when a contact (or ourselves) retracts/deletes their banner. */
+ public void handleDelete(final Jid from) {
+ Log.d(Config.LOGTAG, "BANNER deleted by " + from);
+ applyToContact(from, null);
+ }
+
+ private void applyToContact(final Jid from, final Banner banner) {
+ final var bareJid = from.asBareJid();
+ final var account = getAccount();
+ final String url = banner == null ? null : banner.getUrl();
+ if (account.getJid().asBareJid().equals(bareJid)) {
+ if (account.setBanner(url)) {
+ service.updateAccountUi();
+ }
+ return;
+ }
+ final var contact = getManager(RosterManager.class).getContact(bareJid);
+ if (contact.setBanner(url)) {
+ getManager(RosterManager.class).writeToDatabaseAsync();
+ service.updateRosterUi();
+ }
+ }
+
+ public ListenableFuture<Void> publishBanner(
+ final String url, final String sha1, final int width, final int height) {
+ final var now = DateTimeFormatter.ISO_INSTANT.format(Instant.now());
+ final var selfJid = getAccount().getJid().asBareJid();
+ final var entry = Banner.toEntry(url, sha1, width, height, now);
+ final var pubSubManager = getManager(PubSubManager.class);
+ Log.d(Config.LOGTAG, "BANNER publishing url=" + url + " node=" + BANNER_NODE);
+ return Futures.transform(
+ pubSubManager.publishSingleton(selfJid, entry, BANNER_NODE, NodeConfiguration.PRESENCE),
+ v -> {
+ Log.d(Config.LOGTAG, "BANNER publish succeeded");
+ final var account = getAccount();
+ if (account.setBanner(url)) {
+ service.updateAccountUi();
+ }
+ return v;
+ },
+ MoreExecutors.directExecutor());
+ }
+
+ private static final String PEPPER_PREFS = "jabjab_banner";
+
+ /**
+ * The bare JID used to be the upload path segment directly (banner/{jid}/header.jpg) — easy
+ * to enumerate, since GET on the upload server's permanent categories has no auth at all
+ * (only PUT does): anyone can iterate candidate JIDs and fetch banners for accounts they
+ * have no roster/presence relationship with, bypassing the PubSub item's presence-gated
+ * access model entirely (that only protects the *metadata item* pointing at the URL, not
+ * the raw file once the URL is known/guessed). HMAC-SHA256 of the JID with a random,
+ * per-account, locally-generated pepper (never transmitted — recipients only ever get the
+ * final URL via the PubSub item itself, they never need to recompute this) makes the path
+ * unguessable while staying deterministic across republishes for the *same* account, so the
+ * overwrite-in-place behavior is preserved.
+ */
+ private String hashedPathSegment(final Jid bareJid) {
+ final var prefs =
+ service.getApplicationContext().getSharedPreferences(PEPPER_PREFS, Context.MODE_PRIVATE);
+ final String key = "pepper_" + getAccount().getUuid();
+ String pepperHex = prefs.getString(key, null);
+ if (pepperHex == null) {
+ final byte[] pepper = new byte[32];
+ new SecureRandom().nextBytes(pepper);
+ final var sb = new StringBuilder(64);
+ for (final byte b : pepper) sb.append(String.format("%02x", b));
+ pepperHex = sb.toString();
+ prefs.edit().putString(key, pepperHex).apply();
+ }
+ try {
+ final var mac = Mac.getInstance("HmacSHA256");
+ final byte[] pepperBytes = new byte[pepperHex.length() / 2];
+ for (int i = 0; i < pepperBytes.length; i++) {
+ pepperBytes[i] = (byte) Integer.parseInt(pepperHex.substring(i * 2, i * 2 + 2), 16);
+ }
+ mac.init(new SecretKeySpec(pepperBytes, "HmacSHA256"));
+ final byte[] digest = mac.doFinal(bareJid.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8));
+ final var sb = new StringBuilder(64);
+ // 128 bits (32 hex chars) is plenty to make offline guessing infeasible while
+ // keeping the URL reasonably short.
+ for (int i = 0; i < 16; i++) sb.append(String.format("%02x", digest[i]));
+ return sb.toString();
+ } catch (final Exception e) {
+ throw new IllegalStateException("Could not compute banner path hash", e);
+ }
+ }
+
+ /**
+ * Uploads the banner bypassing Prosody's XEP-0363 flow for xmpp.tel accounts, so it lands
+ * in the upload server's permanent banner/ category (never purged, unlike files/'s 30-day
+ * TTL) at a fixed path derived from the account — banner/{hash}/header.webp — that gets
+ * overwritten in place on every republish instead of accumulating a new file per upload.
+ * Mirrors StoriesManager.uploadStory()'s bypass for the same server. Accounts on other
+ * servers fall back to that server's own XEP-0363 upload component (files/-equivalent
+ * there; no permanent-category guarantee off xmpp.tel).
+ */
+ public ListenableFuture<Void> uploadAndPublish(final File file, final String mime) {
+ final var selfJid = getAccount().getJid().asBareJid();
+ final ListenableFuture<okhttp3.HttpUrl> uploadFuture;
+ if (Config.UPLOAD_BYPASS_DOMAIN.equals(selfJid.getDomain().toString())) {
+ final okhttp3.HttpUrl url = okhttp3.HttpUrl.parse(
+ Config.BANNER_UPLOAD_BASE + "/" + hashedPathSegment(selfJid) + "/header.webp");
+ if (url == null) {
+ return Futures.immediateFailedFuture(new IllegalStateException("Invalid banner upload URL"));
+ }
+ final var slot = new HttpUploadManager.Slot(url, url,
+ new okhttp3.Headers.Builder()
+ .add("X-Author-Jid", selfJid.toString())
+ .add("User-Agent", "JabJab")
+ .build());
+ Log.d(Config.LOGTAG, "BANNER direct upload slot: " + url);
+ uploadFuture = getManager(HttpUploadManager.class).upload(file, mime, slot);
+ } else {
+ uploadFuture = getManager(HttpUploadManager.class).upload(file, mime, (Purpose) null);
+ }
+ return Futures.transformAsync(
+ uploadFuture,
+ url -> {
+ // The physical path is fixed (overwritten in place each publish), so the
+ // URL string itself never changes — without a cache-busting param, clients
+ // (their own LinkPreviewFetcher cache, and any other client's image cache)
+ // would keep showing the old bitmap forever after a republish.
+ final String cacheBustedUrl = url.newBuilder()
+ .setQueryParameter("v", String.valueOf(System.currentTimeMillis()))
+ .build()
+ .toString();
+ return publishBanner(cacheBustedUrl, null, 0, 0);
+ },
+ MoreExecutors.directExecutor());
+ }
+
+ public ListenableFuture<Void> deleteBanner() {
+ final var selfJid = getAccount().getJid().asBareJid();
+ return Futures.transform(
+ getManager(PubSubManager.class).retract(selfJid, "current", BANNER_NODE),
+ iq -> {
+ final var account = getAccount();
+ if (account.setBanner(null)) {
+ service.updateAccountUi();
+ }
+ return (Void) null;
+ },
+ MoreExecutors.directExecutor());
+ }
+
+ public ListenableFuture<Banner> fetchBanner(final Jid bareJid) {
+ return Futures.transform(
+ getManager(PubSubManager.class)
+ .fetchMostRecentItem(bareJid, BANNER_NODE, Entry.class),
+ Banner::fromEntry,
+ MoreExecutors.directExecutor());
+ }
+
+ /**
+ * Fetches bareJid's banner and applies it to the Contact/Account cache (same effect as a
+ * live PEP push), so callers (e.g. ContactDetailsActivity.onCreate) can force a refresh
+ * without waiting for a push — banner state isn't persisted to the DB, so this is the
+ * "cold start" rehydration path. Swallows fetch failures (item-not-found is the normal
+ * "no banner set" case) rather than propagating them to the caller.
+ */
+ public void fetchAndCache(final Jid bareJid) {
+ Futures.addCallback(
+ fetchBanner(bareJid),
+ new com.google.common.util.concurrent.FutureCallback<Banner>() {
+ @Override
+ public void onSuccess(final Banner banner) {
+ applyToContact(bareJid, banner);
+ }
+
+ @Override
+ public void onFailure(final Throwable t) {
+ Log.d(Config.LOGTAG, "BANNER fetch failed for " + bareJid + ": " + t.getMessage());
+ }
+ },
+ MoreExecutors.directExecutor());
+ }
+}
diff --git a/src/main/java/tel/xmpp/jabjab/xmpp/manager/DiscoManager.java b/src/main/java/tel/xmpp/jabjab/xmpp/manager/DiscoManager.java
index 9449a71..ec10466 100644
--- a/src/main/java/tel/xmpp/jabjab/xmpp/manager/DiscoManager.java
+++ b/src/main/java/tel/xmpp/jabjab/xmpp/manager/DiscoManager.java
@@ -363,6 +363,7 @@ public class DiscoManager extends AbstractManager {
// chat messages. This makes the server actually deliver live push for both.
features.add(notify(StoriesManager.STORIES_NODE));
features.add(notify(BlogManager.BLOG_NODE));
+ features.add(notify(BannerManager.BANNER_NODE));
if (!appSettings.isUseTor() && !account.isOnion()) {
features.addAll(PRIVACY_SENSITIVE);
features.addAll(VOIP_NAMESPACES);
diff --git a/src/main/java/tel/xmpp/jabjab/xmpp/manager/PubSubManager.java b/src/main/java/tel/xmpp/jabjab/xmpp/manager/PubSubManager.java
index 4d23116..73a9290 100644
--- a/src/main/java/tel/xmpp/jabjab/xmpp/manager/PubSubManager.java
+++ b/src/main/java/tel/xmpp/jabjab/xmpp/manager/PubSubManager.java
@@ -205,6 +205,9 @@ public class PubSubManager extends AbstractManager {
if (isFromBare && BlogManager.BLOG_NODE.equals(node)) {
getManager(BlogManager.class).handleIncomingItems(from, items);
}
+ if (isFromBare && BannerManager.BANNER_NODE.equals(node)) {
+ getManager(BannerManager.class).handleIncomingItems(from, items);
+ }
}
private void handlePurge(final Message message, final Purge purge) {
@@ -218,6 +221,9 @@ public class PubSubManager extends AbstractManager {
// purge (delete all items in a node) is functionally equivalent to delete
getManager(AvatarManager.class).handleDelete(from);
}
+ if (isFromBare && BannerManager.BANNER_NODE.equals(node)) {
+ getManager(BannerManager.class).handleDelete(from);
+ }
}
private void handleDelete(final Message message, final Delete delete) {
@@ -232,6 +238,10 @@ public class PubSubManager extends AbstractManager {
getManager(AvatarManager.class).handleDelete(from);
return;
}
+ if (isFromBare && BannerManager.BANNER_NODE.equals(node)) {
+ getManager(BannerManager.class).handleDelete(from);
+ return;
+ }
if (isFromBare && Namespace.NICK.equals(node)) {
getManager(NickManager.class).handleDelete(from);
}
diff --git a/src/main/res/drawable/circle_solid.xml b/src/main/res/drawable/circle_solid.xml
new file mode 100644
index 0000000..4e89b12
--- /dev/null
+++ b/src/main/res/drawable/circle_solid.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+ android:shape="oval">
+ <solid android:color="@android:color/darker_gray" />
+</shape>
diff --git a/src/main/res/drawable/ic_circle_24dp.xml b/src/main/res/drawable/ic_circle_24dp.xml
new file mode 100644
index 0000000..1fbb846
--- /dev/null
+++ b/src/main/res/drawable/ic_circle_24dp.xml
@@ -0,0 +1,10 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24"
+ android:tint="?attr/colorControlNormal">
+ <path
+ android:fillColor="@android:color/white"
+ android:pathData="M12,12m-8,0a8,8 0,1 1,16 0a8,8 0,1 1,-16 0" />
+</vector>
diff --git a/src/main/res/layout/activity_contact_details.xml b/src/main/res/layout/activity_contact_details.xml
index ac7a4fa..8ea228e 100644
--- a/src/main/res/layout/activity_contact_details.xml
+++ b/src/main/res/layout/activity_contact_details.xml
@@ -31,53 +31,136 @@
android:layout_height="wrap_content"
android:orientation="vertical">
+ <!-- Banner: full-width image with the avatar overlapping its bottom edge
+ (Instagram/WhatsApp-style). Explicit height (banner + half the avatar)
+ rather than wrap_content: the avatar overlaps the banner's bottom edge
+ WITHIN this container's own bounds, so the details card below starts
+ after the avatar's true bottom instead of drawing over it. -->
+ <FrameLayout
+ android:layout_width="match_parent"
+ android:layout_height="248dp">
+
+ <ImageView
+ android:id="@+id/details_banner"
+ android:layout_width="match_parent"
+ android:layout_height="200dp"
+ android:layout_gravity="top"
+ android:background="?colorSurfaceVariant"
+ android:scaleType="centerCrop" />
+
+ <!-- Presence-colored ring: a plain circular View, tinted per-availability in
+ code, sitting BEHIND a smaller, CENTERED avatar — not a
+ ShapeableImageView stroke (that clipped visibly: a MaterialShapeDrawable
+ stroke draws centered on the view's own edge, so half of it needs to
+ paint outside the view's own canvas, which a View can never do). Both
+ views live in their own shared box, sized to the ring, with the avatar
+ gravity="center" inside it — NOT each independently gravity="bottom" in
+ the outer header, which made their bottom edges coincide exactly and
+ piled the whole size difference onto the top only (a crescent, not a
+ ring). Centering them on each other here guarantees an even ring. -->
+ <FrameLayout
+ android:layout_width="99dp"
+ android:layout_height="99dp"
+ android:layout_gravity="bottom|center_horizontal">
+
+ <View
+ android:id="@+id/details_avatar_ring"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="@drawable/circle_solid" />
+
+ <com.google.android.material.imageview.ShapeableImageView
+ android:id="@+id/details_avatar"
+ android:layout_width="96dp"
+ android:layout_height="96dp"
+ android:layout_gravity="center"
+ android:scaleType="centerCrop"
+ app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
+ </FrameLayout>
+ </FrameLayout>
+
+ <!-- Presence status text (RFC 6121 <status/> — the field every XMPP client
+ shows, unlike vCard bio which many clients don't surface at all at all) on
+ top, vCard bio/note (the longer, static "about" field) below it. JID moved
+ to the info list below; presence color now lives on the avatar's stroke
+ ring instead of a separate badge here — nothing under the avatar sits in
+ a card/box. -->
+ <TextView
+ android:id="@+id/status_message"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:layout_marginTop="@dimen/activity_vertical_margin"
+ android:layout_marginHorizontal="32dp"
+ android:gravity="center_horizontal"
+ android:textAppearance="?textAppearanceBodyMedium"
+ android:textColor="?colorOnSurfaceVariant"
+ tools:text="Hey there! I’m using Conversations" />
+
+ <TextView
+ android:id="@+id/vcard_bio"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:layout_marginTop="4dp"
+ android:layout_marginHorizontal="32dp"
+ android:gravity="center_horizontal"
+ android:textAppearance="?textAppearanceBodySmall"
+ android:textColor="?colorOnSurfaceVariant"
+ android:visibility="gone"
+ tools:text="Photographer. Coffee enthusiast. Berlin." />
+
+ <!-- Guaranteed minimum gap before the info-list card — status_message/vcard_bio
+ are both GONE (not just empty) when unset, so without this the card would
+ sit right up against the avatar with no breathing room. -->
+ <Space
+ android:layout_width="match_parent"
+ android:layout_height="20dp" />
+
+ <!-- Info list: JID / status / online-resource rows, each icon + value, same
+ table-row shape used elsewhere for icon-led lists. Moved above the
+ buttons/presence card per request. -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:layout_marginLeft="@dimen/activity_horizontal_margin"
+ android:layout_marginHorizontal="@dimen/activity_horizontal_margin"
android:layout_marginTop="@dimen/activity_vertical_margin"
- android:layout_marginRight="@dimen/activity_horizontal_margin"
android:layout_marginBottom="@dimen/activity_vertical_margin">
- <RelativeLayout
+ <LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:padding="@dimen/card_padding_regular">
-
- <com.google.android.material.imageview.ShapeableImageView
- android:id="@+id/details_avatar"
- android:layout_width="@dimen/publish_avatar_size"
- android:layout_height="@dimen/publish_avatar_size"
- android:layout_alignParentTop="true"
- android:layout_centerHorizontal="true"
- android:scaleType="centerCrop"
- app:shapeAppearanceOverlay="@style/ShapeAppearance.RoundedCornersMedium" />
+ android:orientation="vertical"
+ android:paddingVertical="4dp">
<LinearLayout
- android:id="@+id/details_contact_name_box"
- android:layout_width="wrap_content"
+ android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:layout_below="@+id/details_avatar"
- android:layout_centerHorizontal="true"
- android:layout_marginTop="16sp"
- android:gravity="center"
- android:minWidth="288dp"
- android:orientation="horizontal">
+ android:gravity="center_vertical"
+ android:orientation="horizontal"
+ android:paddingHorizontal="@dimen/card_padding_regular"
+ android:paddingVertical="12dp">
+
+ <ImageView
+ android:layout_width="24dp"
+ android:layout_height="24dp"
+ android:src="@drawable/ic_person_24dp"
+ app:tint="?colorOnSurfaceVariant" />
<TextView
- android:id="@+id/details_contact_xmpp_address"
- android:layout_width="wrap_content"
+ android:id="@+id/details_row_jid"
+ android:layout_width="0dp"
android:layout_height="wrap_content"
- android:layout_gravity="center_vertical"
- android:gravity="center_horizontal"
- android:text="@string/account_settings_example_jabber_id"
- android:textAppearance="?textAppearanceTitleLarge" />
+ android:layout_marginStart="20dp"
+ android:layout_weight="1"
+ android:textAppearance="?textAppearanceBodyMedium"
+ android:textIsSelectable="true"
+ tools:text="jid@xmpp.tel" />
<ImageView
android:id="@+id/details_verified_badge_icon"
android:layout_width="18dp"
android:layout_height="18dp"
- android:layout_gravity="center_vertical"
android:layout_marginStart="6dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
@@ -87,19 +170,126 @@
android:src="@drawable/ic_check_circle_24dp"
android:visibility="gone"
app:tint="?attr/colorPrimary" />
+ </LinearLayout>
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:gravity="center_vertical"
+ android:orientation="horizontal"
+ android:paddingHorizontal="@dimen/card_padding_regular"
+ android:paddingVertical="12dp">
+
+ <ImageView
+ android:layout_width="24dp"
+ android:layout_height="24dp"
+ android:src="@drawable/ic_circle_24dp"
+ app:tint="?colorOnSurfaceVariant" />
+
+ <!-- Colorful pill, tinted per-availability in code (see
+ ContactDetailsActivity#populateStatusRow) — same color also
+ goes on the avatar's ring. Sits right after the icon, like every
+ other row's value, instead of pushed to the far end. -->
+ <TextView
+ android:id="@+id/details_row_status"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="20dp"
+ android:background="@drawable/bg_search_pill"
+ android:paddingHorizontal="12dp"
+ android:paddingVertical="3dp"
+ android:textAppearance="?textAppearanceLabelMedium"
+ android:textColor="@android:color/white"
+ tools:text="Online" />
</LinearLayout>
+ <LinearLayout
+ android:id="@+id/details_row_resources"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:gravity="center_vertical"
+ android:orientation="horizontal"
+ android:paddingHorizontal="@dimen/card_padding_regular"
+ android:paddingVertical="12dp"
+ android:visibility="gone">
+ <ImageView
+ android:layout_width="24dp"
+ android:layout_height="24dp"
+ android:src="@drawable/ic_smartphone_24dp"
+ app:tint="?colorOnSurfaceVariant" />
+ <LinearLayout
+ android:id="@+id/client_resources_box"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="20dp"
+ android:layout_weight="1"
+ android:orientation="horizontal" />
+ </LinearLayout>
+
+ <!-- Presence-sharing controls, moved up from the buttons/presence card
+ below into this same table-row shape. Both use the same visibility
+ icon (send = they can see your presence, receive = you can see
+ theirs) — no directional icon pair exists, so one icon fits both. -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:gravity="center_vertical"
+ android:orientation="horizontal"
+ android:paddingHorizontal="@dimen/card_padding_regular"
+ android:paddingVertical="8dp">
+
+ <ImageView
+ android:layout_width="24dp"
+ android:layout_height="24dp"
+ android:src="@drawable/ic_call_made_24dp"
+ app:tint="?colorOnSurfaceVariant" />
+
+ <CheckBox
+ android:id="@+id/details_send_presence"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="20dp"
+ android:layout_weight="1"
+ android:textAppearance="?textAppearanceBodyMedium"
+ android:text="@string/send_presence_updates" />
+ </LinearLayout>
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:gravity="center_vertical"
+ android:orientation="horizontal"
+ android:paddingHorizontal="@dimen/card_padding_regular"
+ android:paddingVertical="8dp">
+
+ <ImageView
+ android:layout_width="24dp"
+ android:layout_height="24dp"
+ android:src="@drawable/ic_call_received_24dp"
+ app:tint="?colorOnSurfaceVariant" />
+
+ <CheckBox
+ android:id="@+id/details_receive_presence"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="20dp"
+ android:layout_weight="1"
+ android:textAppearance="?textAppearanceBodyMedium"
+ android:text="@string/receive_presence_updates" />
+ </LinearLayout>
+
+ <!-- Add-contact / save-to-contact buttons, moved up here — the second
+ card that used to hold them (and tags) has been removed entirely. -->
<LinearLayout
android:id="@+id/button_box"
- android:layout_width="wrap_content"
+ android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:layout_below="@+id/details_contact_name_box"
- android:layout_centerHorizontal="true"
- android:layout_marginTop="16dp"
- android:orientation="horizontal">
+ android:gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:paddingHorizontal="@dimen/card_padding_regular"
+ android:paddingVertical="8dp">
<Button
android:id="@+id/add_contact_button"
@@ -118,14 +308,12 @@
app:icon="@drawable/ic_contacts_24dp" />
</LinearLayout>
-
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/tags"
- android:layout_width="wrap_content"
+ android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:layout_below="@+id/button_box"
- android:layout_centerHorizontal="true"
- android:layout_marginTop="16sp"
+ android:layout_marginHorizontal="@dimen/card_padding_regular"
+ android:layout_marginBottom="8dp"
android:visibility="gone">
<androidx.constraintlayout.helper.widget.Flow
@@ -142,68 +330,20 @@
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
- <LinearLayout
- android:id="@+id/status_message_subscription_box"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_below="@+id/tags"
- android:layout_alignStart="@+id/details_contact_name_box"
- android:layout_marginTop="8sp"
- android:orientation="vertical">
-
- <TextView
- android:id="@+id/status_message"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginBottom="8sp"
- 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"
- android:layout_height="wrap_content"
- android:layout_marginBottom="8sp"
- android:textAppearance="?textAppearanceBodySmall"
- android:textColor="?colorOnSurfaceVariant"
- android:visibility="gone" />
-
- <CheckBox
- android:id="@+id/details_send_presence"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:text="@string/send_presence_updates" />
-
- <CheckBox
- android:id="@+id/details_receive_presence"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:text="@string/receive_presence_updates" />
- </LinearLayout>
-
+ <!-- "Using account: …" tucked dim in the corner, not a full row. -->
<TextView
android:id="@+id/details_account"
- android:layout_width="wrap_content"
+ android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:layout_below="@+id/status_message_subscription_box"
- android:layout_alignParentEnd="true"
- android:layout_alignParentBottom="true"
- android:layout_marginTop="16sp"
+ android:layout_gravity="end"
+ android:gravity="end"
+ android:paddingHorizontal="@dimen/card_padding_regular"
+ android:paddingBottom="8dp"
android:text="@string/using_account"
- android:textAppearance="?textAppearanceLabelMedium" />
- </RelativeLayout>
+ android:textAppearance="?textAppearanceLabelSmall"
+ android:textColor="?colorOnSurfaceVariant" />
+
+ </LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Stories preview — hidden until at least one live story is fetched -->
diff --git a/src/main/res/layout/activity_edit_account.xml b/src/main/res/layout/activity_edit_account.xml
index cb13abb..fd1100c 100644
--- a/src/main/res/layout/activity_edit_account.xml
+++ b/src/main/res/layout/activity_edit_account.xml
@@ -34,6 +34,106 @@
android:orientation="vertical">
+ <!-- Live mini profile preview: banner/avatar/name/status/bio, refreshed on every
+ OnAccountUpdate so it reflects changes made elsewhere instantly. Sits on
+ the plain background — deliberately no card/border around it (matches how
+ a contact's own profile header looks, full-bleed) — and above/separate from
+ the "editor" card below, not nested inside it (having both showed a second,
+ redundant avatar/name right next to each other and looked repetitive). The
+ whole block is tappable -> EditProfileActivity, since "how do I change my
+ profile" was previously only reachable via the overflow menu
+ (R.id.action_edit_profile) — this makes it the obvious, visible entry point
+ instead, doubling as its own preview. -->
+ <LinearLayout
+ android:id="@+id/mini_profile_header"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="12dp"
+ android:background="?attr/selectableItemBackground"
+ android:clickable="true"
+ android:focusable="true"
+ android:orientation="vertical">
+
+ <!-- Same exact dimensions as the real profile header (banner 200dp, avatar
+ 96dp, 248dp container — EditProfileActivity/ContactDetailsActivity) so
+ the same banner image crops/composes identically here, instead of a
+ scaled-down version looking like a different photo. -->
+ <FrameLayout
+ android:layout_width="match_parent"
+ android:layout_height="248dp">
+
+ <ImageView
+ android:id="@+id/mini_profile_banner"
+ android:layout_width="match_parent"
+ android:layout_height="200dp"
+ android:layout_gravity="top"
+ android:background="?colorSurfaceVariant"
+ android:scaleType="centerCrop" />
+
+ <com.google.android.material.imageview.ShapeableImageView
+ android:id="@+id/mini_profile_avatar"
+ android:layout_width="96dp"
+ android:layout_height="96dp"
+ android:layout_gravity="bottom|start"
+ android:layout_marginStart="16dp"
+ android:scaleType="centerCrop"
+ app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
+
+ <ImageView
+ android:layout_width="32dp"
+ android:layout_height="32dp"
+ android:layout_gravity="top|end"
+ android:layout_margin="8dp"
+ android:background="@drawable/circle_solid"
+ android:backgroundTint="?colorSurface"
+ android:contentDescription="@string/edit_profile_title"
+ android:padding="6dp"
+ android:src="@drawable/ic_edit_24dp"
+ app:tint="?colorOnSurfaceVariant" />
+ </FrameLayout>
+
+ <TextView
+ android:id="@+id/mini_profile_name"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="16dp"
+ android:layout_marginTop="8dp"
+ android:textAppearance="?textAppearanceTitleMedium"
+ tools:text="Desperado" />
+
+ <!-- Plain dim text — no badge/pill (too heavy here) and no presence-color
+ tint (that's what the info-list's colorful badge is for elsewhere;
+ here it's just the status message, same tone as the bio below it). -->
+ <TextView
+ android:id="@+id/mini_profile_status"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="16dp"
+ android:layout_marginTop="2dp"
+ android:textAppearance="?textAppearanceLabelMedium"
+ android:textColor="?colorOnSurfaceVariant"
+ android:visibility="gone"
+ tools:text="Online"
+ tools:visibility="visible" />
+
+ <TextView
+ android:id="@+id/mini_profile_bio"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="16dp"
+ android:layout_marginTop="4dp"
+ android:layout_marginEnd="16dp"
+ android:layout_marginBottom="12dp"
+ android:ellipsize="end"
+ android:maxLines="2"
+ android:textAppearance="?textAppearanceBodySmall"
+ android:textColor="?colorOnSurfaceVariant"
+ android:visibility="gone"
+ tools:text="Photographer. Coffee enthusiast. Berlin."
+ tools:visibility="visible" />
+
+ </LinearLayout>
+
<com.google.android.material.card.MaterialCardView
android:id="@+id/editor"
android:layout_width="fill_parent"
diff --git a/src/main/res/layout/activity_edit_profile.xml b/src/main/res/layout/activity_edit_profile.xml
index 3ef19f1..c253adb 100644
--- a/src/main/res/layout/activity_edit_profile.xml
+++ b/src/main/res/layout/activity_edit_profile.xml
@@ -30,27 +30,50 @@
android:orientation="vertical"
android:paddingBottom="24dp">
- <!-- Avatar section -->
+ <!-- Banner + avatar header: banner fills the top, circular avatar overlaps its
+ bottom edge (Instagram/WhatsApp-style). paddingBottom on the outer FrameLayout
+ reserves room for the overlap within its own measured bounds, so nothing
+ overflows into (and gets covered by) the next sibling below. -->
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:background="?colorSurfaceVariant"
- android:paddingVertical="24dp">
+ android:paddingBottom="48dp">
<ImageView
+ android:id="@+id/profile_banner"
+ android:layout_width="match_parent"
+ android:layout_height="200dp"
+ android:background="?colorSurfaceVariant"
+ android:scaleType="centerCrop" />
+
+ <com.google.android.material.floatingactionbutton.FloatingActionButton
+ android:id="@+id/banner_edit_fab"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="top|end"
+ android:layout_marginTop="156dp"
+ android:layout_marginEnd="16dp"
+ app:fabSize="mini"
+ app:srcCompat="@drawable/ic_camera_alt_24dp"
+ app:tint="?colorOnPrimaryContainer" />
+
+ <com.google.android.material.imageview.ShapeableImageView
android:id="@+id/profile_avatar"
android:layout_width="96dp"
android:layout_height="96dp"
- android:layout_gravity="center_horizontal"
- android:scaleType="centerCrop" />
+ android:layout_gravity="start"
+ android:layout_marginStart="16dp"
+ android:layout_marginTop="152dp"
+ android:scaleType="centerCrop"
+ app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/avatar_edit_fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:layout_gravity="center_horizontal|bottom"
- android:layout_marginTop="56dp"
- android:layout_marginStart="48dp"
+ android:layout_gravity="start|top"
+ android:layout_marginStart="88dp"
+ android:layout_marginTop="216dp"
app:fabSize="mini"
app:srcCompat="@drawable/ic_camera_alt_24dp"
app:tint="?colorOnPrimaryContainer" />
@@ -82,24 +105,35 @@
android:layout_height="wrap_content"
android:inputType="textPersonName"
android:imeOptions="actionNext"
+ android:maxLength="100"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
+ <!-- No separate "Nickname" field — vCard NICKNAME is set to the same value as
+ display name automatically in the background (EditProfileActivity#saveProfile).
+ JabJab itself never reads vCard NICKNAME (it's a cross-client-compatibility-only
+ field, distinct from the XEP-0172 nickname that actually drives what every
+ contact sees), so exposing it as a separate input just confused users into
+ thinking it was a second, meaningfully-different name field. -->
+
+ <!-- Live presence <status/> — RFC 6121, not vCard — kept separate from bio, above
+ it, per the same status-vs-bio distinction used in ContactDetailsActivity. -->
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="12dp"
- android:hint="@string/profile_field_nickname"
+ android:hint="@string/profile_field_status"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
- android:id="@+id/profile_nickname"
+ android:id="@+id/profile_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:inputType="textNoSuggestions"
+ android:inputType="text|textCapSentences"
android:imeOptions="actionNext"
+ android:maxLength="100"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
@@ -117,6 +151,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textMultiLine|textCapSentences"
+ android:maxLength="500"
android:maxLines="5"
android:minLines="3"
android:gravity="top" />
diff --git a/src/main/res/layout/activity_publish_banner.xml b/src/main/res/layout/activity_publish_banner.xml
new file mode 100644
index 0000000..fc96976
--- /dev/null
+++ b/src/main/res/layout/activity_publish_banner.xml
@@ -0,0 +1,108 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools">
+
+ <RelativeLayout
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:fitsSystemWindows="true">
+
+ <com.google.android.material.appbar.AppBarLayout
+ android:id="@+id/app_bar_layout"
+ 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="wrap_content"
+ android:minHeight="?attr/actionBarSize" />
+
+ </com.google.android.material.appbar.AppBarLayout>
+
+ <ScrollView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_above="@+id/button_bar"
+ android:layout_below="@id/app_bar_layout">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginLeft="@dimen/activity_horizontal_margin"
+ android:layout_marginTop="@dimen/activity_vertical_margin"
+ android:layout_marginRight="@dimen/activity_horizontal_margin"
+ android:layout_marginBottom="@dimen/activity_vertical_margin"
+ android:gravity="center_horizontal"
+ android:orientation="vertical">
+
+ <com.google.android.material.card.MaterialCardView
+ android:id="@+id/banner_image_wrapper"
+ android:layout_width="match_parent"
+ android:layout_height="200dp"
+ android:layout_marginTop="16dp"
+ android:layout_marginBottom="8dp"
+ app:cardCornerRadius="8dp"
+ app:cardElevation="0dp"
+ app:cardBackgroundColor="?colorSurfaceVariant">
+
+ <ImageView
+ android:id="@+id/banner_image"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:scaleType="centerCrop"
+ android:contentDescription="@string/tap_to_choose_banner" />
+ </com.google.android.material.card.MaterialCardView>
+
+ <TextView
+ android:id="@+id/hint"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@string/tap_to_choose_banner"
+ android:textAppearance="?textAppearanceBodyMedium" />
+
+ <TextView
+ android:id="@+id/hint_or_warning"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginVertical="12dp"
+ android:textAppearance="?textAppearanceBodyMedium"
+ android:textColor="?colorError"
+ android:visibility="gone"
+ tools:text="@string/error_saving_avatar" />
+ </LinearLayout>
+
+ </ScrollView>
+
+ <RelativeLayout
+ android:id="@+id/button_bar"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignParentStart="true"
+ android:layout_alignParentEnd="true"
+ android:layout_alignParentBottom="true"
+ android:paddingHorizontal="16dp"
+ android:paddingVertical="8dp">
+
+ <Button
+ android:id="@+id/cancel_button"
+ style="@style/Widget.Material3.Button.TextButton"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignParentStart="true"
+ android:layout_centerInParent="true"
+ android:text="@string/cancel" />
+
+ <Button
+ android:id="@+id/publish_button"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignParentEnd="true"
+ android:layout_centerInParent="true"
+ android:enabled="false"
+ android:text="@string/publish" />
+ </RelativeLayout>
+
+ </RelativeLayout>
+</layout>
diff --git a/src/main/res/menu/activity_publish_banner.xml b/src/main/res/menu/activity_publish_banner.xml
new file mode 100644
index 0000000..fa74eb0
--- /dev/null
+++ b/src/main/res/menu/activity_publish_banner.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<menu xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto">
+
+ <item
+ android:id="@+id/action_delete_banner"
+ android:title="@string/delete_banner"
+ app:showAsAction="never" />
+</menu>
diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml
index b00d65f..1af955c 100644
--- a/src/main/res/values/strings.xml
+++ b/src/main/res/values/strings.xml
@@ -338,6 +338,14 @@
<string name="contacts_and_n_more_have_read_up_to_this_point">%1$s +%2$d others have read up to this point</string>
<string name="everyone_has_read_up_to_this_point">Everyone has read up to this point</string>
<string name="publish">Publish</string>
+ <string name="tap_to_choose_banner">Tap to choose a profile banner</string>
+ <string name="publish_banner_title">Profile banner</string>
+ <string name="banner_has_been_published">Banner published</string>
+ <string name="delete_banner">Remove banner</string>
+ <string name="delete_banner_message">This removes your profile banner for everyone who can see it.</string>
+ <string name="error_publish_banner_converting">Could not process this image</string>
+ <string name="error_banner_too_large">Image is too large (max 5 MB)</string>
+ <string name="action_delete_banner">Remove banner</string>
<string name="touch_to_choose_picture">Tap avatar to select picture from gallery</string>
<string name="publishing">Publishing…</string>
<string name="error_publish_avatar_server_reject">The server rejected your publication</string>
@@ -632,6 +640,7 @@
<string name="gif_deleted">GIF removed</string>
<string name="my_gifs">My GIFs</string>
<string name="gif_favorites_empty">No saved GIFs yet.\nLong-press a GIF in chat to save it.</string>
+ <string name="status">Status</string>
<string name="presence_chat">Free for Chat</string>
<string name="presence_online">Online</string>
<string name="presence_away">Away</string>
@@ -1452,10 +1461,12 @@
<string name="edit_profile_title">Edit Profile</string>
<string name="profile_field_display_name">Display name</string>
<string name="profile_field_nickname">Nickname (optional)</string>
+ <string name="profile_field_status">Status</string>
<string name="profile_field_bio">Bio</string>
<string name="save_profile">Save</string>
<string name="profile_saved">Profile saved</string>
<string name="profile_save_failed">Failed to save profile</string>
+ <string name="profile_saved_offline">Saved — will publish when back online</string>
<string name="action_edit_profile">Edit profile</string>
<string name="pref_category_link_preview">Link preview</string>
<string name="pref_link_preview">Show link previews</string>
diff --git a/src/main/res/values/themes.xml b/src/main/res/values/themes.xml
index 7fa1010..ff296d7 100644
--- a/src/main/res/values/themes.xml
+++ b/src/main/res/values/themes.xml
@@ -218,6 +218,11 @@
<item name="cornerSize">8dp</item>
</style>
+ <style name="ShapeAppearance.Circle" parent="ShapeAppearance.MaterialComponents.SmallComponent">
+ <item name="cornerFamily">rounded</item>
+ <item name="cornerSize">50%</item>
+ </style>
+
<style name="ShapeAppearance.RoundedCornersLight" parent="ShapeAppearance.MaterialComponents.SmallComponent">
<item name="cornerFamily">rounded</item>
<item name="cornerSize">6dp</item>
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.