From 51a7acbeefccfbc462be56b9a0e0ff36ea5db417 Mon Sep 17 00:00:00 2001 From: Arne Date: Mon, 29 Dec 2025 22:07:23 +0100 Subject: [PATCH 001/180] Add rudimentary support for viewing stories --- src/main/AndroidManifest.xml | 3 + .../siacs/conversations/entities/Story.java | 112 +++++++ .../generator/AbstractGenerator.java | 3 + .../conversations/generator/IqGenerator.java | 46 +++ .../siacs/conversations/parser/IqParser.java | 20 ++ .../conversations/parser/MessageParser.java | 9 + .../services/XmppConnectionService.java | 128 +++++++- .../ui/ConversationsOverviewFragment.java | 280 +++++++++++++----- .../conversations/ui/StoryViewActivity.java | 71 +++++ .../ui/adapter/StoryAdapter.java | 84 ++++++ .../eu/siacs/conversations/xml/Namespace.java | 3 + .../res/drawable/outline_amp_stories_24.xml | 5 + src/main/res/layout/activity_story_view.xml | 13 + .../fragment_conversations_overview.xml | 109 ++++--- src/main/res/layout/list_item_story.xml | 23 ++ src/main/res/values/dimens.xml | 1 + src/main/res/values/strings.xml | 5 + 17 files changed, 781 insertions(+), 134 deletions(-) create mode 100644 src/main/java/eu/siacs/conversations/entities/Story.java create mode 100644 src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java create mode 100644 src/main/java/eu/siacs/conversations/ui/adapter/StoryAdapter.java create mode 100644 src/main/res/drawable/outline_amp_stories_24.xml create mode 100644 src/main/res/layout/activity_story_view.xml create mode 100644 src/main/res/layout/list_item_story.xml diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index 6749e19f7..2eaa46ed8 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -396,6 +396,9 @@ + diff --git a/src/main/java/eu/siacs/conversations/entities/Story.java b/src/main/java/eu/siacs/conversations/entities/Story.java new file mode 100644 index 000000000..b554df9e5 --- /dev/null +++ b/src/main/java/eu/siacs/conversations/entities/Story.java @@ -0,0 +1,112 @@ +package eu.siacs.conversations.entities; + +import static eu.siacs.conversations.parser.AbstractParser.parseTimestamp; + +import android.content.ContentValues; + +import java.util.ArrayList; +import java.util.List; + +import eu.siacs.conversations.xml.Element; +import eu.siacs.conversations.xmpp.Jid; + +public class Story extends AbstractEntity { + + public static final String CONTACT = "contact"; + public static final String URL = "url"; + public static final String TYPE = "type"; + public static final String TITLE = "title"; + public static final String PUBLISHED = "published"; + + private final Jid contact; + private final String url; + private final String type; + private final String title; + private final long published; + + public Story(final String uuid, final Jid contact, final String url, final String type, final String title, final long published) { + this.uuid = uuid; + this.contact = contact; + this.url = url; + this.type = type; + this.title = title; + this.published = published; + } + + public static Story fromElement(Element item, Jid contact) { + Element entry = item.findChild("entry", "http://www.w3.org/2005/Atom"); + if (entry == null) { + return null; + } + Element published = entry.findChild("published"); + Element link = null; + for (Element child : entry.getChildren()) { + if ("link".equals(child.getName()) && "enclosure".equals(child.getAttribute("rel"))) { + link = child; + break; + } + } + if (link == null) { + return null; + } + return new Story( + item.getAttribute("id"), + contact, + link.getAttribute("href"), + link.getAttribute("type"), + entry.findChildContent("title"), + parseTimestamp(published) + ); + } + + public static List parseFromPubSub(Element pubsub, Jid contact) { + List stories = new ArrayList<>(); + if (pubsub == null) { + return stories; + } + Element items = pubsub.findChild("items"); + if (items != null) { + for (Element item : items.getChildren()) { + if (item.getName().equals("item")) { + Story story = fromElement(item, contact); + if (story != null) { + stories.add(story); + } + } + } + } + return stories; + } + + @Override + public ContentValues getContentValues() { + ContentValues values = new ContentValues(); + values.put(UUID, uuid); + values.put(CONTACT, contact.toString()); + values.put(URL, url); + values.put(TYPE, type); + values.put(TITLE, title); + values.put(PUBLISHED, published); + return values; + } + + public Jid getContact() { + return contact; + } + + public String getUrl() { + return url; + } + + public String getType() { + return type; + } + + public String getTitle() { + return title; + } + + public long getPublished() { + return published; + } +} \ No newline at end of file diff --git a/src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java b/src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java index c1ba8e09f..099d5666f 100644 --- a/src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java +++ b/src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java @@ -116,6 +116,9 @@ public abstract class AbstractGenerator { final ArrayList features = new ArrayList<>(Arrays.asList(STATIC_FEATURES)); features.add("http://jabber.org/protocol/xhtml-im"); features.add("urn:xmpp:bob"); + features.add(Namespace.PUBSUB_SOCIAL_FEED); + features.add(Namespace.PUBSUB_STORIES); + features.add(Namespace.PUBSUB_STORIES + "+notify"); if (Config.MESSAGE_DISPLAYED_SYNCHRONIZATION) { features.add(Namespace.MDS_DISPLAYED + "+notify"); } diff --git a/src/main/java/eu/siacs/conversations/generator/IqGenerator.java b/src/main/java/eu/siacs/conversations/generator/IqGenerator.java index f4acd27ce..8bd87df66 100644 --- a/src/main/java/eu/siacs/conversations/generator/IqGenerator.java +++ b/src/main/java/eu/siacs/conversations/generator/IqGenerator.java @@ -244,6 +244,36 @@ public class IqGenerator extends AbstractGenerator { return publish(namespace, item, options); } + public Iq publishStory(final String url, final String type, final String title, Bundle options) { + final Element item = new Element("item"); + item.setAttribute("id", UUID.randomUUID().toString()); + + final Element entry = item.addChild("entry", Namespace.ATOM); + if (title != null) { + entry.addChild("title").setContent(title); + } + entry.addChild("published").setContent(getTimestamp(System.currentTimeMillis())); + + final Element link = entry.addChild("link"); + link.setAttribute("rel", "enclosure"); + link.setAttribute("href", url); + link.setAttribute("type", type); + if (title != null) { + link.setAttribute("title", title); + } + + return publish(Namespace.PUBSUB_STORIES, item, options); + } + + public Iq retrieveStories(Jid jid) { + final Iq iq = new Iq(Iq.Type.GET); + iq.setTo(jid); + final Element pubsub = iq.addChild("pubsub", Namespace.PUBSUB); + final Element items = pubsub.addChild("items"); + items.setAttribute("node", Namespace.PUBSUB_STORIES); + return iq; + } + public Iq publishAvatarMetadata(final Avatar avatar, final Bundle options) { final Element item = new Element("item"); item.setAttribute("id", avatar.sha1sum); @@ -672,6 +702,22 @@ public class IqGenerator extends AbstractGenerator { return options; } + public static Bundle defaultStoriesConfiguration() { + Bundle options = new Bundle(); + options.putString("pubsub#node_type", "leaf"); + options.putString("pubsub#type", Namespace.PUBSUB_STORIES); + options.putString("pubsub#access_model", "presence"); + options.putString("pubsub#item_expire", "86400"); + options.putString("pubsub#persist_items", "1"); + options.putString("pubsub#notify_retract", "1"); + return options; + } + + public Iq createStoriesNode() { + final Data data = Data.create(null, defaultStoriesConfiguration()); + return publishPubsubConfiguration(null, Namespace.PUBSUB_STORIES, data); + } + public Iq requestPubsubConfiguration(Jid jid, String node) { return pubsubConfiguration(jid, node, null); } diff --git a/src/main/java/eu/siacs/conversations/parser/IqParser.java b/src/main/java/eu/siacs/conversations/parser/IqParser.java index f8d4a02ae..c25f28b49 100644 --- a/src/main/java/eu/siacs/conversations/parser/IqParser.java +++ b/src/main/java/eu/siacs/conversations/parser/IqParser.java @@ -11,6 +11,7 @@ import eu.siacs.conversations.crypto.axolotl.AxolotlService; import eu.siacs.conversations.entities.Account; import eu.siacs.conversations.entities.Contact; import eu.siacs.conversations.entities.Room; +import eu.siacs.conversations.entities.Story; import eu.siacs.conversations.services.XmppConnectionService; import eu.siacs.conversations.xml.Element; import eu.siacs.conversations.xml.Namespace; @@ -438,6 +439,25 @@ public class IqParser extends AbstractParser implements Consumer { account.getRoster().markAllAsNotInRoster(); } this.rosterItems(account, query); + } else if (packet.hasChild("pubsub", Namespace.PUBSUB)) { + Element pubsub = packet.findChild("pubsub", Namespace.PUBSUB); + Element items = pubsub.findChild("items"); + if (items != null) { + String node = items.getAttribute("node"); + if (Namespace.PUBSUB_STORIES.equals(node)) { + Jid from = packet.getFrom(); + if (from != null) { + for (Element item : items.getChildren()) { + if (item.getName().equals("item")) { + Story story = Story.fromElement(item, from); + if (story != null) { + mXmppConnectionService.onStoryReceived(story); + } + } + } + } + } + } } else if ((packet.hasChild("block", Namespace.BLOCKING) || packet.hasChild("blocklist", Namespace.BLOCKING)) && packet.fromServer(account)) { diff --git a/src/main/java/eu/siacs/conversations/parser/MessageParser.java b/src/main/java/eu/siacs/conversations/parser/MessageParser.java index 96e05f690..50f1c8d4d 100644 --- a/src/main/java/eu/siacs/conversations/parser/MessageParser.java +++ b/src/main/java/eu/siacs/conversations/parser/MessageParser.java @@ -37,6 +37,7 @@ import java.util.stream.Collectors; import eu.siacs.conversations.crypto.OtrService; import eu.siacs.conversations.entities.Presence; import eu.siacs.conversations.entities.ServiceDiscoveryResult; +import eu.siacs.conversations.entities.Story; import eu.siacs.conversations.xmpp.pep.UserTune; import io.ipfs.cid.Cid; @@ -464,6 +465,14 @@ public class MessageParser extends AbstractParser && account.getJid().asBareJid().equals(from)) { final Element item = items.findChild("item"); mXmppConnectionService.processMdsItem(account, item); + } else if (Namespace.PUBSUB_STORIES.equals(node)) { + final Element item = items.findChild("item"); + if (item != null) { + final Story story = Story.fromElement(item, from); + if (story != null) { + mXmppConnectionService.onStoryReceived(story); + } + } } else if (Namespace.USER_TUNE.equals(node)) { final Conversation conversation = mXmppConnectionService.find(account, from.asBareJid()); diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java index 858289dc0..671d6d0f6 100644 --- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +++ b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java @@ -344,19 +344,20 @@ public class XmppConnectionService extends Service { } if (online) { conversation.endOtrIfNeeded(); - if (contact.getPresences().size() == 1) { - sendUnsentMessages(conversation); - } - } else { - //check if the resource we are haveing a conversation with is still online - if (conversation.hasValidOtrSession()) { - String otrResource = conversation.getOtrSession().getSessionID().getUserID(); - if (!(Arrays.asList(contact.getPresences().toResourceArray()).contains(otrResource))) { - conversation.endOtrIfNeeded(); + if (contact.getPresences().size() == 1) { + sendUnsentMessages(conversation); } + fetchStories(conversation.getAccount(), conversation.getContact()); + } else { + //check if the resource we are haveing a conversation with is still online + if (conversation.hasValidOtrSession()) { + String otrResource = conversation.getOtrSession().getSessionID().getUserID(); + if (!(Arrays.asList(contact.getPresences().toResourceArray()).contains(otrResource))) { + conversation.endOtrIfNeeded(); + } + } } - } - }; + }; private final PresenceGenerator mPresenceGenerator = new PresenceGenerator(this); private List accounts; private final JingleConnectionManager mJingleConnectionManager = @@ -4199,7 +4200,8 @@ public class XmppConnectionService extends Service { && this.mOnUpdateBlocklist.isEmpty() && this.mOnShowErrorToasts.isEmpty() && this.onJingleRtpConnectionUpdate.isEmpty() - && this.mOnKeyStatusUpdated.isEmpty()); + && this.mOnKeyStatusUpdated.isEmpty() + && this.mOnStoriesUpdates.isEmpty()); } private void switchToForeground() { @@ -7758,4 +7760,106 @@ public class XmppConnectionService extends Service { }); } + public void publishStory(final Account account, final String url, final String type, final String title, final UiCallback callback) { + publishStory(account, url, type, title, true, callback); + } + + private void publishStory(final Account account, final String url, final String type, final String title, boolean retry, final UiCallback callback) { + if (account.getStatus() != Account.State.ONLINE) { + if (callback != null) { + callback.error(R.string.not_connected_try_again, null); + } + return; + } + final Iq packet = getIqGenerator().publishStory(url, type, title, null); + sendIqPacket(account, packet, response -> { + if (response.getType() == Iq.Type.RESULT) { + if (callback != null) { + callback.success(null); + } + } else if (retry && PublishOptions.preconditionNotMet(response)) { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": stories node does not exist. creating it"); + final Iq createRequest = getIqGenerator().createStoriesNode(); + createRequest.setTo(account.getJid().asBareJid()); + sendIqPacket(account, createRequest, createResponse -> { + if (createResponse.getType() == Iq.Type.RESULT) { + publishStory(account, url, type, title, false, callback); + } else { + if (callback != null) { + callback.error(R.string.error_publish_avatar_server_reject, null); + } + } + }); + } else { + if (callback != null) { + callback.error(R.string.error_publish_avatar_server_reject, null); + } + } + }); + } + + private final List stories = new java.util.concurrent.CopyOnWriteArrayList<>(); + private final Set mOnStoriesUpdates = + java.util.Collections.newSetFromMap(new java.util.WeakHashMap<>()); + + public List getStories() { + return this.stories; + } + + public void onStoryReceived(eu.siacs.conversations.entities.Story story) { + if (story == null) { + return; + } + for (int i = 0; i < stories.size(); ++i) { + if (stories.get(i).getUuid().equals(story.getUuid())) { + stories.set(i, story); + updateStoriesUi(); + return; + } + } + this.stories.add(story); + java.util.Collections.sort(stories, (a, b) -> Long.compare(b.getPublished(), a.getPublished())); + updateStoriesUi(); + } + + public void setOnStoriesUpdateListener(OnStoriesUpdate listener) { + synchronized (LISTENER_LOCK) { + this.mOnStoriesUpdates.add(listener); + } + } + + public void removeOnStoriesUpdateListener(OnStoriesUpdate listener) { + synchronized (LISTENER_LOCK) { + this.mOnStoriesUpdates.remove(listener); + } + } + + public void updateStoriesUi() { + for (OnStoriesUpdate listener : threadSafeList(this.mOnStoriesUpdates)) { + listener.onStoriesUpdate(); + } + } + + public interface OnStoriesUpdate { + void onStoriesUpdate(); + } + + public void fetchStories(Account account, Contact contact) { + if (account.getStatus() != Account.State.ONLINE) { + return; + } + Log.d(Config.LOGTAG, "fetching stories for " + contact.getJid()); + Iq request = getIqGenerator().retrieveStories(contact.getJid()); + sendIqPacket(account, request, response -> { + if (response.getType() == Iq.Type.RESULT) { + final Element pubsub = response.findChild("pubsub", Namespace.PUBSUB); + if (pubsub != null) { + final List stories = eu.siacs.conversations.entities.Story.parseFromPubSub(pubsub, contact.getJid()); + for (eu.siacs.conversations.entities.Story story : stories) { + onStoryReceived(story); + } + } + } + }); + } } diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java b/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java index 8f3eedc94..7ecbbc5ca 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java @@ -36,6 +36,7 @@ import android.app.Activity; import android.app.Fragment; import android.content.Intent; import android.graphics.Canvas; +import android.net.Uri; import android.os.Bundle; import android.util.Log; import android.view.ContextMenu; @@ -65,9 +66,12 @@ import eu.siacs.conversations.entities.Account; import eu.siacs.conversations.entities.Contact; import eu.siacs.conversations.entities.Conversation; import eu.siacs.conversations.entities.Conversational; +import eu.siacs.conversations.entities.Message; +import eu.siacs.conversations.entities.Story; import eu.siacs.conversations.services.XmppConnectionService; import eu.siacs.conversations.services.QuickConversationsService; import eu.siacs.conversations.ui.adapter.ConversationAdapter; +import eu.siacs.conversations.ui.adapter.StoryAdapter; import eu.siacs.conversations.ui.interfaces.OnConversationArchived; import eu.siacs.conversations.ui.interfaces.OnConversationSelected; import eu.siacs.conversations.ui.util.MenuDoubleTabUtil; @@ -84,22 +88,30 @@ import static androidx.recyclerview.widget.ItemTouchHelper.LEFT; import static androidx.recyclerview.widget.ItemTouchHelper.RIGHT; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; -public class ConversationsOverviewFragment extends XmppFragment { +public class ConversationsOverviewFragment extends XmppFragment implements XmppConnectionService.OnStoriesUpdate { private static final String STATE_SCROLL_POSITION = ConversationsOverviewFragment.class.getName() + ".scroll_state"; + private static final int REQUEST_CHOOSE_STORY_IMAGE = 0x2b01; + private final List conversations = new ArrayList<>(); + private final List stories = new ArrayList<>(); private final PendingItem swipedConversation = new PendingItem<>(); private final PendingItem pendingScrollState = new PendingItem<>(); private FragmentConversationsOverviewBinding binding; private ConversationAdapter conversationsAdapter; + private StoryAdapter storyAdapter; private XmppActivity activity; private final PendingActionHelper pendingActionHelper = new PendingActionHelper(); + private Account mSelectedAccount; + private final ItemTouchHelper.SimpleCallback callback = new ItemTouchHelper.SimpleCallback(0, LEFT | RIGHT) { @Override @@ -228,7 +240,7 @@ public class ConversationsOverviewFragment extends XmppFragment { pendingActionHelper.push( () -> { - if (snackbar.isShownOrQueued()) { + if (snackbar.isShownOrQueued()) { snackbar.dismiss(); } final Conversation conversation = swipedConversation.pop(); @@ -302,6 +314,7 @@ public class ConversationsOverviewFragment extends XmppFragment { super.onDestroyView(); this.binding = null; this.conversationsAdapter = null; + this.storyAdapter = null; this.touchHelper = null; } @@ -315,6 +328,7 @@ public class ConversationsOverviewFragment extends XmppFragment { public void onPause() { Log.d(Config.LOGTAG, "ConversationsOverviewFragment.onPause()"); pendingActionHelper.execute(); + activity.xmppConnectionService.removeOnStoriesUpdateListener(this); super.onPause(); } @@ -339,6 +353,8 @@ public class ConversationsOverviewFragment extends XmppFragment { this.binding.fab.setOnClickListener( (view) -> StartConversationActivity.launch(getActivity())); + this.binding.fabStory.setOnClickListener(v -> selectAccountToPublishStory()); + this.conversationsAdapter = new ConversationAdapter(this.activity, this.conversations); this.conversationsAdapter.setConversationClickListener( (view, conversation) -> { @@ -353,6 +369,10 @@ public class ConversationsOverviewFragment extends XmppFragment { this.binding.list.setAdapter(this.conversationsAdapter); this.binding.list.setLayoutManager( new LinearLayoutManager(getActivity(), LinearLayoutManager.VERTICAL, false)); + this.storyAdapter = new StoryAdapter(this.activity, this.stories); + this.binding.storiesList.setAdapter(this.storyAdapter); + this.binding.storiesList.setLayoutManager( + new LinearLayoutManager(getActivity(), LinearLayoutManager.HORIZONTAL, false)); registerForContextMenu(this.binding.list); this.binding.list.addOnScrollListener(ExtendedFabSizeChanger.of(binding.fab)); if (activity.getPreferences().getBoolean("swipe_to_archive", true)) this.touchHelper = new ItemTouchHelper(this.callback); @@ -448,6 +468,7 @@ public class ConversationsOverviewFragment extends XmppFragment { @Override public void onBackendConnected() { refresh(); + activity.xmppConnectionService.setOnStoriesUpdateListener(this); } private void setupSwipe() { @@ -578,85 +599,103 @@ public class ConversationsOverviewFragment extends XmppFragment { EasyOnboardingInviteActivity.launch(account, activity); } - @Override - protected void refresh() { - if (binding == null || this.activity == null) { - Log.d(Config.LOGTAG,"ConversationsOverviewFragment.refresh() skipped updated because view binding or activity was null"); - return; - } - this.activity.populateWithOrderedConversations(this.conversations); - Conversation removed = this.swipedConversation.peek(); - if (removed != null) { - if (removed.isRead(activity == null ? null : activity.xmppConnectionService)) { - this.conversations.remove(removed); - } else { - pendingActionHelper.execute(); - } - } - this.conversationsAdapter.notifyDataSetChanged(); - ScrollState scrollState = pendingScrollState.pop(); - if (scrollState != null) { - setScrollPosition(scrollState); - } + protected void refresh() { + if (binding == null || this.activity == null) { + Log.d(Config.LOGTAG,"ConversationsOverviewFragment.refresh() skipped updated because view binding or activity was null"); + return; + } - if (activity.xmppConnectionService != null && activity.xmppConnectionService.isOnboarding()) { - binding.fab.setVisibility(View.GONE); + this.stories.clear(); + this.stories.addAll( + this.activity.xmppConnectionService.getStories().stream() + .collect(Collectors.toMap( + story -> story.getContact().asBareJid(), + story -> story, + (a, b) -> a.getPublished() > b.getPublished() ? a : b + )) + .values() + ); + Collections.sort(this.stories, (a,b) -> Long.compare(b.getPublished(), a.getPublished())); - if (this.conversations.size() == 1) { - if (activity instanceof OnConversationSelected) { - ((OnConversationSelected) activity).onConversationSelected(this.conversations.get(0)); - } else { - Log.w(ConversationsOverviewFragment.class.getCanonicalName(), "Activity does not implement OnConversationSelected"); - } - } - } else { - if (activity instanceof ConversationsActivity) { - boolean showed = ((ConversationsActivity) activity).showNavigationBar(); + if (this.stories.isEmpty()) { + binding.storiesList.setVisibility(View.GONE); + } else { + binding.storiesList.setVisibility(View.VISIBLE); + } + this.storyAdapter.notifyDataSetChanged(); - if (showed) { - this.binding.fab.setVisibility(View.GONE); - } else { - this.binding.fab.setVisibility(View.VISIBLE); - } - } - } - if (activity.getPreferences().getBoolean("swipe_to_archive", true)) setupSwipe(); + this.activity.populateWithOrderedConversations(this.conversations); + Conversation removed = this.swipedConversation.peek(); + if (removed != null) { + if (removed.isRead(activity == null ? null : activity.xmppConnectionService)) { + this.conversations.remove(removed); + } else { + pendingActionHelper.execute(); + } + } + this.conversationsAdapter.notifyDataSetChanged(); + ScrollState scrollState = pendingScrollState.pop(); + if (scrollState != null) { + setScrollPosition(scrollState); + } + if (activity.xmppConnectionService != null && activity.xmppConnectionService.isOnboarding()) { + binding.fab.setVisibility(View.GONE); - if (activity.xmppConnectionService == null || binding == null || binding.overviewSnackbar == null) return; - binding.overviewSnackbar.setVisibility(View.GONE); - for (final var account : activity.xmppConnectionService.getAccounts()) { - if (activity.getPreferences().getBoolean("no_mam_pref_warn:" + account.getUuid(), false)) continue; - if (account.mamPrefs() != null && !"always".equals(account.mamPrefs().getAttribute("default"))) { - binding.overviewSnackbar.setVisibility(View.VISIBLE); - binding.overviewSnackbarMessage.setText(R.string.your_account + " " + account.getJid().asBareJid().toString() + " " + R.string.archiving_not_enabled_text); - binding.overviewSnackbarAction.setOnClickListener((v) -> { - final var prefs = account.mamPrefs(); - prefs.setAttribute("default", "always"); - activity.xmppConnectionService.pushMamPreferences(account, prefs); - refresh(); - }); + if (this.conversations.size() == 1) { + if (activity instanceof OnConversationSelected) { + ((OnConversationSelected) activity).onConversationSelected(this.conversations.get(0)); + } else { + Log.w(ConversationsOverviewFragment.class.getCanonicalName(), "Activity does not implement OnConversationSelected"); + } + } + } else { + if (activity instanceof ConversationsActivity) { + boolean showed = ((ConversationsActivity) activity).showNavigationBar(); - binding.overviewSnackbarAction.setOnLongClickListener((v) -> { - PopupMenu popupMenu = new PopupMenu(getActivity(), v); - popupMenu.inflate(R.menu.mam_pref_fix); - popupMenu.setOnMenuItemClickListener(menuItem -> { - switch (menuItem.getItemId()) { - case R.id.ignore: - final var editor = activity.getPreferences().edit(); - editor.putBoolean("no_mam_pref_warn:" + account.getUuid(), true).apply(); - editor.apply(); - refresh(); - return true; - } - return true; - }); - popupMenu.show(); - return true; - }); - break; - } - } - } + if (showed) { + this.binding.fab.setVisibility(View.GONE); + } else { + this.binding.fab.setVisibility(View.VISIBLE); + } + } + } + if (activity.getPreferences().getBoolean("swipe_to_archive", true)) setupSwipe(); + + if (activity.xmppConnectionService == null || binding == null || binding.overviewSnackbar == null) return; + binding.overviewSnackbar.setVisibility(View.GONE); + for (final var account : activity.xmppConnectionService.getAccounts()) { + if (activity.getPreferences().getBoolean("no_mam_pref_warn:" + account.getUuid(), false)) continue; + if (account.mamPrefs() != null && !"always".equals(account.mamPrefs().getAttribute("default"))) { + binding.overviewSnackbar.setVisibility(View.VISIBLE); + binding.overviewSnackbarMessage.setText(R.string.your_account + " " + account.getJid().asBareJid().toString() + " " + R.string.archiving_not_enabled_text); + binding.overviewSnackbarAction.setOnClickListener((v) -> { + final var prefs = account.mamPrefs(); + prefs.setAttribute("default", "always"); + activity.xmppConnectionService.pushMamPreferences(account, prefs); + refresh(); + }); + + binding.overviewSnackbarAction.setOnLongClickListener((v) -> { + PopupMenu popupMenu = new PopupMenu(getActivity(), v); + popupMenu.inflate(R.menu.mam_pref_fix); + popupMenu.setOnMenuItemClickListener(menuItem -> { + switch (menuItem.getItemId()) { + case R.id.ignore: + final var editor = activity.getPreferences().edit(); + editor.putBoolean("no_mam_pref_warn:" + account.getUuid(), true).apply(); + editor.apply(); + refresh(); + return true; + } + return true; + }); + popupMenu.show(); + return true; + }); + break; + } + } + } private void setScrollPosition(ScrollState scrollPosition) { if (scrollPosition != null) { @@ -666,4 +705,93 @@ public class ConversationsOverviewFragment extends XmppFragment { scrollPosition.position, scrollPosition.offset); } } + + @Override + public void onStoriesUpdate() { + activity.runOnUiThread(this::refresh); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, final Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (resultCode == Activity.RESULT_OK) { + if (requestCode == REQUEST_CHOOSE_STORY_IMAGE) { + if (data != null && mSelectedAccount != null) { + final Uri uri = data.getData(); + if (uri == null) { + return; + } + final String mimeType = activity.getContentResolver().getType(uri); + final Conversation selfConversation = activity.xmppConnectionService.findOrCreateConversation(mSelectedAccount, mSelectedAccount.getJid().asBareJid(), false, false); + Toast.makeText(activity, R.string.uploading_story, Toast.LENGTH_SHORT).show(); + activity.xmppConnectionService.attachFileToConversation(selfConversation, uri, mimeType, null, new UiCallback() { + @Override + public void success(Message message) { + final String url = message.getBody(); + if (url != null) { + activity.xmppConnectionService.publishStory(mSelectedAccount, url, mimeType, null, new UiCallback() { + @Override + public void success(Void aVoid) { + activity.runOnUiThread(() -> Toast.makeText(activity, R.string.story_published, Toast.LENGTH_SHORT).show()); + activity.xmppConnectionService.deleteMessage(message); + } + + @Override + public void error(int errorCode, Void object) { + activity.runOnUiThread(() -> Toast.makeText(activity, errorCode, Toast.LENGTH_SHORT).show()); + activity.xmppConnectionService.deleteMessage(message); + } + + @Override + public void userInputRequired(android.app.PendingIntent pi, Void object) { + // not used + } + }); + } else { + error(R.string.upload_failed_server_not_found, message); + } + } + + @Override + public void error(int errorCode, Message object) { + activity.runOnUiThread(() -> Toast.makeText(activity, errorCode, Toast.LENGTH_SHORT).show()); + } + + @Override + public void userInputRequired(android.app.PendingIntent pi, Message object) { + // not used + } + }); + } + } + } + } + + private void selectAccountToPublishStory() { + final List accounts = activity.xmppConnectionService.getAccounts().stream().filter(Account::isEnabled).collect(Collectors.toList()); + if (accounts.isEmpty()) { + Toast.makeText(getActivity(), R.string.no_active_account, Toast.LENGTH_SHORT).show(); + } else if (accounts.size() == 1) { + openStoryImagePicker(accounts.get(0)); + } else { + final AtomicReference selectedAccount = new AtomicReference<>(accounts.get(0)); + final MaterialAlertDialogBuilder alertDialogBuilder = new MaterialAlertDialogBuilder(activity); + alertDialogBuilder.setTitle(R.string.choose_account); + final String[] asStrings = + accounts.stream().map(a -> a.getJid().asBareJid().toString()).toArray(String[]::new); + alertDialogBuilder.setSingleChoiceItems( + asStrings, 0, (dialog, which) -> selectedAccount.set(accounts.get(which))); + alertDialogBuilder.setNegativeButton(R.string.cancel, null); + alertDialogBuilder.setPositiveButton( + R.string.ok, (dialog, which) -> openStoryImagePicker(selectedAccount.get())); + alertDialogBuilder.create().show(); + } + } + + private void openStoryImagePicker(Account account) { + this.mSelectedAccount = account; + Intent intent = new Intent(Intent.ACTION_GET_CONTENT); + intent.setType("image/*"); + startActivityForResult(intent, REQUEST_CHOOSE_STORY_IMAGE); + } } diff --git a/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java b/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java new file mode 100644 index 000000000..2561c7197 --- /dev/null +++ b/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java @@ -0,0 +1,71 @@ +package eu.siacs.conversations.ui; + +import android.os.Bundle; +import android.widget.ImageView; +import android.widget.Toast; + +import androidx.core.util.Consumer; + +import com.bumptech.glide.Glide; + +import eu.siacs.conversations.R; +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.entities.Conversation; +import eu.siacs.conversations.entities.DownloadableFile; +import eu.siacs.conversations.entities.Message; +import eu.siacs.conversations.http.HttpConnectionManager; + +public class StoryViewActivity extends XmppActivity { + + public static final String EXTRA_URL = "url"; + public static final String EXTRA_ACCOUNT = "account"; + + private ImageView imageView; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_story_view); + imageView = findViewById(R.id.story_image_view); + } + + @Override + protected void refreshUiReal() { + + } + + @Override + public void onBackendConnected() { + String url = getIntent().getStringExtra(EXTRA_URL); + String accountUuid = getIntent().getStringExtra(EXTRA_ACCOUNT); + if (url != null && accountUuid != null) { + Account account = xmppConnectionService.findAccountByUuid(accountUuid); + if (account != null) { + // Create a transient conversation and message to wrap the download request + Conversation conversation = new Conversation(account.getDisplayName(), account, account.getJid().asBareJid(), Conversation.MODE_SINGLE); + final Message message = new Message(conversation, "", Message.ENCRYPTION_NONE); + message.getFileParams().url = url; + + HttpConnectionManager manager = this.xmppConnectionService.getHttpConnectionManager(); + manager.createNewDownloadConnection(message, false, new Consumer() { + @Override + public void accept(final DownloadableFile file) { + runOnUiThread(() -> { + if (file != null && !isFinishing()) { + Glide.with(StoryViewActivity.this).load(file).into(imageView); + } else if (!isFinishing()) { + Toast.makeText(StoryViewActivity.this, R.string.download_failed_file_not_found, Toast.LENGTH_SHORT).show(); + finish(); + } + }); + } + }); + } else { + Toast.makeText(this, R.string.no_active_account, Toast.LENGTH_SHORT).show(); + finish(); + } + } else { + finish(); + } + } +} \ No newline at end of file diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/StoryAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/StoryAdapter.java new file mode 100644 index 000000000..2c9d9bec1 --- /dev/null +++ b/src/main/java/eu/siacs/conversations/ui/adapter/StoryAdapter.java @@ -0,0 +1,84 @@ +package eu.siacs.conversations.ui.adapter; + +import android.content.Intent; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; +import java.util.List; +import eu.siacs.conversations.R; +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.entities.Contact; +import eu.siacs.conversations.entities.Story; +import eu.siacs.conversations.ui.StoryViewActivity; +import eu.siacs.conversations.ui.XmppActivity; +import eu.siacs.conversations.xmpp.Jid; + +public class StoryAdapter extends RecyclerView.Adapter { + + private final XmppActivity activity; + private final List stories; + + public StoryAdapter(XmppActivity activity, List stories) { + this.activity = activity; + this.stories = stories; + } + + @NonNull + @Override + public StoryViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.list_item_story, parent, false); + return new StoryViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull StoryViewHolder holder, int position) { + final Story story = stories.get(position); + final Jid jid = story.getContact(); + Contact contact = null; + Account storyAccount = null; + for (Account account : activity.xmppConnectionService.getAccounts()) { + contact = account.getRoster().getContact(jid); + if (contact != null) { + storyAccount = account; + break; + } + } + if (contact != null) { + holder.storyTitle.setText(contact.getDisplayName()); + holder.storyImage.setImageDrawable(activity.xmppConnectionService.getAvatarService().get(contact, activity.getResources().getDimensionPixelSize(R.dimen.avatar_story_size))); + } else { + holder.storyTitle.setText(jid.asBareJid().toString()); + holder.storyImage.setImageResource(R.drawable.ic_person_black_48dp); + } + final Account finalStoryAccount = storyAccount; + holder.itemView.setOnClickListener(v -> { + Intent intent = new Intent(activity, StoryViewActivity.class); + intent.putExtra(StoryViewActivity.EXTRA_URL, story.getUrl()); + if (finalStoryAccount != null) { + intent.putExtra(StoryViewActivity.EXTRA_ACCOUNT, finalStoryAccount.getUuid()); + } + activity.startActivity(intent); + }); + } + + @Override + public int getItemCount() { + return stories.size(); + } + + static class StoryViewHolder extends RecyclerView.ViewHolder { + + final ImageView storyImage; + final TextView storyTitle; + + StoryViewHolder(@NonNull View itemView) { + super(itemView); + storyImage = itemView.findViewById(R.id.story_image); + storyTitle = itemView.findViewById(R.id.story_title); + } + } +} diff --git a/src/main/java/eu/siacs/conversations/xml/Namespace.java b/src/main/java/eu/siacs/conversations/xml/Namespace.java index 210b8d417..6c6069269 100644 --- a/src/main/java/eu/siacs/conversations/xml/Namespace.java +++ b/src/main/java/eu/siacs/conversations/xml/Namespace.java @@ -111,6 +111,9 @@ public final class Namespace { public static final String HASHES = "urn:xmpp:hashes:2"; public static final String MDS_DISPLAYED = "urn:xmpp:mds:displayed:0"; public static final String MDS_SERVER_ASSIST = "urn:xmpp:mds:server-assist:0"; + public static final String PUBSUB_SOCIAL_FEED = "urn:xmpp:pubsub-social-feed:1"; + public static final String PUBSUB_STORIES = "urn:xmpp:pubsub-social-feed:stories:0"; + public static final String ATOM = "http://www.w3.org/2005/Atom"; public static final String ENTITY_CAPABILITIES = "http://jabber.org/protocol/caps"; public static final String ENTITY_CAPABILITIES_2 = "urn:xmpp:caps"; diff --git a/src/main/res/drawable/outline_amp_stories_24.xml b/src/main/res/drawable/outline_amp_stories_24.xml new file mode 100644 index 000000000..8822e8e19 --- /dev/null +++ b/src/main/res/drawable/outline_amp_stories_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/layout/activity_story_view.xml b/src/main/res/layout/activity_story_view.xml new file mode 100644 index 000000000..5efc4b7a9 --- /dev/null +++ b/src/main/res/layout/activity_story_view.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/src/main/res/layout/fragment_conversations_overview.xml b/src/main/res/layout/fragment_conversations_overview.xml index 26cd65afe..556f24338 100644 --- a/src/main/res/layout/fragment_conversations_overview.xml +++ b/src/main/res/layout/fragment_conversations_overview.xml @@ -6,57 +6,74 @@ android:layout_height="match_parent"> - - - - - - - - - + android:orientation="vertical"> + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/res/values/dimens.xml b/src/main/res/values/dimens.xml index 8bae3f42e..125e08b5a 100644 --- a/src/main/res/values/dimens.xml +++ b/src/main/res/values/dimens.xml @@ -25,6 +25,7 @@ 48dp 32dp 48dp + 64dp 10dp 10dp 6dp diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index 58bc35f49..9a4e69b15 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -1573,4 +1573,9 @@ Open calendar Calendar Total messages per month: %s + Uploading story… + Story published + No active account + Upload failed. Server not found + Story \ No newline at end of file -- 2.39.5 From aae28d0b2f022b55e701fceaca9b0e6a5fe84c38 Mon Sep 17 00:00:00 2001 From: Arne Date: Tue, 30 Dec 2025 00:02:57 +0100 Subject: [PATCH 002/180] Fix rudimentary uploading and publishing stories --- .../conversations/generator/IqGenerator.java | 29 ++- .../services/XmppConnectionService.java | 48 +++++ .../ui/ConversationsOverviewFragment.java | 171 +++++++++--------- .../conversations/ui/StoryViewActivity.java | 93 +++++++--- 4 files changed, 220 insertions(+), 121 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/generator/IqGenerator.java b/src/main/java/eu/siacs/conversations/generator/IqGenerator.java index 8bd87df66..b1703d352 100644 --- a/src/main/java/eu/siacs/conversations/generator/IqGenerator.java +++ b/src/main/java/eu/siacs/conversations/generator/IqGenerator.java @@ -22,7 +22,9 @@ import java.io.IOException; import java.nio.ByteBuffer; import java.security.cert.CertificateEncodingException; import java.security.cert.X509Certificate; +import java.text.SimpleDateFormat; import java.util.ArrayList; +import java.util.Date; import java.util.List; import java.util.Locale; import java.util.Set; @@ -246,21 +248,32 @@ public class IqGenerator extends AbstractGenerator { public Iq publishStory(final String url, final String type, final String title, Bundle options) { final Element item = new Element("item"); + // This is the pubsub ID, which is different from the atom item.setAttribute("id", UUID.randomUUID().toString()); - final Element entry = item.addChild("entry", Namespace.ATOM); - if (title != null) { - entry.addChild("title").setContent(title); - } - entry.addChild("published").setContent(getTimestamp(System.currentTimeMillis())); + // atom:id is a mandatory element for the entry, must be a unique and permanent URI + entry.addChild("id").setContent("urn:uuid:"+UUID.randomUUID().toString()); + + // atom:title is mandatory + String effectiveTitle = title; + if (Strings.isNullOrEmpty(effectiveTitle)) { + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US); + effectiveTitle = "Story " + sdf.format(new Date()); + } + entry.addChild("title").setContent(effectiveTitle); + + // atom:updated is mandatory + final String timestamp = getTimestamp(System.currentTimeMillis()); + entry.addChild("updated").setContent(timestamp); + entry.addChild("published").setContent(timestamp); // Also add published for compatibility + + // The element as specified by the XEP final Element link = entry.addChild("link"); link.setAttribute("rel", "enclosure"); link.setAttribute("href", url); link.setAttribute("type", type); - if (title != null) { - link.setAttribute("title", title); - } + link.setAttribute("title", effectiveTitle); return publish(Namespace.PUBSUB_STORIES, item, options); } diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java index 671d6d0f6..4a07655b2 100644 --- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +++ b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java @@ -7798,6 +7798,54 @@ public class XmppConnectionService extends Service { }); } + public void uploadFileForUrl(final Account account, final android.net.Uri uri, final String mimeType, final UiCallback callback) { + if (account == null) { + callback.error(R.string.no_active_account, null); + return; + } + // Create a dummy conversation and message + final Conversation conversation = new Conversation(account.getDisplayName(), account, account.getJid().asBareJid(), Conversation.MODE_SINGLE); + final Message message = new Message(conversation, "", Message.ENCRYPTION_NONE); + if (mimeType != null && mimeType.startsWith("image/")) { + message.setType(Message.TYPE_IMAGE); + } else { + message.setType(Message.TYPE_FILE); + } + + + Runnable runnable = () -> { + try { + if (mimeType != null && mimeType.startsWith("image/")) { + getFileBackend().copyImageToPrivateStorage(message, uri); + } else { + getFileBackend().copyFileToPrivateStorage(message, uri, mimeType); + } + } catch (eu.siacs.conversations.persistance.FileBackend.ImageCompressionException e) { + Log.d(Config.LOGTAG, "unable to compress image for story. falling back to file transfer", e); + message.setType(Message.TYPE_FILE); + try { + getFileBackend().copyFileToPrivateStorage(message, uri, mimeType); + } catch (eu.siacs.conversations.persistance.FileBackend.FileCopyException ex) { + callback.error(ex.getResId(), null); + return; + } + } catch (final eu.siacs.conversations.persistance.FileBackend.FileCopyException e) { + callback.error(e.getResId(), null); + return; + } + + mHttpConnectionManager.createNewUploadConnection(message, false, () -> { + final String url = message.getBody(); + if (url != null) { + callback.success(url); + } else { + callback.error(R.string.upload_failed_server_not_found, null); + } + }); + }; + FILE_ATTACHMENT_EXECUTOR.execute(runnable); + } + private final List stories = new java.util.concurrent.CopyOnWriteArrayList<>(); private final Set mOnStoriesUpdates = java.util.Collections.newSetFromMap(new java.util.WeakHashMap<>()); diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java b/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java index 7ecbbc5ca..54faaf9b8 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java @@ -98,9 +98,10 @@ public class ConversationsOverviewFragment extends XmppFragment implements XmppC private static final String STATE_SCROLL_POSITION = ConversationsOverviewFragment.class.getName() + ".scroll_state"; - private static final int REQUEST_CHOOSE_STORY_IMAGE = 0x2b01; + private static final int REQUEST_CHOOSE_STORY_IMAGE = 0x2b01; + private Account mSelectedAccount; - private final List conversations = new ArrayList<>(); + private final List conversations = new ArrayList<>(); private final List stories = new ArrayList<>(); private final PendingItem swipedConversation = new PendingItem<>(); private final PendingItem pendingScrollState = new PendingItem<>(); @@ -110,8 +111,6 @@ public class ConversationsOverviewFragment extends XmppFragment implements XmppC private XmppActivity activity; private final PendingActionHelper pendingActionHelper = new PendingActionHelper(); - private Account mSelectedAccount; - private final ItemTouchHelper.SimpleCallback callback = new ItemTouchHelper.SimpleCallback(0, LEFT | RIGHT) { @Override @@ -353,8 +352,7 @@ public class ConversationsOverviewFragment extends XmppFragment implements XmppC this.binding.fab.setOnClickListener( (view) -> StartConversationActivity.launch(getActivity())); - this.binding.fabStory.setOnClickListener(v -> selectAccountToPublishStory()); - + this.binding.fabStory.setOnClickListener(v -> selectAccountToPublishStory()); this.conversationsAdapter = new ConversationAdapter(this.activity, this.conversations); this.conversationsAdapter.setConversationClickListener( (view, conversation) -> { @@ -711,87 +709,94 @@ public class ConversationsOverviewFragment extends XmppFragment implements XmppC activity.runOnUiThread(this::refresh); } - @Override - public void onActivityResult(int requestCode, int resultCode, final Intent data) { - super.onActivityResult(requestCode, resultCode, data); - if (resultCode == Activity.RESULT_OK) { - if (requestCode == REQUEST_CHOOSE_STORY_IMAGE) { - if (data != null && mSelectedAccount != null) { - final Uri uri = data.getData(); - if (uri == null) { - return; - } - final String mimeType = activity.getContentResolver().getType(uri); - final Conversation selfConversation = activity.xmppConnectionService.findOrCreateConversation(mSelectedAccount, mSelectedAccount.getJid().asBareJid(), false, false); - Toast.makeText(activity, R.string.uploading_story, Toast.LENGTH_SHORT).show(); - activity.xmppConnectionService.attachFileToConversation(selfConversation, uri, mimeType, null, new UiCallback() { - @Override - public void success(Message message) { - final String url = message.getBody(); - if (url != null) { - activity.xmppConnectionService.publishStory(mSelectedAccount, url, mimeType, null, new UiCallback() { - @Override - public void success(Void aVoid) { - activity.runOnUiThread(() -> Toast.makeText(activity, R.string.story_published, Toast.LENGTH_SHORT).show()); - activity.xmppConnectionService.deleteMessage(message); - } + @Override + public void onActivityResult(int requestCode, int resultCode, final Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (resultCode == Activity.RESULT_OK) { + if (requestCode == REQUEST_CHOOSE_STORY_IMAGE) { + if (data != null && mSelectedAccount != null) { + final Uri uri = data.getData(); + if (uri == null) { + return; + } + final String mimeType = activity.getContentResolver().getType(uri); + Toast.makeText(activity, R.string.uploading_story, Toast.LENGTH_SHORT).show(); - @Override - public void error(int errorCode, Void object) { - activity.runOnUiThread(() -> Toast.makeText(activity, errorCode, Toast.LENGTH_SHORT).show()); - activity.xmppConnectionService.deleteMessage(message); - } + // Use a dummy conversation with the user's own JID + final Conversation selfConversation = activity.xmppConnectionService.findOrCreateConversation(mSelectedAccount, mSelectedAccount.getJid().asBareJid(), false, false); - @Override - public void userInputRequired(android.app.PendingIntent pi, Void object) { - // not used - } - }); - } else { - error(R.string.upload_failed_server_not_found, message); - } - } + activity.xmppConnectionService.attachFileToConversation(selfConversation, uri, mimeType, null, new UiCallback() { + @Override + public void success(Message message) { + // This callback is triggered after the message is sent. + // Now we can get the URL and publish the story. + final String url = message.getFileParams().url.toString(); + if (url != null) { + activity.xmppConnectionService.publishStory(mSelectedAccount, url, mimeType, null, new UiCallback() { + @Override + public void success(Void aVoid) { + activity.runOnUiThread(() -> Toast.makeText(activity, R.string.story_published, Toast.LENGTH_SHORT).show()); + // Clean up the dummy message + activity.xmppConnectionService.deleteMessage(message); + } - @Override - public void error(int errorCode, Message object) { - activity.runOnUiThread(() -> Toast.makeText(activity, errorCode, Toast.LENGTH_SHORT).show()); - } + @Override + public void error(int errorCode, Void object) { + activity.runOnUiThread(() -> Toast.makeText(activity, errorCode, Toast.LENGTH_SHORT).show()); + activity.xmppConnectionService.deleteMessage(message); + } - @Override - public void userInputRequired(android.app.PendingIntent pi, Message object) { - // not used - } - }); - } - } - } - } + @Override + public void userInputRequired(android.app.PendingIntent pi, Void object) { + // not used + } + }); + } else { + error(R.string.upload_failed_server_not_found, message); + } + } - private void selectAccountToPublishStory() { - final List accounts = activity.xmppConnectionService.getAccounts().stream().filter(Account::isEnabled).collect(Collectors.toList()); - if (accounts.isEmpty()) { - Toast.makeText(getActivity(), R.string.no_active_account, Toast.LENGTH_SHORT).show(); - } else if (accounts.size() == 1) { - openStoryImagePicker(accounts.get(0)); - } else { - final AtomicReference selectedAccount = new AtomicReference<>(accounts.get(0)); - final MaterialAlertDialogBuilder alertDialogBuilder = new MaterialAlertDialogBuilder(activity); - alertDialogBuilder.setTitle(R.string.choose_account); - final String[] asStrings = - accounts.stream().map(a -> a.getJid().asBareJid().toString()).toArray(String[]::new); - alertDialogBuilder.setSingleChoiceItems( - asStrings, 0, (dialog, which) -> selectedAccount.set(accounts.get(which))); - alertDialogBuilder.setNegativeButton(R.string.cancel, null); - alertDialogBuilder.setPositiveButton( - R.string.ok, (dialog, which) -> openStoryImagePicker(selectedAccount.get())); - alertDialogBuilder.create().show(); - } - } + @Override + public void error(int errorCode, Message object) { + activity.runOnUiThread(() -> Toast.makeText(activity, errorCode, Toast.LENGTH_SHORT).show()); + } + + @Override + public void userInputRequired(android.app.PendingIntent pi, Message object) { + // not used + } + }); + } + } + } + } + + private void selectAccountToPublishStory() { + final List accounts = activity.xmppConnectionService.getAccounts().stream().filter(Account::isEnabled).collect(Collectors.toList()); + if (accounts.isEmpty()) { + Toast.makeText(getActivity(), R.string.no_active_account, Toast.LENGTH_SHORT).show(); + } else if (accounts.size() == 1) { + openStoryImagePicker(accounts.get(0)); + } else { + final AtomicReference selectedAccount = new AtomicReference<>(accounts.get(0)); + final MaterialAlertDialogBuilder alertDialogBuilder = new MaterialAlertDialogBuilder(activity); + alertDialogBuilder.setTitle(R.string.choose_account); + final String[] asStrings = + accounts.stream().map(a -> a.getJid().asBareJid().toString()).toArray(String[]::new); + alertDialogBuilder.setSingleChoiceItems( + asStrings, 0, (dialog, which) -> selectedAccount.set(accounts.get(which))); + alertDialogBuilder.setNegativeButton(R.string.cancel, null); + alertDialogBuilder.setPositiveButton( + R.string.ok, (dialog, which) -> openStoryImagePicker(selectedAccount.get())); + alertDialogBuilder.create().show(); + } + } + + private void openStoryImagePicker(Account account) { + this.mSelectedAccount = account; + Intent intent = new Intent(Intent.ACTION_GET_CONTENT); + intent.setType("image/*"); + startActivityForResult(intent, REQUEST_CHOOSE_STORY_IMAGE); + } - private void openStoryImagePicker(Account account) { - this.mSelectedAccount = account; - Intent intent = new Intent(Intent.ACTION_GET_CONTENT); - intent.setType("image/*"); - startActivityForResult(intent, REQUEST_CHOOSE_STORY_IMAGE); - } } diff --git a/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java b/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java index 2561c7197..ba919ba30 100644 --- a/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java @@ -1,19 +1,22 @@ package eu.siacs.conversations.ui; import android.os.Bundle; +import android.util.Log; import android.widget.ImageView; import android.widget.Toast; -import androidx.core.util.Consumer; - import com.bumptech.glide.Glide; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; + +import eu.siacs.conversations.Config; import eu.siacs.conversations.R; import eu.siacs.conversations.entities.Account; -import eu.siacs.conversations.entities.Conversation; -import eu.siacs.conversations.entities.DownloadableFile; -import eu.siacs.conversations.entities.Message; import eu.siacs.conversations.http.HttpConnectionManager; +import okhttp3.HttpUrl; public class StoryViewActivity extends XmppActivity { @@ -38,34 +41,64 @@ public class StoryViewActivity extends XmppActivity { public void onBackendConnected() { String url = getIntent().getStringExtra(EXTRA_URL); String accountUuid = getIntent().getStringExtra(EXTRA_ACCOUNT); - if (url != null && accountUuid != null) { - Account account = xmppConnectionService.findAccountByUuid(accountUuid); - if (account != null) { - // Create a transient conversation and message to wrap the download request - Conversation conversation = new Conversation(account.getDisplayName(), account, account.getJid().asBareJid(), Conversation.MODE_SINGLE); - final Message message = new Message(conversation, "", Message.ENCRYPTION_NONE); - message.getFileParams().url = url; - HttpConnectionManager manager = this.xmppConnectionService.getHttpConnectionManager(); - manager.createNewDownloadConnection(message, false, new Consumer() { - @Override - public void accept(final DownloadableFile file) { - runOnUiThread(() -> { - if (file != null && !isFinishing()) { - Glide.with(StoryViewActivity.this).load(file).into(imageView); - } else if (!isFinishing()) { - Toast.makeText(StoryViewActivity.this, R.string.download_failed_file_not_found, Toast.LENGTH_SHORT).show(); - finish(); - } - }); + if (url == null || accountUuid == null) { + finish(); + return; + } + + Account account = xmppConnectionService.findAccountByUuid(accountUuid); + if (account == null) { + Toast.makeText(this, R.string.no_active_account, Toast.LENGTH_SHORT).show(); + finish(); + return; + } + + final HttpUrl httpUrl; + try { + httpUrl = HttpUrl.get(url); + } catch (IllegalArgumentException e) { + Toast.makeText(this, "Invalid URL", Toast.LENGTH_SHORT).show(); + finish(); + return; + } + + final boolean useTor = xmppConnectionService.useTorToConnect() || account.isOnion(); + final boolean useI2p = xmppConnectionService.useI2PToConnect() || account.isI2P(); + + // Use a background thread for networking and file I/O + new Thread(() -> { + File tempFile = null; + try { + // Create a temporary file in the cache directory + tempFile = File.createTempFile("story", ".tmp", getCacheDir()); + try (InputStream inputStream = HttpConnectionManager.open(httpUrl, useTor, useI2p); + FileOutputStream outputStream = new FileOutputStream(tempFile)) { + + byte[] buffer = new byte[4096]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + } + } + + final File finalTempFile = tempFile; + runOnUiThread(() -> { + if (!isFinishing()) { + Glide.with(StoryViewActivity.this).load(finalTempFile).into(imageView); } }); - } else { - Toast.makeText(this, R.string.no_active_account, Toast.LENGTH_SHORT).show(); - finish(); + + } catch (IOException e) { + Log.e(Config.LOGTAG, "Failed to download story image", e); + if (tempFile != null) { + tempFile.delete(); + } + runOnUiThread(() -> { + Toast.makeText(StoryViewActivity.this, R.string.download_failed_file_not_found, Toast.LENGTH_SHORT).show(); + finish(); + }); } - } else { - finish(); - } + }).start(); } } \ No newline at end of file -- 2.39.5 From 3c2990821c6493a9ec5c572f6ef57ebc8966facc Mon Sep 17 00:00:00 2001 From: Arne Date: Tue, 30 Dec 2025 00:31:55 +0100 Subject: [PATCH 003/180] Show titles of stories --- .../eu/siacs/conversations/ui/StoryViewActivity.java | 12 +++++++++--- .../siacs/conversations/ui/adapter/StoryAdapter.java | 1 + src/main/res/layout/activity_story_view.xml | 11 +++++++++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java b/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java index ba919ba30..d937c0390 100644 --- a/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java @@ -3,6 +3,7 @@ package eu.siacs.conversations.ui; import android.os.Bundle; import android.util.Log; import android.widget.ImageView; +import android.widget.TextView; import android.widget.Toast; import com.bumptech.glide.Glide; @@ -22,14 +23,21 @@ public class StoryViewActivity extends XmppActivity { public static final String EXTRA_URL = "url"; public static final String EXTRA_ACCOUNT = "account"; + public static final String EXTRA_TITLE = "title"; private ImageView imageView; + private TextView titleView; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_story_view); imageView = findViewById(R.id.story_image_view); + titleView = findViewById(R.id.story_title_view); + final String title = getIntent().getStringExtra(EXTRA_TITLE); + if (title != null) { + titleView.setText(title); + } } @Override @@ -66,11 +74,9 @@ public class StoryViewActivity extends XmppActivity { final boolean useTor = xmppConnectionService.useTorToConnect() || account.isOnion(); final boolean useI2p = xmppConnectionService.useI2PToConnect() || account.isI2P(); - // Use a background thread for networking and file I/O new Thread(() -> { File tempFile = null; try { - // Create a temporary file in the cache directory tempFile = File.createTempFile("story", ".tmp", getCacheDir()); try (InputStream inputStream = HttpConnectionManager.open(httpUrl, useTor, useI2p); FileOutputStream outputStream = new FileOutputStream(tempFile)) { @@ -101,4 +107,4 @@ public class StoryViewActivity extends XmppActivity { } }).start(); } -} \ No newline at end of file +} diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/StoryAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/StoryAdapter.java index 2c9d9bec1..0393352d2 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/StoryAdapter.java +++ b/src/main/java/eu/siacs/conversations/ui/adapter/StoryAdapter.java @@ -61,6 +61,7 @@ public class StoryAdapter extends RecyclerView.Adapter + + -- 2.39.5 From fd71bb55c27c0f2dfdf6f9fb2d6646de4d0ea82c Mon Sep 17 00:00:00 2001 From: Arne Date: Tue, 30 Dec 2025 01:02:02 +0100 Subject: [PATCH 004/180] Allow adding a title when publishing a story --- .../ui/ConversationsOverviewFragment.java | 80 +++++++++++-------- src/main/res/values/strings.xml | 2 + 2 files changed, 49 insertions(+), 33 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java b/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java index 54faaf9b8..bb6507e75 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java @@ -47,6 +47,7 @@ import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.widget.AdapterView.AdapterContextMenuInfo; +import android.widget.EditText; import android.widget.PopupMenu; import android.widget.Toast; import androidx.annotation.NonNull; @@ -720,52 +721,65 @@ public class ConversationsOverviewFragment extends XmppFragment implements XmppC return; } final String mimeType = activity.getContentResolver().getType(uri); - Toast.makeText(activity, R.string.uploading_story, Toast.LENGTH_SHORT).show(); - // Use a dummy conversation with the user's own JID - final Conversation selfConversation = activity.xmppConnectionService.findOrCreateConversation(mSelectedAccount, mSelectedAccount.getJid().asBareJid(), false, false); + final EditText input = new EditText(getActivity()); + input.setHint(R.string.title_optional); - activity.xmppConnectionService.attachFileToConversation(selfConversation, uri, mimeType, null, new UiCallback() { - @Override - public void success(Message message) { - // This callback is triggered after the message is sent. - // Now we can get the URL and publish the story. - final String url = message.getFileParams().url.toString(); - if (url != null) { - activity.xmppConnectionService.publishStory(mSelectedAccount, url, mimeType, null, new UiCallback() { + new MaterialAlertDialogBuilder(activity) + .setTitle(R.string.add_story_title) + .setView(input) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.publish, (dialog, which) -> { + final String title = input.getText().toString(); + Toast.makeText(activity, R.string.uploading_story, Toast.LENGTH_SHORT).show(); + + // Use a dummy conversation with the user's own JID + final Conversation selfConversation = activity.xmppConnectionService.findOrCreateConversation(mSelectedAccount, mSelectedAccount.getJid().asBareJid(), false, false); + + activity.xmppConnectionService.attachFileToConversation(selfConversation, uri, mimeType, null, new UiCallback() { @Override - public void success(Void aVoid) { - activity.runOnUiThread(() -> Toast.makeText(activity, R.string.story_published, Toast.LENGTH_SHORT).show()); - // Clean up the dummy message - activity.xmppConnectionService.deleteMessage(message); + public void success(Message message) { + // This callback is triggered after the message is sent. + // Now we can get the URL and publish the story. + final String url = message.getFileParams().url.toString(); + if (url != null) { + activity.xmppConnectionService.publishStory(mSelectedAccount, url, mimeType, title, new UiCallback() { + @Override + public void success(Void aVoid) { + activity.runOnUiThread(() -> Toast.makeText(activity, R.string.story_published, Toast.LENGTH_SHORT).show()); + // Clean up the dummy message + activity.xmppConnectionService.deleteMessage(message); + } + + @Override + public void error(int errorCode, Void object) { + activity.runOnUiThread(() -> Toast.makeText(activity, errorCode, Toast.LENGTH_SHORT).show()); + activity.xmppConnectionService.deleteMessage(message); + } + + @Override + public void userInputRequired(android.app.PendingIntent pi, Void object) { + // not used + } + }); + } else { + error(R.string.upload_failed_server_not_found, message); + } } @Override - public void error(int errorCode, Void object) { + public void error(int errorCode, Message object) { activity.runOnUiThread(() -> Toast.makeText(activity, errorCode, Toast.LENGTH_SHORT).show()); - activity.xmppConnectionService.deleteMessage(message); } @Override - public void userInputRequired(android.app.PendingIntent pi, Void object) { + public void userInputRequired(android.app.PendingIntent pi, Message object) { // not used } }); - } else { - error(R.string.upload_failed_server_not_found, message); - } - } - - @Override - public void error(int errorCode, Message object) { - activity.runOnUiThread(() -> Toast.makeText(activity, errorCode, Toast.LENGTH_SHORT).show()); - } - - @Override - public void userInputRequired(android.app.PendingIntent pi, Message object) { - // not used - } - }); + }) + .create() + .show(); } } } diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index 9a4e69b15..d5901bc50 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -1578,4 +1578,6 @@ No active account Upload failed. Server not found Story + Add a title + Title (optional) \ No newline at end of file -- 2.39.5 From 117cfbc4fa4751984fad9e3864d61e0e7ad01b32 Mon Sep 17 00:00:00 2001 From: Arne Date: Tue, 30 Dec 2025 01:18:56 +0100 Subject: [PATCH 005/180] Refactor story list item to use AvatarView --- src/main/res/layout/list_item_story.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/res/layout/list_item_story.xml b/src/main/res/layout/list_item_story.xml index 99942422e..8a1298a5a 100644 --- a/src/main/res/layout/list_item_story.xml +++ b/src/main/res/layout/list_item_story.xml @@ -5,10 +5,10 @@ android:orientation="vertical" android:padding="8dp"> - Date: Tue, 30 Dec 2025 01:26:18 +0100 Subject: [PATCH 006/180] Check for published element when parsing Story --- src/main/java/eu/siacs/conversations/entities/Story.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/eu/siacs/conversations/entities/Story.java b/src/main/java/eu/siacs/conversations/entities/Story.java index b554df9e5..15f513a7d 100644 --- a/src/main/java/eu/siacs/conversations/entities/Story.java +++ b/src/main/java/eu/siacs/conversations/entities/Story.java @@ -39,6 +39,9 @@ public class Story extends AbstractEntity { return null; } Element published = entry.findChild("published"); + if (published == null) { + return null; + } Element link = null; for (Element child : entry.getChildren()) { if ("link".equals(child.getName()) && "enclosure".equals(child.getAttribute("rel"))) { -- 2.39.5 From 37125ce5211ca974cd920835bd6dba5474666093 Mon Sep 17 00:00:00 2001 From: Arne Date: Tue, 30 Dec 2025 01:45:45 +0100 Subject: [PATCH 007/180] Refactor story publishing to not use dummy messages --- .../services/XmppConnectionService.java | 3 +- .../ui/ConversationsOverviewFragment.java | 50 +++++++------------ 2 files changed, 20 insertions(+), 33 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java index 4a07655b2..1c3353451 100644 --- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +++ b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java @@ -7835,7 +7835,7 @@ public class XmppConnectionService extends Service { } mHttpConnectionManager.createNewUploadConnection(message, false, () -> { - final String url = message.getBody(); + final String url = message.getFileParams().url.toString(); if (url != null) { callback.success(url); } else { @@ -7844,6 +7844,7 @@ public class XmppConnectionService extends Service { }); }; FILE_ATTACHMENT_EXECUTOR.execute(runnable); + deleteMessage(message); } private final List stories = new java.util.concurrent.CopyOnWriteArrayList<>(); diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java b/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java index bb6507e75..1e0310505 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java @@ -732,48 +732,34 @@ public class ConversationsOverviewFragment extends XmppFragment implements XmppC .setPositiveButton(R.string.publish, (dialog, which) -> { final String title = input.getText().toString(); Toast.makeText(activity, R.string.uploading_story, Toast.LENGTH_SHORT).show(); - - // Use a dummy conversation with the user's own JID - final Conversation selfConversation = activity.xmppConnectionService.findOrCreateConversation(mSelectedAccount, mSelectedAccount.getJid().asBareJid(), false, false); - - activity.xmppConnectionService.attachFileToConversation(selfConversation, uri, mimeType, null, new UiCallback() { + activity.xmppConnectionService.uploadFileForUrl(mSelectedAccount, uri, mimeType, new UiCallback() { @Override - public void success(Message message) { - // This callback is triggered after the message is sent. - // Now we can get the URL and publish the story. - final String url = message.getFileParams().url.toString(); - if (url != null) { - activity.xmppConnectionService.publishStory(mSelectedAccount, url, mimeType, title, new UiCallback() { - @Override - public void success(Void aVoid) { - activity.runOnUiThread(() -> Toast.makeText(activity, R.string.story_published, Toast.LENGTH_SHORT).show()); - // Clean up the dummy message - activity.xmppConnectionService.deleteMessage(message); - } + public void success(String url) { + activity.xmppConnectionService.publishStory(mSelectedAccount, url, mimeType, title, new UiCallback() { + @Override + public void success(Void aVoid) { + activity.runOnUiThread(() -> Toast.makeText(activity, R.string.story_published, Toast.LENGTH_SHORT).show()); + } - @Override - public void error(int errorCode, Void object) { - activity.runOnUiThread(() -> Toast.makeText(activity, errorCode, Toast.LENGTH_SHORT).show()); - activity.xmppConnectionService.deleteMessage(message); - } + @Override + public void error(int errorCode, Void object) { + activity.runOnUiThread(() -> Toast.makeText(activity, errorCode, Toast.LENGTH_SHORT).show()); + } - @Override - public void userInputRequired(android.app.PendingIntent pi, Void object) { - // not used - } - }); - } else { - error(R.string.upload_failed_server_not_found, message); - } + @Override + public void userInputRequired(android.app.PendingIntent pi, Void object) { + // not used + } + }); } @Override - public void error(int errorCode, Message object) { + public void error(int errorCode, String object) { activity.runOnUiThread(() -> Toast.makeText(activity, errorCode, Toast.LENGTH_SHORT).show()); } @Override - public void userInputRequired(android.app.PendingIntent pi, Message object) { + public void userInputRequired(android.app.PendingIntent pi, String object) { // not used } }); -- 2.39.5 From d263614cec5266907afd712ef4c3367e15c0aa87 Mon Sep 17 00:00:00 2001 From: Arne Date: Tue, 30 Dec 2025 11:47:55 +0100 Subject: [PATCH 008/180] Allow deleting own stories --- .../services/XmppConnectionService.java | 15 +++ .../conversations/ui/StoryViewActivity.java | 94 ++++++++++++++++++- .../ui/adapter/StoryAdapter.java | 29 ++++-- src/main/res/layout/activity_story_view.xml | 11 ++- src/main/res/menu/activity_story_view.xml | 10 ++ src/main/res/values/strings.xml | 5 + 6 files changed, 152 insertions(+), 12 deletions(-) create mode 100644 src/main/res/menu/activity_story_view.xml diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java index 1c3353451..8a2cb3b9b 100644 --- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +++ b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java @@ -7911,4 +7911,19 @@ public class XmppConnectionService extends Service { } }); } + + public void retractStory(Account account, String storyId, final UiCallback callback) { + Iq iq = getIqGenerator().deleteItem(Namespace.PUBSUB_STORIES, storyId); + this.sendIqPacket(account, iq, response -> { + if (response.getType() == Iq.Type.RESULT) { + if (callback != null) { + callback.success(null); + } + } else { + if (callback != null) { + callback.error(R.string.error_deleting_story, null); + } + } + }); + } } diff --git a/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java b/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java index d937c0390..8e23ab711 100644 --- a/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java @@ -2,11 +2,16 @@ package eu.siacs.conversations.ui; import android.os.Bundle; import android.util.Log; +import android.view.Menu; +import android.view.MenuItem; import android.widget.ImageView; import android.widget.TextView; import android.widget.Toast; +import androidx.appcompat.widget.Toolbar; + import com.bumptech.glide.Glide; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; import java.io.File; import java.io.FileOutputStream; @@ -17,6 +22,7 @@ import eu.siacs.conversations.Config; import eu.siacs.conversations.R; import eu.siacs.conversations.entities.Account; import eu.siacs.conversations.http.HttpConnectionManager; +import eu.siacs.conversations.xmpp.Jid; import okhttp3.HttpUrl; public class StoryViewActivity extends XmppActivity { @@ -24,19 +30,47 @@ public class StoryViewActivity extends XmppActivity { public static final String EXTRA_URL = "url"; public static final String EXTRA_ACCOUNT = "account"; public static final String EXTRA_TITLE = "title"; + public static final String EXTRA_STORY_ID = "story_id"; + public static final String EXTRA_CONTACT = "contact"; private ImageView imageView; private TextView titleView; + private String storyId; + private Jid contact; + private Account mAccount; + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_story_view); + + Toolbar toolbar = findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + if (getSupportActionBar() != null) { + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + } + imageView = findViewById(R.id.story_image_view); titleView = findViewById(R.id.story_title_view); + final String title = getIntent().getStringExtra(EXTRA_TITLE); if (title != null) { titleView.setText(title); + if (getSupportActionBar() != null) { + getSupportActionBar().setTitle(title); + } + } else { + if (getSupportActionBar() != null) { + getSupportActionBar().setTitle(R.string.story); + } + } + + storyId = getIntent().getStringExtra(EXTRA_STORY_ID); + try { + contact = Jid.of(getIntent().getStringExtra(EXTRA_CONTACT)); + } catch (final Exception e) { + //ignore } } @@ -45,6 +79,56 @@ public class StoryViewActivity extends XmppActivity { } + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.activity_story_view, menu); + MenuItem deleteButton = menu.findItem(R.id.action_delete_story); + if (mAccount != null && contact != null && mAccount.getJid().asBareJid().equals(contact)) { + deleteButton.setVisible(true); + } + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + finish(); + return true; + } + if (item.getItemId() == R.id.action_delete_story) { + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.delete_story_dialog_title) + .setMessage(R.string.delete_story_dialog_message) + .setPositiveButton(R.string.delete, (dialog, which) -> { + xmppConnectionService.retractStory(mAccount, storyId, new UiCallback() { + @Override + public void success(Void aVoid) { + runOnUiThread(() -> { + Toast.makeText(StoryViewActivity.this, R.string.story_deleted, Toast.LENGTH_SHORT).show(); + finish(); + }); + } + + @Override + public void error(int errorCode, Void object) { + runOnUiThread(() -> Toast.makeText(StoryViewActivity.this, errorCode, Toast.LENGTH_SHORT).show()); + } + + @Override + public void userInputRequired(android.app.PendingIntent pi, Void object) { + + } + }); + }) + .setNegativeButton(R.string.cancel, null) + .create() + .show(); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override public void onBackendConnected() { String url = getIntent().getStringExtra(EXTRA_URL); @@ -55,13 +139,15 @@ public class StoryViewActivity extends XmppActivity { return; } - Account account = xmppConnectionService.findAccountByUuid(accountUuid); - if (account == null) { + mAccount = xmppConnectionService.findAccountByUuid(accountUuid); + if (mAccount == null) { Toast.makeText(this, R.string.no_active_account, Toast.LENGTH_SHORT).show(); finish(); return; } + invalidateOptionsMenu(); + final HttpUrl httpUrl; try { httpUrl = HttpUrl.get(url); @@ -71,8 +157,8 @@ public class StoryViewActivity extends XmppActivity { return; } - final boolean useTor = xmppConnectionService.useTorToConnect() || account.isOnion(); - final boolean useI2p = xmppConnectionService.useI2PToConnect() || account.isI2P(); + final boolean useTor = xmppConnectionService.useTorToConnect() || mAccount.isOnion(); + final boolean useI2p = xmppConnectionService.useI2PToConnect() || mAccount.isI2P(); new Thread(() -> { File tempFile = null; diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/StoryAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/StoryAdapter.java index 0393352d2..95715708a 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/StoryAdapter.java +++ b/src/main/java/eu/siacs/conversations/ui/adapter/StoryAdapter.java @@ -38,15 +38,28 @@ public class StoryAdapter extends RecyclerView.Adapter + + + + + diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index d5901bc50..1ef7b4aab 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -1580,4 +1580,9 @@ Story Add a title Title (optional) + Could not delete story + Delete story + Delete story + Do you really want to delete this story + Story deleted \ No newline at end of file -- 2.39.5 From 2c4cab4acb74a163b1ec5af8330390bfb129d400 Mon Sep 17 00:00:00 2001 From: Arne Date: Tue, 30 Dec 2025 11:58:59 +0100 Subject: [PATCH 009/180] Remove deleted story from internal list immediately --- .../conversations/services/XmppConnectionService.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java index 8a2cb3b9b..e7015efdc 100644 --- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +++ b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java @@ -7916,6 +7916,16 @@ public class XmppConnectionService extends Service { Iq iq = getIqGenerator().deleteItem(Namespace.PUBSUB_STORIES, storyId); this.sendIqPacket(account, iq, response -> { if (response.getType() == Iq.Type.RESULT) { + final List newStories = new ArrayList<>(stories); + for (Iterator iterator = newStories.iterator(); iterator.hasNext(); ) { + if (iterator.next().getUuid().equals(storyId)) { + iterator.remove(); + break; + } + } + this.stories.clear(); + this.stories.addAll(newStories); + updateStoriesUi(); if (callback != null) { callback.success(null); } -- 2.39.5 From f72e57cabd1572180c42a2c10cfe1dbf022e1e3d Mon Sep 17 00:00:00 2001 From: Arne Date: Tue, 30 Dec 2025 13:09:57 +0100 Subject: [PATCH 010/180] Allow viewing all stories from a single contact --- .../conversations/parser/MessageParser.java | 13 ++-- .../conversations/ui/StoryViewActivity.java | 76 ++++++++++--------- .../ui/adapter/StoryAdapter.java | 27 +++++-- 3 files changed, 66 insertions(+), 50 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/parser/MessageParser.java b/src/main/java/eu/siacs/conversations/parser/MessageParser.java index 50f1c8d4d..1b21e46cb 100644 --- a/src/main/java/eu/siacs/conversations/parser/MessageParser.java +++ b/src/main/java/eu/siacs/conversations/parser/MessageParser.java @@ -466,11 +466,14 @@ public class MessageParser extends AbstractParser final Element item = items.findChild("item"); mXmppConnectionService.processMdsItem(account, item); } else if (Namespace.PUBSUB_STORIES.equals(node)) { - final Element item = items.findChild("item"); - if (item != null) { - final Story story = Story.fromElement(item, from); - if (story != null) { - mXmppConnectionService.onStoryReceived(story); + if (items != null) { + for (Element item : items.getChildren()) { + if ("item".equals(item.getName())) { + final Story story = Story.fromElement(item, from); + if (story != null) { + mXmppConnectionService.onStoryReceived(story); + } + } } } } else if (Namespace.USER_TUNE.equals(node)) { diff --git a/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java b/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java index 8e23ab711..8b87381c2 100644 --- a/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java @@ -17,6 +17,7 @@ import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; +import java.util.ArrayList; import eu.siacs.conversations.Config; import eu.siacs.conversations.R; @@ -27,22 +28,26 @@ import okhttp3.HttpUrl; public class StoryViewActivity extends XmppActivity { - public static final String EXTRA_URL = "url"; + public static final String EXTRA_URLS = "urls"; + public static final String EXTRA_TITLES = "titles"; + public static final String EXTRA_STORY_IDS = "story_ids"; public static final String EXTRA_ACCOUNT = "account"; - public static final String EXTRA_TITLE = "title"; - public static final String EXTRA_STORY_ID = "story_id"; public static final String EXTRA_CONTACT = "contact"; private ImageView imageView; private TextView titleView; - private String storyId; + private ArrayList urls; + private ArrayList titles; + private ArrayList storyIds; + private int currentIndex = 0; private Jid contact; private Account mAccount; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + setTheme(R.style.Theme_Conversations3); setContentView(R.layout.activity_story_view); Toolbar toolbar = findViewById(R.id.toolbar); @@ -54,19 +59,19 @@ public class StoryViewActivity extends XmppActivity { imageView = findViewById(R.id.story_image_view); titleView = findViewById(R.id.story_title_view); - final String title = getIntent().getStringExtra(EXTRA_TITLE); - if (title != null) { - titleView.setText(title); - if (getSupportActionBar() != null) { - getSupportActionBar().setTitle(title); - } - } else { - if (getSupportActionBar() != null) { - getSupportActionBar().setTitle(R.string.story); - } - } + urls = getIntent().getStringArrayListExtra(EXTRA_URLS); + titles = getIntent().getStringArrayListExtra(EXTRA_TITLES); + storyIds = getIntent().getStringArrayListExtra(EXTRA_STORY_IDS); + + imageView.setOnClickListener(v -> { + currentIndex++; + if (currentIndex < urls.size()) { + loadStory(); + } else { + finish(); + } + }); - storyId = getIntent().getStringExtra(EXTRA_STORY_ID); try { contact = Jid.of(getIntent().getStringExtra(EXTRA_CONTACT)); } catch (final Exception e) { @@ -74,7 +79,8 @@ public class StoryViewActivity extends XmppActivity { } } - @Override + + @Override protected void refreshUiReal() { } @@ -91,16 +97,12 @@ public class StoryViewActivity extends XmppActivity { @Override public boolean onOptionsItemSelected(MenuItem item) { - if (item.getItemId() == android.R.id.home) { - finish(); - return true; - } if (item.getItemId() == R.id.action_delete_story) { new MaterialAlertDialogBuilder(this) .setTitle(R.string.delete_story_dialog_title) .setMessage(R.string.delete_story_dialog_message) .setPositiveButton(R.string.delete, (dialog, which) -> { - xmppConnectionService.retractStory(mAccount, storyId, new UiCallback() { + xmppConnectionService.retractStory(mAccount, storyIds.get(currentIndex), new UiCallback() { @Override public void success(Void aVoid) { runOnUiThread(() -> { @@ -131,23 +133,23 @@ public class StoryViewActivity extends XmppActivity { @Override public void onBackendConnected() { - String url = getIntent().getStringExtra(EXTRA_URL); String accountUuid = getIntent().getStringExtra(EXTRA_ACCOUNT); - - if (url == null || accountUuid == null) { - finish(); - return; + if (accountUuid != null) { + mAccount = xmppConnectionService.findAccountByUuid(accountUuid); } - - mAccount = xmppConnectionService.findAccountByUuid(accountUuid); - if (mAccount == null) { - Toast.makeText(this, R.string.no_active_account, Toast.LENGTH_SHORT).show(); - finish(); - return; - } - invalidateOptionsMenu(); + loadStory(); + } + private void loadStory() { + if (urls == null || currentIndex >= urls.size()) { + finish();return; + } + titleView.setText(titles.get(currentIndex)); + if (getSupportActionBar() != null) { + getSupportActionBar().setTitle(titles.get(currentIndex)); + } + final String url = urls.get(currentIndex); final HttpUrl httpUrl; try { httpUrl = HttpUrl.get(url); @@ -157,8 +159,8 @@ public class StoryViewActivity extends XmppActivity { return; } - final boolean useTor = xmppConnectionService.useTorToConnect() || mAccount.isOnion(); - final boolean useI2p = xmppConnectionService.useI2PToConnect() || mAccount.isI2P(); + final boolean useTor = mAccount != null && (xmppConnectionService.useTorToConnect() || mAccount.isOnion()); + final boolean useI2p = mAccount != null && (xmppConnectionService.useI2PToConnect() || mAccount.isI2P()); new Thread(() -> { File tempFile = null; diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/StoryAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/StoryAdapter.java index 95715708a..1369a43f1 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/StoryAdapter.java +++ b/src/main/java/eu/siacs/conversations/ui/adapter/StoryAdapter.java @@ -8,6 +8,7 @@ import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; +import java.util.ArrayList; import java.util.List; import eu.siacs.conversations.R; import eu.siacs.conversations.entities.Account; @@ -38,8 +39,8 @@ public class StoryAdapter extends RecyclerView.Adapter { Intent intent = new Intent(activity, StoryViewActivity.class); - intent.putExtra(StoryViewActivity.EXTRA_URL, story.getUrl()); + ArrayList urls = new ArrayList<>(); + ArrayList titles = new ArrayList<>(); + ArrayList storyIds = new ArrayList<>(); + + // This is the corrected logic: Get the FULL list from the service + for (Story s : activity.xmppConnectionService.getStories()) { + if (s.getContact().asBareJid().equals(story.getContact().asBareJid())) { + urls.add(s.getUrl()); + titles.add(s.getTitle()); + storyIds.add(s.getUuid()); + } + } + intent.putStringArrayListExtra(StoryViewActivity.EXTRA_URLS, urls); + intent.putStringArrayListExtra(StoryViewActivity.EXTRA_TITLES, titles); + intent.putStringArrayListExtra(StoryViewActivity.EXTRA_STORY_IDS, storyIds); + intent.putExtra(StoryViewActivity.EXTRA_CONTACT, story.getContact().asBareJid().toString()); if (finalStoryAccount != null) { intent.putExtra(StoryViewActivity.EXTRA_ACCOUNT, finalStoryAccount.getUuid()); } - intent.putExtra(StoryViewActivity.EXTRA_TITLE, story.getTitle()); - intent.putExtra(StoryViewActivity.EXTRA_STORY_ID, story.getUuid()); - intent.putExtra(StoryViewActivity.EXTRA_CONTACT, story.getContact().asBareJid().toString()); activity.startActivity(intent); }); } -- 2.39.5 From dad737eec3eec69e6bcfb1c9ead14d27cc10bc96 Mon Sep 17 00:00:00 2001 From: Arne Date: Tue, 30 Dec 2025 14:45:03 +0100 Subject: [PATCH 011/180] Add menu option to show/hide stories --- .../ui/ConversationsOverviewFragment.java | 145 +++++++++++++----- .../menu/fragment_conversations_overview.xml | 63 +++----- src/main/res/values/strings.xml | 4 + 3 files changed, 131 insertions(+), 81 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java b/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java index 1e0310505..2893a0552 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java @@ -35,6 +35,7 @@ import static androidx.recyclerview.widget.ItemTouchHelper.RIGHT; import android.app.Activity; import android.app.Fragment; import android.content.Intent; +import android.content.SharedPreferences; import android.graphics.Canvas; import android.net.Uri; import android.os.Bundle; @@ -52,6 +53,7 @@ import android.widget.PopupMenu; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.databinding.DataBindingUtil; +import androidx.preference.PreferenceManager; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; @@ -379,24 +381,34 @@ public class ConversationsOverviewFragment extends XmppFragment implements XmppC return binding.getRoot(); } - @Override - public void onCreateOptionsMenu(Menu menu, MenuInflater menuInflater) { - menuInflater.inflate(R.menu.fragment_conversations_overview, menu); - AccountUtils.showHideMenuItems(menu); - final MenuItem easyOnboardInvite = menu.findItem(R.id.action_easy_invite); - MenuItem noteToSelf = menu.findItem(R.id.action_note_to_self); - easyOnboardInvite.setVisible(EasyOnboardingInvite.anyHasSupport(activity == null ? null : activity.xmppConnectionService)); - if (activity != null && activity.xmppConnectionService != null && activity.xmppConnectionService.isOnboarding()) { - final MenuItem manageAccounts = menu.findItem(R.id.action_accounts); - if (manageAccounts != null) manageAccounts.setVisible(false); + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater menuInflater) { + menuInflater.inflate(R.menu.fragment_conversations_overview, menu); + AccountUtils.showHideMenuItems(menu); + final MenuItem easyOnboardInvite = menu.findItem(R.id.action_easy_invite); + MenuItem noteToSelf = menu.findItem(R.id.action_note_to_self); + easyOnboardInvite.setVisible(EasyOnboardingInvite.anyHasSupport(activity == null ? null : activity.xmppConnectionService)); + if (activity != null && activity.xmppConnectionService != null && activity.xmppConnectionService.isOnboarding()) { + final MenuItem manageAccounts = menu.findItem(R.id.action_accounts); + if (manageAccounts != null) manageAccounts.setVisible(false); - final MenuItem settings = menu.findItem(R.id.action_settings); - if (settings != null) settings.setVisible(false); - } - if (activity == null || activity.xmppConnectionService == null || activity.xmppConnectionService.getAccounts().size() != 1) { - noteToSelf.setVisible(false); - } - } + final MenuItem settings = menu.findItem(R.id.action_settings); + if (settings != null) settings.setVisible(false); + } + if (activity == null || activity.xmppConnectionService == null || activity.xmppConnectionService.getAccounts().size() != 1) { + noteToSelf.setVisible(false); + } + final MenuItem stories = menu.findItem(R.id.action_toggle_stories); + if (stories != null) { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); + boolean show = preferences.getBoolean("show_stories", true); + if (show) { + stories.setTitle(R.string.hide_stories); + } else { + stories.setTitle(R.string.show_stories); + } + } + } @Override public void onCreateContextMenu(ContextMenu menu, View view, ContextMenu.ContextMenuInfo menuInfo) { @@ -508,11 +520,22 @@ public class ConversationsOverviewFragment extends XmppFragment implements XmppC boolean navBarVisible = activity instanceof ConversationsActivity && ((ConversationsActivity) activity).navigationBarVisible(); MenuItem manageAccount = menu.findItem(R.id.action_account); MenuItem manageAccounts = menu.findItem(R.id.action_accounts); + MenuItem addStory = menu.findItem(R.id.action_add_story); if (navBarVisible) { manageAccount.setVisible(false); manageAccounts.setVisible(false); + if (stories != null) { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); + boolean show = preferences.getBoolean("show_stories", true); + if (show) { + addStory.setVisible(true); + } else { + addStory.setVisible(false); + } + } } else { AccountUtils.showHideMenuItems(menu); + addStory.setVisible(false); } } @@ -528,9 +551,19 @@ public class ConversationsOverviewFragment extends XmppFragment implements XmppC if (showed) { this.binding.fab.setVisibility(View.GONE); + binding.fabStory.setVisibility(View.GONE); } else { this.binding.fab.setVisibility(View.VISIBLE); - } + if (stories != null) { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); + boolean show = preferences.getBoolean("show_stories", true); + if (show) { + this.binding.fabStory.setVisibility(View.VISIBLE); + } else { + this.binding.fabStory.setVisibility(View.GONE); + } + } + } } } @@ -540,30 +573,46 @@ public class ConversationsOverviewFragment extends XmppFragment implements XmppC Log.d(Config.LOGTAG, "ConversationsOverviewFragment.onResume()"); } - @Override - public boolean onOptionsItemSelected(final MenuItem item) { - if (MenuDoubleTabUtil.shouldIgnoreTap()) { - return false; - } - switch (item.getItemId()) { - case R.id.action_search: - startActivity(new Intent(getActivity(), SearchActivity.class)); - return true; - case R.id.action_easy_invite: - selectAccountToStartEasyInvite(); - return true; - case R.id.action_note_to_self: - final List accounts = activity.xmppConnectionService.getAccounts(); - if (accounts.size() == 1) { - final Contact self = new Contact(accounts.get(0).getSelfContact()); - Conversation conversation = activity.xmppConnectionService.findOrCreateConversation(self.getAccount(), self.getJid(), false, false, null, true, null); - SoftKeyboardUtils.hideSoftKeyboard(activity); - activity.switchToConversation(conversation); - } - } - return super.onOptionsItemSelected(item); - } + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + if (MenuDoubleTabUtil.shouldIgnoreTap()) { + return false; + } + switch (item.getItemId()) { + case R.id.action_search:startActivity(new Intent(getActivity(), SearchActivity.class)); + return true; + case R.id.action_easy_invite: + selectAccountToStartEasyInvite(); + return true; + case R.id.action_note_to_self: + final List accounts = activity.xmppConnectionService.getAccounts(); + if (accounts.size() == 1) { + final Contact self = new Contact(accounts.get(0).getSelfContact()); + Conversation conversation = activity.xmppConnectionService.findOrCreateConversation(self.getAccount(), self.getJid(), false, false, null, true, null); + SoftKeyboardUtils.hideSoftKeyboard(activity); + activity.switchToConversation(conversation); + } + return true; + case R.id.action_toggle_stories: + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); + boolean show = preferences.getBoolean("show_stories", true); + preferences.edit().putBoolean("show_stories", !show).apply(); + refresh(); + activity.invalidateOptionsMenu(); + return true; + case R.id.action_add_story: + selectAccountToPublishStory(); + return true; + } + return super.onOptionsItemSelected(item); + } + private void setShowStories(boolean show) { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); + preferences.edit().putBoolean("show_stories", show).apply(); + refresh(); + activity.invalidateOptionsMenu(); + } private void selectAccountToStartEasyInvite() { final List accounts = EasyOnboardingInvite.getSupportingAccounts(activity.xmppConnectionService); @@ -616,7 +665,10 @@ public class ConversationsOverviewFragment extends XmppFragment implements XmppC ); Collections.sort(this.stories, (a,b) -> Long.compare(b.getPublished(), a.getPublished())); - if (this.stories.isEmpty()) { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); + boolean show = preferences.getBoolean("show_stories", true); + + if (this.stories.isEmpty() || !show) { binding.storiesList.setVisibility(View.GONE); } else { binding.storiesList.setVisibility(View.VISIBLE); @@ -639,6 +691,7 @@ public class ConversationsOverviewFragment extends XmppFragment implements XmppC } if (activity.xmppConnectionService != null && activity.xmppConnectionService.isOnboarding()) { binding.fab.setVisibility(View.GONE); + binding.fabStory.setVisibility(View.GONE); if (this.conversations.size() == 1) { if (activity instanceof OnConversationSelected) { @@ -653,8 +706,16 @@ public class ConversationsOverviewFragment extends XmppFragment implements XmppC if (showed) { this.binding.fab.setVisibility(View.GONE); + this.binding.fabStory.setVisibility(View.GONE); } else { this.binding.fab.setVisibility(View.VISIBLE); + if (stories != null) { + if (show) { + this.binding.fabStory.setVisibility(View.VISIBLE); + } else { + this.binding.fabStory.setVisibility(View.GONE); + } + } } } } diff --git a/src/main/res/menu/fragment_conversations_overview.xml b/src/main/res/menu/fragment_conversations_overview.xml index a4ca10cb0..dcc4eb3a1 100644 --- a/src/main/res/menu/fragment_conversations_overview.xml +++ b/src/main/res/menu/fragment_conversations_overview.xml @@ -1,67 +1,52 @@ - - + + + app:showAsAction="never" /> + android:orderInCategory="89" + android:title="@string/invite_to_app" + app:showAsAction="ifRoom" /> + - + android:visible="false" + app:showAsAction="never" /> Delete story Do you really want to delete this story Story deleted + Show stories + Hide stories + Stories + Add story \ No newline at end of file -- 2.39.5 From 5aa9c8ca88ccacdd8da8f6b6d0f123ee6e7b2bf2 Mon Sep 17 00:00:00 2001 From: Arne Date: Tue, 30 Dec 2025 16:00:59 +0100 Subject: [PATCH 012/180] Add support for XEP-0501 (Pubsub Stories) --- monocles_chat.doap | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/monocles_chat.doap b/monocles_chat.doap index 150a2e2f5..1265550b5 100644 --- a/monocles_chat.doap +++ b/monocles_chat.doap @@ -567,5 +567,12 @@ Receiving retractions + + + + complete + 0.2.0 + + -- 2.39.5 From d123d28e02a296b0643c9db7f2674cef5465d891 Mon Sep 17 00:00:00 2001 From: Arne Date: Tue, 30 Dec 2025 17:46:24 +0100 Subject: [PATCH 013/180] Add camera option to story image picker --- .../ui/ConversationsOverviewFragment.java | 60 +++++++++++++++---- 1 file changed, 48 insertions(+), 12 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java b/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java index 2893a0552..c539013d5 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java @@ -32,13 +32,17 @@ package eu.siacs.conversations.ui; import static androidx.recyclerview.widget.ItemTouchHelper.LEFT; import static androidx.recyclerview.widget.ItemTouchHelper.RIGHT; +import android.Manifest; import android.app.Activity; import android.app.Fragment; +import android.content.ActivityNotFoundException; import android.content.Intent; import android.content.SharedPreferences; +import android.content.pm.PackageManager; import android.graphics.Canvas; import android.net.Uri; import android.os.Bundle; +import android.provider.MediaStore; import android.util.Log; import android.view.ContextMenu; import android.view.LayoutInflater; @@ -102,7 +106,10 @@ public class ConversationsOverviewFragment extends XmppFragment implements XmppC ConversationsOverviewFragment.class.getName() + ".scroll_state"; private static final int REQUEST_CHOOSE_STORY_IMAGE = 0x2b01; + private static final int REQUEST_CAMERA_PERMISSION = 0x2b03; private Account mSelectedAccount; + private final PendingItem pendingTakePhotoUri = new PendingItem<>(); + private final List conversations = new ArrayList<>(); private final List stories = new ArrayList<>(); @@ -772,20 +779,21 @@ public class ConversationsOverviewFragment extends XmppFragment implements XmppC } @Override - public void onActivityResult(int requestCode, int resultCode, final Intent data) { - super.onActivityResult(requestCode, resultCode, data); + public void onActivityResult(int requestCode, int resultCode, final Intent data) {super.onActivityResult(requestCode, resultCode, data); if (resultCode == Activity.RESULT_OK) { if (requestCode == REQUEST_CHOOSE_STORY_IMAGE) { - if (data != null && mSelectedAccount != null) { - final Uri uri = data.getData(); - if (uri == null) { - return; - } + Uri uri; + if (data != null && data.getData() != null) { + uri = data.getData(); + } else if (pendingTakePhotoUri.peek() != null) { + uri = pendingTakePhotoUri.pop(); + } else { + uri = null; + } + if (uri != null && mSelectedAccount != null) { final String mimeType = activity.getContentResolver().getType(uri); - final EditText input = new EditText(getActivity()); input.setHint(R.string.title_optional); - new MaterialAlertDialogBuilder(activity) .setTitle(R.string.add_story_title) .setView(input) @@ -829,6 +837,8 @@ public class ConversationsOverviewFragment extends XmppFragment implements XmppC .show(); } } + } else { + pendingTakePhotoUri.pop(); } } @@ -855,9 +865,35 @@ public class ConversationsOverviewFragment extends XmppFragment implements XmppC private void openStoryImagePicker(Account account) { this.mSelectedAccount = account; - Intent intent = new Intent(Intent.ACTION_GET_CONTENT); - intent.setType("image/*"); - startActivityForResult(intent, REQUEST_CHOOSE_STORY_IMAGE); + if (activity.checkSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) { + requestPermissions(new String[]{Manifest.permission.CAMERA}, REQUEST_CAMERA_PERMISSION); + } else { + final Intent galleryIntent = new Intent(Intent.ACTION_GET_CONTENT); + galleryIntent.setType("image/*"); + + final Intent cameraIntent = new Intent(android.provider.MediaStore.ACTION_IMAGE_CAPTURE); + final Uri takePhotoUri = activity.xmppConnectionService.getFileBackend().getTakePhotoUri(); + pendingTakePhotoUri.push(takePhotoUri); + cameraIntent.putExtra(MediaStore.EXTRA_OUTPUT, takePhotoUri); + + final Intent chooserIntent = Intent.createChooser(galleryIntent, getString(R.string.perform_action_with)); + chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, new Intent[]{cameraIntent}); + + try { + startActivityForResult(chooserIntent, REQUEST_CHOOSE_STORY_IMAGE); + } catch (final ActivityNotFoundException e) { + Toast.makeText(activity, R.string.no_application_found, Toast.LENGTH_LONG).show(); + } + } } + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + if (requestCode == REQUEST_CAMERA_PERMISSION) { + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + openStoryImagePicker(mSelectedAccount); + } + } + } } -- 2.39.5 From 18ff45e1b38e7b96a0b348203b978726e0bd209f Mon Sep 17 00:00:00 2001 From: Arne Date: Tue, 30 Dec 2025 19:30:50 +0100 Subject: [PATCH 014/180] Replace 'Add Story' icon with 'Add a Photo' icon --- src/main/res/drawable/outline_add_a_photo_24.xml | 5 +++++ src/main/res/layout/fragment_conversations_overview.xml | 2 +- src/main/res/menu/fragment_conversations_overview.xml | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 src/main/res/drawable/outline_add_a_photo_24.xml diff --git a/src/main/res/drawable/outline_add_a_photo_24.xml b/src/main/res/drawable/outline_add_a_photo_24.xml new file mode 100644 index 000000000..6741aa5ae --- /dev/null +++ b/src/main/res/drawable/outline_add_a_photo_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/layout/fragment_conversations_overview.xml b/src/main/res/layout/fragment_conversations_overview.xml index 556f24338..4717e233d 100644 --- a/src/main/res/layout/fragment_conversations_overview.xml +++ b/src/main/res/layout/fragment_conversations_overview.xml @@ -72,7 +72,7 @@ android:layout_gravity="end|bottom" android:layout_marginEnd="16dp" android:layout_marginBottom="92dp" - app:srcCompat="@drawable/outline_amp_stories_24" /> + app:srcCompat="@drawable/outline_add_a_photo_24" /> Date: Tue, 30 Dec 2025 20:23:03 +0100 Subject: [PATCH 015/180] Make web links clickable in story viewer --- src/main/res/layout/activity_story_view.xml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/res/layout/activity_story_view.xml b/src/main/res/layout/activity_story_view.xml index 0bf09c6eb..00f27d629 100644 --- a/src/main/res/layout/activity_story_view.xml +++ b/src/main/res/layout/activity_story_view.xml @@ -28,6 +28,8 @@ android:background="#80000000" android:padding="16dp" android:textColor="@android:color/white" - android:textSize="18sp" /> + android:textSize="18sp" + android:autoLink="web" + android:linksClickable="true" /> -- 2.39.5 From 391776080bb1920fa8f1eeec93d11f893f8907a0 Mon Sep 17 00:00:00 2001 From: Arne Date: Tue, 30 Dec 2025 21:10:08 +0100 Subject: [PATCH 016/180] Add reply button to story view --- .../java/eu/siacs/conversations/ui/StoryViewActivity.java | 6 ++++++ src/main/res/menu/activity_story_view.xml | 7 ++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java b/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java index 8b87381c2..cf7000a78 100644 --- a/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java @@ -1,5 +1,6 @@ package eu.siacs.conversations.ui; +import android.content.Intent; import android.os.Bundle; import android.util.Log; import android.view.Menu; @@ -22,6 +23,7 @@ import java.util.ArrayList; import eu.siacs.conversations.Config; import eu.siacs.conversations.R; import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.entities.Conversation; import eu.siacs.conversations.http.HttpConnectionManager; import eu.siacs.conversations.xmpp.Jid; import okhttp3.HttpUrl; @@ -126,6 +128,10 @@ public class StoryViewActivity extends XmppActivity { .create() .show(); return true; + } else if (item.getItemId() == R.id.action_reply_to_story) { + Conversation conversation = xmppConnectionService.findOrCreateConversation(mAccount, contact, false, false); + switchToConversation(conversation); + return true; } return super.onOptionsItemSelected(item); } diff --git a/src/main/res/menu/activity_story_view.xml b/src/main/res/menu/activity_story_view.xml index 6824ae58d..fb85a7582 100644 --- a/src/main/res/menu/activity_story_view.xml +++ b/src/main/res/menu/activity_story_view.xml @@ -1,10 +1,15 @@ + -- 2.39.5 From 1b0dbc2ddcd5a439c9a15c080cd853a1c7138f18 Mon Sep 17 00:00:00 2001 From: Arne Date: Tue, 30 Dec 2025 22:15:21 +0100 Subject: [PATCH 017/180] Add reply button to story view --- .../conversations/ui/StoryViewActivity.java | 29 +++++++++++++++---- src/main/res/values/strings.xml | 1 + 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java b/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java index cf7000a78..b54fd7aca 100644 --- a/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java @@ -24,6 +24,7 @@ import eu.siacs.conversations.Config; import eu.siacs.conversations.R; import eu.siacs.conversations.entities.Account; import eu.siacs.conversations.entities.Conversation; +import eu.siacs.conversations.entities.Message; import eu.siacs.conversations.http.HttpConnectionManager; import eu.siacs.conversations.xmpp.Jid; import okhttp3.HttpUrl; @@ -45,6 +46,7 @@ public class StoryViewActivity extends XmppActivity { private int currentIndex = 0; private Jid contact; private Account mAccount; + private Message storyMessage; @Override protected void onCreate(Bundle savedInstanceState) { @@ -99,7 +101,11 @@ public class StoryViewActivity extends XmppActivity { @Override public boolean onOptionsItemSelected(MenuItem item) { - if (item.getItemId() == R.id.action_delete_story) { + final int itemId = item.getItemId(); + if (itemId == android.R.id.home) { + finish(); + return true; + } else if (itemId == R.id.action_delete_story) { new MaterialAlertDialogBuilder(this) .setTitle(R.string.delete_story_dialog_title) .setMessage(R.string.delete_story_dialog_message) @@ -128,9 +134,12 @@ public class StoryViewActivity extends XmppActivity { .create() .show(); return true; - } else if (item.getItemId() == R.id.action_reply_to_story) { - Conversation conversation = xmppConnectionService.findOrCreateConversation(mAccount, contact, false, false); - switchToConversation(conversation); + } else if (itemId == R.id.action_reply_to_story) { + if (storyMessage != null) { + Conversation conversation = xmppConnectionService.findOrCreateConversation(mAccount, contact, false, false); + conversation.setReplyTo(storyMessage); + switchToConversation(conversation); + } return true; } return super.onOptionsItemSelected(item); @@ -149,7 +158,8 @@ public class StoryViewActivity extends XmppActivity { private void loadStory() { if (urls == null || currentIndex >= urls.size()) { - finish();return; + finish(); + return; } titleView.setText(titles.get(currentIndex)); if (getSupportActionBar() != null) { @@ -165,6 +175,13 @@ public class StoryViewActivity extends XmppActivity { return; } + // Correctly create a message object representing the story, including its ID + Conversation conversation = xmppConnectionService.findOrCreateConversation(mAccount, contact, false, false); + storyMessage = new Message(conversation, titles.get(currentIndex), Message.ENCRYPTION_NONE, Message.STATUS_RECEIVED); + // storyMessage.setRemoteMsgId(storyIds.get(currentIndex)); + // storyMessage.setFileParams(new Message.FileParams(url)); // TODO: Add image support later + storyMessage.setBody(getString(R.string.reply_to_story) + " " + "\"" + titles.get(currentIndex) + "\""); + final boolean useTor = mAccount != null && (xmppConnectionService.useTorToConnect() || mAccount.isOnion()); final boolean useI2p = mAccount != null && (xmppConnectionService.useI2PToConnect() || mAccount.isI2P()); @@ -183,6 +200,8 @@ public class StoryViewActivity extends XmppActivity { } final File finalTempFile = tempFile; + // Associate the downloaded file with our story message + // storyMessage.setRelativeFilePath(finalTempFile.getAbsolutePath()); // TODO: Add image support later runOnUiThread(() -> { if (!isFinishing()) { Glide.with(StoryViewActivity.this).load(finalTempFile).into(imageView); diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index 18ff7006b..fb073a215 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -1589,4 +1589,5 @@ Hide stories Stories Add story + Reply to story: \ No newline at end of file -- 2.39.5 From 55c302302e9ee1b41f00c13d4a34362b24a30c3c Mon Sep 17 00:00:00 2001 From: Arne Date: Tue, 30 Dec 2025 22:28:30 +0100 Subject: [PATCH 018/180] Show progress indicator while story image is loading --- .../eu/siacs/conversations/ui/StoryViewActivity.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java b/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java index b54fd7aca..9a896710a 100644 --- a/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java @@ -1,6 +1,5 @@ package eu.siacs.conversations.ui; -import android.content.Intent; import android.os.Bundle; import android.util.Log; import android.view.Menu; @@ -10,6 +9,7 @@ import android.widget.TextView; import android.widget.Toast; import androidx.appcompat.widget.Toolbar; +import androidx.swiperefreshlayout.widget.CircularProgressDrawable; import com.bumptech.glide.Glide; import com.google.android.material.dialog.MaterialAlertDialogBuilder; @@ -204,7 +204,12 @@ public class StoryViewActivity extends XmppActivity { // storyMessage.setRelativeFilePath(finalTempFile.getAbsolutePath()); // TODO: Add image support later runOnUiThread(() -> { if (!isFinishing()) { - Glide.with(StoryViewActivity.this).load(finalTempFile).into(imageView); + CircularProgressDrawable circularProgressDrawable = new CircularProgressDrawable(this); + circularProgressDrawable.setStrokeWidth(10f); + circularProgressDrawable.setCenterRadius(50f); + circularProgressDrawable.setColorSchemeColors(0xFFFFFFFF); + circularProgressDrawable.start(); + Glide.with(StoryViewActivity.this).load(finalTempFile).placeholder(circularProgressDrawable).into(imageView); } }); -- 2.39.5 From e3ef4809b3a638f3d424b8adc28de995ced6c9f5 Mon Sep 17 00:00:00 2001 From: Arne Date: Wed, 31 Dec 2025 10:21:40 +0100 Subject: [PATCH 019/180] Refactor stories into its own activity --- src/main/AndroidManifest.xml | 3 + .../ui/ConversationsActivity.java | 7 + .../ui/ConversationsOverviewFragment.java | 253 +------------ .../ui/StartConversationActivity.java | 7 + .../conversations/ui/StoriesActivity.java | 331 ++++++++++++++++++ .../ui/adapter/StoryAdapter.java | 8 + .../drawable/stories_selected_black_24.xml | 5 + .../drawable/stories_selected_white_24.xml | 5 + .../drawable/stories_unselected_black_24.xml | 5 + .../drawable/stories_unselected_white_24.xml | 5 + src/main/res/layout/activity_stories.xml | 61 ++++ .../fragment_conversations_overview.xml | 17 - src/main/res/layout/list_item_story.xml | 34 +- src/main/res/menu/activity_conversations.xml | 1 + src/main/res/menu/activity_stories.xml | 9 + .../menu/bottom_navigation_menu_accounts.xml | 4 + .../res/menu/bottom_navigation_menu_chat.xml | 4 + .../menu/bottom_navigation_menu_contacts.xml | 4 + .../menu/bottom_navigation_menu_stories.xml | 19 + .../menu/fragment_conversations_overview.xml | 17 +- src/main/res/values-night/themes.xml | 2 + src/main/res/values/themes.xml | 2 + .../ui/ManageAccountActivity.java | 7 + src/monocleschat/res/values/attrs.xml | 2 + 24 files changed, 531 insertions(+), 281 deletions(-) create mode 100644 src/main/java/eu/siacs/conversations/ui/StoriesActivity.java create mode 100644 src/main/res/drawable/stories_selected_black_24.xml create mode 100644 src/main/res/drawable/stories_selected_white_24.xml create mode 100644 src/main/res/drawable/stories_unselected_black_24.xml create mode 100644 src/main/res/drawable/stories_unselected_white_24.xml create mode 100644 src/main/res/layout/activity_stories.xml create mode 100644 src/main/res/menu/activity_stories.xml create mode 100644 src/main/res/menu/bottom_navigation_menu_stories.xml diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index 2eaa46ed8..507fdd2d1 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -396,6 +396,9 @@ + diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java b/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java index 67a96b506..bed8e59b0 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java @@ -1136,6 +1136,13 @@ public class ConversationsActivity extends XmppActivity overridePendingTransition(R.animator.fade_in, R.animator.fade_out); return true; } + case R.id.stories -> { + Intent i = new Intent(getApplicationContext(), StoriesActivity.class); + i.putExtra("show_nav_bar", true); + startActivity(i); + overridePendingTransition(R.animator.fade_in, R.animator.fade_out); + return true; + } case R.id.manageaccounts -> { Intent i = new Intent(getApplicationContext(), MANAGE_ACCOUNT_ACTIVITY); i.putExtra("show_nav_bar", true); diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java b/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java index c539013d5..00ba3a659 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java @@ -32,17 +32,11 @@ package eu.siacs.conversations.ui; import static androidx.recyclerview.widget.ItemTouchHelper.LEFT; import static androidx.recyclerview.widget.ItemTouchHelper.RIGHT; -import android.Manifest; import android.app.Activity; import android.app.Fragment; -import android.content.ActivityNotFoundException; import android.content.Intent; -import android.content.SharedPreferences; -import android.content.pm.PackageManager; import android.graphics.Canvas; -import android.net.Uri; import android.os.Bundle; -import android.provider.MediaStore; import android.util.Log; import android.view.ContextMenu; import android.view.LayoutInflater; @@ -52,12 +46,10 @@ import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.widget.AdapterView.AdapterContextMenuInfo; -import android.widget.EditText; import android.widget.PopupMenu; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.databinding.DataBindingUtil; -import androidx.preference.PreferenceManager; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; @@ -65,7 +57,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.android.material.snackbar.Snackbar; import com.google.common.base.Optional; import com.google.common.collect.Collections2; -import eu.siacs.conversations.BuildConfig; + import eu.siacs.conversations.Config; import eu.siacs.conversations.R; import eu.siacs.conversations.databinding.FragmentConversationsOverviewBinding; @@ -73,12 +65,8 @@ import eu.siacs.conversations.entities.Account; import eu.siacs.conversations.entities.Contact; import eu.siacs.conversations.entities.Conversation; import eu.siacs.conversations.entities.Conversational; -import eu.siacs.conversations.entities.Message; -import eu.siacs.conversations.entities.Story; import eu.siacs.conversations.services.XmppConnectionService; -import eu.siacs.conversations.services.QuickConversationsService; import eu.siacs.conversations.ui.adapter.ConversationAdapter; -import eu.siacs.conversations.ui.adapter.StoryAdapter; import eu.siacs.conversations.ui.interfaces.OnConversationArchived; import eu.siacs.conversations.ui.interfaces.OnConversationSelected; import eu.siacs.conversations.ui.util.MenuDoubleTabUtil; @@ -88,36 +76,21 @@ import eu.siacs.conversations.ui.util.ScrollState; import eu.siacs.conversations.ui.util.SoftKeyboardUtils; import eu.siacs.conversations.utils.AccountUtils; import eu.siacs.conversations.utils.EasyOnboardingInvite; -import eu.siacs.conversations.utils.ThemeHelper; import eu.siacs.conversations.xmpp.jingle.OngoingRtpSession; -import static androidx.recyclerview.widget.ItemTouchHelper.LEFT; -import static androidx.recyclerview.widget.ItemTouchHelper.RIGHT; - import java.util.ArrayList; -import java.util.Collections; import java.util.List; import java.util.concurrent.atomic.AtomicReference; -import java.util.stream.Collectors; -public class ConversationsOverviewFragment extends XmppFragment implements XmppConnectionService.OnStoriesUpdate { +public class ConversationsOverviewFragment extends XmppFragment { private static final String STATE_SCROLL_POSITION = ConversationsOverviewFragment.class.getName() + ".scroll_state"; - - private static final int REQUEST_CHOOSE_STORY_IMAGE = 0x2b01; - private static final int REQUEST_CAMERA_PERMISSION = 0x2b03; - private Account mSelectedAccount; - private final PendingItem pendingTakePhotoUri = new PendingItem<>(); - - private final List conversations = new ArrayList<>(); - private final List stories = new ArrayList<>(); private final PendingItem swipedConversation = new PendingItem<>(); private final PendingItem pendingScrollState = new PendingItem<>(); private FragmentConversationsOverviewBinding binding; private ConversationAdapter conversationsAdapter; - private StoryAdapter storyAdapter; private XmppActivity activity; private final PendingActionHelper pendingActionHelper = new PendingActionHelper(); @@ -323,7 +296,6 @@ public class ConversationsOverviewFragment extends XmppFragment implements XmppC super.onDestroyView(); this.binding = null; this.conversationsAdapter = null; - this.storyAdapter = null; this.touchHelper = null; } @@ -337,7 +309,6 @@ public class ConversationsOverviewFragment extends XmppFragment implements XmppC public void onPause() { Log.d(Config.LOGTAG, "ConversationsOverviewFragment.onPause()"); pendingActionHelper.execute(); - activity.xmppConnectionService.removeOnStoriesUpdateListener(this); super.onPause(); } @@ -362,7 +333,6 @@ public class ConversationsOverviewFragment extends XmppFragment implements XmppC this.binding.fab.setOnClickListener( (view) -> StartConversationActivity.launch(getActivity())); - this.binding.fabStory.setOnClickListener(v -> selectAccountToPublishStory()); this.conversationsAdapter = new ConversationAdapter(this.activity, this.conversations); this.conversationsAdapter.setConversationClickListener( (view, conversation) -> { @@ -377,10 +347,6 @@ public class ConversationsOverviewFragment extends XmppFragment implements XmppC this.binding.list.setAdapter(this.conversationsAdapter); this.binding.list.setLayoutManager( new LinearLayoutManager(getActivity(), LinearLayoutManager.VERTICAL, false)); - this.storyAdapter = new StoryAdapter(this.activity, this.stories); - this.binding.storiesList.setAdapter(this.storyAdapter); - this.binding.storiesList.setLayoutManager( - new LinearLayoutManager(getActivity(), LinearLayoutManager.HORIZONTAL, false)); registerForContextMenu(this.binding.list); this.binding.list.addOnScrollListener(ExtendedFabSizeChanger.of(binding.fab)); if (activity.getPreferences().getBoolean("swipe_to_archive", true)) this.touchHelper = new ItemTouchHelper(this.callback); @@ -397,24 +363,15 @@ public class ConversationsOverviewFragment extends XmppFragment implements XmppC easyOnboardInvite.setVisible(EasyOnboardingInvite.anyHasSupport(activity == null ? null : activity.xmppConnectionService)); if (activity != null && activity.xmppConnectionService != null && activity.xmppConnectionService.isOnboarding()) { final MenuItem manageAccounts = menu.findItem(R.id.action_accounts); - if (manageAccounts != null) manageAccounts.setVisible(false); - final MenuItem settings = menu.findItem(R.id.action_settings); + final MenuItem stories = menu.findItem(R.id.action_stories); + if (manageAccounts != null) manageAccounts.setVisible(false); if (settings != null) settings.setVisible(false); + if (stories != null) stories.setVisible(false); } if (activity == null || activity.xmppConnectionService == null || activity.xmppConnectionService.getAccounts().size() != 1) { noteToSelf.setVisible(false); } - final MenuItem stories = menu.findItem(R.id.action_toggle_stories); - if (stories != null) { - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); - boolean show = preferences.getBoolean("show_stories", true); - if (show) { - stories.setTitle(R.string.hide_stories); - } else { - stories.setTitle(R.string.show_stories); - } - } } @Override @@ -486,7 +443,6 @@ public class ConversationsOverviewFragment extends XmppFragment implements XmppC @Override public void onBackendConnected() { refresh(); - activity.xmppConnectionService.setOnStoriesUpdateListener(this); } private void setupSwipe() { @@ -527,22 +483,13 @@ public class ConversationsOverviewFragment extends XmppFragment implements XmppC boolean navBarVisible = activity instanceof ConversationsActivity && ((ConversationsActivity) activity).navigationBarVisible(); MenuItem manageAccount = menu.findItem(R.id.action_account); MenuItem manageAccounts = menu.findItem(R.id.action_accounts); - MenuItem addStory = menu.findItem(R.id.action_add_story); + MenuItem stories = menu.findItem(R.id.action_stories); if (navBarVisible) { manageAccount.setVisible(false); manageAccounts.setVisible(false); - if (stories != null) { - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); - boolean show = preferences.getBoolean("show_stories", true); - if (show) { - addStory.setVisible(true); - } else { - addStory.setVisible(false); - } - } + stories.setVisible(false); } else { AccountUtils.showHideMenuItems(menu); - addStory.setVisible(false); } } @@ -558,18 +505,8 @@ public class ConversationsOverviewFragment extends XmppFragment implements XmppC if (showed) { this.binding.fab.setVisibility(View.GONE); - binding.fabStory.setVisibility(View.GONE); } else { this.binding.fab.setVisibility(View.VISIBLE); - if (stories != null) { - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); - boolean show = preferences.getBoolean("show_stories", true); - if (show) { - this.binding.fabStory.setVisibility(View.VISIBLE); - } else { - this.binding.fabStory.setVisibility(View.GONE); - } - } } } } @@ -586,7 +523,8 @@ public class ConversationsOverviewFragment extends XmppFragment implements XmppC return false; } switch (item.getItemId()) { - case R.id.action_search:startActivity(new Intent(getActivity(), SearchActivity.class)); + case R.id.action_search: + startActivity(new Intent(getActivity(), SearchActivity.class)); return true; case R.id.action_easy_invite: selectAccountToStartEasyInvite(); @@ -600,26 +538,12 @@ public class ConversationsOverviewFragment extends XmppFragment implements XmppC activity.switchToConversation(conversation); } return true; - case R.id.action_toggle_stories: - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); - boolean show = preferences.getBoolean("show_stories", true); - preferences.edit().putBoolean("show_stories", !show).apply(); - refresh(); - activity.invalidateOptionsMenu(); - return true; - case R.id.action_add_story: - selectAccountToPublishStory(); + case R.id.action_stories: + startActivity(new Intent(getActivity(), StoriesActivity.class)); return true; } return super.onOptionsItemSelected(item); } - - private void setShowStories(boolean show) { - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); - preferences.edit().putBoolean("show_stories", show).apply(); - refresh(); - activity.invalidateOptionsMenu(); - } private void selectAccountToStartEasyInvite() { final List accounts = EasyOnboardingInvite.getSupportingAccounts(activity.xmppConnectionService); @@ -659,29 +583,6 @@ public class ConversationsOverviewFragment extends XmppFragment implements XmppC Log.d(Config.LOGTAG,"ConversationsOverviewFragment.refresh() skipped updated because view binding or activity was null"); return; } - - this.stories.clear(); - this.stories.addAll( - this.activity.xmppConnectionService.getStories().stream() - .collect(Collectors.toMap( - story -> story.getContact().asBareJid(), - story -> story, - (a, b) -> a.getPublished() > b.getPublished() ? a : b - )) - .values() - ); - Collections.sort(this.stories, (a,b) -> Long.compare(b.getPublished(), a.getPublished())); - - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); - boolean show = preferences.getBoolean("show_stories", true); - - if (this.stories.isEmpty() || !show) { - binding.storiesList.setVisibility(View.GONE); - } else { - binding.storiesList.setVisibility(View.VISIBLE); - } - this.storyAdapter.notifyDataSetChanged(); - this.activity.populateWithOrderedConversations(this.conversations); Conversation removed = this.swipedConversation.peek(); if (removed != null) { @@ -698,7 +599,6 @@ public class ConversationsOverviewFragment extends XmppFragment implements XmppC } if (activity.xmppConnectionService != null && activity.xmppConnectionService.isOnboarding()) { binding.fab.setVisibility(View.GONE); - binding.fabStory.setVisibility(View.GONE); if (this.conversations.size() == 1) { if (activity instanceof OnConversationSelected) { @@ -713,16 +613,8 @@ public class ConversationsOverviewFragment extends XmppFragment implements XmppC if (showed) { this.binding.fab.setVisibility(View.GONE); - this.binding.fabStory.setVisibility(View.GONE); } else { this.binding.fab.setVisibility(View.VISIBLE); - if (stories != null) { - if (show) { - this.binding.fabStory.setVisibility(View.VISIBLE); - } else { - this.binding.fabStory.setVisibility(View.GONE); - } - } } } } @@ -773,127 +665,4 @@ public class ConversationsOverviewFragment extends XmppFragment implements XmppC } } - @Override - public void onStoriesUpdate() { - activity.runOnUiThread(this::refresh); - } - - @Override - public void onActivityResult(int requestCode, int resultCode, final Intent data) {super.onActivityResult(requestCode, resultCode, data); - if (resultCode == Activity.RESULT_OK) { - if (requestCode == REQUEST_CHOOSE_STORY_IMAGE) { - Uri uri; - if (data != null && data.getData() != null) { - uri = data.getData(); - } else if (pendingTakePhotoUri.peek() != null) { - uri = pendingTakePhotoUri.pop(); - } else { - uri = null; - } - if (uri != null && mSelectedAccount != null) { - final String mimeType = activity.getContentResolver().getType(uri); - final EditText input = new EditText(getActivity()); - input.setHint(R.string.title_optional); - new MaterialAlertDialogBuilder(activity) - .setTitle(R.string.add_story_title) - .setView(input) - .setNegativeButton(R.string.cancel, null) - .setPositiveButton(R.string.publish, (dialog, which) -> { - final String title = input.getText().toString(); - Toast.makeText(activity, R.string.uploading_story, Toast.LENGTH_SHORT).show(); - activity.xmppConnectionService.uploadFileForUrl(mSelectedAccount, uri, mimeType, new UiCallback() { - @Override - public void success(String url) { - activity.xmppConnectionService.publishStory(mSelectedAccount, url, mimeType, title, new UiCallback() { - @Override - public void success(Void aVoid) { - activity.runOnUiThread(() -> Toast.makeText(activity, R.string.story_published, Toast.LENGTH_SHORT).show()); - } - - @Override - public void error(int errorCode, Void object) { - activity.runOnUiThread(() -> Toast.makeText(activity, errorCode, Toast.LENGTH_SHORT).show()); - } - - @Override - public void userInputRequired(android.app.PendingIntent pi, Void object) { - // not used - } - }); - } - - @Override - public void error(int errorCode, String object) { - activity.runOnUiThread(() -> Toast.makeText(activity, errorCode, Toast.LENGTH_SHORT).show()); - } - - @Override - public void userInputRequired(android.app.PendingIntent pi, String object) { - // not used - } - }); - }) - .create() - .show(); - } - } - } else { - pendingTakePhotoUri.pop(); - } - } - - private void selectAccountToPublishStory() { - final List accounts = activity.xmppConnectionService.getAccounts().stream().filter(Account::isEnabled).collect(Collectors.toList()); - if (accounts.isEmpty()) { - Toast.makeText(getActivity(), R.string.no_active_account, Toast.LENGTH_SHORT).show(); - } else if (accounts.size() == 1) { - openStoryImagePicker(accounts.get(0)); - } else { - final AtomicReference selectedAccount = new AtomicReference<>(accounts.get(0)); - final MaterialAlertDialogBuilder alertDialogBuilder = new MaterialAlertDialogBuilder(activity); - alertDialogBuilder.setTitle(R.string.choose_account); - final String[] asStrings = - accounts.stream().map(a -> a.getJid().asBareJid().toString()).toArray(String[]::new); - alertDialogBuilder.setSingleChoiceItems( - asStrings, 0, (dialog, which) -> selectedAccount.set(accounts.get(which))); - alertDialogBuilder.setNegativeButton(R.string.cancel, null); - alertDialogBuilder.setPositiveButton( - R.string.ok, (dialog, which) -> openStoryImagePicker(selectedAccount.get())); - alertDialogBuilder.create().show(); - } - } - - private void openStoryImagePicker(Account account) { - this.mSelectedAccount = account; - if (activity.checkSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) { - requestPermissions(new String[]{Manifest.permission.CAMERA}, REQUEST_CAMERA_PERMISSION); - } else { - final Intent galleryIntent = new Intent(Intent.ACTION_GET_CONTENT); - galleryIntent.setType("image/*"); - - final Intent cameraIntent = new Intent(android.provider.MediaStore.ACTION_IMAGE_CAPTURE); - final Uri takePhotoUri = activity.xmppConnectionService.getFileBackend().getTakePhotoUri(); - pendingTakePhotoUri.push(takePhotoUri); - cameraIntent.putExtra(MediaStore.EXTRA_OUTPUT, takePhotoUri); - - final Intent chooserIntent = Intent.createChooser(galleryIntent, getString(R.string.perform_action_with)); - chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, new Intent[]{cameraIntent}); - - try { - startActivityForResult(chooserIntent, REQUEST_CHOOSE_STORY_IMAGE); - } catch (final ActivityNotFoundException e) { - Toast.makeText(activity, R.string.no_application_found, Toast.LENGTH_LONG).show(); - } - } - } - - @Override - public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults); - if (requestCode == REQUEST_CAMERA_PERMISSION) { - if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - openStoryImagePicker(mSelectedAccount); - } - } - } } diff --git a/src/main/java/eu/siacs/conversations/ui/StartConversationActivity.java b/src/main/java/eu/siacs/conversations/ui/StartConversationActivity.java index f881fb6b3..350db30f8 100644 --- a/src/main/java/eu/siacs/conversations/ui/StartConversationActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/StartConversationActivity.java @@ -452,6 +452,13 @@ public class StartConversationActivity extends XmppActivity case R.id.contactslist -> { return true; } + case R.id.stories -> { + Intent i = new Intent(getApplicationContext(), StoriesActivity.class); + i.putExtra("show_nav_bar", true); + startActivity(i); + overridePendingTransition(R.animator.fade_in, R.animator.fade_out); + return true; + } case R.id.manageaccounts -> { Intent i = new Intent(getApplicationContext(), MANAGE_ACCOUNT_ACTIVITY); i.putExtra("show_nav_bar", true); diff --git a/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java b/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java new file mode 100644 index 000000000..01cb70504 --- /dev/null +++ b/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java @@ -0,0 +1,331 @@ +package eu.siacs.conversations.ui; + +import static android.view.View.VISIBLE; + +import static eu.siacs.conversations.utils.AccountUtils.MANAGE_ACCOUNT_ACTIVITY; + +import android.Manifest; +import android.app.Activity; +import android.app.PendingIntent; +import android.content.ActivityNotFoundException; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.graphics.Color; +import android.net.Uri; +import android.os.Bundle; +import android.provider.MediaStore; +import android.util.Log; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.widget.EditText; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.ActionBar; +import androidx.databinding.DataBindingUtil; +import androidx.preference.PreferenceManager; +import androidx.recyclerview.widget.LinearLayoutManager; + +import com.google.android.material.bottomnavigation.BottomNavigationView; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; + +import eu.siacs.conversations.Config; +import eu.siacs.conversations.R; +import eu.siacs.conversations.databinding.ActivityStoriesBinding; +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.entities.Story; +import eu.siacs.conversations.services.XmppConnectionService; +import eu.siacs.conversations.ui.adapter.StoryAdapter; +import eu.siacs.conversations.ui.util.PendingItem; + +public class StoriesActivity extends XmppActivity implements XmppConnectionService.OnStoriesUpdate { + + private static final int REQUEST_CHOOSE_STORY_IMAGE = 0x2b01; + private static final int REQUEST_CAMERA_PERMISSION = 0x2b03; + private Account mSelectedAccount; + private final PendingItem pendingTakePhotoUri = new PendingItem<>(); + + private ActivityStoriesBinding binding; + private StoryAdapter storyAdapter; + private final List stories = new ArrayList<>(); + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + binding = DataBindingUtil.setContentView(this, R.layout.activity_stories); + setSupportActionBar(binding.toolbar); + configureActionBar(getSupportActionBar()); + binding.fabAddStory.setOnClickListener(v -> selectAccountToPublishStory()); + storyAdapter = new StoryAdapter(this, stories); + binding.storiesList.setLayoutManager(new LinearLayoutManager(this)); + binding.storiesList.setAdapter(storyAdapter); + + // Bottom Navigation Setup + BottomNavigationView bottomNavigationView = findViewById(R.id.bottom_navigation); + bottomNavigationView.setBackgroundColor(Color.TRANSPARENT); + bottomNavigationView.setOnItemSelectedListener(item -> { + switch (item.getItemId()) { + case R.id.chats -> { + startActivity(new Intent(getApplicationContext(), ConversationsActivity.class)); + overridePendingTransition(R.animator.fade_in, R.animator.fade_out); + return true; + } + case R.id.contactslist -> { + Intent i = new Intent(getApplicationContext(), StartConversationActivity.class); + i.putExtra("show_nav_bar", true); + startActivity(i); + overridePendingTransition(R.animator.fade_in, R.animator.fade_out); + return true; + } + case R.id.stories -> { + return true; + } + case R.id.manageaccounts -> { + Intent i = new Intent(getApplicationContext(), MANAGE_ACCOUNT_ACTIVITY); + i.putExtra("show_nav_bar", true); + startActivity(i); + overridePendingTransition(R.animator.fade_in, R.animator.fade_out); + return true; + } + default -> + throw new IllegalStateException("Unexpected value: " + item.getItemId()); + } + }); + } + + @Override + public void onStart() { + super.onStart(); + BottomNavigationView bottomNavigationView=findViewById(R.id.bottom_navigation); + bottomNavigationView.setSelectedItemId(R.id.stories); + + if (getBooleanPreference("show_nav_bar", R.bool.show_nav_bar) && getIntent().getBooleanExtra("show_nav_bar", false)) { + bottomNavigationView.setVisibility(VISIBLE); + } else { + bottomNavigationView.setVisibility(View.GONE); + } + if (xmppConnectionService != null) { + xmppConnectionService.setOnStoriesUpdateListener(this); + refresh(); + } + } + + @Override + protected void onStop() { + super.onStop(); + if (xmppConnectionService != null) { + xmppConnectionService.removeOnStoriesUpdateListener(this); + } + } + + @Override + protected void onBackendConnected() { + if (xmppConnectionService != null) { + xmppConnectionService.setOnStoriesUpdateListener(this); + refresh(); + } + refreshUiReal(); + } + + @Override + public void onBackPressed() { + if (findViewById(R.id.bottom_navigation).getVisibility() == VISIBLE) { + Intent intent = new Intent(this, ConversationsActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION); + startActivity(intent); + overridePendingTransition(R.animator.fade_in, R.animator.fade_out); + } + + super.onBackPressed(); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.activity_stories, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == R.id.action_settings) { + startActivity(new Intent(this, eu.siacs.conversations.ui.activity.SettingsActivity.class)); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, final Intent data) {super.onActivityResult(requestCode, resultCode, data); + if (resultCode == Activity.RESULT_OK) { + if (requestCode == REQUEST_CHOOSE_STORY_IMAGE) { + Uri uri; + if (data != null && data.getData() != null) { + uri = data.getData(); + } else if (pendingTakePhotoUri.peek() != null) { + uri = pendingTakePhotoUri.pop(); + } else { + uri = null; + } + if (uri != null && mSelectedAccount != null) { + final String mimeType = getContentResolver().getType(uri); + final EditText input = new EditText(this); + input.setHint(R.string.title_optional); + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.add_story_title) + .setView(input) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.publish, (dialog, which) -> { + final String title = input.getText().toString(); + Toast.makeText(this, R.string.uploading_story, Toast.LENGTH_SHORT).show(); + xmppConnectionService.uploadFileForUrl(mSelectedAccount, uri, mimeType, new UiCallback() { + @Override + public void success(String url) { + xmppConnectionService.publishStory(mSelectedAccount, url, mimeType, title, new UiCallback() { + @Override + public void success(Void aVoid) { + runOnUiThread(() -> Toast.makeText(xmppConnectionService, R.string.story_published, Toast.LENGTH_SHORT).show()); + } + + @Override + public void error(int errorCode, Void object) { + runOnUiThread(() -> Toast.makeText(xmppConnectionService, errorCode, Toast.LENGTH_SHORT).show()); + } + + @Override + public void userInputRequired(PendingIntent pi, Void object) { + // not used + } + }); + } + + @Override + public void error(int errorCode, String object) { + runOnUiThread(() -> Toast.makeText(xmppConnectionService, errorCode, Toast.LENGTH_SHORT).show()); + } + + @Override + public void userInputRequired(PendingIntent pi, String object) { + // not used + } + }); + }) + .create() + .show(); + } + } + } else { + pendingTakePhotoUri.pop(); + } + } + + private void selectAccountToPublishStory() { + final List accounts = xmppConnectionService.getAccounts().stream().filter(Account::isEnabled).collect(Collectors.toList()); + if (accounts.isEmpty()) { + Toast.makeText(this, R.string.no_active_account, Toast.LENGTH_SHORT).show(); + } else if (accounts.size() == 1) { + openStoryImagePicker(accounts.get(0)); + } else { + final AtomicReference selectedAccount = new AtomicReference<>(accounts.get(0)); + final MaterialAlertDialogBuilder alertDialogBuilder = new MaterialAlertDialogBuilder(this); + alertDialogBuilder.setTitle(R.string.choose_account); + final String[] asStrings = + accounts.stream().map(a -> a.getJid().asBareJid().toString()).toArray(String[]::new); + alertDialogBuilder.setSingleChoiceItems( + asStrings, 0, (dialog, which) -> selectedAccount.set(accounts.get(which))); + alertDialogBuilder.setNegativeButton(R.string.cancel, null); + alertDialogBuilder.setPositiveButton( + R.string.ok, (dialog, which) -> openStoryImagePicker(selectedAccount.get())); + alertDialogBuilder.create().show(); + } + } + + private void openStoryImagePicker(Account account) { + this.mSelectedAccount = account; + if (checkSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) { + requestPermissions(new String[]{Manifest.permission.CAMERA}, REQUEST_CAMERA_PERMISSION); + } else { + final Intent galleryIntent = new Intent(Intent.ACTION_GET_CONTENT); + galleryIntent.setType("image/*"); + + final Intent cameraIntent = new Intent(android.provider.MediaStore.ACTION_IMAGE_CAPTURE); + final Uri takePhotoUri = xmppConnectionService.getFileBackend().getTakePhotoUri(); + pendingTakePhotoUri.push(takePhotoUri); + cameraIntent.putExtra(MediaStore.EXTRA_OUTPUT, takePhotoUri); + + final Intent chooserIntent = Intent.createChooser(galleryIntent, getString(R.string.perform_action_with)); + chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, new Intent[]{cameraIntent}); + + try { + startActivityForResult(chooserIntent, REQUEST_CHOOSE_STORY_IMAGE); + } catch (final ActivityNotFoundException e) { + Toast.makeText(this, R.string.no_application_found, Toast.LENGTH_LONG).show(); + } + } + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + if (requestCode == REQUEST_CAMERA_PERMISSION) { + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + openStoryImagePicker(mSelectedAccount); + } + } + } + + @Override + protected void refreshUiReal() { + ActionBar actionBar = getSupportActionBar(); + + // Show badge for unread message in bottom nav + int unreadCount = xmppConnectionService.unreadCount(); + BottomNavigationView bottomnav = findViewById(R.id.bottom_navigation); + var bottomBadge = bottomnav.getOrCreateBadge(R.id.chats); + bottomBadge.setNumber(unreadCount); + bottomBadge.setVisible(unreadCount > 0); + bottomBadge.setHorizontalOffset(20); + + boolean showNavBar = bottomnav.getVisibility() == VISIBLE; + if (actionBar != null) { + actionBar.setHomeButtonEnabled(!showNavBar); + actionBar.setDisplayHomeAsUpEnabled(!showNavBar); + } + refresh(); + } + + private void refresh() { + if (xmppConnectionService == null) { + return; + } + this.stories.clear(); + this.stories.addAll( + this.xmppConnectionService.getStories().stream() + .collect(Collectors.toMap( + story -> story.getContact().asBareJid(), + story -> story, + (a, b) -> a.getPublished() > b.getPublished() ? a : b + )) + .values() + ); + Collections.sort(this.stories, (a,b) -> Long.compare(b.getPublished(), a.getPublished())); + + if (this.stories.isEmpty()) { + binding.storiesList.setVisibility(View.GONE); + } else { + binding.storiesList.setVisibility(View.VISIBLE); + } + this.storyAdapter.notifyDataSetChanged(); + } + + @Override + public void onStoriesUpdate() { + runOnUiThread(this::refresh); + } +} diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/StoryAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/StoryAdapter.java index 1369a43f1..c5434062c 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/StoryAdapter.java +++ b/src/main/java/eu/siacs/conversations/ui/adapter/StoryAdapter.java @@ -8,6 +8,9 @@ import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; + +import com.bumptech.glide.Glide; + import java.util.ArrayList; import java.util.List; import eu.siacs.conversations.R; @@ -66,6 +69,9 @@ public class StoryAdapter extends RecyclerView.Adapter { Intent intent = new Intent(activity, StoryViewActivity.class); @@ -101,11 +107,13 @@ public class StoryAdapter extends RecyclerView.Adapter + + + + diff --git a/src/main/res/drawable/stories_selected_white_24.xml b/src/main/res/drawable/stories_selected_white_24.xml new file mode 100644 index 000000000..2f463ebd2 --- /dev/null +++ b/src/main/res/drawable/stories_selected_white_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/stories_unselected_black_24.xml b/src/main/res/drawable/stories_unselected_black_24.xml new file mode 100644 index 000000000..90f017dde --- /dev/null +++ b/src/main/res/drawable/stories_unselected_black_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/stories_unselected_white_24.xml b/src/main/res/drawable/stories_unselected_white_24.xml new file mode 100644 index 000000000..1d85a1060 --- /dev/null +++ b/src/main/res/drawable/stories_unselected_white_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/layout/activity_stories.xml b/src/main/res/layout/activity_stories.xml new file mode 100644 index 000000000..c7ad24f01 --- /dev/null +++ b/src/main/res/layout/activity_stories.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/res/layout/fragment_conversations_overview.xml b/src/main/res/layout/fragment_conversations_overview.xml index 4717e233d..7fc368758 100644 --- a/src/main/res/layout/fragment_conversations_overview.xml +++ b/src/main/res/layout/fragment_conversations_overview.xml @@ -10,14 +10,6 @@ android:layout_height="match_parent" android:orientation="vertical"> - - - - - + android:padding="12dp" + android:minHeight="?android:attr/listPreferredItemHeight"> + android:maxLines="1" + android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1" /> - + + + diff --git a/src/main/res/menu/activity_conversations.xml b/src/main/res/menu/activity_conversations.xml index 88276f1be..58ace95d5 100644 --- a/src/main/res/menu/activity_conversations.xml +++ b/src/main/res/menu/activity_conversations.xml @@ -1,6 +1,7 @@ + + + diff --git a/src/main/res/menu/bottom_navigation_menu_accounts.xml b/src/main/res/menu/bottom_navigation_menu_accounts.xml index 1d49b367c..a561fbc25 100644 --- a/src/main/res/menu/bottom_navigation_menu_accounts.xml +++ b/src/main/res/menu/bottom_navigation_menu_accounts.xml @@ -8,6 +8,10 @@ android:id="@+id/contactslist" android:icon="?attr/ic_group_unselected" android:title="@string/contacts"/> + + + + + + + + + \ No newline at end of file diff --git a/src/main/res/menu/fragment_conversations_overview.xml b/src/main/res/menu/fragment_conversations_overview.xml index 4ff179d13..ecce783b1 100644 --- a/src/main/res/menu/fragment_conversations_overview.xml +++ b/src/main/res/menu/fragment_conversations_overview.xml @@ -8,13 +8,6 @@ android:title="@string/search_messages" android:visible="@bool/show_individual_search_options" app:showAsAction="ifRoom" /> - - + @drawable/accounts_selected_white_24 @drawable/outline_group_white_24 @drawable/ic_group_selected_white_24 + @drawable/stories_unselected_white_24 + @drawable/stories_selected_white_24 @drawable/bg_dark_blue diff --git a/src/main/res/values/themes.xml b/src/main/res/values/themes.xml index e04bda688..f8e685d3a 100644 --- a/src/main/res/values/themes.xml +++ b/src/main/res/values/themes.xml @@ -61,6 +61,8 @@ @drawable/accounts_selected_black_24 @drawable/outline_group_black_24dp @drawable/ic_group_selected_black_24 + @drawable/stories_unselected_black_24 + @drawable/stories_selected_black_24 @drawable/bg_light_blue diff --git a/src/monocleschat/java/eu/siacs/conversations/ui/ManageAccountActivity.java b/src/monocleschat/java/eu/siacs/conversations/ui/ManageAccountActivity.java index f02ca9114..5af38297b 100644 --- a/src/monocleschat/java/eu/siacs/conversations/ui/ManageAccountActivity.java +++ b/src/monocleschat/java/eu/siacs/conversations/ui/ManageAccountActivity.java @@ -232,6 +232,13 @@ public class ManageAccountActivity extends XmppActivity implements XmppConnectio overridePendingTransition(R.animator.fade_in, R.animator.fade_out); return true; } + case R.id.stories -> { + Intent i = new Intent(getApplicationContext(), StoriesActivity.class); + i.putExtra("show_nav_bar", true); + startActivity(i); + overridePendingTransition(R.animator.fade_in, R.animator.fade_out); + return true; + } case R.id.manageaccounts -> { return true; } diff --git a/src/monocleschat/res/values/attrs.xml b/src/monocleschat/res/values/attrs.xml index 14f911554..d6ef61952 100644 --- a/src/monocleschat/res/values/attrs.xml +++ b/src/monocleschat/res/values/attrs.xml @@ -10,6 +10,8 @@ + + -- 2.39.5 From 0166c89d836af393b753b2ae194debbd113940ca Mon Sep 17 00:00:00 2001 From: Arne Date: Wed, 31 Dec 2025 12:45:13 +0100 Subject: [PATCH 020/180] Set status and navigation bar colors in StoriesActivity --- src/main/java/eu/siacs/conversations/ui/StoriesActivity.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java b/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java index 01cb70504..f7a888440 100644 --- a/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java @@ -60,6 +60,7 @@ public class StoriesActivity extends XmppActivity implements XmppConnectionServi protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); binding = DataBindingUtil.setContentView(this, R.layout.activity_stories); + Activities.setStatusAndNavigationBarColors(this, findViewById(android.R.id.content)); setSupportActionBar(binding.toolbar); configureActionBar(getSupportActionBar()); binding.fabAddStory.setOnClickListener(v -> selectAccountToPublishStory()); -- 2.39.5 From 9a07321c378e926b0754a51ec62a1221be68e5c8 Mon Sep 17 00:00:00 2001 From: Arne Date: Wed, 31 Dec 2025 13:29:23 +0100 Subject: [PATCH 021/180] Show placeholder animation immediately on load Story UI thread --- .../conversations/ui/StoryViewActivity.java | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java b/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java index 9a896710a..f0c8231fc 100644 --- a/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java @@ -185,6 +185,14 @@ public class StoryViewActivity extends XmppActivity { final boolean useTor = mAccount != null && (xmppConnectionService.useTorToConnect() || mAccount.isOnion()); final boolean useI2p = mAccount != null && (xmppConnectionService.useI2PToConnect() || mAccount.isI2P()); + // Create and set the placeholder animation immediately on the UI thread + final CircularProgressDrawable circularProgressDrawable = new CircularProgressDrawable(this); + circularProgressDrawable.setStrokeWidth(10f); + circularProgressDrawable.setCenterRadius(50f); + circularProgressDrawable.setColorSchemeColors(0xFFFFFFFF); + circularProgressDrawable.start(); + imageView.setImageDrawable(circularProgressDrawable); + new Thread(() -> { File tempFile = null; try { @@ -204,12 +212,8 @@ public class StoryViewActivity extends XmppActivity { // storyMessage.setRelativeFilePath(finalTempFile.getAbsolutePath()); // TODO: Add image support later runOnUiThread(() -> { if (!isFinishing()) { - CircularProgressDrawable circularProgressDrawable = new CircularProgressDrawable(this); - circularProgressDrawable.setStrokeWidth(10f); - circularProgressDrawable.setCenterRadius(50f); - circularProgressDrawable.setColorSchemeColors(0xFFFFFFFF); - circularProgressDrawable.start(); - Glide.with(StoryViewActivity.this).load(finalTempFile).placeholder(circularProgressDrawable).into(imageView); + // Now load the actual image, replacing the spinner + Glide.with(StoryViewActivity.this).load(finalTempFile).into(imageView); } }); -- 2.39.5 From af57091048bb577567f051df5dd0cfdb63d06e38 Mon Sep 17 00:00:00 2001 From: Arne Date: Wed, 31 Dec 2025 14:28:23 +0100 Subject: [PATCH 022/180] Round story preview image corners --- src/main/res/layout/list_item_story.xml | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/main/res/layout/list_item_story.xml b/src/main/res/layout/list_item_story.xml index b7bec3432..36d7ef3e7 100644 --- a/src/main/res/layout/list_item_story.xml +++ b/src/main/res/layout/list_item_story.xml @@ -1,9 +1,10 @@ + android:minHeight="?android:attr/listPreferredItemHeight" + android:padding="12dp"> - + app:cardCornerRadius="8dp" + app:cardElevation="0dp"> + + + -- 2.39.5 From 2623d8e5744db4f28582c2ef2440941f5f1649c8 Mon Sep 17 00:00:00 2001 From: Arne Date: Wed, 31 Dec 2025 14:29:15 +0100 Subject: [PATCH 023/180] Filter stories to only show those from the last 24 hours --- .../eu/siacs/conversations/generator/IqGenerator.java | 10 ++++++++-- .../conversations/services/XmppConnectionService.java | 5 +++-- .../eu/siacs/conversations/ui/StoriesActivity.java | 6 ++++-- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/generator/IqGenerator.java b/src/main/java/eu/siacs/conversations/generator/IqGenerator.java index b1703d352..00eecc065 100644 --- a/src/main/java/eu/siacs/conversations/generator/IqGenerator.java +++ b/src/main/java/eu/siacs/conversations/generator/IqGenerator.java @@ -727,8 +727,14 @@ public class IqGenerator extends AbstractGenerator { } public Iq createStoriesNode() { - final Data data = Data.create(null, defaultStoriesConfiguration()); - return publishPubsubConfiguration(null, Namespace.PUBSUB_STORIES, data); + final Iq iq = new Iq(Iq.Type.SET); + final Element pubsub = iq.addChild("pubsub", Namespace.PUBSUB); + pubsub.addChild("create").setAttribute("node", Namespace.PUBSUB_STORIES); + final Element configure = pubsub.addChild("configure"); + // Correctly use the 'pubsub#node_config' namespace + final Data data = Data.create("http://jabber.org/protocol/pubsub#node_config", defaultStoriesConfiguration()); + configure.addChild(data); + return iq; } public Iq requestPubsubConfiguration(Jid jid, String node) { diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java index e7015efdc..88f2e2763 100644 --- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +++ b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java @@ -7771,7 +7771,8 @@ public class XmppConnectionService extends Service { } return; } - final Iq packet = getIqGenerator().publishStory(url, type, title, null); + final Bundle options = retry ? IqGenerator.defaultStoriesConfiguration() : null; + final Iq packet = getIqGenerator().publishStory(url, type, title, options); sendIqPacket(account, packet, response -> { if (response.getType() == Iq.Type.RESULT) { if (callback != null) { @@ -7780,9 +7781,9 @@ public class XmppConnectionService extends Service { } else if (retry && PublishOptions.preconditionNotMet(response)) { Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": stories node does not exist. creating it"); final Iq createRequest = getIqGenerator().createStoriesNode(); - createRequest.setTo(account.getJid().asBareJid()); sendIqPacket(account, createRequest, createResponse -> { if (createResponse.getType() == Iq.Type.RESULT) { + // After successfully creating the node, retry publishing without the config options publishStory(account, url, type, title, false, callback); } else { if (callback != null) { diff --git a/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java b/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java index f7a888440..18e180ada 100644 --- a/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java @@ -306,8 +306,10 @@ public class StoriesActivity extends XmppActivity implements XmppConnectionServi return; } this.stories.clear(); + long twentyFourHoursAgo = System.currentTimeMillis() - 86400000; this.stories.addAll( this.xmppConnectionService.getStories().stream() + .filter(s -> s.getPublished() >= twentyFourHoursAgo) .collect(Collectors.toMap( story -> story.getContact().asBareJid(), story -> story, @@ -315,14 +317,14 @@ public class StoriesActivity extends XmppActivity implements XmppConnectionServi )) .values() ); - Collections.sort(this.stories, (a,b) -> Long.compare(b.getPublished(), a.getPublished())); + Collections.sort(this.stories, (a, b) -> Long.compare(b.getPublished(), a.getPublished())); if (this.stories.isEmpty()) { binding.storiesList.setVisibility(View.GONE); } else { binding.storiesList.setVisibility(View.VISIBLE); } - this.storyAdapter.notifyDataSetChanged(); + storyAdapter.notifyDataSetChanged(); } @Override -- 2.39.5 From ba83a3e5aa9a4cf9cd789ad1a866b2ae07940d44 Mon Sep 17 00:00:00 2001 From: Arne Date: Wed, 31 Dec 2025 14:42:13 +0100 Subject: [PATCH 024/180] Hide stories and stories FAB if no account is online --- .../eu/siacs/conversations/ui/StoriesActivity.java | 11 ++++++++++- src/main/res/layout/activity_stories.xml | 8 ++++++++ src/main/res/values/strings.xml | 1 + 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java b/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java index 18e180ada..4892cfffd 100644 --- a/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java @@ -227,7 +227,7 @@ public class StoriesActivity extends XmppActivity implements XmppConnectionServi } private void selectAccountToPublishStory() { - final List accounts = xmppConnectionService.getAccounts().stream().filter(Account::isEnabled).collect(Collectors.toList()); + final List accounts = xmppConnectionService.getAccounts().stream().filter(account -> account.getStatus() == Account.State.ONLINE).collect(Collectors.toList()); if (accounts.isEmpty()) { Toast.makeText(this, R.string.no_active_account, Toast.LENGTH_SHORT).show(); } else if (accounts.size() == 1) { @@ -305,6 +305,15 @@ public class StoriesActivity extends XmppActivity implements XmppConnectionServi if (xmppConnectionService == null) { return; } + if (xmppConnectionService.getAccounts().stream().noneMatch(account -> account.getStatus() == Account.State.ONLINE)) { + binding.storiesList.setVisibility(View.GONE); + binding.fabAddStory.setVisibility(View.GONE); + binding.placeholder.setVisibility(View.VISIBLE); + binding.placeholder.setText(R.string.no_active_account_to_show_stories); + return; + } + binding.placeholder.setVisibility(View.GONE); + binding.fabAddStory.setVisibility(View.VISIBLE); this.stories.clear(); long twentyFourHoursAgo = System.currentTimeMillis() - 86400000; this.stories.addAll( diff --git a/src/main/res/layout/activity_stories.xml b/src/main/res/layout/activity_stories.xml index c7ad24f01..f33c2bdd6 100644 --- a/src/main/res/layout/activity_stories.xml +++ b/src/main/res/layout/activity_stories.xml @@ -38,6 +38,14 @@ android:layout_width="match_parent" android:layout_height="match_parent" /> + + Stories Add story Reply to story: + No active account to show or publish stories \ No newline at end of file -- 2.39.5 From 37d817e2f82fd42c3ec8b9eef72366982e61c8b5 Mon Sep 17 00:00:00 2001 From: Arne Date: Wed, 31 Dec 2025 15:20:43 +0100 Subject: [PATCH 025/180] Do not finish StoryViewActivity on invalid URL --- src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java b/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java index f0c8231fc..59f3f53fa 100644 --- a/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java @@ -171,7 +171,6 @@ public class StoryViewActivity extends XmppActivity { httpUrl = HttpUrl.get(url); } catch (IllegalArgumentException e) { Toast.makeText(this, "Invalid URL", Toast.LENGTH_SHORT).show(); - finish(); return; } -- 2.39.5 From be5aa295b0b39ab41c5c103bb84fe00c198e1368 Mon Sep 17 00:00:00 2001 From: Arne Date: Wed, 31 Dec 2025 15:48:14 +0100 Subject: [PATCH 026/180] Fix story publishing and display relative time of a story --- .../services/XmppConnectionService.java | 11 ++- .../ui/adapter/StoryAdapter.java | 81 ++++++++++++++----- src/main/res/layout/list_item_story.xml | 29 +++++-- 3 files changed, 92 insertions(+), 29 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java index 88f2e2763..fa820d8a2 100644 --- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +++ b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java @@ -7771,8 +7771,8 @@ public class XmppConnectionService extends Service { } return; } - final Bundle options = retry ? IqGenerator.defaultStoriesConfiguration() : null; - final Iq packet = getIqGenerator().publishStory(url, type, title, options); + // This is the corrected publish request. It sends NO configuration options. + final Iq packet = getIqGenerator().publishStory(url, type, title, null); sendIqPacket(account, packet, response -> { if (response.getType() == Iq.Type.RESULT) { if (callback != null) { @@ -7780,18 +7780,22 @@ public class XmppConnectionService extends Service { } } else if (retry && PublishOptions.preconditionNotMet(response)) { Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": stories node does not exist. creating it"); + // The node does not exist. Now we create it with the correct configuration. final Iq createRequest = getIqGenerator().createStoriesNode(); + createRequest.setTo(account.getJid().asBareJid()); sendIqPacket(account, createRequest, createResponse -> { if (createResponse.getType() == Iq.Type.RESULT) { - // After successfully creating the node, retry publishing without the config options + // Node created. Now, retry publishing the story ONE more time, without the retry/create logic. publishStory(account, url, type, title, false, callback); } else { + Log.e(Config.LOGTAG, "Failed to create stories node: " + createResponse); if (callback != null) { callback.error(R.string.error_publish_avatar_server_reject, null); } } }); } else { + Log.e(Config.LOGTAG, "Failed to publish story: " + response); if (callback != null) { callback.error(R.string.error_publish_avatar_server_reject, null); } @@ -7845,7 +7849,6 @@ public class XmppConnectionService extends Service { }); }; FILE_ATTACHMENT_EXECUTOR.execute(runnable); - deleteMessage(message); } private final List stories = new java.util.concurrent.CopyOnWriteArrayList<>(); diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/StoryAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/StoryAdapter.java index c5434062c..3d7806730 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/StoryAdapter.java +++ b/src/main/java/eu/siacs/conversations/ui/adapter/StoryAdapter.java @@ -1,6 +1,7 @@ package eu.siacs.conversations.ui.adapter; import android.content.Intent; +import android.text.format.DateUtils; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -8,6 +9,8 @@ import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; +import android.os.Handler; +import androidx.recyclerview.widget.LinearLayoutManager; import com.bumptech.glide.Glide; @@ -26,9 +29,30 @@ public class StoryAdapter extends RecyclerView.Adapter stories; + private final Handler handler = new Handler(); + private final Runnable refreshRunnable; + private RecyclerView recyclerView; + public StoryAdapter(XmppActivity activity, List stories) { this.activity = activity; this.stories = stories; + this.refreshRunnable = new Runnable() { + @Override + public void run() { + if (recyclerView != null) { + // This is a more efficient way to refresh just the visible items + final LinearLayoutManager layoutManager = (LinearLayoutManager) recyclerView.getLayoutManager(); + if (layoutManager != null) { + final int first = layoutManager.findFirstVisibleItemPosition(); + final int last = layoutManager.findLastVisibleItemPosition(); + if (first != RecyclerView.NO_POSITION) { + notifyItemRangeChanged(first, (last - first) + 1, "payload_time"); + } + } + } + handler.postDelayed(this, 60000); // Run again in 1 minute + } + }; } @NonNull @@ -38,30 +62,35 @@ public class StoryAdapter extends RecyclerView.Adapter payloads) { + if (payloads.contains("payload_time")) { + final Story story = stories.get(position); + holder.storyTime.setText(DateUtils.getRelativeTimeSpanString(story.getPublished(), System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS)); + } else { + super.onBindViewHolder(holder, position, payloads); + } + } + @Override public void onBindViewHolder(@NonNull StoryViewHolder holder, int position) { final Story story = stories.get(position); final Jid jid = story.getContact(); Contact contact = null; Account storyAccount = null; - - // Check if the story author is one of our own accounts - storyAccount = activity.xmppConnectionService.findAccountByJid(jid); - - if (storyAccount != null) { - // It's our own story - contact = storyAccount.getSelfContact(); - } else { - // It's from someone else. Find which of our accounts knows them. - for (Account account : activity.xmppConnectionService.getAccounts()) { - contact = account.getRoster().getContact(jid); - if (contact != null) { - storyAccount = account; // The account that has this contact in its roster - break; - } + for (Account account : activity.xmppConnectionService.getAccounts()) { + contact = account.getRoster().getContact(jid); + if (contact != null) { + storyAccount = account; + break; + } + } + if (contact == null) { + storyAccount = activity.xmppConnectionService.findAccountByJid(jid); + if (storyAccount != null) { + contact = storyAccount.getSelfContact(); } } - if (contact != null) { holder.storyTitle.setText(contact.getDisplayName()); holder.storyImage.setImageDrawable(activity.xmppConnectionService.getAvatarService().get(contact, activity.getResources().getDimensionPixelSize(R.dimen.avatar_story_size))); @@ -70,6 +99,8 @@ public class StoryAdapter extends RecyclerView.Adapter urls = new ArrayList<>(); ArrayList titles = new ArrayList<>(); ArrayList storyIds = new ArrayList<>(); - - // This is the corrected logic: Get the FULL list from the service for (Story s : activity.xmppConnectionService.getStories()) { if (s.getContact().asBareJid().equals(story.getContact().asBareJid())) { urls.add(s.getUrl()); @@ -107,13 +136,29 @@ public class StoryAdapter extends RecyclerView.Adapter + android:padding="12dp" + android:minHeight="?android:attr/listPreferredItemHeight"> - + android:orientation="vertical"> + + + + + + Date: Wed, 31 Dec 2025 16:22:16 +0100 Subject: [PATCH 027/180] Show new stories badge in bottom navigation --- .../eu/siacs/conversations/ui/ConversationsActivity.java | 6 ++++++ .../siacs/conversations/ui/StartConversationActivity.java | 6 ++++++ .../java/eu/siacs/conversations/ui/StoriesActivity.java | 7 +++++++ .../eu/siacs/conversations/ui/ManageAccountActivity.java | 6 ++++++ 4 files changed, 25 insertions(+) diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java b/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java index bed8e59b0..51031fcbf 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java @@ -253,6 +253,12 @@ public class ConversationsActivity extends XmppActivity bottomBadge.setVisible(unreadCount > 0); bottomBadge.setHorizontalOffset(20); + // Show badge for new stories in bottom nav + long lastRead = getPreferences().getLong("last_read_story_timestamp", 0); + boolean hasNewStories = xmppConnectionService.getStories().stream().anyMatch(s -> s.getPublished() > lastRead); + var storiesBadge = bottomnav.getOrCreateBadge(R.id.stories); + storiesBadge.setVisible(hasNewStories); + final var chatRequestsPref = xmppConnectionService.getStringPreference("chat_requests", R.string.default_chat_requests); final var accountUnreads = new HashMap(); binding.drawer.apply(dr -> { diff --git a/src/main/java/eu/siacs/conversations/ui/StartConversationActivity.java b/src/main/java/eu/siacs/conversations/ui/StartConversationActivity.java index 350db30f8..4721eedb1 100644 --- a/src/main/java/eu/siacs/conversations/ui/StartConversationActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/StartConversationActivity.java @@ -1217,6 +1217,12 @@ public class StartConversationActivity extends XmppActivity bottomBadge.setNumber(unreadCount); bottomBadge.setVisible(unreadCount > 0); bottomBadge.setHorizontalOffset(20); + + // Show badge for new stories in bottom nav + long lastRead = getPreferences().getLong("last_read_story_timestamp", 0); + boolean hasNewStories = xmppConnectionService.getStories().stream().anyMatch(s -> s.getPublished() > lastRead); + var storiesBadge = bottomnav.getOrCreateBadge(R.id.stories); + storiesBadge.setVisible(hasNewStories); } @Override diff --git a/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java b/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java index 4892cfffd..7623502cd 100644 --- a/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java @@ -114,6 +114,7 @@ public class StoriesActivity extends XmppActivity implements XmppConnectionServi } if (xmppConnectionService != null) { xmppConnectionService.setOnStoriesUpdateListener(this); + getPreferences().edit().putLong("last_read_story_timestamp", System.currentTimeMillis()).apply(); refresh(); } } @@ -293,6 +294,12 @@ public class StoriesActivity extends XmppActivity implements XmppConnectionServi bottomBadge.setVisible(unreadCount > 0); bottomBadge.setHorizontalOffset(20); + // Show badge for new stories in bottom nav + long lastRead = getPreferences().getLong("last_read_story_timestamp", 0); + boolean hasNewStories = xmppConnectionService.getStories().stream().anyMatch(s -> s.getPublished() > lastRead); + var storiesBadge = bottomnav.getOrCreateBadge(R.id.stories); + storiesBadge.setVisible(hasNewStories); + boolean showNavBar = bottomnav.getVisibility() == VISIBLE; if (actionBar != null) { actionBar.setHomeButtonEnabled(!showNavBar); diff --git a/src/monocleschat/java/eu/siacs/conversations/ui/ManageAccountActivity.java b/src/monocleschat/java/eu/siacs/conversations/ui/ManageAccountActivity.java index 5af38297b..62f4ff3a7 100644 --- a/src/monocleschat/java/eu/siacs/conversations/ui/ManageAccountActivity.java +++ b/src/monocleschat/java/eu/siacs/conversations/ui/ManageAccountActivity.java @@ -95,6 +95,12 @@ public class ManageAccountActivity extends XmppActivity implements XmppConnectio bottomBadge.setVisible(unreadCount > 0); bottomBadge.setHorizontalOffset(20); + // Show badge for new stories in bottom nav + long lastRead = getPreferences().getLong("last_read_story_timestamp", 0); + boolean hasNewStories = xmppConnectionService.getStories().stream().anyMatch(s -> s.getPublished() > lastRead); + var storiesBadge = bottomnav.getOrCreateBadge(R.id.stories); + storiesBadge.setVisible(hasNewStories); + boolean showNavBar = bottomnav.getVisibility() == VISIBLE; if (actionBar != null) { actionBar.setHomeButtonEnabled(!this.accountList.isEmpty() && !showNavBar); -- 2.39.5 From 36fd0e70cd099f3d58656cfc0f248e8b2dad95cc Mon Sep 17 00:00:00 2001 From: Arne Date: Wed, 31 Dec 2025 16:56:07 +0100 Subject: [PATCH 028/180] Show delete button in StoryViewActivity only if the correct account is online --- .../java/eu/siacs/conversations/ui/StoryViewActivity.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java b/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java index 59f3f53fa..87c7a5300 100644 --- a/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java @@ -93,8 +93,12 @@ public class StoryViewActivity extends XmppActivity { public boolean onCreateOptionsMenu(Menu menu) { getMenuInflater().inflate(R.menu.activity_story_view, menu); MenuItem deleteButton = menu.findItem(R.id.action_delete_story); - if (mAccount != null && contact != null && mAccount.getJid().asBareJid().equals(contact)) { - deleteButton.setVisible(true); + if (contact != null && xmppConnectionService != null) { + final Account storyOwner = xmppConnectionService.findAccountByJid(contact); + if (storyOwner != null && storyOwner.isOnlineAndConnected()) { + deleteButton.setVisible(true); + this.mAccount = storyOwner; + } } return true; } -- 2.39.5 From ccef12e552317ee80b61fd4ef211ed60152fdcf3 Mon Sep 17 00:00:00 2001 From: Arne Date: Wed, 31 Dec 2025 17:38:40 +0100 Subject: [PATCH 029/180] Handle story retractions --- .../conversations/parser/MessageParser.java | 21 +++++++++++++------ .../services/XmppConnectionService.java | 19 +++++++++++++++++ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/parser/MessageParser.java b/src/main/java/eu/siacs/conversations/parser/MessageParser.java index 1b21e46cb..250956d7a 100644 --- a/src/main/java/eu/siacs/conversations/parser/MessageParser.java +++ b/src/main/java/eu/siacs/conversations/parser/MessageParser.java @@ -466,12 +466,21 @@ public class MessageParser extends AbstractParser final Element item = items.findChild("item"); mXmppConnectionService.processMdsItem(account, item); } else if (Namespace.PUBSUB_STORIES.equals(node)) { - if (items != null) { - for (Element item : items.getChildren()) { - if ("item".equals(item.getName())) { - final Story story = Story.fromElement(item, from); - if (story != null) { - mXmppConnectionService.onStoryReceived(story); + final Element retract = items.findChild("retract"); + if (retract != null) { + final String id = retract.getAttribute("id"); + if (id != null) { + mXmppConnectionService.onStoryRetracted(id); + } + } else { + final List children = items.getChildren(); + if (children != null) { + for (Element item : children) { + if ("item".equals(item.getName())) { + final Story story = Story.fromElement(item, from); + if (story != null) { + mXmppConnectionService.onStoryReceived(story); + } } } } diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java index fa820d8a2..c0bf104a2 100644 --- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +++ b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java @@ -122,6 +122,7 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import eu.siacs.conversations.Conversations; +import eu.siacs.conversations.entities.Story; import eu.siacs.conversations.xmpp.jid.OtrJidHelper; import io.ipfs.cid.Cid; @@ -7940,4 +7941,22 @@ public class XmppConnectionService extends Service { } }); } + + public void onStoryRetracted(String storyId) { + if (storyId == null) { + return; + } + Story storyToRemove = null; + for (final Story story : this.stories) { + if (story.getUuid().equals(storyId)) { + storyToRemove = story; + break; + } + } + if (storyToRemove != null) { + this.stories.remove(storyToRemove); + updateStoriesUi(); + Log.d(Config.LOGTAG, "Retracted story with id: " + storyId); + } + } } -- 2.39.5 From fbf61765c22283fb69bccc3b5e80e26d65e7def3 Mon Sep 17 00:00:00 2001 From: Arne Date: Wed, 31 Dec 2025 18:22:10 +0100 Subject: [PATCH 030/180] Prioritize real avatars over generated ones in stories --- .../ui/adapter/StoryAdapter.java | 36 ++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/StoryAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/StoryAdapter.java index 3d7806730..245608329 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/StoryAdapter.java +++ b/src/main/java/eu/siacs/conversations/ui/adapter/StoryAdapter.java @@ -1,6 +1,7 @@ package eu.siacs.conversations.ui.adapter; import android.content.Intent; +import android.graphics.drawable.Drawable; import android.text.format.DateUtils; import android.view.LayoutInflater; import android.view.View; @@ -76,24 +77,43 @@ public class StoryAdapter extends RecyclerView.Adapter Date: Wed, 31 Dec 2025 20:31:11 +0100 Subject: [PATCH 031/180] Show contact name and progress in StoryViewActivity --- .../conversations/ui/StoryViewActivity.java | 32 ++++++++++++++++++- src/main/res/layout/activity_story_view.xml | 28 ++++++++++++---- src/main/res/values/strings.xml | 1 + 3 files changed, 53 insertions(+), 8 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java b/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java index 87c7a5300..a57d9dd65 100644 --- a/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java @@ -23,6 +23,7 @@ import java.util.ArrayList; import eu.siacs.conversations.Config; import eu.siacs.conversations.R; import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.entities.Contact; import eu.siacs.conversations.entities.Conversation; import eu.siacs.conversations.entities.Message; import eu.siacs.conversations.http.HttpConnectionManager; @@ -39,6 +40,7 @@ public class StoryViewActivity extends XmppActivity { private ImageView imageView; private TextView titleView; + private TextView progressView; private ArrayList urls; private ArrayList titles; @@ -62,6 +64,7 @@ public class StoryViewActivity extends XmppActivity { imageView = findViewById(R.id.story_image_view); titleView = findViewById(R.id.story_title_view); + progressView = findViewById(R.id.story_progress_view); urls = getIntent().getStringArrayListExtra(EXTRA_URLS); titles = getIntent().getStringArrayListExtra(EXTRA_TITLES); @@ -167,8 +170,35 @@ public class StoryViewActivity extends XmppActivity { } titleView.setText(titles.get(currentIndex)); if (getSupportActionBar() != null) { - getSupportActionBar().setTitle(titles.get(currentIndex)); + String displayName = null; + if (contact != null && xmppConnectionService != null) { + // Prioritize finding the contact in an online account + for (Account account : xmppConnectionService.getAccounts()) { + if (account.getStatus() == Account.State.ONLINE) { + final Contact c = account.getRoster().getContact(contact); + if (c != null) { + displayName = c.getDisplayName(); + break; + } + } + } + // If no online account has the contact, fall back to any account + if (displayName == null) { + for (Account account : xmppConnectionService.getAccounts()) { + final Contact c = account.getRoster().getContact(contact); + if (c != null) { + displayName = c.getDisplayName(); + break; + } + } + } + } + if (displayName == null && contact != null) { + displayName = contact.asBareJid().toString(); + } + getSupportActionBar().setTitle(displayName); } + progressView.setText((currentIndex + 1) + " " + getString(R.string.of) + " " + urls.size()); final String url = urls.get(currentIndex); final HttpUrl httpUrl; try { diff --git a/src/main/res/layout/activity_story_view.xml b/src/main/res/layout/activity_story_view.xml index 00f27d629..168c575f9 100644 --- a/src/main/res/layout/activity_story_view.xml +++ b/src/main/res/layout/activity_story_view.xml @@ -20,16 +20,30 @@ android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar" app:popupTheme="@style/ThemeOverlay.AppCompat.Light" /> - + android:orientation="vertical" + android:padding="16dp"> + + + + + diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index 1b81c9fa1..aa0d687c4 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -1591,4 +1591,5 @@ Add story Reply to story: No active account to show or publish stories + of \ No newline at end of file -- 2.39.5 From c8d25aa9bd74ab36806563af1ca9e948c76b24e9 Mon Sep 17 00:00:00 2001 From: Arne Date: Wed, 31 Dec 2025 20:39:50 +0100 Subject: [PATCH 032/180] Prioritize accounts with better avatars and online status for stories fixes adding contacts to other accounts --- .../ui/adapter/StoryAdapter.java | 46 ++++++++++--------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/StoryAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/StoryAdapter.java index 245608329..4c045b8f5 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/StoryAdapter.java +++ b/src/main/java/eu/siacs/conversations/ui/adapter/StoryAdapter.java @@ -77,30 +77,37 @@ public class StoryAdapter extends RecyclerView.Adapter contacts = activity.xmppConnectionService.findContacts(jid, null); + if (!contacts.isEmpty()) { + Contact bestContact = null; + int bestScore = -1; + for (Contact c : contacts) { + int score = 0; + if (c.getAccount().getStatus() == Account.State.ONLINE) { + score += 2; } - if (contact == null) { - contact = c; - storyAccount = account; - avatar = d; + Drawable d = activity.xmppConnectionService.getAvatarService().get(c, avatarSize); + if (!(d instanceof eu.siacs.conversations.services.AvatarService.TextDrawable)) { + score += 1; + } + if (score > bestScore) { + bestScore = score; + bestContact = c; } } + contact = bestContact; } - if (contact == null) { + if (contact != null) { + storyAccount = contact.getAccount(); + avatar = activity.xmppConnectionService.getAvatarService().get(contact, avatarSize); + } else if (activity.xmppConnectionService.findAccountByJid(jid) != null) { storyAccount = activity.xmppConnectionService.findAccountByJid(jid); if (storyAccount != null) { contact = storyAccount.getSelfContact(); @@ -108,12 +115,9 @@ public class StoryAdapter extends RecyclerView.Adapter Date: Thu, 1 Jan 2026 01:03:23 +0100 Subject: [PATCH 033/180] Use AvatarWorkerTask to load avatars in StoryAdapter and improve StoryViewActivity actionbar --- .../conversations/ui/StoryViewActivity.java | 47 ++++++++++++++++--- .../ui/adapter/StoryAdapter.java | 8 ++-- src/main/res/layout/activity_story_view.xml | 44 +++++++++++++++-- 3 files changed, 85 insertions(+), 14 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java b/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java index a57d9dd65..89bd823a2 100644 --- a/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java @@ -1,9 +1,13 @@ package eu.siacs.conversations.ui; +import android.graphics.drawable.Drawable; import android.os.Bundle; +import android.text.format.DateUtils; import android.util.Log; +import android.util.TypedValue; import android.view.Menu; import android.view.MenuItem; +import android.view.View; import android.widget.ImageView; import android.widget.TextView; import android.widget.Toast; @@ -27,6 +31,8 @@ import eu.siacs.conversations.entities.Contact; import eu.siacs.conversations.entities.Conversation; import eu.siacs.conversations.entities.Message; import eu.siacs.conversations.http.HttpConnectionManager; +import eu.siacs.conversations.ui.util.AvatarWorkerTask; +import eu.siacs.conversations.ui.widget.AvatarView; import eu.siacs.conversations.xmpp.Jid; import okhttp3.HttpUrl; @@ -60,6 +66,7 @@ public class StoryViewActivity extends XmppActivity { setSupportActionBar(toolbar); if (getSupportActionBar() != null) { getSupportActionBar().setDisplayHomeAsUpEnabled(true); + getSupportActionBar().setDisplayShowTitleEnabled(false); } imageView = findViewById(R.id.story_image_view); @@ -170,33 +177,61 @@ public class StoryViewActivity extends XmppActivity { } titleView.setText(titles.get(currentIndex)); if (getSupportActionBar() != null) { - String displayName = null; + Contact storyContact = null; if (contact != null && xmppConnectionService != null) { // Prioritize finding the contact in an online account for (Account account : xmppConnectionService.getAccounts()) { if (account.getStatus() == Account.State.ONLINE) { final Contact c = account.getRoster().getContact(contact); if (c != null) { - displayName = c.getDisplayName(); + storyContact = c; break; } } } // If no online account has the contact, fall back to any account - if (displayName == null) { + if (storyContact == null) { for (Account account : xmppConnectionService.getAccounts()) { final Contact c = account.getRoster().getContact(contact); if (c != null) { - displayName = c.getDisplayName(); + storyContact = c; break; } } } } - if (displayName == null && contact != null) { + + String displayName; + AvatarView toolbarAvatar = findViewById(R.id.toolbar_avatar); + TextView toolbarTitle = findViewById(R.id.toolbar_title); + TextView toolbarSubtitle = findViewById(R.id.toolbar_subtitle); + if (storyContact != null) { + displayName = storyContact.getDisplayName(); + Conversation conversation = xmppConnectionService.findOrCreateConversation(mAccount, contact, false, false); + AvatarWorkerTask.loadAvatar(conversation, toolbarAvatar, R.dimen.muc_avatar_actionbar); + } else if (contact != null) { displayName = contact.asBareJid().toString(); + } else { + displayName = ""; + } + toolbarTitle.setText(displayName); + long publishedTimestamp = 0; + if (storyIds != null && currentIndex < storyIds.size()) { + final String currentStoryId = storyIds.get(currentIndex); + if (xmppConnectionService != null) { + for (eu.siacs.conversations.entities.Story story : xmppConnectionService.getStories()) { + if (story.getUuid().equals(currentStoryId)) { + publishedTimestamp = story.getPublished(); + break; + } + } + } + } + if (publishedTimestamp > 0) { + toolbarSubtitle.setText(DateUtils.getRelativeTimeSpanString(publishedTimestamp, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS)); + } else { + toolbarSubtitle.setText(""); } - getSupportActionBar().setTitle(displayName); } progressView.setText((currentIndex + 1) + " " + getString(R.string.of) + " " + urls.size()); final String url = urls.get(currentIndex); diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/StoryAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/StoryAdapter.java index 4c045b8f5..3c5949b69 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/StoryAdapter.java +++ b/src/main/java/eu/siacs/conversations/ui/adapter/StoryAdapter.java @@ -20,9 +20,11 @@ import java.util.List; import eu.siacs.conversations.R; import eu.siacs.conversations.entities.Account; import eu.siacs.conversations.entities.Contact; +import eu.siacs.conversations.entities.Conversation; import eu.siacs.conversations.entities.Story; import eu.siacs.conversations.ui.StoryViewActivity; import eu.siacs.conversations.ui.XmppActivity; +import eu.siacs.conversations.ui.util.AvatarWorkerTask; import eu.siacs.conversations.xmpp.Jid; public class StoryAdapter extends RecyclerView.Adapter { @@ -81,7 +83,6 @@ public class StoryAdapter extends RecyclerView.Adapter contacts = activity.xmppConnectionService.findContacts(jid, null); if (!contacts.isEmpty()) { @@ -106,18 +107,17 @@ public class StoryAdapter extends RecyclerView.Adapter - + app:popupTheme="@style/ThemeOverlay.AppCompat.Light"> + + + + + + + + + + + + + + + Date: Thu, 1 Jan 2026 01:35:06 +0100 Subject: [PATCH 034/180] Allow editing images before posting to stories --- .../conversations/ui/StoriesActivity.java | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java b/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java index 7623502cd..b8c54d2c9 100644 --- a/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java @@ -14,7 +14,6 @@ import android.graphics.Color; import android.net.Uri; import android.os.Bundle; import android.provider.MediaStore; -import android.util.Log; import android.view.Menu; import android.view.MenuItem; import android.view.View; @@ -24,7 +23,6 @@ import android.widget.Toast; import androidx.annotation.NonNull; import androidx.appcompat.app.ActionBar; import androidx.databinding.DataBindingUtil; -import androidx.preference.PreferenceManager; import androidx.recyclerview.widget.LinearLayoutManager; import com.google.android.material.bottomnavigation.BottomNavigationView; @@ -36,12 +34,12 @@ import java.util.List; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; -import eu.siacs.conversations.Config; import eu.siacs.conversations.R; import eu.siacs.conversations.databinding.ActivityStoriesBinding; import eu.siacs.conversations.entities.Account; import eu.siacs.conversations.entities.Story; import eu.siacs.conversations.services.XmppConnectionService; +import eu.siacs.conversations.medialib.activities.EditActivity; import eu.siacs.conversations.ui.adapter.StoryAdapter; import eu.siacs.conversations.ui.util.PendingItem; @@ -49,6 +47,7 @@ public class StoriesActivity extends XmppActivity implements XmppConnectionServi private static final int REQUEST_CHOOSE_STORY_IMAGE = 0x2b01; private static final int REQUEST_CAMERA_PERMISSION = 0x2b03; + private static final int REQUEST_EDIT_STORY_IMAGE = 0x2b04; private Account mSelectedAccount; private final PendingItem pendingTakePhotoUri = new PendingItem<>(); @@ -164,7 +163,8 @@ public class StoriesActivity extends XmppActivity implements XmppConnectionServi } @Override - public void onActivityResult(int requestCode, int resultCode, final Intent data) {super.onActivityResult(requestCode, resultCode, data); + public void onActivityResult(int requestCode, int resultCode, final Intent data) { + super.onActivityResult(requestCode, resultCode, data); if (resultCode == Activity.RESULT_OK) { if (requestCode == REQUEST_CHOOSE_STORY_IMAGE) { Uri uri; @@ -175,10 +175,22 @@ public class StoriesActivity extends XmppActivity implements XmppConnectionServi } else { uri = null; } + if (uri != null && mSelectedAccount != null) { + Intent intent = new Intent(this, EditActivity.class); + intent.setData(uri); + intent.putExtra(EditActivity.KEY_CHAT_NAME, mSelectedAccount.getDisplayName()); + startActivityForResult(intent, REQUEST_EDIT_STORY_IMAGE); + } + } else if (requestCode == REQUEST_EDIT_STORY_IMAGE) { + Uri uri = data != null ? (Uri) data.getParcelableExtra(EditActivity.KEY_EDITED_URI) : null; + if (uri == null && data != null) { + uri = data.getData(); + } if (uri != null && mSelectedAccount != null) { final String mimeType = getContentResolver().getType(uri); final EditText input = new EditText(this); input.setHint(R.string.title_optional); + Uri finalUri = uri; new MaterialAlertDialogBuilder(this) .setTitle(R.string.add_story_title) .setView(input) @@ -186,18 +198,18 @@ public class StoriesActivity extends XmppActivity implements XmppConnectionServi .setPositiveButton(R.string.publish, (dialog, which) -> { final String title = input.getText().toString(); Toast.makeText(this, R.string.uploading_story, Toast.LENGTH_SHORT).show(); - xmppConnectionService.uploadFileForUrl(mSelectedAccount, uri, mimeType, new UiCallback() { + xmppConnectionService.uploadFileForUrl(mSelectedAccount, finalUri, mimeType, new UiCallback() { @Override public void success(String url) { xmppConnectionService.publishStory(mSelectedAccount, url, mimeType, title, new UiCallback() { @Override public void success(Void aVoid) { - runOnUiThread(() -> Toast.makeText(xmppConnectionService, R.string.story_published, Toast.LENGTH_SHORT).show()); + runOnUiThread(() -> Toast.makeText(StoriesActivity.this, R.string.story_published, Toast.LENGTH_SHORT).show()); } @Override public void error(int errorCode, Void object) { - runOnUiThread(() -> Toast.makeText(xmppConnectionService, errorCode, Toast.LENGTH_SHORT).show()); + runOnUiThread(() -> Toast.makeText(StoriesActivity.this, errorCode, Toast.LENGTH_SHORT).show()); } @Override @@ -209,7 +221,7 @@ public class StoriesActivity extends XmppActivity implements XmppConnectionServi @Override public void error(int errorCode, String object) { - runOnUiThread(() -> Toast.makeText(xmppConnectionService, errorCode, Toast.LENGTH_SHORT).show()); + runOnUiThread(() -> Toast.makeText(StoriesActivity.this, errorCode, Toast.LENGTH_SHORT).show()); } @Override -- 2.39.5 From 76e005afc84c5de06930cd94c8a5e4760251d0f5 Mon Sep 17 00:00:00 2001 From: Arne Date: Thu, 1 Jan 2026 10:16:20 +0100 Subject: [PATCH 035/180] Improve and fix timestamp parsing --- .../siacs/conversations/entities/Story.java | 87 +++++++++++++++---- 1 file changed, 70 insertions(+), 17 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/entities/Story.java b/src/main/java/eu/siacs/conversations/entities/Story.java index 15f513a7d..1c7e5785f 100644 --- a/src/main/java/eu/siacs/conversations/entities/Story.java +++ b/src/main/java/eu/siacs/conversations/entities/Story.java @@ -3,7 +3,9 @@ package eu.siacs.conversations.entities; import static eu.siacs.conversations.parser.AbstractParser.parseTimestamp; import android.content.ContentValues; +import android.database.Cursor; +import java.text.ParseException; import java.util.ArrayList; import java.util.List; @@ -33,32 +35,79 @@ public class Story extends AbstractEntity { this.published = published; } + public static Story fromCursor(Cursor cursor) { + return new Story( + cursor.getString(cursor.getColumnIndex(UUID)), + Jid.of(cursor.getString(cursor.getColumnIndex(CONTACT))), + cursor.getString(cursor.getColumnIndex(URL)), + cursor.getString(cursor.getColumnIndex(TYPE)), + cursor.getString(cursor.getColumnIndex(TITLE)), + cursor.getLong(cursor.getColumnIndex(PUBLISHED)) + ); + } + public static Story fromElement(Element item, Jid contact) { - Element entry = item.findChild("entry", "http://www.w3.org/2005/Atom"); + return fromElement(item, contact, 0); + } + + public static Story fromElement(Element item, Jid contact, long fallbackTimestamp) { + final Element entry = item.findChild("entry", "http://www.w3.org/2005/Atom"); if (entry == null) { return null; } - Element published = entry.findChild("published"); - if (published == null) { - return null; - } + Element link = null; - for (Element child : entry.getChildren()) { - if ("link".equals(child.getName()) && "enclosure".equals(child.getAttribute("rel"))) { - link = child; - break; + final List children = entry.getChildren(); + if (children != null) { + for (Element child : children) { + if ("link".equals(child.getName()) && "enclosure".equals(child.getAttribute("rel"))) { + link = child; + break; + } } } + if (link == null) { return null; } + + long timestamp = 0; + + if (fallbackTimestamp > 0) { + timestamp = fallbackTimestamp; + } else { + Element published = entry.findChild("published", "http://www.w3.org/2005/Atom"); + String publishedContent = published == null ? null : published.getContent(); + if (publishedContent != null) { + try { + timestamp = parseTimestamp(publishedContent); + } catch (ParseException e) { + } + } + } + + if (timestamp == 0) { + Element updated = entry.findChild("updated", "http://www.w3.org/2005/Atom"); + String updatedContent = updated == null ? null : updated.getContent(); + if (updatedContent != null) { + try { + timestamp = parseTimestamp(updatedContent); + } catch (ParseException e) { + } + } + } + + if (timestamp == 0) { + timestamp = System.currentTimeMillis(); + } + return new Story( item.getAttribute("id"), contact, link.getAttribute("href"), link.getAttribute("type"), - entry.findChildContent("title"), - parseTimestamp(published) + entry.findChildContent("title", "http://www.w3.org/2005/Atom"), + timestamp ); } @@ -69,11 +118,15 @@ public class Story extends AbstractEntity { } Element items = pubsub.findChild("items"); if (items != null) { - for (Element item : items.getChildren()) { - if (item.getName().equals("item")) { - Story story = fromElement(item, contact); - if (story != null) { - stories.add(story); + final List children = items.getChildren(); + if (children != null) { + for (Element item : children) { + if (item.getName().equals("item")) { + long timestamp = parseTimestamp(item, 0L); + Story story = fromElement(item, contact, timestamp); + if (story != null) { + stories.add(story); + } } } } @@ -112,4 +165,4 @@ public class Story extends AbstractEntity { public long getPublished() { return published; } -} \ No newline at end of file +} -- 2.39.5 From abb6249551dcea4b8435518c2245a77480b4e6df Mon Sep 17 00:00:00 2001 From: Arne Date: Thu, 1 Jan 2026 17:58:28 +0100 Subject: [PATCH 036/180] Automatically retract stories older than 24 hours --- .../services/XmppConnectionService.java | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java index c0bf104a2..5d496feb0 100644 --- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +++ b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java @@ -291,6 +291,8 @@ public class XmppConnectionService extends Service { private final ScheduledExecutorService userTuneUpdateExecutor = Executors.newSingleThreadScheduledExecutor(); private ScheduledFuture pendingUserTuneUpdate; + + private final ScheduledExecutorService storyRetractionExecutor = Executors.newSingleThreadScheduledExecutor();//... private static final SerialSingleThreadExecutor VIDEO_COMPRESSION_EXECUTOR = new SerialSingleThreadExecutor("VideoCompression"); private final SerialSingleThreadExecutor mDatabaseWriterExecutor = @@ -1847,8 +1849,10 @@ public class XmppConnectionService extends Service { toggleForegroundService(); rescanStickers(); cleanupCache(); + internalPingExecutor.scheduleWithFixedDelay( this::manageAccountConnectionStatesInternal, 10, 10, TimeUnit.SECONDS); + storyRetractionExecutor.scheduleWithFixedDelay(this::retractOldStories, 1, 60, TimeUnit.MINUTES); final SharedPreferences sharedPreferences = androidx.preference.PreferenceManager.getDefaultSharedPreferences(this); sharedPreferences.registerOnSharedPreferenceChangeListener( @@ -1940,6 +1944,7 @@ public class XmppConnectionService extends Service { destroyed = false; fileObserver.stopWatching(); internalPingExecutor.shutdown(); + storyRetractionExecutor.shutdown(); super.onDestroy(); } @@ -7959,4 +7964,26 @@ public class XmppConnectionService extends Service { Log.d(Config.LOGTAG, "Retracted story with id: " + storyId); } } + + public void retractOldStories() { + final long twentyFourHoursAgo = System.currentTimeMillis() - 86400000; + final Map onlineAccounts = new HashMap<>(); + for (final Account account : getAccounts()) { + if (account.isOnlineAndConnected()) { + onlineAccounts.put(account.getJid().asBareJid(), account); + } + } + if (onlineAccounts.isEmpty()) { + return; + } + for (final Story story : this.stories) { + if (story.getPublished() < twentyFourHoursAgo) { + final Account owner = onlineAccounts.get(story.getContact()); + if (owner != null) { + Log.d(Config.LOGTAG, "Retracting old story from account: " + owner.getJid().asBareJid()); + retractStory(owner, story.getUuid(), null); + } + } + } + } } -- 2.39.5 From 0360e622cbf0bd19b69cb372d77c526bd22d90b6 Mon Sep 17 00:00:00 2001 From: Arne Date: Thu, 1 Jan 2026 19:30:01 +0100 Subject: [PATCH 037/180] Fix publishing stories with correct url --- .../conversations/ui/StoriesActivity.java | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java b/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java index b8c54d2c9..3765fafad 100644 --- a/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java @@ -173,24 +173,27 @@ public class StoriesActivity extends XmppActivity implements XmppConnectionServi } else if (pendingTakePhotoUri.peek() != null) { uri = pendingTakePhotoUri.pop(); } else { - uri = null; + return; //No image was selected } - if (uri != null && mSelectedAccount != null) { + if (mSelectedAccount != null) { Intent intent = new Intent(this, EditActivity.class); intent.setData(uri); intent.putExtra(EditActivity.KEY_CHAT_NAME, mSelectedAccount.getDisplayName()); startActivityForResult(intent, REQUEST_EDIT_STORY_IMAGE); } } else if (requestCode == REQUEST_EDIT_STORY_IMAGE) { - Uri uri = data != null ? (Uri) data.getParcelableExtra(EditActivity.KEY_EDITED_URI) : null; - if (uri == null && data != null) { - uri = data.getData(); - } + Uri uri = data != null ? data.getData() : null; if (uri != null && mSelectedAccount != null) { - final String mimeType = getContentResolver().getType(uri); + String mimeType = data.getType(); + if (mimeType == null) { + mimeType = getContentResolver().getType(uri); + } + if (mimeType == null) { + mimeType = "image/jpeg"; // Fallback for file URIs + } final EditText input = new EditText(this); input.setHint(R.string.title_optional); - Uri finalUri = uri; + String finalMimeType = mimeType; new MaterialAlertDialogBuilder(this) .setTitle(R.string.add_story_title) .setView(input) @@ -198,10 +201,10 @@ public class StoriesActivity extends XmppActivity implements XmppConnectionServi .setPositiveButton(R.string.publish, (dialog, which) -> { final String title = input.getText().toString(); Toast.makeText(this, R.string.uploading_story, Toast.LENGTH_SHORT).show(); - xmppConnectionService.uploadFileForUrl(mSelectedAccount, finalUri, mimeType, new UiCallback() { + xmppConnectionService.uploadFileForUrl(mSelectedAccount, uri, finalMimeType, new UiCallback() { @Override public void success(String url) { - xmppConnectionService.publishStory(mSelectedAccount, url, mimeType, title, new UiCallback() { + xmppConnectionService.publishStory(mSelectedAccount, url, finalMimeType, title, new UiCallback() { @Override public void success(Void aVoid) { runOnUiThread(() -> Toast.makeText(StoriesActivity.this, R.string.story_published, Toast.LENGTH_SHORT).show()); @@ -239,6 +242,7 @@ public class StoriesActivity extends XmppActivity implements XmppConnectionServi } } + private void selectAccountToPublishStory() { final List accounts = xmppConnectionService.getAccounts().stream().filter(account -> account.getStatus() == Account.State.ONLINE).collect(Collectors.toList()); if (accounts.isEmpty()) { -- 2.39.5 From eaea75ac2ee7aed1d10e3d2396a41ac1f44c9c28 Mon Sep 17 00:00:00 2001 From: Arne Date: Fri, 2 Jan 2026 00:26:17 +0100 Subject: [PATCH 038/180] Fix publishing edited story images --- .../eu/siacs/conversations/ui/StoriesActivity.java | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java b/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java index 3765fafad..8a825a731 100644 --- a/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java @@ -173,16 +173,19 @@ public class StoriesActivity extends XmppActivity implements XmppConnectionServi } else if (pendingTakePhotoUri.peek() != null) { uri = pendingTakePhotoUri.pop(); } else { - return; //No image was selected + uri = null; } - if (mSelectedAccount != null) { + if (uri != null && mSelectedAccount != null) { Intent intent = new Intent(this, EditActivity.class); intent.setData(uri); intent.putExtra(EditActivity.KEY_CHAT_NAME, mSelectedAccount.getDisplayName()); startActivityForResult(intent, REQUEST_EDIT_STORY_IMAGE); } } else if (requestCode == REQUEST_EDIT_STORY_IMAGE) { - Uri uri = data != null ? data.getData() : null; + Uri uri = data != null ? (Uri) data.getParcelableExtra(EditActivity.KEY_EDITED_URI) : null; + if (uri == null && data != null) { + uri = data.getData(); + } if (uri != null && mSelectedAccount != null) { String mimeType = data.getType(); if (mimeType == null) { @@ -193,6 +196,7 @@ public class StoriesActivity extends XmppActivity implements XmppConnectionServi } final EditText input = new EditText(this); input.setHint(R.string.title_optional); + Uri finalUri = uri; String finalMimeType = mimeType; new MaterialAlertDialogBuilder(this) .setTitle(R.string.add_story_title) @@ -201,7 +205,7 @@ public class StoriesActivity extends XmppActivity implements XmppConnectionServi .setPositiveButton(R.string.publish, (dialog, which) -> { final String title = input.getText().toString(); Toast.makeText(this, R.string.uploading_story, Toast.LENGTH_SHORT).show(); - xmppConnectionService.uploadFileForUrl(mSelectedAccount, uri, finalMimeType, new UiCallback() { + xmppConnectionService.uploadFileForUrl(mSelectedAccount, finalUri, finalMimeType, new UiCallback() { @Override public void success(String url) { xmppConnectionService.publishStory(mSelectedAccount, url, finalMimeType, title, new UiCallback() { @@ -242,7 +246,6 @@ public class StoriesActivity extends XmppActivity implements XmppConnectionServi } } - private void selectAccountToPublishStory() { final List accounts = xmppConnectionService.getAccounts().stream().filter(account -> account.getStatus() == Account.State.ONLINE).collect(Collectors.toList()); if (accounts.isEmpty()) { -- 2.39.5 From e2d85da88c1bc137559bbb14c032f5b31f793b55 Mon Sep 17 00:00:00 2001 From: Arne Date: Fri, 2 Jan 2026 02:59:14 +0100 Subject: [PATCH 039/180] Allow publishing video stories --- .../services/XmppConnectionService.java | 27 ++-- .../conversations/ui/StoriesActivity.java | 117 ++++++++++-------- .../conversations/ui/StoryViewActivity.java | 35 +++++- .../ui/adapter/StoryAdapter.java | 3 + src/main/res/layout/activity_story_view.xml | 7 ++ 5 files changed, 120 insertions(+), 69 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java index 5d496feb0..3433e1f48 100644 --- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +++ b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java @@ -7814,16 +7814,18 @@ public class XmppConnectionService extends Service { callback.error(R.string.no_active_account, null); return; } - // Create a dummy conversation and message final Conversation conversation = new Conversation(account.getDisplayName(), account, account.getJid().asBareJid(), Conversation.MODE_SINGLE); final Message message = new Message(conversation, "", Message.ENCRYPTION_NONE); + message.setStatus(Message.STATUS_DUMMY); + if (mimeType != null && mimeType.startsWith("image/")) { message.setType(Message.TYPE_IMAGE); + } else if (mimeType != null && mimeType.startsWith("video/")) { + message.setType(Message.TYPE_FILE); } else { message.setType(Message.TYPE_FILE); } - Runnable runnable = () -> { try { if (mimeType != null && mimeType.startsWith("image/")) { @@ -7831,26 +7833,27 @@ public class XmppConnectionService extends Service { } else { getFileBackend().copyFileToPrivateStorage(message, uri, mimeType); } - } catch (eu.siacs.conversations.persistance.FileBackend.ImageCompressionException e) { - Log.d(Config.LOGTAG, "unable to compress image for story. falling back to file transfer", e); - message.setType(Message.TYPE_FILE); + } catch (FileBackend.ImageCompressionException e) { try { getFileBackend().copyFileToPrivateStorage(message, uri, mimeType); - } catch (eu.siacs.conversations.persistance.FileBackend.FileCopyException ex) { + } catch (FileBackend.FileCopyException ex) { callback.error(ex.getResId(), null); return; } - } catch (final eu.siacs.conversations.persistance.FileBackend.FileCopyException e) { + } catch (final FileBackend.FileCopyException e) { callback.error(e.getResId(), null); return; } mHttpConnectionManager.createNewUploadConnection(message, false, () -> { - final String url = message.getFileParams().url.toString(); - if (url != null) { - callback.success(url); - } else { - callback.error(R.string.upload_failed_server_not_found, null); + try { + if (message.getFileParams() != null && message.getFileParams().url != null) { + callback.success(message.getFileParams().url.toString()); + } else { + callback.error(R.string.upload_failed_server_not_found, null); + } + } finally { + getFileBackend().deleteFile(message); } }); }; diff --git a/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java b/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java index 8a825a731..19c0d5810 100644 --- a/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java @@ -162,6 +162,55 @@ public class StoriesActivity extends XmppActivity implements XmppConnectionServi return super.onOptionsItemSelected(item); } + private void publish(Uri uri, String mimeType) { + if (uri != null && mSelectedAccount != null) { + final EditText input = new EditText(this); + input.setHint(R.string.title_optional); + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.add_story_title) + .setView(input) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.publish, (dialog, which) -> { + final String title = input.getText().toString(); + Toast.makeText(this, R.string.uploading_story, Toast.LENGTH_SHORT).show(); + xmppConnectionService.uploadFileForUrl(mSelectedAccount, uri, mimeType, new UiCallback() { + @Override + public void success(String url) { + xmppConnectionService.publishStory(mSelectedAccount, url, mimeType, title, new UiCallback() { + @Override + public void success(Void aVoid) { + runOnUiThread(() -> Toast.makeText(StoriesActivity.this, R.string.story_published, Toast.LENGTH_SHORT).show()); + } + + @Override + public void error(int errorCode, Void object) { + runOnUiThread(() -> Toast.makeText(StoriesActivity.this, errorCode, Toast.LENGTH_SHORT).show()); + } + + @Override + public void userInputRequired(PendingIntent pi, Void object) { + // not used + } + }); + } + + @Override + public void error(int errorCode, String object) { + runOnUiThread(() -> Toast.makeText(StoriesActivity.this, errorCode, Toast.LENGTH_SHORT).show()); + } + + @Override + public void userInputRequired(PendingIntent pi, String object) { + // not used + } + }); + }) + .create() + .show(); + } + } + + @Override public void onActivityResult(int requestCode, int resultCode, final Intent data) { super.onActivityResult(requestCode, resultCode, data); @@ -173,20 +222,24 @@ public class StoriesActivity extends XmppActivity implements XmppConnectionServi } else if (pendingTakePhotoUri.peek() != null) { uri = pendingTakePhotoUri.pop(); } else { - uri = null; + return; //No image was selected } - if (uri != null && mSelectedAccount != null) { - Intent intent = new Intent(this, EditActivity.class); - intent.setData(uri); - intent.putExtra(EditActivity.KEY_CHAT_NAME, mSelectedAccount.getDisplayName()); - startActivityForResult(intent, REQUEST_EDIT_STORY_IMAGE); + if (mSelectedAccount != null) { + String mimeType = getContentResolver().getType(uri); + if (mimeType != null && mimeType.startsWith("video/")) { + publish(uri, mimeType); + } else { + Intent intent = new Intent(this, EditActivity.class); + intent.setData(uri); + startActivityForResult(intent, REQUEST_EDIT_STORY_IMAGE); + } } } else if (requestCode == REQUEST_EDIT_STORY_IMAGE) { Uri uri = data != null ? (Uri) data.getParcelableExtra(EditActivity.KEY_EDITED_URI) : null; if (uri == null && data != null) { uri = data.getData(); } - if (uri != null && mSelectedAccount != null) { + if (uri != null) { String mimeType = data.getType(); if (mimeType == null) { mimeType = getContentResolver().getType(uri); @@ -194,51 +247,7 @@ public class StoriesActivity extends XmppActivity implements XmppConnectionServi if (mimeType == null) { mimeType = "image/jpeg"; // Fallback for file URIs } - final EditText input = new EditText(this); - input.setHint(R.string.title_optional); - Uri finalUri = uri; - String finalMimeType = mimeType; - new MaterialAlertDialogBuilder(this) - .setTitle(R.string.add_story_title) - .setView(input) - .setNegativeButton(R.string.cancel, null) - .setPositiveButton(R.string.publish, (dialog, which) -> { - final String title = input.getText().toString(); - Toast.makeText(this, R.string.uploading_story, Toast.LENGTH_SHORT).show(); - xmppConnectionService.uploadFileForUrl(mSelectedAccount, finalUri, finalMimeType, new UiCallback() { - @Override - public void success(String url) { - xmppConnectionService.publishStory(mSelectedAccount, url, finalMimeType, title, new UiCallback() { - @Override - public void success(Void aVoid) { - runOnUiThread(() -> Toast.makeText(StoriesActivity.this, R.string.story_published, Toast.LENGTH_SHORT).show()); - } - - @Override - public void error(int errorCode, Void object) { - runOnUiThread(() -> Toast.makeText(StoriesActivity.this, errorCode, Toast.LENGTH_SHORT).show()); - } - - @Override - public void userInputRequired(PendingIntent pi, Void object) { - // not used - } - }); - } - - @Override - public void error(int errorCode, String object) { - runOnUiThread(() -> Toast.makeText(StoriesActivity.this, errorCode, Toast.LENGTH_SHORT).show()); - } - - @Override - public void userInputRequired(PendingIntent pi, String object) { - // not used - } - }); - }) - .create() - .show(); + publish(uri, mimeType); } } } else { @@ -267,13 +276,15 @@ public class StoriesActivity extends XmppActivity implements XmppConnectionServi } } + private void openStoryImagePicker(Account account) { this.mSelectedAccount = account; if (checkSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) { requestPermissions(new String[]{Manifest.permission.CAMERA}, REQUEST_CAMERA_PERMISSION); } else { final Intent galleryIntent = new Intent(Intent.ACTION_GET_CONTENT); - galleryIntent.setType("image/*"); + galleryIntent.setType("image/*,video/*"); + galleryIntent.putExtra(Intent.EXTRA_MIME_TYPES, new String[]{"image/*", "video/*"}); final Intent cameraIntent = new Intent(android.provider.MediaStore.ACTION_IMAGE_CAPTURE); final Uri takePhotoUri = xmppConnectionService.getFileBackend().getTakePhotoUri(); diff --git a/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java b/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java index 89bd823a2..65b76798f 100644 --- a/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java @@ -1,6 +1,7 @@ package eu.siacs.conversations.ui; import android.graphics.drawable.Drawable; +import android.net.Uri; import android.os.Bundle; import android.text.format.DateUtils; import android.util.Log; @@ -11,6 +12,7 @@ import android.view.View; import android.widget.ImageView; import android.widget.TextView; import android.widget.Toast; +import android.widget.VideoView; import androidx.appcompat.widget.Toolbar; import androidx.swiperefreshlayout.widget.CircularProgressDrawable; @@ -43,14 +45,17 @@ public class StoryViewActivity extends XmppActivity { public static final String EXTRA_STORY_IDS = "story_ids"; public static final String EXTRA_ACCOUNT = "account"; public static final String EXTRA_CONTACT = "contact"; + public static final String EXTRA_MIME_TYPES = "story_mime_types"; private ImageView imageView; + private VideoView videoView; private TextView titleView; private TextView progressView; private ArrayList urls; private ArrayList titles; private ArrayList storyIds; + private ArrayList mimeTypes; private int currentIndex = 0; private Jid contact; private Account mAccount; @@ -70,21 +75,25 @@ public class StoryViewActivity extends XmppActivity { } imageView = findViewById(R.id.story_image_view); + videoView = findViewById(R.id.story_video_view); titleView = findViewById(R.id.story_title_view); progressView = findViewById(R.id.story_progress_view); urls = getIntent().getStringArrayListExtra(EXTRA_URLS); titles = getIntent().getStringArrayListExtra(EXTRA_TITLES); storyIds = getIntent().getStringArrayListExtra(EXTRA_STORY_IDS); + mimeTypes = getIntent().getStringArrayListExtra(EXTRA_MIME_TYPES); - imageView.setOnClickListener(v -> { + View.OnClickListener nextListener = v -> { currentIndex++; if (currentIndex < urls.size()) { loadStory(); } else { finish(); } - }); + }; + imageView.setOnClickListener(nextListener); + videoView.setOnClickListener(nextListener); try { contact = Jid.of(getIntent().getStringExtra(EXTRA_CONTACT)); @@ -279,12 +288,30 @@ public class StoryViewActivity extends XmppActivity { // Associate the downloaded file with our story message // storyMessage.setRelativeFilePath(finalTempFile.getAbsolutePath()); // TODO: Add image support later runOnUiThread(() -> { + circularProgressDrawable.stop(); if (!isFinishing()) { - // Now load the actual image, replacing the spinner - Glide.with(StoryViewActivity.this).load(finalTempFile).into(imageView); + String mimeType = (mimeTypes != null && currentIndex < mimeTypes.size()) ? mimeTypes.get(currentIndex) : null; + if (mimeType == null) { + mimeType = getContentResolver().getType(Uri.fromFile(finalTempFile)); + } + videoView.stopPlayback(); // Stop any previous video + if (mimeType != null && mimeType.startsWith("video/")) { + imageView.setVisibility(View.GONE); + videoView.setVisibility(View.VISIBLE); + videoView.setVideoURI(Uri.fromFile(finalTempFile)); + videoView.setOnPreparedListener(mp -> { + mp.setLooping(true); + videoView.start(); // Start playback here + }); + } else { + videoView.setVisibility(View.GONE); + imageView.setVisibility(View.VISIBLE); + Glide.with(StoryViewActivity.this).load(finalTempFile).into(imageView); + } } }); + } catch (IOException e) { Log.e(Config.LOGTAG, "Failed to download story image", e); if (tempFile != null) { diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/StoryAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/StoryAdapter.java index 3c5949b69..5c9713c89 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/StoryAdapter.java +++ b/src/main/java/eu/siacs/conversations/ui/adapter/StoryAdapter.java @@ -133,16 +133,19 @@ public class StoryAdapter extends RecyclerView.Adapter urls = new ArrayList<>(); ArrayList titles = new ArrayList<>(); ArrayList storyIds = new ArrayList<>(); + ArrayList mimeTypes = new ArrayList<>(); for (Story s : activity.xmppConnectionService.getStories()) { if (s.getContact().asBareJid().equals(story.getContact().asBareJid())) { urls.add(s.getUrl()); titles.add(s.getTitle()); storyIds.add(s.getUuid()); + mimeTypes.add(s.getType()); } } intent.putStringArrayListExtra(StoryViewActivity.EXTRA_URLS, urls); intent.putStringArrayListExtra(StoryViewActivity.EXTRA_TITLES, titles); intent.putStringArrayListExtra(StoryViewActivity.EXTRA_STORY_IDS, storyIds); + intent.putStringArrayListExtra(StoryViewActivity.EXTRA_MIME_TYPES, mimeTypes); intent.putExtra(StoryViewActivity.EXTRA_CONTACT, story.getContact().asBareJid().toString()); if (finalStoryAccount != null) { intent.putExtra(StoryViewActivity.EXTRA_ACCOUNT, finalStoryAccount.getUuid()); diff --git a/src/main/res/layout/activity_story_view.xml b/src/main/res/layout/activity_story_view.xml index 2deafd8c3..711257ce5 100644 --- a/src/main/res/layout/activity_story_view.xml +++ b/src/main/res/layout/activity_story_view.xml @@ -6,6 +6,13 @@ android:fitsSystemWindows="true" android:background="@android:color/black"> + + Date: Fri, 2 Jan 2026 03:05:27 +0100 Subject: [PATCH 040/180] Add video capture option to story picker --- .../java/eu/siacs/conversations/ui/StoriesActivity.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java b/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java index 19c0d5810..141017b5c 100644 --- a/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java @@ -277,8 +277,7 @@ public class StoriesActivity extends XmppActivity implements XmppConnectionServi } - private void openStoryImagePicker(Account account) { - this.mSelectedAccount = account; + private void openStoryImagePicker(Account account) { this.mSelectedAccount = account; if (checkSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) { requestPermissions(new String[]{Manifest.permission.CAMERA}, REQUEST_CAMERA_PERMISSION); } else { @@ -291,8 +290,10 @@ public class StoriesActivity extends XmppActivity implements XmppConnectionServi pendingTakePhotoUri.push(takePhotoUri); cameraIntent.putExtra(MediaStore.EXTRA_OUTPUT, takePhotoUri); + final Intent videoIntent = new Intent(android.provider.MediaStore.ACTION_VIDEO_CAPTURE); + final Intent chooserIntent = Intent.createChooser(galleryIntent, getString(R.string.perform_action_with)); - chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, new Intent[]{cameraIntent}); + chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, new Intent[]{cameraIntent, videoIntent}); try { startActivityForResult(chooserIntent, REQUEST_CHOOSE_STORY_IMAGE); -- 2.39.5 From b132f4aae26b4b61298dfed162516c4611471623 Mon Sep 17 00:00:00 2001 From: Arne Date: Fri, 2 Jan 2026 03:23:26 +0100 Subject: [PATCH 041/180] Implement caching for stories --- .../persistance/FileBackend.java | 22 ++++++ .../services/XmppConnectionService.java | 23 ++++++- .../conversations/ui/StoriesActivity.java | 3 +- .../conversations/ui/StoryViewActivity.java | 68 ++++++++----------- 4 files changed, 73 insertions(+), 43 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/persistance/FileBackend.java b/src/main/java/eu/siacs/conversations/persistance/FileBackend.java index 071d75d33..99940d0a7 100644 --- a/src/main/java/eu/siacs/conversations/persistance/FileBackend.java +++ b/src/main/java/eu/siacs/conversations/persistance/FileBackend.java @@ -2428,6 +2428,28 @@ public class FileBackend { return file.delete(); } + public File getStoryCacheDirectory() { + File cacheDir = mXmppConnectionService.getCacheDir(); + File storyCache = new File(cacheDir, "stories"); + if (!storyCache.exists()) { + storyCache.mkdirs(); + } + return storyCache; + } + + public File getStoryCacheFile(String url) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-1"); + digest.update(url.getBytes()); + byte[] messageDigest = digest.digest(); + String sha1 = CryptoHelper.bytesToHex(messageDigest); + return new File(getStoryCacheDirectory(), sha1); + } catch (NoSuchAlgorithmException e) { + // This should not happen, but as a fallback, use a random name + return new File(getStoryCacheDirectory(), UUID.randomUUID().toString()); + } + } + private static class Dimensions { public final int width; public final int height; diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java index 3433e1f48..f230f6f3b 100644 --- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +++ b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java @@ -292,7 +292,8 @@ public class XmppConnectionService extends Service { Executors.newSingleThreadScheduledExecutor(); private ScheduledFuture pendingUserTuneUpdate; - private final ScheduledExecutorService storyRetractionExecutor = Executors.newSingleThreadScheduledExecutor();//... + private final ScheduledExecutorService storyRetractionExecutor = Executors.newSingleThreadScheduledExecutor(); + private final ScheduledExecutorService storyCacheExecutor = Executors.newSingleThreadScheduledExecutor(); private static final SerialSingleThreadExecutor VIDEO_COMPRESSION_EXECUTOR = new SerialSingleThreadExecutor("VideoCompression"); private final SerialSingleThreadExecutor mDatabaseWriterExecutor = @@ -1853,6 +1854,7 @@ public class XmppConnectionService extends Service { internalPingExecutor.scheduleWithFixedDelay( this::manageAccountConnectionStatesInternal, 10, 10, TimeUnit.SECONDS); storyRetractionExecutor.scheduleWithFixedDelay(this::retractOldStories, 1, 60, TimeUnit.MINUTES); + storyCacheExecutor.scheduleWithFixedDelay(this::cleanupStoryCache, 1, 1, TimeUnit.HOURS); final SharedPreferences sharedPreferences = androidx.preference.PreferenceManager.getDefaultSharedPreferences(this); sharedPreferences.registerOnSharedPreferenceChangeListener( @@ -1945,6 +1947,7 @@ public class XmppConnectionService extends Service { fileObserver.stopWatching(); internalPingExecutor.shutdown(); storyRetractionExecutor.shutdown(); + storyCacheExecutor.shutdown(); super.onDestroy(); } @@ -7989,4 +7992,22 @@ public class XmppConnectionService extends Service { } } } + + public void cleanupStoryCache() { + Log.d(Config.LOGTAG, "Cleaning up story cache"); + final File storyCacheDir = getFileBackend().getStoryCacheDirectory(); + if (storyCacheDir.exists()) { + final long twentyFourHoursAgo = System.currentTimeMillis() - 86400000; + final File[] files = storyCacheDir.listFiles(); + if (files != null) { + for (File file : files) { + if (file.lastModified() < twentyFourHoursAgo) { + if (file.delete()) { + Log.d(Config.LOGTAG, "Deleted old story cache file: " + file.getName()); + } + } + } + } + } + } } diff --git a/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java b/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java index 141017b5c..87e6cbc18 100644 --- a/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java @@ -277,7 +277,8 @@ public class StoriesActivity extends XmppActivity implements XmppConnectionServi } - private void openStoryImagePicker(Account account) { this.mSelectedAccount = account; + private void openStoryImagePicker(Account account) { + this.mSelectedAccount = account; if (checkSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) { requestPermissions(new String[]{Manifest.permission.CAMERA}, REQUEST_CAMERA_PERMISSION); } else { diff --git a/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java b/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java index 65b76798f..eea27bd34 100644 --- a/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java @@ -188,7 +188,6 @@ public class StoryViewActivity extends XmppActivity { if (getSupportActionBar() != null) { Contact storyContact = null; if (contact != null && xmppConnectionService != null) { - // Prioritize finding the contact in an online account for (Account account : xmppConnectionService.getAccounts()) { if (account.getStatus() == Account.State.ONLINE) { final Contact c = account.getRoster().getContact(contact); @@ -198,7 +197,6 @@ public class StoryViewActivity extends XmppActivity { } } } - // If no online account has the contact, fall back to any account if (storyContact == null) { for (Account account : xmppConnectionService.getAccounts()) { final Contact c = account.getRoster().getContact(contact); @@ -243,79 +241,67 @@ public class StoryViewActivity extends XmppActivity { } } progressView.setText((currentIndex + 1) + " " + getString(R.string.of) + " " + urls.size()); + final String url = urls.get(currentIndex); - final HttpUrl httpUrl; - try { - httpUrl = HttpUrl.get(url); - } catch (IllegalArgumentException e) { - Toast.makeText(this, "Invalid URL", Toast.LENGTH_SHORT).show(); - return; - } + final File cacheFile = xmppConnectionService.getFileBackend().getStoryCacheFile(url); - // Correctly create a message object representing the story, including its ID - Conversation conversation = xmppConnectionService.findOrCreateConversation(mAccount, contact, false, false); - storyMessage = new Message(conversation, titles.get(currentIndex), Message.ENCRYPTION_NONE, Message.STATUS_RECEIVED); - // storyMessage.setRemoteMsgId(storyIds.get(currentIndex)); - // storyMessage.setFileParams(new Message.FileParams(url)); // TODO: Add image support later - storyMessage.setBody(getString(R.string.reply_to_story) + " " + "\"" + titles.get(currentIndex) + "\""); - - final boolean useTor = mAccount != null && (xmppConnectionService.useTorToConnect() || mAccount.isOnion()); - final boolean useI2p = mAccount != null && (xmppConnectionService.useI2PToConnect() || mAccount.isI2P()); - - // Create and set the placeholder animation immediately on the UI thread final CircularProgressDrawable circularProgressDrawable = new CircularProgressDrawable(this); circularProgressDrawable.setStrokeWidth(10f); circularProgressDrawable.setCenterRadius(50f); circularProgressDrawable.setColorSchemeColors(0xFFFFFFFF); - circularProgressDrawable.start(); imageView.setImageDrawable(circularProgressDrawable); + videoView.setVisibility(View.GONE); + imageView.setVisibility(View.VISIBLE); new Thread(() -> { - File tempFile = null; try { - tempFile = File.createTempFile("story", ".tmp", getCacheDir()); - try (InputStream inputStream = HttpConnectionManager.open(httpUrl, useTor, useI2p); - FileOutputStream outputStream = new FileOutputStream(tempFile)) { + if (!cacheFile.exists() || cacheFile.length() == 0) { + Log.d(Config.LOGTAG, "Story not in cache. Downloading from: " + url); + runOnUiThread(circularProgressDrawable::start); + final HttpUrl httpUrl = HttpUrl.get(url); + final boolean useTor = mAccount != null && (xmppConnectionService.useTorToConnect() || mAccount.isOnion()); + final boolean useI2p = mAccount != null && (xmppConnectionService.useI2PToConnect() || mAccount.isI2P()); + try (InputStream inputStream = HttpConnectionManager.open(httpUrl, useTor, useI2p); + FileOutputStream outputStream = new FileOutputStream(cacheFile)) { - byte[] buffer = new byte[4096]; - int bytesRead; - while ((bytesRead = inputStream.read(buffer)) != -1) { - outputStream.write(buffer, 0, bytesRead); + byte[] buffer = new byte[4096]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + } } + } else { + Log.d(Config.LOGTAG, "Loading story from cache: " + cacheFile.getName()); } - final File finalTempFile = tempFile; - // Associate the downloaded file with our story message - // storyMessage.setRelativeFilePath(finalTempFile.getAbsolutePath()); // TODO: Add image support later runOnUiThread(() -> { circularProgressDrawable.stop(); if (!isFinishing()) { String mimeType = (mimeTypes != null && currentIndex < mimeTypes.size()) ? mimeTypes.get(currentIndex) : null; if (mimeType == null) { - mimeType = getContentResolver().getType(Uri.fromFile(finalTempFile)); + mimeType = getContentResolver().getType(Uri.fromFile(cacheFile)); } - videoView.stopPlayback(); // Stop any previous video + videoView.stopPlayback(); if (mimeType != null && mimeType.startsWith("video/")) { imageView.setVisibility(View.GONE); videoView.setVisibility(View.VISIBLE); - videoView.setVideoURI(Uri.fromFile(finalTempFile)); + videoView.setVideoURI(Uri.fromFile(cacheFile)); videoView.setOnPreparedListener(mp -> { mp.setLooping(true); - videoView.start(); // Start playback here + videoView.start(); }); } else { videoView.setVisibility(View.GONE); imageView.setVisibility(View.VISIBLE); - Glide.with(StoryViewActivity.this).load(finalTempFile).into(imageView); + Glide.with(StoryViewActivity.this).load(cacheFile).into(imageView); } } }); - } catch (IOException e) { - Log.e(Config.LOGTAG, "Failed to download story image", e); - if (tempFile != null) { - tempFile.delete(); + Log.e(Config.LOGTAG, "Failed to download or load story", e); + if (cacheFile != null && cacheFile.exists()) { + cacheFile.delete(); } runOnUiThread(() -> { Toast.makeText(StoryViewActivity.this, R.string.download_failed_file_not_found, Toast.LENGTH_SHORT).show(); -- 2.39.5 From fbab76909d214090db07e6cda9981980083cb165 Mon Sep 17 00:00:00 2001 From: Arne Date: Fri, 2 Jan 2026 14:38:37 +0100 Subject: [PATCH 042/180] Add author to story posts --- .../eu/siacs/conversations/generator/IqGenerator.java | 10 ++++++++-- .../conversations/services/XmppConnectionService.java | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/generator/IqGenerator.java b/src/main/java/eu/siacs/conversations/generator/IqGenerator.java index 00eecc065..392a1adeb 100644 --- a/src/main/java/eu/siacs/conversations/generator/IqGenerator.java +++ b/src/main/java/eu/siacs/conversations/generator/IqGenerator.java @@ -246,7 +246,7 @@ public class IqGenerator extends AbstractGenerator { return publish(namespace, item, options); } - public Iq publishStory(final String url, final String type, final String title, Bundle options) { + public Iq publishStory(final Account account, final String url, final String type, final String title, Bundle options) { final Element item = new Element("item"); // This is the pubsub ID, which is different from the atom item.setAttribute("id", UUID.randomUUID().toString()); @@ -266,7 +266,11 @@ public class IqGenerator extends AbstractGenerator { // atom:updated is mandatory final String timestamp = getTimestamp(System.currentTimeMillis()); entry.addChild("updated").setContent(timestamp); - entry.addChild("published").setContent(timestamp); // Also add published for compatibility + entry.addChild("published").setContent(timestamp); + + if (account != null) { + entry.addChild("author").addChild("uri").setContent("xmpp:" + account.getJid().asBareJid()); + } // The element as specified by the XEP final Element link = entry.addChild("link"); @@ -723,6 +727,8 @@ public class IqGenerator extends AbstractGenerator { options.putString("pubsub#item_expire", "86400"); options.putString("pubsub#persist_items", "1"); options.putString("pubsub#notify_retract", "1"); + options.putString("pubsub#send_last_published_item", "on_sub_and_presence"); + options.putString("pubsub#publisher", "publishers"); return options; } diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java index f230f6f3b..a5dc64794 100644 --- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +++ b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java @@ -7781,7 +7781,7 @@ public class XmppConnectionService extends Service { return; } // This is the corrected publish request. It sends NO configuration options. - final Iq packet = getIqGenerator().publishStory(url, type, title, null); + final Iq packet = getIqGenerator().publishStory(account, url, type, title, null); sendIqPacket(account, packet, response -> { if (response.getType() == Iq.Type.RESULT) { if (callback != null) { -- 2.39.5 From a24266b86bb38f1b60d81b0bbb481da5a6b0db42 Mon Sep 17 00:00:00 2001 From: Arne Date: Fri, 2 Jan 2026 14:54:56 +0100 Subject: [PATCH 043/180] Refuse to parse story if author does not match publisher --- .../eu/siacs/conversations/entities/Story.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/main/java/eu/siacs/conversations/entities/Story.java b/src/main/java/eu/siacs/conversations/entities/Story.java index 1c7e5785f..78b512c91 100644 --- a/src/main/java/eu/siacs/conversations/entities/Story.java +++ b/src/main/java/eu/siacs/conversations/entities/Story.java @@ -56,6 +56,24 @@ public class Story extends AbstractEntity { return null; } + final Element author = entry.findChild("author", "http://www.w3.org/2005/Atom"); + if (author != null) { + final Element uri = author.findChild("uri", "http://www.w3.org/2005/Atom"); + String authorUri = uri != null ? uri.getContent() : null; + if (authorUri != null && authorUri.startsWith("xmpp:")) { + try { + Jid authorJid = Jid.of(authorUri.substring(5)); + if (!authorJid.asBareJid().equals(contact.asBareJid())) { + android.util.Log.w(eu.siacs.conversations.Config.LOGTAG, "Story author JID (" + authorJid + ") does not match publisher JID (" + contact + "). Ignoring story."); + return null; + } + } catch (IllegalArgumentException e) { + android.util.Log.w(eu.siacs.conversations.Config.LOGTAG, "Invalid JID in story author URI: " + authorUri); + return null; + } + } + } + Element link = null; final List children = entry.getChildren(); if (children != null) { -- 2.39.5 From 2b744b882ee71ac4a240553c2179a303ae631855 Mon Sep 17 00:00:00 2001 From: Arne Date: Fri, 2 Jan 2026 15:54:57 +0100 Subject: [PATCH 044/180] Toggle UI visibility and navigate stories on touch --- .../conversations/ui/StoryViewActivity.java | 73 ++++++++++++++++--- src/main/res/layout/activity_story_view.xml | 5 +- 2 files changed, 67 insertions(+), 11 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java b/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java index eea27bd34..e78b852f3 100644 --- a/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java @@ -8,12 +8,14 @@ import android.util.Log; import android.util.TypedValue; import android.view.Menu; import android.view.MenuItem; +import android.view.MotionEvent; import android.view.View; import android.widget.ImageView; import android.widget.TextView; import android.widget.Toast; import android.widget.VideoView; +import androidx.appcompat.app.ActionBar; import androidx.appcompat.widget.Toolbar; import androidx.swiperefreshlayout.widget.CircularProgressDrawable; @@ -51,6 +53,7 @@ public class StoryViewActivity extends XmppActivity { private VideoView videoView; private TextView titleView; private TextView progressView; + private View bottomPanel; private ArrayList urls; private ArrayList titles; @@ -64,7 +67,6 @@ public class StoryViewActivity extends XmppActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - setTheme(R.style.Theme_Conversations3); setContentView(R.layout.activity_story_view); Toolbar toolbar = findViewById(R.id.toolbar); @@ -78,22 +80,41 @@ public class StoryViewActivity extends XmppActivity { videoView = findViewById(R.id.story_video_view); titleView = findViewById(R.id.story_title_view); progressView = findViewById(R.id.story_progress_view); + bottomPanel = findViewById(R.id.bottom_panel); urls = getIntent().getStringArrayListExtra(EXTRA_URLS); titles = getIntent().getStringArrayListExtra(EXTRA_TITLES); storyIds = getIntent().getStringArrayListExtra(EXTRA_STORY_IDS); mimeTypes = getIntent().getStringArrayListExtra(EXTRA_MIME_TYPES); - View.OnClickListener nextListener = v -> { - currentIndex++; - if (currentIndex < urls.size()) { - loadStory(); - } else { - finish(); + View.OnTouchListener touchListener = (v, event) -> { + if (event.getAction() == MotionEvent.ACTION_DOWN) { + if (event.getX() < v.getWidth() / 3) { + currentIndex--; + if (currentIndex >= 0) { + loadStory(); + } else { + finish(); + } + } else if (event.getX() > v.getWidth() * 2 / 3) { + currentIndex++; + if (currentIndex < urls.size()) { + loadStory(); + } else { + finish(); + } + } else { + if (isSystemUiVisible()) { + hideSystemUi(); + } else { + showSystemUi(); + } + } } + return true; }; - imageView.setOnClickListener(nextListener); - videoView.setOnClickListener(nextListener); + imageView.setOnTouchListener(touchListener); + videoView.setOnTouchListener(touchListener); try { contact = Jid.of(getIntent().getStringExtra(EXTRA_CONTACT)); @@ -242,6 +263,8 @@ public class StoryViewActivity extends XmppActivity { } progressView.setText((currentIndex + 1) + " " + getString(R.string.of) + " " + urls.size()); + showSystemUi(); + final String url = urls.get(currentIndex); final File cacheFile = xmppConnectionService.getFileBackend().getStoryCacheFile(url); @@ -310,4 +333,36 @@ public class StoryViewActivity extends XmppActivity { } }).start(); } + + private void hideSystemUi() { + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.hide(); + } + bottomPanel.setVisibility(View.GONE); + getWindow().getDecorView().setSystemUiVisibility( + View.SYSTEM_UI_FLAG_IMMERSIVE + | View.SYSTEM_UI_FLAG_LAYOUT_STABLE + | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_FULLSCREEN); + } + + private void showSystemUi() { + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.show(); + } + bottomPanel.setVisibility(View.VISIBLE); + getWindow().getDecorView().setSystemUiVisibility( + View.SYSTEM_UI_FLAG_LAYOUT_STABLE + | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN); + } + + private boolean isSystemUiVisible() { + return (getWindow().getDecorView().getSystemUiVisibility() & View.SYSTEM_UI_FLAG_FULLSCREEN) == 0; + } + } diff --git a/src/main/res/layout/activity_story_view.xml b/src/main/res/layout/activity_story_view.xml index 711257ce5..f29039b3b 100644 --- a/src/main/res/layout/activity_story_view.xml +++ b/src/main/res/layout/activity_story_view.xml @@ -3,8 +3,8 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" - android:fitsSystemWindows="true" - android:background="@android:color/black"> + android:background="@android:color/black" + android:fitsSystemWindows="true"> Date: Fri, 2 Jan 2026 16:22:28 +0100 Subject: [PATCH 045/180] Migrate StoryViewActivity to ViewPager2 --- .../siacs/conversations/ui/StoryFragment.java | 65 ++++ .../conversations/ui/StoryViewActivity.java | 336 +++++++----------- src/main/res/layout/activity_story_view.xml | 14 +- src/main/res/layout/fragment_story.xml | 19 + 4 files changed, 216 insertions(+), 218 deletions(-) create mode 100644 src/main/java/eu/siacs/conversations/ui/StoryFragment.java create mode 100644 src/main/res/layout/fragment_story.xml diff --git a/src/main/java/eu/siacs/conversations/ui/StoryFragment.java b/src/main/java/eu/siacs/conversations/ui/StoryFragment.java new file mode 100644 index 000000000..6dbecedf8 --- /dev/null +++ b/src/main/java/eu/siacs/conversations/ui/StoryFragment.java @@ -0,0 +1,65 @@ +package eu.siacs.conversations.ui; + +import android.net.Uri; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.VideoView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; + +import com.bumptech.glide.Glide; + +import java.io.File; + +import eu.siacs.conversations.R; + +public class StoryFragment extends Fragment { + + private static final String ARG_URL = "url"; + private static final String ARG_MIME_TYPE = "mime_type"; + + public static StoryFragment newInstance(String url, String mimeType) { + StoryFragment fragment = new StoryFragment(); + Bundle args = new Bundle(); + args.putString(ARG_URL, url); + args.putString(ARG_MIME_TYPE, mimeType); + fragment.setArguments(args); + return fragment; + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_story, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + ImageView imageView = view.findViewById(R.id.story_image_view); + VideoView videoView = view.findViewById(R.id.story_video_view); + + String url = getArguments().getString(ARG_URL); + String mimeType = getArguments().getString(ARG_MIME_TYPE); + + if (mimeType != null && mimeType.startsWith("video/")) { + imageView.setVisibility(View.GONE); + videoView.setVisibility(View.VISIBLE); + videoView.setVideoURI(Uri.parse(url)); + videoView.setOnPreparedListener(mp -> { + mp.setLooping(true); + videoView.start(); + }); + } else { + videoView.setVisibility(View.GONE); + imageView.setVisibility(View.VISIBLE); + Glide.with(this).load(url).into(imageView); + } + } +} diff --git a/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java b/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java index e78b852f3..e7ed527c6 100644 --- a/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java @@ -1,44 +1,37 @@ package eu.siacs.conversations.ui; -import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.Bundle; import android.text.format.DateUtils; -import android.util.Log; import android.util.TypedValue; +import android.view.GestureDetector; import android.view.Menu; import android.view.MenuItem; import android.view.MotionEvent; import android.view.View; -import android.widget.ImageView; import android.widget.TextView; import android.widget.Toast; -import android.widget.VideoView; +import androidx.annotation.NonNull; import androidx.appcompat.app.ActionBar; import androidx.appcompat.widget.Toolbar; -import androidx.swiperefreshlayout.widget.CircularProgressDrawable; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; +import androidx.viewpager2.adapter.FragmentStateAdapter; +import androidx.viewpager2.widget.ViewPager2; -import com.bumptech.glide.Glide; import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; import java.util.ArrayList; -import eu.siacs.conversations.Config; import eu.siacs.conversations.R; import eu.siacs.conversations.entities.Account; import eu.siacs.conversations.entities.Contact; import eu.siacs.conversations.entities.Conversation; import eu.siacs.conversations.entities.Message; -import eu.siacs.conversations.http.HttpConnectionManager; import eu.siacs.conversations.ui.util.AvatarWorkerTask; import eu.siacs.conversations.ui.widget.AvatarView; import eu.siacs.conversations.xmpp.Jid; -import okhttp3.HttpUrl; public class StoryViewActivity extends XmppActivity { @@ -49,8 +42,7 @@ public class StoryViewActivity extends XmppActivity { public static final String EXTRA_CONTACT = "contact"; public static final String EXTRA_MIME_TYPES = "story_mime_types"; - private ImageView imageView; - private VideoView videoView; + private ViewPager2 viewPager; private TextView titleView; private TextView progressView; private View bottomPanel; @@ -59,10 +51,10 @@ public class StoryViewActivity extends XmppActivity { private ArrayList titles; private ArrayList storyIds; private ArrayList mimeTypes; - private int currentIndex = 0; private Jid contact; private Account mAccount; - private Message storyMessage; + + private GestureDetector gestureDetector; @Override protected void onCreate(Bundle savedInstanceState) { @@ -76,8 +68,7 @@ public class StoryViewActivity extends XmppActivity { getSupportActionBar().setDisplayShowTitleEnabled(false); } - imageView = findViewById(R.id.story_image_view); - videoView = findViewById(R.id.story_video_view); + viewPager = findViewById(R.id.view_pager); titleView = findViewById(R.id.story_title_view); progressView = findViewById(R.id.story_progress_view); bottomPanel = findViewById(R.id.bottom_panel); @@ -87,125 +78,43 @@ public class StoryViewActivity extends XmppActivity { storyIds = getIntent().getStringArrayListExtra(EXTRA_STORY_IDS); mimeTypes = getIntent().getStringArrayListExtra(EXTRA_MIME_TYPES); - View.OnTouchListener touchListener = (v, event) -> { - if (event.getAction() == MotionEvent.ACTION_DOWN) { - if (event.getX() < v.getWidth() / 3) { - currentIndex--; - if (currentIndex >= 0) { - loadStory(); - } else { - finish(); - } - } else if (event.getX() > v.getWidth() * 2 / 3) { - currentIndex++; - if (currentIndex < urls.size()) { - loadStory(); - } else { - finish(); - } - } else { - if (isSystemUiVisible()) { - hideSystemUi(); - } else { - showSystemUi(); - } - } - } - return true; - }; - imageView.setOnTouchListener(touchListener); - videoView.setOnTouchListener(touchListener); - try { contact = Jid.of(getIntent().getStringExtra(EXTRA_CONTACT)); } catch (final Exception e) { //ignore } - } + class GestureListener extends GestureDetector.SimpleOnGestureListener { - @Override - protected void refreshUiReal() { - - } - - @Override - public boolean onCreateOptionsMenu(Menu menu) { - getMenuInflater().inflate(R.menu.activity_story_view, menu); - MenuItem deleteButton = menu.findItem(R.id.action_delete_story); - if (contact != null && xmppConnectionService != null) { - final Account storyOwner = xmppConnectionService.findAccountByJid(contact); - if (storyOwner != null && storyOwner.isOnlineAndConnected()) { - deleteButton.setVisible(true); - this.mAccount = storyOwner; + @Override + public boolean onSingleTapConfirmed(MotionEvent e) { + if (isSystemUiVisible()) { + hideSystemUi(); + } else { + showSystemUi(); + } + return true; } } - return true; - } - @Override - public boolean onOptionsItemSelected(MenuItem item) { - final int itemId = item.getItemId(); - if (itemId == android.R.id.home) { - finish(); - return true; - } else if (itemId == R.id.action_delete_story) { - new MaterialAlertDialogBuilder(this) - .setTitle(R.string.delete_story_dialog_title) - .setMessage(R.string.delete_story_dialog_message) - .setPositiveButton(R.string.delete, (dialog, which) -> { - xmppConnectionService.retractStory(mAccount, storyIds.get(currentIndex), new UiCallback() { - @Override - public void success(Void aVoid) { - runOnUiThread(() -> { - Toast.makeText(StoryViewActivity.this, R.string.story_deleted, Toast.LENGTH_SHORT).show(); - finish(); - }); - } + gestureDetector = new GestureDetector(this, new GestureListener()); - @Override - public void error(int errorCode, Void object) { - runOnUiThread(() -> Toast.makeText(StoryViewActivity.this, errorCode, Toast.LENGTH_SHORT).show()); - } + viewPager.setOnTouchListener((v, event) -> gestureDetector.onTouchEvent(event)); - @Override - public void userInputRequired(android.app.PendingIntent pi, Void object) { - - } - }); - }) - .setNegativeButton(R.string.cancel, null) - .create() - .show(); - return true; - } else if (itemId == R.id.action_reply_to_story) { - if (storyMessage != null) { - Conversation conversation = xmppConnectionService.findOrCreateConversation(mAccount, contact, false, false); - conversation.setReplyTo(storyMessage); - switchToConversation(conversation); + StoryPagerAdapter adapter = new StoryPagerAdapter(this); + viewPager.setAdapter(adapter); + viewPager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() { + @Override + public void onPageSelected(int position) { + super.onPageSelected(position); + updateUiForPosition(position); } - return true; - } - return super.onOptionsItemSelected(item); + }); + + updateUiForPosition(0); } - - @Override - public void onBackendConnected() { - String accountUuid = getIntent().getStringExtra(EXTRA_ACCOUNT); - if (accountUuid != null) { - mAccount = xmppConnectionService.findAccountByUuid(accountUuid); - } - invalidateOptionsMenu(); - loadStory(); - } - - private void loadStory() { - if (urls == null || currentIndex >= urls.size()) { - finish(); - return; - } - titleView.setText(titles.get(currentIndex)); + private void updateUiForPosition(int position) { if (getSupportActionBar() != null) { Contact storyContact = null; if (contact != null && xmppConnectionService != null) { @@ -244,8 +153,8 @@ public class StoryViewActivity extends XmppActivity { } toolbarTitle.setText(displayName); long publishedTimestamp = 0; - if (storyIds != null && currentIndex < storyIds.size()) { - final String currentStoryId = storyIds.get(currentIndex); + if (storyIds != null && position < storyIds.size()) { + final String currentStoryId = storyIds.get(position); if (xmppConnectionService != null) { for (eu.siacs.conversations.entities.Story story : xmppConnectionService.getStories()) { if (story.getUuid().equals(currentStoryId)) { @@ -261,77 +170,9 @@ public class StoryViewActivity extends XmppActivity { toolbarSubtitle.setText(""); } } - progressView.setText((currentIndex + 1) + " " + getString(R.string.of) + " " + urls.size()); - showSystemUi(); - - final String url = urls.get(currentIndex); - final File cacheFile = xmppConnectionService.getFileBackend().getStoryCacheFile(url); - - final CircularProgressDrawable circularProgressDrawable = new CircularProgressDrawable(this); - circularProgressDrawable.setStrokeWidth(10f); - circularProgressDrawable.setCenterRadius(50f); - circularProgressDrawable.setColorSchemeColors(0xFFFFFFFF); - imageView.setImageDrawable(circularProgressDrawable); - videoView.setVisibility(View.GONE); - imageView.setVisibility(View.VISIBLE); - - new Thread(() -> { - try { - if (!cacheFile.exists() || cacheFile.length() == 0) { - Log.d(Config.LOGTAG, "Story not in cache. Downloading from: " + url); - runOnUiThread(circularProgressDrawable::start); - final HttpUrl httpUrl = HttpUrl.get(url); - final boolean useTor = mAccount != null && (xmppConnectionService.useTorToConnect() || mAccount.isOnion()); - final boolean useI2p = mAccount != null && (xmppConnectionService.useI2PToConnect() || mAccount.isI2P()); - try (InputStream inputStream = HttpConnectionManager.open(httpUrl, useTor, useI2p); - FileOutputStream outputStream = new FileOutputStream(cacheFile)) { - - byte[] buffer = new byte[4096]; - int bytesRead; - while ((bytesRead = inputStream.read(buffer)) != -1) { - outputStream.write(buffer, 0, bytesRead); - } - } - } else { - Log.d(Config.LOGTAG, "Loading story from cache: " + cacheFile.getName()); - } - - runOnUiThread(() -> { - circularProgressDrawable.stop(); - if (!isFinishing()) { - String mimeType = (mimeTypes != null && currentIndex < mimeTypes.size()) ? mimeTypes.get(currentIndex) : null; - if (mimeType == null) { - mimeType = getContentResolver().getType(Uri.fromFile(cacheFile)); - } - videoView.stopPlayback(); - if (mimeType != null && mimeType.startsWith("video/")) { - imageView.setVisibility(View.GONE); - videoView.setVisibility(View.VISIBLE); - videoView.setVideoURI(Uri.fromFile(cacheFile)); - videoView.setOnPreparedListener(mp -> { - mp.setLooping(true); - videoView.start(); - }); - } else { - videoView.setVisibility(View.GONE); - imageView.setVisibility(View.VISIBLE); - Glide.with(StoryViewActivity.this).load(cacheFile).into(imageView); - } - } - }); - - } catch (IOException e) { - Log.e(Config.LOGTAG, "Failed to download or load story", e); - if (cacheFile != null && cacheFile.exists()) { - cacheFile.delete(); - } - runOnUiThread(() -> { - Toast.makeText(StoryViewActivity.this, R.string.download_failed_file_not_found, Toast.LENGTH_SHORT).show(); - finish(); - }); - } - }).start(); + titleView.setText(titles.get(position)); + progressView.setText((position + 1) + " " + getString(R.string.of) + " " + urls.size()); } private void hideSystemUi() { @@ -340,13 +181,6 @@ public class StoryViewActivity extends XmppActivity { actionBar.hide(); } bottomPanel.setVisibility(View.GONE); - getWindow().getDecorView().setSystemUiVisibility( - View.SYSTEM_UI_FLAG_IMMERSIVE - | View.SYSTEM_UI_FLAG_LAYOUT_STABLE - | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION - | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN - | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION - | View.SYSTEM_UI_FLAG_FULLSCREEN); } private void showSystemUi() { @@ -355,14 +189,102 @@ public class StoryViewActivity extends XmppActivity { actionBar.show(); } bottomPanel.setVisibility(View.VISIBLE); - getWindow().getDecorView().setSystemUiVisibility( - View.SYSTEM_UI_FLAG_LAYOUT_STABLE - | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION - | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN); } private boolean isSystemUiVisible() { - return (getWindow().getDecorView().getSystemUiVisibility() & View.SYSTEM_UI_FLAG_FULLSCREEN) == 0; + ActionBar actionBar = getSupportActionBar(); + return actionBar != null && actionBar.isShowing(); } + @Override + protected void refreshUiReal() { + + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.activity_story_view, menu); + MenuItem deleteButton = menu.findItem(R.id.action_delete_story); + if (contact != null && xmppConnectionService != null) { + final Account storyOwner = xmppConnectionService.findAccountByJid(contact); + if (storyOwner != null && storyOwner.isOnlineAndConnected()) { + deleteButton.setVisible(true); + this.mAccount = storyOwner; + } + } + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + final int itemId = item.getItemId(); + if (itemId == android.R.id.home) { + finish(); + return true; + } else if (itemId == R.id.action_delete_story) { + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.delete_story_dialog_title) + .setMessage(R.string.delete_story_dialog_message) + .setPositiveButton(R.string.delete, (dialog, which) -> { + xmppConnectionService.retractStory(mAccount, storyIds.get(viewPager.getCurrentItem()), new UiCallback() { + @Override + public void success(Void aVoid) { + runOnUiThread(() -> { + Toast.makeText(StoryViewActivity.this, R.string.story_deleted, Toast.LENGTH_SHORT).show(); + finish(); + }); + } + + @Override + public void error(int errorCode, Void object) { + runOnUiThread(() -> Toast.makeText(StoryViewActivity.this, errorCode, Toast.LENGTH_SHORT).show()); + } + + @Override + public void userInputRequired(android.app.PendingIntent pi, Void object) { + + } + }); + }) + .setNegativeButton(R.string.cancel, null) + .create() + .show(); + return true; + } else if (itemId == R.id.action_reply_to_story) { + int currentPos = viewPager.getCurrentItem(); + Message storyMessage = new Message(null, titles.get(currentPos), Message.ENCRYPTION_NONE, Message.STATUS_RECEIVED); + Conversation conversation = xmppConnectionService.findOrCreateConversation(mAccount, contact, false, false); + conversation.setReplyTo(storyMessage); + switchToConversation(conversation); + return true; + } + return super.onOptionsItemSelected(item); + } + + private class StoryPagerAdapter extends FragmentStateAdapter { + + public StoryPagerAdapter(@NonNull FragmentActivity fragmentActivity) { + super(fragmentActivity); + } + + @NonNull + @Override + public Fragment createFragment(int position) { + return StoryFragment.newInstance(urls.get(position), mimeTypes.get(position)); + } + + @Override + public int getItemCount() { + return urls.size(); + } + } + + @Override + protected void onBackendConnected() { + String accountUuid = getIntent().getStringExtra(EXTRA_ACCOUNT); + if (accountUuid != null) { + mAccount = xmppConnectionService.findAccountByUuid(accountUuid); + } + invalidateOptionsMenu(); + } } diff --git a/src/main/res/layout/activity_story_view.xml b/src/main/res/layout/activity_story_view.xml index f29039b3b..a61a3a442 100644 --- a/src/main/res/layout/activity_story_view.xml +++ b/src/main/res/layout/activity_story_view.xml @@ -6,18 +6,10 @@ android:background="@android:color/black" android:fitsSystemWindows="true"> - - - + android:layout_height="match_parent" /> + + + + + + + -- 2.39.5 From f8e7639720c60a27b4fa36b0927fbdd4797e3f89 Mon Sep 17 00:00:00 2001 From: Arne Date: Fri, 2 Jan 2026 16:42:25 +0100 Subject: [PATCH 046/180] Reapply download and cache stories before displaying them + Fix single click to fade out panels --- .../siacs/conversations/ui/StoryFragment.java | 118 ++++++++++-- .../conversations/ui/StoryViewActivity.java | 180 +++++++++--------- src/main/res/layout/activity_story_view.xml | 1 + src/main/res/layout/fragment_story.xml | 8 + 4 files changed, 202 insertions(+), 105 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/ui/StoryFragment.java b/src/main/java/eu/siacs/conversations/ui/StoryFragment.java index 6dbecedf8..6a016fcd4 100644 --- a/src/main/java/eu/siacs/conversations/ui/StoryFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/StoryFragment.java @@ -1,11 +1,15 @@ package eu.siacs.conversations.ui; +import android.content.Context; import android.net.Uri; import android.os.Bundle; +import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; +import android.widget.ProgressBar; +import android.widget.Toast; import android.widget.VideoView; import androidx.annotation.NonNull; @@ -15,14 +19,26 @@ import androidx.fragment.app.Fragment; import com.bumptech.glide.Glide; import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import eu.siacs.conversations.Config; import eu.siacs.conversations.R; +import eu.siacs.conversations.http.HttpConnectionManager; +import okhttp3.HttpUrl; public class StoryFragment extends Fragment { private static final String ARG_URL = "url"; private static final String ARG_MIME_TYPE = "mime_type"; + private OnStoryTapListener mListener; + + public interface OnStoryTapListener { + void onStoryTapped(); + } + public static StoryFragment newInstance(String url, String mimeType) { StoryFragment fragment = new StoryFragment(); Bundle args = new Bundle(); @@ -32,6 +48,16 @@ public class StoryFragment extends Fragment { return fragment; } + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + if (context instanceof OnStoryTapListener) { + mListener = (OnStoryTapListener) context; + } else { + throw new RuntimeException(context.toString() + " must implement OnStoryTapListener"); + } + } + @Nullable @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { @@ -42,24 +68,82 @@ public class StoryFragment extends Fragment { public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); - ImageView imageView = view.findViewById(R.id.story_image_view); - VideoView videoView = view.findViewById(R.id.story_video_view); + final ImageView imageView = view.findViewById(R.id.story_image_view); + final VideoView videoView = view.findViewById(R.id.story_video_view); + final ProgressBar progressBar = view.findViewById(R.id.story_progress); - String url = getArguments().getString(ARG_URL); - String mimeType = getArguments().getString(ARG_MIME_TYPE); + view.setOnClickListener(v -> { + if (mListener != null) { + mListener.onStoryTapped(); + } + }); - if (mimeType != null && mimeType.startsWith("video/")) { - imageView.setVisibility(View.GONE); - videoView.setVisibility(View.VISIBLE); - videoView.setVideoURI(Uri.parse(url)); - videoView.setOnPreparedListener(mp -> { - mp.setLooping(true); - videoView.start(); - }); - } else { - videoView.setVisibility(View.GONE); - imageView.setVisibility(View.VISIBLE); - Glide.with(this).load(url).into(imageView); + final String url = getArguments().getString(ARG_URL); + final String mimeType = getArguments().getString(ARG_MIME_TYPE); + + final StoryViewActivity activity = (StoryViewActivity) getActivity(); + if (activity == null || activity.xmppConnectionService == null) { + return; } + + final File cacheFile = activity.getStoryCacheFile(url); + + new Thread(() -> { + try { + if (cacheFile != null && (!cacheFile.exists() || cacheFile.length() == 0)) { + activity.runOnUiThread(() -> progressBar.setVisibility(View.VISIBLE)); + Log.d(Config.LOGTAG, "Story not in cache. Downloading from: " + url); + final HttpUrl httpUrl = HttpUrl.get(url); + final boolean useTor = activity.xmppConnectionService.useTorToConnect(); + final boolean useI2p = activity.xmppConnectionService.useI2PToConnect(); + try (InputStream inputStream = HttpConnectionManager.open(httpUrl, useTor, useI2p); + FileOutputStream outputStream = new FileOutputStream(cacheFile)) { + byte[] buffer = new byte[4096]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + } + } + } else if (cacheFile != null) { + Log.d(Config.LOGTAG, "Loading story from cache: " + cacheFile.getName()); + } + + activity.runOnUiThread(() -> { + progressBar.setVisibility(View.GONE); + if (isAdded() && getActivity() != null && cacheFile != null) { + videoView.stopPlayback(); + if (mimeType != null && mimeType.startsWith("video/")) { + imageView.setVisibility(View.GONE); + videoView.setVisibility(View.VISIBLE); + videoView.setVideoURI(Uri.fromFile(cacheFile)); + videoView.setOnPreparedListener(mp -> { + mp.setLooping(true); + videoView.start(); + }); + } else { + videoView.setVisibility(View.GONE); + imageView.setVisibility(View.VISIBLE); + Glide.with(this).load(cacheFile).into(imageView); + } + } + }); + + } catch (Exception e) { + Log.e(Config.LOGTAG, "Failed to download or load story", e); + if (cacheFile != null && cacheFile.exists()) { + cacheFile.delete(); + } + activity.runOnUiThread(() -> { + if(isAdded()) { + Toast.makeText(getContext(), R.string.download_failed_file_not_found, Toast.LENGTH_SHORT).show(); + } + }); + } + }).start(); } -} + @Override + public void onDetach() { + super.onDetach(); + mListener = null; + } +} \ No newline at end of file diff --git a/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java b/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java index e7ed527c6..459a01c5e 100644 --- a/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java @@ -1,27 +1,24 @@ package eu.siacs.conversations.ui; -import android.net.Uri; import android.os.Bundle; import android.text.format.DateUtils; -import android.util.TypedValue; -import android.view.GestureDetector; import android.view.Menu; import android.view.MenuItem; -import android.view.MotionEvent; import android.view.View; import android.widget.TextView; import android.widget.Toast; import androidx.annotation.NonNull; -import androidx.appcompat.app.ActionBar; import androidx.appcompat.widget.Toolbar; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; import androidx.viewpager2.adapter.FragmentStateAdapter; import androidx.viewpager2.widget.ViewPager2; +import com.google.android.material.appbar.AppBarLayout; import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import java.io.File; import java.util.ArrayList; import eu.siacs.conversations.R; @@ -33,7 +30,7 @@ import eu.siacs.conversations.ui.util.AvatarWorkerTask; import eu.siacs.conversations.ui.widget.AvatarView; import eu.siacs.conversations.xmpp.Jid; -public class StoryViewActivity extends XmppActivity { +public class StoryViewActivity extends XmppActivity implements StoryFragment.OnStoryTapListener { public static final String EXTRA_URLS = "urls"; public static final String EXTRA_TITLES = "titles"; @@ -46,6 +43,10 @@ public class StoryViewActivity extends XmppActivity { private TextView titleView; private TextView progressView; private View bottomPanel; + private AppBarLayout appBarLayout; + private AvatarView toolbarAvatar; + private TextView toolbarTitle; + private TextView toolbarSubtitle; private ArrayList urls; private ArrayList titles; @@ -54,8 +55,6 @@ public class StoryViewActivity extends XmppActivity { private Jid contact; private Account mAccount; - private GestureDetector gestureDetector; - @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -68,6 +67,11 @@ public class StoryViewActivity extends XmppActivity { getSupportActionBar().setDisplayShowTitleEnabled(false); } + appBarLayout = findViewById(R.id.app_bar_layout); + toolbarAvatar = findViewById(R.id.toolbar_avatar); + toolbarTitle = findViewById(R.id.toolbar_title); + toolbarSubtitle = findViewById(R.id.toolbar_subtitle); + viewPager = findViewById(R.id.view_pager); titleView = findViewById(R.id.story_title_view); progressView = findViewById(R.id.story_progress_view); @@ -84,23 +88,6 @@ public class StoryViewActivity extends XmppActivity { //ignore } - class GestureListener extends GestureDetector.SimpleOnGestureListener { - - @Override - public boolean onSingleTapConfirmed(MotionEvent e) { - if (isSystemUiVisible()) { - hideSystemUi(); - } else { - showSystemUi(); - } - return true; - } - } - - gestureDetector = new GestureDetector(this, new GestureListener()); - - viewPager.setOnTouchListener((v, event) -> gestureDetector.onTouchEvent(event)); - StoryPagerAdapter adapter = new StoryPagerAdapter(this); viewPager.setAdapter(adapter); viewPager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() { @@ -112,88 +99,105 @@ public class StoryViewActivity extends XmppActivity { }); updateUiForPosition(0); + showSystemUi(); } - private void updateUiForPosition(int position) { - if (getSupportActionBar() != null) { - Contact storyContact = null; - if (contact != null && xmppConnectionService != null) { - for (Account account : xmppConnectionService.getAccounts()) { - if (account.getStatus() == Account.State.ONLINE) { - final Contact c = account.getRoster().getContact(contact); - if (c != null) { - storyContact = c; - break; - } - } - } - if (storyContact == null) { - for (Account account : xmppConnectionService.getAccounts()) { - final Contact c = account.getRoster().getContact(contact); - if (c != null) { - storyContact = c; - break; - } - } - } - } + public File getStoryCacheFile(String url) { + if (xmppConnectionService != null) { + return xmppConnectionService.getFileBackend().getStoryCacheFile(url); + } + return null; + } - String displayName; - AvatarView toolbarAvatar = findViewById(R.id.toolbar_avatar); - TextView toolbarTitle = findViewById(R.id.toolbar_title); - TextView toolbarSubtitle = findViewById(R.id.toolbar_subtitle); - if (storyContact != null) { - displayName = storyContact.getDisplayName(); - Conversation conversation = xmppConnectionService.findOrCreateConversation(mAccount, contact, false, false); - AvatarWorkerTask.loadAvatar(conversation, toolbarAvatar, R.dimen.muc_avatar_actionbar); - } else if (contact != null) { - displayName = contact.asBareJid().toString(); - } else { - displayName = ""; - } - toolbarTitle.setText(displayName); - long publishedTimestamp = 0; - if (storyIds != null && position < storyIds.size()) { - final String currentStoryId = storyIds.get(position); - if (xmppConnectionService != null) { - for (eu.siacs.conversations.entities.Story story : xmppConnectionService.getStories()) { - if (story.getUuid().equals(currentStoryId)) { - publishedTimestamp = story.getPublished(); - break; - } + @Override + public void onStoryTapped() { + if (isSystemUiVisible()) { + hideSystemUi(); + } else { + showSystemUi(); + } + } + + + private void updateUiForPosition(int position) { + Contact storyContact = null; + if (contact != null && xmppConnectionService != null) { + for (Account account : xmppConnectionService.getAccounts()) { + if (account.getStatus() == Account.State.ONLINE) { + final Contact c = account.getRoster().getContact(contact); + if (c != null) { + storyContact = c; + break; } } } - if (publishedTimestamp > 0) { - toolbarSubtitle.setText(DateUtils.getRelativeTimeSpanString(publishedTimestamp, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS)); - } else { - toolbarSubtitle.setText(""); + if (storyContact == null) { + for (Account account : xmppConnectionService.getAccounts()) { + final Contact c = account.getRoster().getContact(contact); + if (c != null) { + storyContact = c; + break; + } + } } } + String displayName; + if (storyContact != null) { + displayName = storyContact.getDisplayName(); + Conversation conversation = xmppConnectionService.findOrCreateConversation(mAccount, contact, false, false); + AvatarWorkerTask.loadAvatar(conversation, toolbarAvatar, R.dimen.muc_avatar_actionbar); + } else if (contact != null) { + displayName = contact.asBareJid().toString(); + } else { + displayName = ""; + } + toolbarTitle.setText(displayName); + long publishedTimestamp = 0; + if (storyIds != null && position < storyIds.size()) { + final String currentStoryId = storyIds.get(position); + if (xmppConnectionService != null) { + for (eu.siacs.conversations.entities.Story story : xmppConnectionService.getStories()) { + if (story.getUuid().equals(currentStoryId)) { + publishedTimestamp = story.getPublished(); + break; + } + } + } + } + if (publishedTimestamp > 0) { + toolbarSubtitle.setText(DateUtils.getRelativeTimeSpanString(publishedTimestamp, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS)); + } else { + toolbarSubtitle.setText(""); + } + titleView.setText(titles.get(position)); progressView.setText((position + 1) + " " + getString(R.string.of) + " " + urls.size()); } private void hideSystemUi() { - ActionBar actionBar = getSupportActionBar(); - if (actionBar != null) { - actionBar.hide(); - } - bottomPanel.setVisibility(View.GONE); + appBarLayout.animate().alpha(0f).setDuration(200); + bottomPanel.animate().alpha(0f).setDuration(200); + getWindow().getDecorView().setSystemUiVisibility( + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + | View.SYSTEM_UI_FLAG_LAYOUT_STABLE + | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_FULLSCREEN); } private void showSystemUi() { - ActionBar actionBar = getSupportActionBar(); - if (actionBar != null) { - actionBar.show(); - } - bottomPanel.setVisibility(View.VISIBLE); + appBarLayout.animate().alpha(1f).setDuration(200); + bottomPanel.animate().alpha(1f).setDuration(200); + getWindow().getDecorView().setSystemUiVisibility( + View.SYSTEM_UI_FLAG_LAYOUT_STABLE + | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN); } private boolean isSystemUiVisible() { - ActionBar actionBar = getSupportActionBar(); - return actionBar != null && actionBar.isShowing(); + return appBarLayout.getAlpha() > 0; } @Override @@ -287,4 +291,4 @@ public class StoryViewActivity extends XmppActivity { } invalidateOptionsMenu(); } -} +} \ No newline at end of file diff --git a/src/main/res/layout/activity_story_view.xml b/src/main/res/layout/activity_story_view.xml index a61a3a442..ee2f2cb73 100644 --- a/src/main/res/layout/activity_story_view.xml +++ b/src/main/res/layout/activity_story_view.xml @@ -12,6 +12,7 @@ android:layout_height="match_parent" /> + + Date: Fri, 2 Jan 2026 18:18:17 +0100 Subject: [PATCH 047/180] Show cached stories even when offline --- .../conversations/ui/StoriesActivity.java | 28 +++++++++---------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java b/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java index 87e6cbc18..aea3e4902 100644 --- a/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java @@ -344,15 +344,6 @@ public class StoriesActivity extends XmppActivity implements XmppConnectionServi if (xmppConnectionService == null) { return; } - if (xmppConnectionService.getAccounts().stream().noneMatch(account -> account.getStatus() == Account.State.ONLINE)) { - binding.storiesList.setVisibility(View.GONE); - binding.fabAddStory.setVisibility(View.GONE); - binding.placeholder.setVisibility(View.VISIBLE); - binding.placeholder.setText(R.string.no_active_account_to_show_stories); - return; - } - binding.placeholder.setVisibility(View.GONE); - binding.fabAddStory.setVisibility(View.VISIBLE); this.stories.clear(); long twentyFourHoursAgo = System.currentTimeMillis() - 86400000; this.stories.addAll( @@ -366,13 +357,20 @@ public class StoriesActivity extends XmppActivity implements XmppConnectionServi .values() ); Collections.sort(this.stories, (a, b) -> Long.compare(b.getPublished(), a.getPublished())); - - if (this.stories.isEmpty()) { - binding.storiesList.setVisibility(View.GONE); - } else { - binding.storiesList.setVisibility(View.VISIBLE); - } storyAdapter.notifyDataSetChanged(); + + final boolean hasOnlineAccounts = xmppConnectionService.getAccounts().stream().anyMatch(account -> account.getStatus() == Account.State.ONLINE); + + if (!hasOnlineAccounts && this.stories.isEmpty()) { + binding.storiesList.setVisibility(View.GONE); + binding.fabAddStory.setVisibility(View.GONE); + binding.placeholder.setVisibility(View.VISIBLE); + binding.placeholder.setText(R.string.no_active_account_to_show_stories); + } else { + binding.placeholder.setVisibility(View.GONE); + binding.fabAddStory.setVisibility(hasOnlineAccounts ? View.VISIBLE : View.GONE); + binding.storiesList.setVisibility(this.stories.isEmpty() ? View.GONE : View.VISIBLE); + } } @Override -- 2.39.5 From 465e28a879de7fcc9fc10f5db3d60d15a276bc50 Mon Sep 17 00:00:00 2001 From: Arne Date: Fri, 2 Jan 2026 19:02:43 +0100 Subject: [PATCH 048/180] Decouple story creation from message objects --- .../http/HttpConnectionManager.java | 11 ++- .../http/HttpUploadConnection.java | 99 +++++++++++++++---- .../persistance/FileBackend.java | 7 ++ .../services/XmppConnectionService.java | 62 +++++++----- src/main/res/values/strings.xml | 1 + 5 files changed, 139 insertions(+), 41 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/http/HttpConnectionManager.java b/src/main/java/eu/siacs/conversations/http/HttpConnectionManager.java index 01f520c8d..a99880ef2 100644 --- a/src/main/java/eu/siacs/conversations/http/HttpConnectionManager.java +++ b/src/main/java/eu/siacs/conversations/http/HttpConnectionManager.java @@ -14,6 +14,7 @@ import eu.siacs.conversations.entities.DownloadableFile; import eu.siacs.conversations.entities.Message; import eu.siacs.conversations.services.AbstractConnectionManager; import eu.siacs.conversations.services.XmppConnectionService; +import eu.siacs.conversations.ui.UiCallback; import eu.siacs.conversations.utils.TLSSocketFactory; import okhttp3.HttpUrl; @@ -121,7 +122,15 @@ public class HttpConnectionManager extends AbstractConnectionManager { } } HttpUploadConnection connection = new HttpUploadConnection(message, Method.determine(message.getConversation().getAccount()), this, cb); - connection.init(delay); + connection.initForMessage(delay); + this.uploadConnections.add(connection); + } + } + + public void createNewUploadConnection(final DownloadableFile file, final Account account, final boolean delay, final UiCallback callback) { + synchronized (this.uploadConnections) { + HttpUploadConnection connection = new HttpUploadConnection(account, file, Method.determine(account), this, callback); + connection.initForFile(); this.uploadConnections.add(connection); } } diff --git a/src/main/java/eu/siacs/conversations/http/HttpUploadConnection.java b/src/main/java/eu/siacs/conversations/http/HttpUploadConnection.java index f6df26396..0db235e5f 100644 --- a/src/main/java/eu/siacs/conversations/http/HttpUploadConnection.java +++ b/src/main/java/eu/siacs/conversations/http/HttpUploadConnection.java @@ -18,12 +18,14 @@ import java.util.List; import java.util.concurrent.Future; import eu.siacs.conversations.Config; +import eu.siacs.conversations.R; import eu.siacs.conversations.entities.Account; import eu.siacs.conversations.entities.DownloadableFile; import eu.siacs.conversations.entities.Message; import eu.siacs.conversations.entities.Transferable; import eu.siacs.conversations.services.AbstractConnectionManager; import eu.siacs.conversations.services.XmppConnectionService; +import eu.siacs.conversations.ui.UiCallback; import eu.siacs.conversations.utils.CryptoHelper; import okhttp3.Call; import okhttp3.Callback; @@ -45,6 +47,7 @@ public class HttpUploadConnection implements Transferable, AbstractConnectionMan private final Method method; private boolean delayed = false; private DownloadableFile file; + @Nullable private final Message message; private SlotRequester.Slot slot; private byte[] key = null; @@ -52,14 +55,34 @@ public class HttpUploadConnection implements Transferable, AbstractConnectionMan private long transmitted = 0; private Call mostRecentCall; private ListenableFuture slotFuture; - private Runnable cb; + + @Nullable + private final Runnable cb; + + @Nullable + private final UiCallback callback; + @NonNull + private final Account account; public HttpUploadConnection(Message message, Method method, HttpConnectionManager httpConnectionManager, Runnable cb) { this.message = message; + this.account = message.getConversation().getAccount(); this.method = method; this.mHttpConnectionManager = httpConnectionManager; this.mXmppConnectionService = httpConnectionManager.getXmppConnectionService(); this.cb = cb; + this.callback = null; + } + + public HttpUploadConnection(Account account, DownloadableFile file, Method method, HttpConnectionManager httpConnectionManager, UiCallback callback) { + this.message = null; + this.account = account; + this.file = file; + this.method = method; + this.mHttpConnectionManager = httpConnectionManager; + this.mXmppConnectionService = httpConnectionManager.getXmppConnectionService(); + this.callback = callback; + this.cb = null; } @Override @@ -90,13 +113,13 @@ public class HttpUploadConnection implements Transferable, AbstractConnectionMan final ListenableFuture slotFuture = this.slotFuture; if (slotFuture != null && !slotFuture.isDone()) { if (slotFuture.cancel(true)) { - Log.d(Config.LOGTAG,"cancelled slot requester"); + Log.d(Config.LOGTAG, "cancelled slot requester"); } } final Call call = this.mostRecentCall; if (call != null && !call.isCanceled()) { call.cancel(); - Log.d(Config.LOGTAG,"cancelled HTTP request"); + Log.d(Config.LOGTAG, "cancelled HTTP request"); } } @@ -105,17 +128,22 @@ public class HttpUploadConnection implements Transferable, AbstractConnectionMan final Call call = this.mostRecentCall; final Future slotFuture = this.slotFuture; final boolean cancelled = (call != null && call.isCanceled()) || (slotFuture != null && slotFuture.isCancelled()); - mXmppConnectionService.markMessage(message, Message.STATUS_SEND_FAILED, cancelled ? Message.ERROR_MESSAGE_CANCELLED : errorMessage); - if (cb != null) cb.run(); + if (this.message != null) { + mXmppConnectionService.markMessage(message, Message.STATUS_SEND_FAILED, cancelled ? Message.ERROR_MESSAGE_CANCELLED : errorMessage); + if (cb != null) cb.run(); + } else if (this.callback != null) { + callback.error(R.string.upload_failed_server_not_found, errorMessage); + } } private void finish() { mHttpConnectionManager.finishUploadConnection(this); - message.setTransferable(null); + if (this.message != null) { + message.setTransferable(null); + } } - public void init(boolean delay) { - final Account account = message.getConversation().getAccount(); + public void initForMessage(boolean delay) { this.file = mXmppConnectionService.getFileBackend().getFile(message, false); final String mime; if (message.getEncryption() == Message.ENCRYPTION_PGP || message.getEncryption() == Message.ENCRYPTION_DECRYPTED) { @@ -147,7 +175,6 @@ public class HttpUploadConnection implements Transferable, AbstractConnectionMan @Override public void onFailure(@NonNull final Throwable throwable) { Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": unable to request slot", throwable); - // TODO consider fall back to jingle in 1-on-1 chats with exactly one online presence fail(throwable.getMessage()); } }, MoreExecutors.directExecutor()); @@ -155,10 +182,40 @@ public class HttpUploadConnection implements Transferable, AbstractConnectionMan mXmppConnectionService.markMessage(message, Message.STATUS_UNSEND); } + public void initForFile() { + final String mime = this.file.getMimeType(); + final long originalFileSize = file.getSize(); + this.delayed = false; + if (Config.ENCRYPT_ON_HTTP_UPLOADED) { + this.key = new byte[44]; + SECURE_RANDOM.nextBytes(this.key); + this.file.setKeyAndIv(this.key); + } + this.file.setExpectedSize(originalFileSize + (file.getKey() != null ? 16 : 0)); + this.slotFuture = new SlotRequester(mXmppConnectionService).request(method, account, file, file.getName(), mime); + Futures.addCallback(this.slotFuture, new FutureCallback() { + @Override + public void onSuccess(@Nullable SlotRequester.Slot result) { + HttpUploadConnection.this.slot = result; + try { + HttpUploadConnection.this.upload(); + } catch (final Exception e) { + fail(e.getMessage()); + } + } + + @Override + public void onFailure(@NonNull final Throwable throwable) { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": unable to request slot", throwable); + fail(throwable.getMessage()); + } + }, MoreExecutors.directExecutor()); + } + private void upload() { final OkHttpClient client = mHttpConnectionManager.buildHttpClient( slot.put, - message.getConversation().getAccount(), + account, 0, true ); @@ -178,7 +235,7 @@ public class HttpUploadConnection implements Transferable, AbstractConnectionMan } @Override - public void onResponse(@NonNull Call call, @NonNull Response response) { + public void onResponse(@NonNull Call call, @NonNull Response response) { final int code = response.code(); if (code == 200 || code == 201) { Log.d(Config.LOGTAG, "finished uploading file"); @@ -188,13 +245,19 @@ public class HttpUploadConnection implements Transferable, AbstractConnectionMan } else { get = slot.get.toString(); } - mXmppConnectionService.getFileBackend().updateFileParams(message, get); - mXmppConnectionService.getFileBackend().updateMediaScanner(file); - finish(); - if (!message.isPrivateMessage()) { - message.setCounterpart(message.getConversation().getJid().asBareJid()); + if (message != null) { + mXmppConnectionService.getFileBackend().updateFileParams(message, get); + mXmppConnectionService.getFileBackend().updateMediaScanner(file); + finish(); + if (!message.isPrivateMessage()) { + message.setCounterpart(message.getConversation().getJid().asBareJid()); + } + mXmppConnectionService.resendMessage(message, delayed, cb); + } else if (callback != null) { + mXmppConnectionService.getFileBackend().updateMediaScanner(file); + finish(); + callback.success(get); } - mXmppConnectionService.resendMessage(message, delayed, cb); } else { Log.d(Config.LOGTAG, "http upload failed because response code was " + code); fail("http upload failed because response code was " + code); @@ -212,4 +275,4 @@ public class HttpUploadConnection implements Transferable, AbstractConnectionMan this.transmitted = progress; mHttpConnectionManager.updateConversationUi(false); } -} +} \ No newline at end of file diff --git a/src/main/java/eu/siacs/conversations/persistance/FileBackend.java b/src/main/java/eu/siacs/conversations/persistance/FileBackend.java index 99940d0a7..ce648c61a 100644 --- a/src/main/java/eu/siacs/conversations/persistance/FileBackend.java +++ b/src/main/java/eu/siacs/conversations/persistance/FileBackend.java @@ -2255,6 +2255,13 @@ public class FileBackend { } } + public DownloadableFile getTemporaryFile(String mimeType) { + final String extension = MimeUtils.guessExtensionFromMimeType(mimeType); + final String filename = UUID.randomUUID().toString() + (extension == null ? "" : "." + extension); + final File file = new File(mXmppConnectionService.getCacheDir(), filename); + return new DownloadableFile(file.getAbsolutePath()); + } + private int getMediaRuntime(final File file) { try { final MediaMetadataRetriever mediaMetadataRetriever = new MediaMetadataRetriever(); diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java index a5dc64794..1698d4284 100644 --- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +++ b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java @@ -106,6 +106,7 @@ import java.util.List; import java.util.ListIterator; import java.util.Map; import java.util.Set; +import java.util.UUID; import java.util.WeakHashMap; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CountDownLatch; @@ -123,6 +124,7 @@ import java.util.function.Consumer; import eu.siacs.conversations.Conversations; import eu.siacs.conversations.entities.Story; +import eu.siacs.conversations.entities.StubConversation; import eu.siacs.conversations.xmpp.jid.OtrJidHelper; import io.ipfs.cid.Cid; @@ -7817,28 +7819,21 @@ public class XmppConnectionService extends Service { callback.error(R.string.no_active_account, null); return; } - final Conversation conversation = new Conversation(account.getDisplayName(), account, account.getJid().asBareJid(), Conversation.MODE_SINGLE); - final Message message = new Message(conversation, "", Message.ENCRYPTION_NONE); - message.setStatus(Message.STATUS_DUMMY); - if (mimeType != null && mimeType.startsWith("image/")) { - message.setType(Message.TYPE_IMAGE); - } else if (mimeType != null && mimeType.startsWith("video/")) { - message.setType(Message.TYPE_FILE); - } else { - message.setType(Message.TYPE_FILE); - } + final DownloadableFile file = getFileBackend().getTemporaryFile(mimeType); Runnable runnable = () -> { try { if (mimeType != null && mimeType.startsWith("image/")) { - getFileBackend().copyImageToPrivateStorage(message, uri); + getFileBackend().copyImageToPrivateStorage(file, uri); } else { - getFileBackend().copyFileToPrivateStorage(message, uri, mimeType); + getFileBackend().copyFileToPrivateStorage(file, uri); } } catch (FileBackend.ImageCompressionException e) { + Log.d(Config.LOGTAG, "unable to compress image for story. falling back to file transfer", e); try { - getFileBackend().copyFileToPrivateStorage(message, uri, mimeType); + // Retry as a generic file if image compression fails + getFileBackend().copyFileToPrivateStorage(file, uri); } catch (FileBackend.FileCopyException ex) { callback.error(ex.getResId(), null); return; @@ -7848,18 +7843,41 @@ public class XmppConnectionService extends Service { return; } - mHttpConnectionManager.createNewUploadConnection(message, false, () -> { - try { - if (message.getFileParams() != null && message.getFileParams().url != null) { - callback.success(message.getFileParams().url.toString()); - } else { - callback.error(R.string.upload_failed_server_not_found, null); + // Create a wrapper callback to ensure the temporary file is deleted. + UiCallback wrapperCallback = new UiCallback() { + @Override + public void success(String url) { + try { + callback.success(url); + } finally { + getFileBackend().deleteFile(file); } - } finally { - getFileBackend().deleteFile(message); } - }); + + @Override + public void error(int errorCode, String object) { + try { + callback.error(errorCode, object); + } finally { + getFileBackend().deleteFile(file); + } + } + + @Override + public void userInputRequired(android.app.PendingIntent pi, String object) { + // This is not expected for file uploads, but we handle it just in case. + try { + callback.userInputRequired(pi, object); + } finally { + getFileBackend().deleteFile(file); + } + } + }; + + // Use the new, message-less upload method. + mHttpConnectionManager.createNewUploadConnection(file, account, false, wrapperCallback); }; + FILE_ATTACHMENT_EXECUTOR.execute(runnable); } diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index aa0d687c4..1239f0670 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -1592,4 +1592,5 @@ Reply to story: No active account to show or publish stories of + Could not create story \ No newline at end of file -- 2.39.5 From f3b7f0cdf1c2ce0ba047eaff7c262b7bdffa5678 Mon Sep 17 00:00:00 2001 From: Arne Date: Fri, 2 Jan 2026 19:34:02 +0100 Subject: [PATCH 049/180] Transcode videos before uploading them for stories --- .../services/XmppConnectionService.java | 71 ++++++++++++++++--- 1 file changed, 63 insertions(+), 8 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java index 1698d4284..1b523705e 100644 --- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +++ b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java @@ -75,6 +75,8 @@ import com.kedia.ogparser.JsoupProxy; import com.kedia.ogparser.OpenGraphCallback; import com.kedia.ogparser.OpenGraphParser; import com.kedia.ogparser.OpenGraphResult; +import com.otaliastudios.transcoder.Transcoder; +import com.otaliastudios.transcoder.TranscoderListener; import net.java.otr4j.session.Session; import net.java.otr4j.session.SessionID; @@ -112,6 +114,7 @@ import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executor; import java.util.concurrent.Executors; +import java.util.concurrent.Future; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.Semaphore; import java.util.concurrent.RejectedExecutionException; @@ -125,6 +128,7 @@ import java.util.function.Consumer; import eu.siacs.conversations.Conversations; import eu.siacs.conversations.entities.Story; import eu.siacs.conversations.entities.StubConversation; +import eu.siacs.conversations.utils.TranscoderStrategies; import eu.siacs.conversations.xmpp.jid.OtrJidHelper; import io.ipfs.cid.Cid; @@ -7816,15 +7820,71 @@ public class XmppConnectionService extends Service { public void uploadFileForUrl(final Account account, final android.net.Uri uri, final String mimeType, final UiCallback callback) { if (account == null) { - callback.error(R.string.no_active_account, null); - return; + callback.error(R.string.no_active_account, null); return; } final DownloadableFile file = getFileBackend().getTemporaryFile(mimeType); Runnable runnable = () -> { try { - if (mimeType != null && mimeType.startsWith("image/")) { + if (mimeType != null && mimeType.startsWith("video/")) { + final AtomicBoolean transcodeFailed = new AtomicBoolean(false); + final TranscoderListener listener = new TranscoderListener() { + @Override + public void onTranscodeProgress(double progress) { + } + + @Override + public void onTranscodeCompleted(int successCode) { + Log.d(Config.LOGTAG, "transcoding successful for story"); + } + + @Override + public void onTranscodeCanceled() { + Log.d(Config.LOGTAG, "transcoding canceled for story"); + transcodeFailed.set(true); + } + + @Override + public void onTranscodeFailed(@NonNull Throwable exception) { + Log.w(Config.LOGTAG, "video transcoding failed for story", exception); + transcodeFailed.set(true); + } + }; + try { + Log.d(Config.LOGTAG, "Attempting to transcode video for story"); + final boolean highQuality = "720".equals(AttachFileToConversationRunnable.getVideoCompression(this)); + Future future = Transcoder.into(file.getAbsolutePath()) + .addDataSource(this, uri) + .setVideoTrackStrategy(highQuality ? TranscoderStrategies.VIDEO_720P : TranscoderStrategies.VIDEO_360P) + .setAudioTrackStrategy(highQuality ? TranscoderStrategies.AUDIO_HQ : TranscoderStrategies.AUDIO_MQ) + .setListener(listener) + .transcode(); + future.get(); + } catch (Exception e) { + Log.w(Config.LOGTAG, "video transcoding setup failed for story", e); + transcodeFailed.set(true); + } + + if (transcodeFailed.get() || file.getSize() == 0) { + Log.d(Config.LOGTAG, "transcoding failed. falling back to copying original for story"); + if (file.exists() && !file.delete()) { + Log.w(Config.LOGTAG,"could not delete failed transcode file"); + } + getFileBackend().copyFileToPrivateStorage(file, uri); + } else { + long originalSize = FileBackend.getFileSize(this, uri); + if (originalSize > 0 && file.getSize() >= originalSize) { + Log.d(Config.LOGTAG, "transcoded file was not smaller. using original for story"); + if (file.exists() && !file.delete()) { + Log.w(Config.LOGTAG,"could not delete failed transcode file"); + } + getFileBackend().copyFileToPrivateStorage(file, uri); + } else { + Log.d(Config.LOGTAG, "using transcoded video for story upload"); + } + } + } else if (mimeType != null && mimeType.startsWith("image/")) { getFileBackend().copyImageToPrivateStorage(file, uri); } else { getFileBackend().copyFileToPrivateStorage(file, uri); @@ -7832,7 +7892,6 @@ public class XmppConnectionService extends Service { } catch (FileBackend.ImageCompressionException e) { Log.d(Config.LOGTAG, "unable to compress image for story. falling back to file transfer", e); try { - // Retry as a generic file if image compression fails getFileBackend().copyFileToPrivateStorage(file, uri); } catch (FileBackend.FileCopyException ex) { callback.error(ex.getResId(), null); @@ -7843,7 +7902,6 @@ public class XmppConnectionService extends Service { return; } - // Create a wrapper callback to ensure the temporary file is deleted. UiCallback wrapperCallback = new UiCallback() { @Override public void success(String url) { @@ -7865,7 +7923,6 @@ public class XmppConnectionService extends Service { @Override public void userInputRequired(android.app.PendingIntent pi, String object) { - // This is not expected for file uploads, but we handle it just in case. try { callback.userInputRequired(pi, object); } finally { @@ -7873,8 +7930,6 @@ public class XmppConnectionService extends Service { } } }; - - // Use the new, message-less upload method. mHttpConnectionManager.createNewUploadConnection(file, account, false, wrapperCallback); }; -- 2.39.5 From 65fbd02534f1113c88dd88a92e3096d074d2658f Mon Sep 17 00:00:00 2001 From: Arne Date: Fri, 2 Jan 2026 19:42:33 +0100 Subject: [PATCH 050/180] Show foreground notification during video transcoding for stories --- .../services/XmppConnectionService.java | 97 ++++++++++--------- 1 file changed, 50 insertions(+), 47 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java index 1b523705e..bd30512c9 100644 --- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +++ b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java @@ -7820,69 +7820,72 @@ public class XmppConnectionService extends Service { public void uploadFileForUrl(final Account account, final android.net.Uri uri, final String mimeType, final UiCallback callback) { if (account == null) { - callback.error(R.string.no_active_account, null); return; - } + callback.error(R.string.no_active_account, null); + return;} final DownloadableFile file = getFileBackend().getTemporaryFile(mimeType); Runnable runnable = () -> { try { if (mimeType != null && mimeType.startsWith("video/")) { - final AtomicBoolean transcodeFailed = new AtomicBoolean(false); - final TranscoderListener listener = new TranscoderListener() { - @Override - public void onTranscodeProgress(double progress) { - } + startOngoingVideoTranscodingForegroundNotification();try { + final AtomicBoolean transcodeFailed = new AtomicBoolean(false); + final TranscoderListener listener = new TranscoderListener() { + @Override + public void onTranscodeProgress(double progress) { + } - @Override - public void onTranscodeCompleted(int successCode) { - Log.d(Config.LOGTAG, "transcoding successful for story"); - } + @Override + public void onTranscodeCompleted(int successCode) { + Log.d(Config.LOGTAG, "transcoding successful for story"); + } - @Override - public void onTranscodeCanceled() { - Log.d(Config.LOGTAG, "transcoding canceled for story"); + @Override + public void onTranscodeCanceled() { + Log.d(Config.LOGTAG, "transcoding canceled for story"); + transcodeFailed.set(true); + } + + @Override + public void onTranscodeFailed(@NonNull Throwable exception) { + Log.w(Config.LOGTAG, "video transcoding failed for story", exception); + transcodeFailed.set(true); + }}; + try { + Log.d(Config.LOGTAG, "Attempting to transcode video for story"); + final boolean highQuality = "720".equals(AttachFileToConversationRunnable.getVideoCompression(this)); + Future future = Transcoder.into(file.getAbsolutePath()) + .addDataSource(this, uri) + .setVideoTrackStrategy(highQuality ? TranscoderStrategies.VIDEO_720P : TranscoderStrategies.VIDEO_360P) + .setAudioTrackStrategy(highQuality ? TranscoderStrategies.AUDIO_HQ : TranscoderStrategies.AUDIO_MQ) + .setListener(listener) + .transcode(); + future.get(); + } catch (Exception e) { + Log.w(Config.LOGTAG, "video transcoding setup failed for story", e); transcodeFailed.set(true); } - @Override - public void onTranscodeFailed(@NonNull Throwable exception) { - Log.w(Config.LOGTAG, "video transcoding failed for story", exception); - transcodeFailed.set(true); - } - }; - try { - Log.d(Config.LOGTAG, "Attempting to transcode video for story"); - final boolean highQuality = "720".equals(AttachFileToConversationRunnable.getVideoCompression(this)); - Future future = Transcoder.into(file.getAbsolutePath()) - .addDataSource(this, uri) - .setVideoTrackStrategy(highQuality ? TranscoderStrategies.VIDEO_720P : TranscoderStrategies.VIDEO_360P) - .setAudioTrackStrategy(highQuality ? TranscoderStrategies.AUDIO_HQ : TranscoderStrategies.AUDIO_MQ) - .setListener(listener) - .transcode(); - future.get(); - } catch (Exception e) { - Log.w(Config.LOGTAG, "video transcoding setup failed for story", e); - transcodeFailed.set(true); - } - - if (transcodeFailed.get() || file.getSize() == 0) { - Log.d(Config.LOGTAG, "transcoding failed. falling back to copying original for story"); - if (file.exists() && !file.delete()) { - Log.w(Config.LOGTAG,"could not delete failed transcode file"); - } - getFileBackend().copyFileToPrivateStorage(file, uri); - } else { - long originalSize = FileBackend.getFileSize(this, uri); - if (originalSize > 0 && file.getSize() >= originalSize) { - Log.d(Config.LOGTAG, "transcoded file was not smaller. using original for story"); + if (transcodeFailed.get() || file.getSize() == 0) { + Log.d(Config.LOGTAG, "transcoding failed. falling back to copying original for story"); if (file.exists() && !file.delete()) { - Log.w(Config.LOGTAG,"could not delete failed transcode file"); + Log.w(Config.LOGTAG, "could not delete failed transcode file"); } getFileBackend().copyFileToPrivateStorage(file, uri); } else { - Log.d(Config.LOGTAG, "using transcoded video for story upload"); + long originalSize = FileBackend.getFileSize(this, uri); + if (originalSize > 0 && file.getSize() >= originalSize) { + Log.d(Config.LOGTAG, "transcoded file was not smaller. using original for story"); + if (file.exists() && !file.delete()) { + Log.w(Config.LOGTAG, "could not delete failed transcode file"); + } + getFileBackend().copyFileToPrivateStorage(file, uri); + } else { + Log.d(Config.LOGTAG, "using transcoded video for story upload"); + } } + } finally { + stopOngoingVideoTranscodingForegroundNotification(); } } else if (mimeType != null && mimeType.startsWith("image/")) { getFileBackend().copyImageToPrivateStorage(file, uri); -- 2.39.5 From 466fa3a40e945beff86c838579622d931566aecb Mon Sep 17 00:00:00 2001 From: Arne Date: Sat, 3 Jan 2026 10:50:54 +0100 Subject: [PATCH 051/180] Fix replying to story --- src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java b/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java index 459a01c5e..07fa8c27c 100644 --- a/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java @@ -256,8 +256,8 @@ public class StoryViewActivity extends XmppActivity implements StoryFragment.OnS return true; } else if (itemId == R.id.action_reply_to_story) { int currentPos = viewPager.getCurrentItem(); - Message storyMessage = new Message(null, titles.get(currentPos), Message.ENCRYPTION_NONE, Message.STATUS_RECEIVED); Conversation conversation = xmppConnectionService.findOrCreateConversation(mAccount, contact, false, false); + Message storyMessage = new Message(conversation, getString(R.string.reply_to_story) + " " + "\"" + titles.get(currentPos) + "\"", conversation.getNextEncryption(), Message.STATUS_RECEIVED); conversation.setReplyTo(storyMessage); switchToConversation(conversation); return true; -- 2.39.5 From 1c82b9afad57ea4a555e9df657c1cc92d8681cc6 Mon Sep 17 00:00:00 2001 From: Arne Date: Sat, 3 Jan 2026 10:57:55 +0100 Subject: [PATCH 052/180] Prepare version 2.0.19 --- build.gradle | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/build.gradle b/build.gradle index c8833964d..e21e8ec48 100644 --- a/build.gradle +++ b/build.gradle @@ -142,8 +142,8 @@ android { defaultConfig { minSdkVersion 23 targetSdkVersion 35 - versionCode 195 - versionName "2.0.18" + versionCode 196 + versionName "2.0.19" applicationId "de.monocles.chat" def appName = "monocles chat" resValue "string", "app_name", appName @@ -209,8 +209,8 @@ android { monocleschat { dimension "mode" applicationId = "de.monocles.chat" - versionCode 195 - versionName "2.0.18" + versionCode 196 + versionName "2.0.19" def appName = "monocles chat" resValue "string", "app_name", appName buildConfigField "String", "APP_NAME", "\"$appName\"" -- 2.39.5 From cd5524f218eaa82cc4ccc7b567ee10ab8e2b5d20 Mon Sep 17 00:00:00 2001 From: Arne Date: Sat, 3 Jan 2026 17:45:31 +0100 Subject: [PATCH 053/180] Defer story loading until fragment is visible --- .../siacs/conversations/ui/StoryFragment.java | 79 +++++++++++++++---- .../conversations/ui/StoryViewActivity.java | 19 ++--- 2 files changed, 75 insertions(+), 23 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/ui/StoryFragment.java b/src/main/java/eu/siacs/conversations/ui/StoryFragment.java index 6a016fcd4..8636d9a56 100644 --- a/src/main/java/eu/siacs/conversations/ui/StoryFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/StoryFragment.java @@ -22,10 +22,14 @@ import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.UUID; import eu.siacs.conversations.Config; import eu.siacs.conversations.R; import eu.siacs.conversations.http.HttpConnectionManager; +import eu.siacs.conversations.utils.CryptoHelper; import okhttp3.HttpUrl; public class StoryFragment extends Fragment { @@ -68,25 +72,46 @@ public class StoryFragment extends Fragment { public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); - final ImageView imageView = view.findViewById(R.id.story_image_view); - final VideoView videoView = view.findViewById(R.id.story_video_view); - final ProgressBar progressBar = view.findViewById(R.id.story_progress); - view.setOnClickListener(v -> { if (mListener != null) { mListener.onStoryTapped(); } }); - final String url = getArguments().getString(ARG_URL); - final String mimeType = getArguments().getString(ARG_MIME_TYPE); - final StoryViewActivity activity = (StoryViewActivity) getActivity(); - if (activity == null || activity.xmppConnectionService == null) { + if (activity != null && activity.xmppConnectionService != null) { + loadStory(); + } + } + + public void loadStory() { + if (!isAdded()) { return; } - final File cacheFile = activity.getStoryCacheFile(url); + final View view = getView(); + if (view == null) { + return; + } + + final ImageView imageView = view.findViewById(R.id.story_image_view); + final VideoView videoView = view.findViewById(R.id.story_video_view); + final ProgressBar progressBar = view.findViewById(R.id.story_progress); + + final Bundle args = getArguments(); + if (args == null) { + return; + } + + final String url = args.getString(ARG_URL); + final String mimeType = args.getString(ARG_MIME_TYPE); + + final StoryViewActivity activity = (StoryViewActivity) getActivity(); + if (activity == null) { + return; + } + + final File cacheFile = getStoryCacheFile(getContext(), url); new Thread(() -> { try { @@ -94,8 +119,13 @@ public class StoryFragment extends Fragment { activity.runOnUiThread(() -> progressBar.setVisibility(View.VISIBLE)); Log.d(Config.LOGTAG, "Story not in cache. Downloading from: " + url); final HttpUrl httpUrl = HttpUrl.get(url); - final boolean useTor = activity.xmppConnectionService.useTorToConnect(); - final boolean useI2p = activity.xmppConnectionService.useI2PToConnect(); + boolean useTor = false; + boolean useI2p = false; + if (activity.xmppConnectionService != null) { + useTor = activity.xmppConnectionService.useTorToConnect(); + useI2p = activity.xmppConnectionService.useI2PToConnect(); + } + try (InputStream inputStream = HttpConnectionManager.open(httpUrl, useTor, useI2p); FileOutputStream outputStream = new FileOutputStream(cacheFile)) { byte[] buffer = new byte[4096]; @@ -110,7 +140,7 @@ public class StoryFragment extends Fragment { activity.runOnUiThread(() -> { progressBar.setVisibility(View.GONE); - if (isAdded() && getActivity() != null && cacheFile != null) { + if (isAdded() && getContext() != null && cacheFile != null) { videoView.stopPlayback(); if (mimeType != null && mimeType.startsWith("video/")) { imageView.setVisibility(View.GONE); @@ -123,7 +153,7 @@ public class StoryFragment extends Fragment { } else { videoView.setVisibility(View.GONE); imageView.setVisibility(View.VISIBLE); - Glide.with(this).load(cacheFile).into(imageView); + Glide.with(getContext()).load(cacheFile).into(imageView); } } }); @@ -134,13 +164,34 @@ public class StoryFragment extends Fragment { cacheFile.delete(); } activity.runOnUiThread(() -> { - if(isAdded()) { + if (isAdded() && getContext() != null) { Toast.makeText(getContext(), R.string.download_failed_file_not_found, Toast.LENGTH_SHORT).show(); } }); } }).start(); } + + private static File getStoryCacheFile(Context context, String url) { + if (context == null || url == null) { + return null; + } + + File cacheDir = context.getCacheDir(); + File storyCache = new File(cacheDir, "stories"); + if (!storyCache.exists()) { + storyCache.mkdirs(); + } + try { + MessageDigest digest = MessageDigest.getInstance("SHA-1"); + digest.update(url.getBytes()); + String sha1 = CryptoHelper.bytesToHex(digest.digest()); + return new File(storyCache, sha1); + } catch (NoSuchAlgorithmException e) { + return new File(storyCache, UUID.randomUUID().toString()); + } + } + @Override public void onDetach() { super.onDetach(); diff --git a/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java b/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java index 07fa8c27c..c64298749 100644 --- a/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java @@ -95,20 +95,16 @@ public class StoryViewActivity extends XmppActivity implements StoryFragment.OnS public void onPageSelected(int position) { super.onPageSelected(position); updateUiForPosition(position); + Fragment currentFragment = getSupportFragmentManager().findFragmentByTag("f" + position); + if (currentFragment instanceof StoryFragment) { + ((StoryFragment) currentFragment).loadStory(); + } } }); - updateUiForPosition(0); showSystemUi(); } - public File getStoryCacheFile(String url) { - if (xmppConnectionService != null) { - return xmppConnectionService.getFileBackend().getStoryCacheFile(url); - } - return null; - } - @Override public void onStoryTapped() { if (isSystemUiVisible()) { @@ -202,7 +198,11 @@ public class StoryViewActivity extends XmppActivity implements StoryFragment.OnS @Override protected void refreshUiReal() { - + updateUiForPosition(viewPager.getCurrentItem()); + Fragment currentFragment = getSupportFragmentManager().findFragmentByTag("f" + viewPager.getCurrentItem()); + if (currentFragment instanceof StoryFragment) { + ((StoryFragment) currentFragment).loadStory(); + } } @Override @@ -290,5 +290,6 @@ public class StoryViewActivity extends XmppActivity implements StoryFragment.OnS mAccount = xmppConnectionService.findAccountByUuid(accountUuid); } invalidateOptionsMenu(); + refreshUi(); } } \ No newline at end of file -- 2.39.5 From 4e1f47b84a16657a16787c781fd150da0e2624b3 Mon Sep 17 00:00:00 2001 From: Arne Date: Sun, 4 Jan 2026 03:09:44 +0100 Subject: [PATCH 054/180] Always show quote in reply to previous message --- src/main/java/eu/siacs/conversations/entities/Message.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/eu/siacs/conversations/entities/Message.java b/src/main/java/eu/siacs/conversations/entities/Message.java index d2b47a2c5..6c5f078f2 100644 --- a/src/main/java/eu/siacs/conversations/entities/Message.java +++ b/src/main/java/eu/siacs/conversations/entities/Message.java @@ -1190,7 +1190,7 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable if (includeReplyTo && getInReplyTo() != null && getModerated() == null) { // Don't show quote if it's the message right before us - if (prev() != null && prev().getUuid().equals(getInReplyTo().getUuid())) return spannableBody; + // if (prev() != null && prev().getUuid().equals(getInReplyTo().getUuid())) return spannableBody; final var quote = getInReplyTo().getSpannableBody(thumbnailer, fallbackImg); if ((getInReplyTo().isFileOrImage() || getInReplyTo().isOOb()) && getInReplyTo().getFileParams() != null) { -- 2.39.5 From 58e3785b094b28fa08106f8f0cbb6c1ec2267603 Mon Sep 17 00:00:00 2001 From: Arne Date: Sun, 4 Jan 2026 12:53:22 +0100 Subject: [PATCH 055/180] Allow editing chat background images --- .../settings/InterfaceSettingsFragment.java | 35 ++++++++++++++++--- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/ui/fragment/settings/InterfaceSettingsFragment.java b/src/main/java/eu/siacs/conversations/ui/fragment/settings/InterfaceSettingsFragment.java index d2f708c86..b454c0d39 100644 --- a/src/main/java/eu/siacs/conversations/ui/fragment/settings/InterfaceSettingsFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/fragment/settings/InterfaceSettingsFragment.java @@ -1,9 +1,11 @@ package eu.siacs.conversations.ui.fragment.settings; +import android.app.Activity; import android.content.Intent; import android.content.pm.PackageManager; -import android.os.Bundle; +import android.net.Uri; import android.os.Build; +import android.os.Bundle; import android.preference.Preference; import android.widget.Toast; @@ -15,8 +17,8 @@ import com.google.android.material.color.DynamicColors; import java.io.File; import eu.siacs.conversations.AppSettings; -import eu.siacs.conversations.Conversations; import eu.siacs.conversations.R; +import eu.siacs.conversations.medialib.activities.EditActivity; import eu.siacs.conversations.ui.activity.SettingsActivity; import eu.siacs.conversations.ui.util.SettingsUtils; import eu.siacs.conversations.utils.ChatBackgroundHelper; @@ -24,6 +26,8 @@ import eu.siacs.conversations.utils.ThemeHelper; public class InterfaceSettingsFragment extends XmppPreferenceFragment { + private static final int REQUEST_EDIT_BACKGROUND = 9124; + @Override public void onCreatePreferences(@Nullable Bundle savedInstanceState, @Nullable String rootKey) { setPreferencesFromResource(R.xml.preferences_interface, rootKey); @@ -145,12 +149,33 @@ public class InterfaceSettingsFragment extends XmppPreferenceFragment { activity.getClass().getName(), SettingsActivity.class.getName())); } - @Override public void onActivityResult(int requestCode, int resultCode, Intent data) { - super.onActivityResult(requestCode, resultCode, data); + if (resultCode == Activity.RESULT_OK) { + if (requestCode == ChatBackgroundHelper.REQUEST_IMPORT_BACKGROUND) { + final Uri imageUri = data == null ? null : data.getData(); + if (imageUri != null) { + final Intent editIntent = new Intent(getActivity(), EditActivity.class); + editIntent.setData(imageUri); + editIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + startActivityForResult(editIntent, REQUEST_EDIT_BACKGROUND); + return; + } + } else if (requestCode == REQUEST_EDIT_BACKGROUND) { + Uri uri = data != null ? (Uri) data.getParcelableExtra(EditActivity.KEY_EDITED_URI) : null; + if (uri == null && data != null) { + uri = data.getData(); + } - ChatBackgroundHelper.onActivityResult(requireSettingsActivity(), requestCode, resultCode, data, null); + if (uri != null) { + Intent resultIntent = new Intent(); + resultIntent.setData(uri); + ChatBackgroundHelper.onActivityResult(requireSettingsActivity(), ChatBackgroundHelper.REQUEST_IMPORT_BACKGROUND, resultCode, resultIntent, null); + } + return; + } + } + super.onActivityResult(requestCode, resultCode, data); } @Override -- 2.39.5 From 1c72dd6f1828c396c603d61d08577fa4e35c6303 Mon Sep 17 00:00:00 2001 From: Arne Date: Sun, 4 Jan 2026 13:02:00 +0100 Subject: [PATCH 056/180] Allow editing chat background images for single chats too --- .../ui/ConversationFragment.java | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java index 6deb80e78..c1a3689e0 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java @@ -298,6 +298,7 @@ public class ConversationFragment extends XmppFragment public static final int ATTACHMENT_CHOICE_INVALID = 0x0306; public static final int ATTACHMENT_CHOICE_RECORD_VIDEO = 0x0307; public static final int ATTACHMENT_CHOICE_EDIT_PHOTO = 0x0308; + private static final int REQUEST_EDIT_BACKGROUND = 9124; public static final String RECENTLY_USED_QUICK_ACTION = "recently_used_quick_action"; public static final String STATE_CONVERSATION_UUID = @@ -1707,7 +1708,29 @@ public class ConversationFragment extends XmppFragment } else { this.postponedActivityResult.push(activityResult); } - if (conversation != null && conversation.getUuid() != null) ChatBackgroundHelper.onActivityResult(activity, requestCode, resultCode, data, conversation.getUuid()); + if (resultCode == Activity.RESULT_OK) { + if (requestCode == ChatBackgroundHelper.REQUEST_IMPORT_BACKGROUND) { + final Uri imageUri = data == null ? null : data.getData(); + if (imageUri != null) { + final Intent editIntent = new Intent(getActivity(), EditActivity.class); + editIntent.setData(imageUri); + editIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + startActivityForResult(editIntent, REQUEST_EDIT_BACKGROUND); + return; + } + } else if (requestCode == REQUEST_EDIT_BACKGROUND) { + Uri uri = data != null ? (Uri) data.getParcelableExtra(EditActivity.KEY_EDITED_URI) : null; + if (uri == null && data != null) { + uri = data.getData(); + } + if (uri != null) { + Intent resultIntent = new Intent(); + resultIntent.setData(uri); + if (conversation != null && conversation.getUuid() != null) ChatBackgroundHelper.onActivityResult(activity, ChatBackgroundHelper.REQUEST_IMPORT_BACKGROUND, resultCode, resultIntent, conversation.getUuid()); + } + return; + } + } if (requestCode == ChatBackgroundHelper.REQUEST_IMPORT_BACKGROUND) { refresh(); -- 2.39.5 From f6ed6f0e14e8b1811cb085ac4bdda435ffcc4fca Mon Sep 17 00:00:00 2001 From: Arne Date: Sun, 4 Jan 2026 16:58:40 +0100 Subject: [PATCH 057/180] Add initial call log screen --- src/main/AndroidManifest.xml | 4 +- .../eu/siacs/conversations/entities/Call.java | 47 ++++++++ .../siacs/conversations/ui/CallsActivity.java | 36 ++++++ .../siacs/conversations/ui/CallsFragment.java | 114 ++++++++++++++++++ .../ui/ConversationsOverviewFragment.java | 7 ++ .../ui/adapter/CallsAdapter.java | 78 ++++++++++++ .../siacs/conversations/utils/UIHelper.java | 19 ++- src/main/res/layout/activity_calls.xml | 29 +++++ src/main/res/layout/fragment_calls.xml | 11 ++ src/main/res/layout/item_call.xml | 49 ++++++++ .../menu/fragment_conversations_overview.xml | 5 + src/main/res/values/strings.xml | 1 + 12 files changed, 395 insertions(+), 5 deletions(-) create mode 100644 src/main/java/eu/siacs/conversations/entities/Call.java create mode 100644 src/main/java/eu/siacs/conversations/ui/CallsActivity.java create mode 100644 src/main/java/eu/siacs/conversations/ui/CallsFragment.java create mode 100644 src/main/java/eu/siacs/conversations/ui/adapter/CallsAdapter.java create mode 100644 src/main/res/layout/activity_calls.xml create mode 100644 src/main/res/layout/fragment_calls.xml create mode 100644 src/main/res/layout/item_call.xml diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index 507fdd2d1..4a4e22e09 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -468,6 +468,8 @@ + - diff --git a/src/main/java/eu/siacs/conversations/entities/Call.java b/src/main/java/eu/siacs/conversations/entities/Call.java new file mode 100644 index 000000000..18ef8b2e6 --- /dev/null +++ b/src/main/java/eu/siacs/conversations/entities/Call.java @@ -0,0 +1,47 @@ + +package eu.siacs.conversations.entities; + +import eu.siacs.conversations.xmpp.Jid; + +public class Call { + + private final String contact; + private final Jid jid; + private final long startTime; + private final int status; + private final boolean isVideoCall; + private final boolean successful; + + public Call(String contact, Jid jid, long startTime, int status, boolean isVideoCall, boolean successful) { + this.contact = contact; + this.jid = jid; + this.startTime = startTime; + this.status = status; + this.isVideoCall = isVideoCall; + this.successful = successful; + } + + public String getContact() { + return contact; + } + + public Jid getJid() { + return jid; + } + + public long getStartTime() { + return startTime; + } + + public int getStatus() { + return status; + } + + public boolean isVideoCall() { + return isVideoCall; + } + + public boolean isSuccessful() { + return successful; + } +} diff --git a/src/main/java/eu/siacs/conversations/ui/CallsActivity.java b/src/main/java/eu/siacs/conversations/ui/CallsActivity.java new file mode 100644 index 000000000..c22dde1f5 --- /dev/null +++ b/src/main/java/eu/siacs/conversations/ui/CallsActivity.java @@ -0,0 +1,36 @@ + +package eu.siacs.conversations.ui; + +import android.os.Bundle; + +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.databinding.DataBindingUtil; + +import eu.siacs.conversations.R; +import eu.siacs.conversations.databinding.ActivityCallsBinding; + +public class CallsActivity extends AppCompatActivity { + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + ActivityCallsBinding binding = DataBindingUtil.setContentView(this, R.layout.activity_calls); + setSupportActionBar(binding.toolbar); + if (getSupportActionBar() != null) { + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + } + + if (savedInstanceState == null) { + getSupportFragmentManager().beginTransaction() + .replace(R.id.fragment_container, new CallsFragment()) + .commit(); + } + } + + @Override + public boolean onSupportNavigateUp() { + onBackPressed(); + return true; + } +} diff --git a/src/main/java/eu/siacs/conversations/ui/CallsFragment.java b/src/main/java/eu/siacs/conversations/ui/CallsFragment.java new file mode 100644 index 000000000..6d759f10a --- /dev/null +++ b/src/main/java/eu/siacs/conversations/ui/CallsFragment.java @@ -0,0 +1,114 @@ + +package eu.siacs.conversations.ui; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.Bundle; +import android.os.IBinder; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.ArrayList; +import java.util.List; + +import eu.siacs.conversations.R; +import eu.siacs.conversations.entities.Call; +import eu.siacs.conversations.entities.Conversation; +import eu.siacs.conversations.entities.Message; +import eu.siacs.conversations.services.XmppConnectionService; +import eu.siacs.conversations.ui.adapter.CallsAdapter; +import eu.siacs.conversations.entities.RtpSessionStatus; + + +public class CallsFragment extends Fragment implements CallsAdapter.OnCallAgainClickListener { + + private XmppConnectionService xmppConnectionService; + private RecyclerView recyclerView; + private CallsAdapter adapter; + private List calls = new ArrayList<>(); + + private ServiceConnection mConnection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName className, IBinder service) { + XmppConnectionService.XmppConnectionBinder binder = (XmppConnectionService.XmppConnectionBinder) service; + xmppConnectionService = binder.getService(); + loadCalls(); + } + + @Override + public void onServiceDisconnected(ComponentName arg0) { + xmppConnectionService = null; + } + }; + + @Override + public void onStart() { + super.onStart(); + Intent intent = new Intent(getActivity(), XmppConnectionService.class); + getActivity().bindService(intent, mConnection, Context.BIND_AUTO_CREATE); + } + + @Override + public void onStop() { + super.onStop(); + getActivity().unbindService(mConnection); + } + + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.fragment_calls, container, false); + + recyclerView = view.findViewById(R.id.list); + recyclerView.setLayoutManager(new LinearLayoutManager(getContext())); + adapter = new CallsAdapter(calls, this); + recyclerView.setAdapter(adapter); + + return view; + } + + private void loadCalls() { + if (xmppConnectionService != null) { + calls.clear(); + calls.addAll(getCalls()); + adapter.notifyDataSetChanged(); + } + } + + private List getCalls() { + List calls = new ArrayList<>(); + for (Conversation conversation : xmppConnectionService.getConversations()) { + for (Message message : xmppConnectionService.databaseBackend.getMessages(conversation, 100)) { // Limiting to last 100 messages per conversation for performance + if (message.getType() == Message.TYPE_RTP_SESSION) { + RtpSessionStatus rtpSessionStatus = RtpSessionStatus.of(message.getBody()); + boolean isVideo = message.getBody().contains("video"); + Call call = new Call(conversation.getName().toString(), conversation.getJid(), message.getTimeSent(), message.getStatus(), isVideo, rtpSessionStatus.successful); + calls.add(call); + } + } + } + return calls; + } + + @Override + public void onCallAgainClick(Call call) { + Intent intent = new Intent(getActivity(), RtpSessionActivity.class); + if (call.isVideoCall()) { + intent.setAction(RtpSessionActivity.ACTION_MAKE_VIDEO_CALL); + } else { + intent.setAction(RtpSessionActivity.ACTION_MAKE_VOICE_CALL); + } + intent.putExtra("jid", call.getJid().toString()); + startActivity(intent); + } +} diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java b/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java index 00ba3a659..4cd82b1ef 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java @@ -365,9 +365,11 @@ public class ConversationsOverviewFragment extends XmppFragment { final MenuItem manageAccounts = menu.findItem(R.id.action_accounts); final MenuItem settings = menu.findItem(R.id.action_settings); final MenuItem stories = menu.findItem(R.id.action_stories); + final MenuItem calls = menu.findItem(R.id.action_calls); if (manageAccounts != null) manageAccounts.setVisible(false); if (settings != null) settings.setVisible(false); if (stories != null) stories.setVisible(false); + if (calls != null) calls.setVisible(false); } if (activity == null || activity.xmppConnectionService == null || activity.xmppConnectionService.getAccounts().size() != 1) { noteToSelf.setVisible(false); @@ -484,10 +486,12 @@ public class ConversationsOverviewFragment extends XmppFragment { MenuItem manageAccount = menu.findItem(R.id.action_account); MenuItem manageAccounts = menu.findItem(R.id.action_accounts); MenuItem stories = menu.findItem(R.id.action_stories); + MenuItem calls = menu.findItem(R.id.action_calls); if (navBarVisible) { manageAccount.setVisible(false); manageAccounts.setVisible(false); stories.setVisible(false); + calls.setVisible(false); } else { AccountUtils.showHideMenuItems(menu); } @@ -541,6 +545,9 @@ public class ConversationsOverviewFragment extends XmppFragment { case R.id.action_stories: startActivity(new Intent(getActivity(), StoriesActivity.class)); return true; + case R.id.action_calls: + startActivity(new Intent(getActivity(), CallsActivity.class)); + return true; } return super.onOptionsItemSelected(item); } diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/CallsAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/CallsAdapter.java new file mode 100644 index 000000000..d3d45a7ef --- /dev/null +++ b/src/main/java/eu/siacs/conversations/ui/adapter/CallsAdapter.java @@ -0,0 +1,78 @@ + +package eu.siacs.conversations.ui.adapter; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageButton; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.bumptech.glide.Glide; + +import java.util.List; + +import eu.siacs.conversations.R; +import eu.siacs.conversations.entities.Call; +import eu.siacs.conversations.ui.util.AvatarWorkerTask; +import eu.siacs.conversations.ui.widget.AvatarView; +import eu.siacs.conversations.utils.UIHelper; + +public class CallsAdapter extends RecyclerView.Adapter { + + private final List calls; + private final OnCallAgainClickListener listener; + + public interface OnCallAgainClickListener { + void onCallAgainClick(Call call); + } + + public CallsAdapter(List calls, OnCallAgainClickListener listener) { + this.calls = calls; + this.listener = listener; + } + + @NonNull + @Override + public CallViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_call, parent, false); + return new CallViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull CallViewHolder holder, int position) { + Call call = calls.get(position); + holder.bind(call, listener); + } + + @Override + public int getItemCount() { + return calls.size(); + } + + static class CallViewHolder extends RecyclerView.ViewHolder { + + private final AvatarView avatar; + private final TextView contactName; + private final TextView callInfo; + private final ImageButton callAgainButton; + + public CallViewHolder(@NonNull View itemView) { + super(itemView); + avatar = itemView.findViewById(R.id.avatar); + contactName = itemView.findViewById(R.id.contact_name); + callInfo = itemView.findViewById(R.id.call_info); + callAgainButton = itemView.findViewById(R.id.call_again); + } + + public void bind(final Call call, final OnCallAgainClickListener listener) { + Glide.with(itemView.getContext()).load(call).into(avatar); + //AvatarWorkerTask.loadAvatar(call, avatar, R.dimen.avatar_story_size); //TODO add correct avatar loading + contactName.setText(call.getContact()); + callInfo.setText(UIHelper.getCallInfo(itemView.getContext(), call)); + callAgainButton.setOnClickListener(v -> listener.onCallAgainClick(call)); + } + } +} diff --git a/src/main/java/eu/siacs/conversations/utils/UIHelper.java b/src/main/java/eu/siacs/conversations/utils/UIHelper.java index e3003e325..89b30c97b 100644 --- a/src/main/java/eu/siacs/conversations/utils/UIHelper.java +++ b/src/main/java/eu/siacs/conversations/utils/UIHelper.java @@ -1,3 +1,4 @@ + package eu.siacs.conversations.utils; import android.content.Context; @@ -38,6 +39,7 @@ import eu.siacs.conversations.Config; import eu.siacs.conversations.R; import eu.siacs.conversations.crypto.axolotl.AxolotlService; import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.entities.Call; import eu.siacs.conversations.entities.Contact; import eu.siacs.conversations.entities.Conversation; import eu.siacs.conversations.entities.Conversational; @@ -60,7 +62,7 @@ import java.util.Locale; public class UIHelper { - private static final List LOCATION_QUESTIONS = + private static final List LOCATION_QUESTIONS = Arrays.asList( "where are you", // en "where are you now", // en @@ -81,12 +83,12 @@ public class UIHelper { "donde estas" // es ); - private static final List PUNCTIONATION = + private static final List PUNCTIONATION = Arrays.asList('.', ',', '?', '!', ';', ':'); - private static final int SHORT_DATE_FLAGS = + private static final int SHORT_DATE_FLAGS = DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_NO_YEAR | DateUtils.FORMAT_ABBREV_ALL; - private static final int FULL_DATE_FLAGS = + private static final int FULL_DATE_FLAGS = DateUtils.FORMAT_SHOW_TIME | DateUtils.FORMAT_ABBREV_ALL | DateUtils.FORMAT_SHOW_DATE; public static String readableTimeDifference(Context context, long time, boolean allowRelative) { @@ -635,6 +637,15 @@ public class UIHelper { ContextCompat.getColor(textView.getContext(), color)))); } + public static String getCallInfo(Context context, Call call) { + final boolean received = call.getStatus() == Message.STATUS_RECEIVED; + if (!call.isSuccessful() && received) { + return context.getString(R.string.missed_call); + } else { + return context.getString(received ? R.string.incoming_call : R.string.outgoing_call); + } + } + public static String filesizeToString(long size) { if (size > (1.5 * 1024 * 1024)) { return Math.round(size * 1f / (1024 * 1024)) + " MiB"; diff --git a/src/main/res/layout/activity_calls.xml b/src/main/res/layout/activity_calls.xml new file mode 100644 index 000000000..8ed4d9d78 --- /dev/null +++ b/src/main/res/layout/activity_calls.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + diff --git a/src/main/res/layout/fragment_calls.xml b/src/main/res/layout/fragment_calls.xml new file mode 100644 index 000000000..02f92bf4e --- /dev/null +++ b/src/main/res/layout/fragment_calls.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/src/main/res/layout/item_call.xml b/src/main/res/layout/item_call.xml new file mode 100644 index 000000000..47589161f --- /dev/null +++ b/src/main/res/layout/item_call.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + diff --git a/src/main/res/menu/fragment_conversations_overview.xml b/src/main/res/menu/fragment_conversations_overview.xml index ecce783b1..5da22f415 100644 --- a/src/main/res/menu/fragment_conversations_overview.xml +++ b/src/main/res/menu/fragment_conversations_overview.xml @@ -34,6 +34,11 @@ android:orderInCategory="92" android:title="@string/stories" app:showAsAction="never" /> + No active account to show or publish stories of Could not create story + Calls \ No newline at end of file -- 2.39.5 From 9b49e58a1df5c15db2187d0c7e37688f8deb87b6 Mon Sep 17 00:00:00 2001 From: Arne Date: Sun, 4 Jan 2026 17:41:27 +0100 Subject: [PATCH 058/180] Replace Accounts with Calls in bottom navigation bar --- .../siacs/conversations/ui/CallsActivity.java | 120 +++++++++++++++++- .../ui/ConversationsActivity.java | 4 +- .../ui/ConversationsOverviewFragment.java | 4 - .../ui/StartConversationActivity.java | 4 +- .../conversations/ui/StoriesActivity.java | 4 +- .../drawable/calls_selected_black_24dp.xml | 5 + .../drawable/calls_selected_white_24dp.xml | 5 + .../drawable/calls_unselected_black_24dp.xml | 5 + .../drawable/calls_unselected_white_24dp.xml | 5 + src/main/res/layout/activity_calls.xml | 25 +++- .../res/layout/activity_manage_accounts.xml | 11 -- src/main/res/menu/activity_calls.xml | 9 ++ ...s.xml => bottom_navigation_menu_calls.xml} | 6 +- .../res/menu/bottom_navigation_menu_chat.xml | 6 +- .../menu/bottom_navigation_menu_contacts.xml | 6 +- .../menu/bottom_navigation_menu_stories.xml | 6 +- src/main/res/values-night/themes.xml | 4 +- src/main/res/values/themes.xml | 4 +- .../ui/ManageAccountActivity.java | 83 +----------- src/monocleschat/res/values/attrs.xml | 4 +- 20 files changed, 194 insertions(+), 126 deletions(-) create mode 100644 src/main/res/drawable/calls_selected_black_24dp.xml create mode 100644 src/main/res/drawable/calls_selected_white_24dp.xml create mode 100644 src/main/res/drawable/calls_unselected_black_24dp.xml create mode 100644 src/main/res/drawable/calls_unselected_white_24dp.xml create mode 100644 src/main/res/menu/activity_calls.xml rename src/main/res/menu/{bottom_navigation_menu_accounts.xml => bottom_navigation_menu_calls.xml} (78%) diff --git a/src/main/java/eu/siacs/conversations/ui/CallsActivity.java b/src/main/java/eu/siacs/conversations/ui/CallsActivity.java index c22dde1f5..1bfdb58ef 100644 --- a/src/main/java/eu/siacs/conversations/ui/CallsActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/CallsActivity.java @@ -1,31 +1,73 @@ package eu.siacs.conversations.ui; +import static android.view.View.VISIBLE; + +import android.content.Intent; +import android.graphics.Color; import android.os.Bundle; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AppCompatActivity; import androidx.databinding.DataBindingUtil; +import com.google.android.material.bottomnavigation.BottomNavigationView; + import eu.siacs.conversations.R; import eu.siacs.conversations.databinding.ActivityCallsBinding; -public class CallsActivity extends AppCompatActivity { +public class CallsActivity extends XmppActivity { @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); ActivityCallsBinding binding = DataBindingUtil.setContentView(this, R.layout.activity_calls); + Activities.setStatusAndNavigationBarColors(this, findViewById(android.R.id.content)); setSupportActionBar(binding.toolbar); - if (getSupportActionBar() != null) { - getSupportActionBar().setDisplayHomeAsUpEnabled(true); - } + configureActionBar(getSupportActionBar()); if (savedInstanceState == null) { getSupportFragmentManager().beginTransaction() .replace(R.id.fragment_container, new CallsFragment()) .commit(); } + + + // Bottom Navigation Setup + BottomNavigationView bottomNavigationView = findViewById(R.id.bottom_navigation); + bottomNavigationView.setBackgroundColor(Color.TRANSPARENT); + bottomNavigationView.setOnItemSelectedListener(item -> { + switch (item.getItemId()) { + case R.id.chats -> { + startActivity(new Intent(getApplicationContext(), ConversationsActivity.class)); + overridePendingTransition(R.animator.fade_in, R.animator.fade_out); + return true; + } + case R.id.contactslist -> { + Intent i = new Intent(getApplicationContext(), StartConversationActivity.class); + i.putExtra("show_nav_bar", true); + startActivity(i); + overridePendingTransition(R.animator.fade_in, R.animator.fade_out); + return true; + } + case R.id.stories -> { + Intent i = new Intent(getApplicationContext(), StoriesActivity.class); + i.putExtra("show_nav_bar", true); + startActivity(i); + overridePendingTransition(R.animator.fade_in, R.animator.fade_out); + return true; + } + case R.id.calls -> { + return true; + } + default -> + throw new IllegalStateException("Unexpected value: " + item.getItemId()); + } + }); } @Override @@ -33,4 +75,74 @@ public class CallsActivity extends AppCompatActivity { onBackPressed(); return true; } + + @Override + public void onStart() { + super.onStart(); + + BottomNavigationView bottomNavigationView=findViewById(R.id.bottom_navigation); + bottomNavigationView.setSelectedItemId(R.id.calls); + + if (getBooleanPreference("show_nav_bar", R.bool.show_nav_bar) && getIntent().getBooleanExtra("show_nav_bar", false)) { + bottomNavigationView.setVisibility(VISIBLE); + } else { + bottomNavigationView.setVisibility(View.GONE); + } + } + + @Override + protected void onBackendConnected() { + refreshUiReal(); + } + + @Override + public void onBackPressed() { + if (findViewById(R.id.bottom_navigation).getVisibility() == VISIBLE) { + Intent intent = new Intent(this, ConversationsActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION); + startActivity(intent); + overridePendingTransition(R.animator.fade_in, R.animator.fade_out); + } + + super.onBackPressed(); + } + + protected void refreshUiReal() { + ActionBar actionBar = getSupportActionBar(); + + // Show badge for unread message in bottom nav + int unreadCount = xmppConnectionService.unreadCount(); + BottomNavigationView bottomnav = findViewById(R.id.bottom_navigation); + var bottomBadge = bottomnav.getOrCreateBadge(R.id.chats); + bottomBadge.setNumber(unreadCount); + bottomBadge.setVisible(unreadCount > 0); + bottomBadge.setHorizontalOffset(20); + + // Show badge for new stories in bottom nav + long lastRead = getPreferences().getLong("last_read_story_timestamp", 0); + boolean hasNewStories = xmppConnectionService.getStories().stream().anyMatch(s -> s.getPublished() > lastRead); + var storiesBadge = bottomnav.getOrCreateBadge(R.id.stories); + storiesBadge.setVisible(hasNewStories); + + boolean showNavBar = bottomnav.getVisibility() == VISIBLE; + if (actionBar != null) { + actionBar.setHomeButtonEnabled(!showNavBar); + actionBar.setDisplayHomeAsUpEnabled(!showNavBar); + } + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.activity_stories, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == R.id.action_settings) { + startActivity(new Intent(this, eu.siacs.conversations.ui.activity.SettingsActivity.class)); + return true; + } + return super.onOptionsItemSelected(item); + } } diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java b/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java index 51031fcbf..02e75d93c 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java @@ -1149,8 +1149,8 @@ public class ConversationsActivity extends XmppActivity overridePendingTransition(R.animator.fade_in, R.animator.fade_out); return true; } - case R.id.manageaccounts -> { - Intent i = new Intent(getApplicationContext(), MANAGE_ACCOUNT_ACTIVITY); + case R.id.calls -> { + Intent i = new Intent(getApplicationContext(), CallsActivity.class); i.putExtra("show_nav_bar", true); startActivity(i); overridePendingTransition(R.animator.fade_in, R.animator.fade_out); diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java b/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java index 4cd82b1ef..4617c6b0f 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java @@ -483,13 +483,9 @@ public class ConversationsOverviewFragment extends XmppFragment { super.onPrepareOptionsMenu(menu); boolean navBarVisible = activity instanceof ConversationsActivity && ((ConversationsActivity) activity).navigationBarVisible(); - MenuItem manageAccount = menu.findItem(R.id.action_account); - MenuItem manageAccounts = menu.findItem(R.id.action_accounts); MenuItem stories = menu.findItem(R.id.action_stories); MenuItem calls = menu.findItem(R.id.action_calls); if (navBarVisible) { - manageAccount.setVisible(false); - manageAccounts.setVisible(false); stories.setVisible(false); calls.setVisible(false); } else { diff --git a/src/main/java/eu/siacs/conversations/ui/StartConversationActivity.java b/src/main/java/eu/siacs/conversations/ui/StartConversationActivity.java index 4721eedb1..99275387d 100644 --- a/src/main/java/eu/siacs/conversations/ui/StartConversationActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/StartConversationActivity.java @@ -459,8 +459,8 @@ public class StartConversationActivity extends XmppActivity overridePendingTransition(R.animator.fade_in, R.animator.fade_out); return true; } - case R.id.manageaccounts -> { - Intent i = new Intent(getApplicationContext(), MANAGE_ACCOUNT_ACTIVITY); + case R.id.calls -> { + Intent i = new Intent(getApplicationContext(), CallsActivity.class); i.putExtra("show_nav_bar", true); startActivity(i); overridePendingTransition(R.animator.fade_in, R.animator.fade_out); diff --git a/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java b/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java index aea3e4902..568c9ff1e 100644 --- a/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java @@ -87,8 +87,8 @@ public class StoriesActivity extends XmppActivity implements XmppConnectionServi case R.id.stories -> { return true; } - case R.id.manageaccounts -> { - Intent i = new Intent(getApplicationContext(), MANAGE_ACCOUNT_ACTIVITY); + case R.id.calls -> { + Intent i = new Intent(getApplicationContext(), CallsActivity.class); i.putExtra("show_nav_bar", true); startActivity(i); overridePendingTransition(R.animator.fade_in, R.animator.fade_out); diff --git a/src/main/res/drawable/calls_selected_black_24dp.xml b/src/main/res/drawable/calls_selected_black_24dp.xml new file mode 100644 index 000000000..fbd2b0a26 --- /dev/null +++ b/src/main/res/drawable/calls_selected_black_24dp.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/calls_selected_white_24dp.xml b/src/main/res/drawable/calls_selected_white_24dp.xml new file mode 100644 index 000000000..03b62ddc2 --- /dev/null +++ b/src/main/res/drawable/calls_selected_white_24dp.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/calls_unselected_black_24dp.xml b/src/main/res/drawable/calls_unselected_black_24dp.xml new file mode 100644 index 000000000..5a059c4dc --- /dev/null +++ b/src/main/res/drawable/calls_unselected_black_24dp.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/calls_unselected_white_24dp.xml b/src/main/res/drawable/calls_unselected_white_24dp.xml new file mode 100644 index 000000000..c24354227 --- /dev/null +++ b/src/main/res/drawable/calls_unselected_white_24dp.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/layout/activity_calls.xml b/src/main/res/layout/activity_calls.xml index 8ed4d9d78..a2780377f 100644 --- a/src/main/res/layout/activity_calls.xml +++ b/src/main/res/layout/activity_calls.xml @@ -1,7 +1,18 @@ + xmlns:app="http://schemas.android.com/apk/res-auto"> + + + + + + + + diff --git a/src/main/res/layout/activity_manage_accounts.xml b/src/main/res/layout/activity_manage_accounts.xml index e21050e7b..9deb30147 100644 --- a/src/main/res/layout/activity_manage_accounts.xml +++ b/src/main/res/layout/activity_manage_accounts.xml @@ -9,7 +9,6 @@ - - - diff --git a/src/main/res/menu/activity_calls.xml b/src/main/res/menu/activity_calls.xml new file mode 100644 index 000000000..a2411e31b --- /dev/null +++ b/src/main/res/menu/activity_calls.xml @@ -0,0 +1,9 @@ + + + + diff --git a/src/main/res/menu/bottom_navigation_menu_accounts.xml b/src/main/res/menu/bottom_navigation_menu_calls.xml similarity index 78% rename from src/main/res/menu/bottom_navigation_menu_accounts.xml rename to src/main/res/menu/bottom_navigation_menu_calls.xml index a561fbc25..71a25235a 100644 --- a/src/main/res/menu/bottom_navigation_menu_accounts.xml +++ b/src/main/res/menu/bottom_navigation_menu_calls.xml @@ -13,7 +13,7 @@ android:icon="?attr/ic_stories_unselected" android:title="@string/stories"/> + android:id="@+id/calls" + android:icon="?attr/ic_calls_selected" + android:title="@string/calls"/> \ No newline at end of file diff --git a/src/main/res/menu/bottom_navigation_menu_chat.xml b/src/main/res/menu/bottom_navigation_menu_chat.xml index 99178af81..447f0fd7c 100644 --- a/src/main/res/menu/bottom_navigation_menu_chat.xml +++ b/src/main/res/menu/bottom_navigation_menu_chat.xml @@ -13,7 +13,7 @@ android:icon="?attr/ic_stories_unselected" android:title="@string/stories"/> + android:id="@+id/calls" + android:icon="?attr/ic_calls_unselected" + android:title="@string/calls"/> \ No newline at end of file diff --git a/src/main/res/menu/bottom_navigation_menu_contacts.xml b/src/main/res/menu/bottom_navigation_menu_contacts.xml index ad827e685..27e773171 100644 --- a/src/main/res/menu/bottom_navigation_menu_contacts.xml +++ b/src/main/res/menu/bottom_navigation_menu_contacts.xml @@ -13,7 +13,7 @@ android:icon="?attr/ic_stories_unselected" android:title="@string/stories"/> + android:id="@+id/calls" + android:icon="?attr/ic_calls_unselected" + android:title="@string/calls"/> \ No newline at end of file diff --git a/src/main/res/menu/bottom_navigation_menu_stories.xml b/src/main/res/menu/bottom_navigation_menu_stories.xml index 4a4f96289..8c3479073 100644 --- a/src/main/res/menu/bottom_navigation_menu_stories.xml +++ b/src/main/res/menu/bottom_navigation_menu_stories.xml @@ -13,7 +13,7 @@ android:icon="?attr/ic_stories_selected" android:title="@string/stories"/> + android:id="@+id/calls" + android:icon="?attr/ic_calls_unselected" + android:title="@string/calls"/> \ No newline at end of file diff --git a/src/main/res/values-night/themes.xml b/src/main/res/values-night/themes.xml index fa5423357..6ffd4cadf 100644 --- a/src/main/res/values-night/themes.xml +++ b/src/main/res/values-night/themes.xml @@ -32,8 +32,8 @@ @drawable/outline_chat_white_24 @drawable/chat_selected_white_24 - @drawable/ic_account_white_24dp - @drawable/accounts_selected_white_24 + @drawable/calls_unselected_white_24dp + @drawable/calls_selected_white_24dp @drawable/outline_group_white_24 @drawable/ic_group_selected_white_24 @drawable/stories_unselected_white_24 diff --git a/src/main/res/values/themes.xml b/src/main/res/values/themes.xml index f8e685d3a..4756294e2 100644 --- a/src/main/res/values/themes.xml +++ b/src/main/res/values/themes.xml @@ -57,8 +57,8 @@ @drawable/outline_chat_black_24 @drawable/chat_selected_black_24 - @drawable/ic_account_black_24dp - @drawable/accounts_selected_black_24 + @drawable/calls_unselected_black_24dp + @drawable/calls_selected_black_24dp @drawable/outline_group_black_24dp @drawable/ic_group_selected_black_24 @drawable/stories_unselected_black_24 diff --git a/src/monocleschat/java/eu/siacs/conversations/ui/ManageAccountActivity.java b/src/monocleschat/java/eu/siacs/conversations/ui/ManageAccountActivity.java index 62f4ff3a7..81b483d02 100644 --- a/src/monocleschat/java/eu/siacs/conversations/ui/ManageAccountActivity.java +++ b/src/monocleschat/java/eu/siacs/conversations/ui/ManageAccountActivity.java @@ -49,8 +49,6 @@ import eu.siacs.conversations.xmpp.XmppConnection; import static eu.siacs.conversations.utils.PermissionUtils.allGranted; import static eu.siacs.conversations.utils.PermissionUtils.writeGranted; -import com.google.android.material.bottomnavigation.BottomNavigationView; - public class ManageAccountActivity extends XmppActivity implements XmppConnectionService.OnConversationUpdate, OnAccountUpdate, KeyChainAliasCallback, XmppConnectionService.OnAccountCreated, AccountAdapter.OnTglAccountState { private final String STATE_SELECTED_ACCOUNT = "selected_account"; @@ -87,24 +85,9 @@ public class ManageAccountActivity extends XmppActivity implements XmppConnectio } ActionBar actionBar = getSupportActionBar(); - // Show badge for unread message in bottom nav - int unreadCount = xmppConnectionService.unreadCount(); - BottomNavigationView bottomnav = findViewById(R.id.bottom_navigation); - var bottomBadge = bottomnav.getOrCreateBadge(R.id.chats); - bottomBadge.setNumber(unreadCount); - bottomBadge.setVisible(unreadCount > 0); - bottomBadge.setHorizontalOffset(20); - - // Show badge for new stories in bottom nav - long lastRead = getPreferences().getLong("last_read_story_timestamp", 0); - boolean hasNewStories = xmppConnectionService.getStories().stream().anyMatch(s -> s.getPublished() > lastRead); - var storiesBadge = bottomnav.getOrCreateBadge(R.id.stories); - storiesBadge.setVisible(hasNewStories); - - boolean showNavBar = bottomnav.getVisibility() == VISIBLE; if (actionBar != null) { - actionBar.setHomeButtonEnabled(!this.accountList.isEmpty() && !showNavBar); - actionBar.setDisplayHomeAsUpEnabled(!this.accountList.isEmpty() && !showNavBar); + actionBar.setHomeButtonEnabled(!this.accountList.isEmpty()); + actionBar.setDisplayHomeAsUpEnabled(!this.accountList.isEmpty()); } invalidateOptionsMenu(); mAccountAdapter.notifyDataSetChanged(); @@ -218,70 +201,8 @@ public class ManageAccountActivity extends XmppActivity implements XmppConnectio }); accountListView.setAdapter(this.mAccountAdapter); - - // Bottom Navigation Setup (unchanged) ... - BottomNavigationView bottomNavigationView = findViewById(R.id.bottom_navigation); - // ... (rest of bottom nav logic) - bottomNavigationView.setBackgroundColor(Color.TRANSPARENT); - bottomNavigationView.setOnItemSelectedListener(item -> { - // ... existing switch case ... - switch (item.getItemId()) { - case R.id.chats -> { - startActivity(new Intent(getApplicationContext(), ConversationsActivity.class)); - overridePendingTransition(R.animator.fade_in, R.animator.fade_out); - return true; - } - case R.id.contactslist -> { - Intent i = new Intent(getApplicationContext(), StartConversationActivity.class); - i.putExtra("show_nav_bar", true); - startActivity(i); - overridePendingTransition(R.animator.fade_in, R.animator.fade_out); - return true; - } - case R.id.stories -> { - Intent i = new Intent(getApplicationContext(), StoriesActivity.class); - i.putExtra("show_nav_bar", true); - startActivity(i); - overridePendingTransition(R.animator.fade_in, R.animator.fade_out); - return true; - } - case R.id.manageaccounts -> { - return true; - } - default -> - throw new IllegalStateException("Unexpected value: " + item.getItemId()); - } - }); } - - @Override - public void onStart() { - super.onStart(); - - BottomNavigationView bottomNavigationView=findViewById(R.id.bottom_navigation); - bottomNavigationView.setSelectedItemId(R.id.manageaccounts); - - if (getBooleanPreference("show_nav_bar", R.bool.show_nav_bar) && getIntent().getBooleanExtra("show_nav_bar", false)) { - bottomNavigationView.setVisibility(VISIBLE); - } else { - bottomNavigationView.setVisibility(View.GONE); - } - } - - @Override - public void onBackPressed() { - if (findViewById(R.id.bottom_navigation).getVisibility() == VISIBLE) { - Intent intent = new Intent(this, ConversationsActivity.class); - intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION); - startActivity(intent); - overridePendingTransition(R.animator.fade_in, R.animator.fade_out); - } - - super.onBackPressed(); - } - - @Override public void onSaveInstanceState(final Bundle savedInstanceState) { if (selectedAccount != null) { diff --git a/src/monocleschat/res/values/attrs.xml b/src/monocleschat/res/values/attrs.xml index d6ef61952..558e9c7d1 100644 --- a/src/monocleschat/res/values/attrs.xml +++ b/src/monocleschat/res/values/attrs.xml @@ -6,8 +6,8 @@ - - + + -- 2.39.5 From ce922ba8ce54ad31f5aa775509a16627351e7f9b Mon Sep 17 00:00:00 2001 From: Arne Date: Sun, 4 Jan 2026 19:40:36 +0100 Subject: [PATCH 059/180] Refactor CallsFragment to use Message object and enable call again functionality --- .../eu/siacs/conversations/entities/Call.java | 14 +- .../siacs/conversations/ui/CallsFragment.java | 132 +++++++++++++++--- .../ui/adapter/CallsAdapter.java | 26 ++-- 3 files changed, 140 insertions(+), 32 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/entities/Call.java b/src/main/java/eu/siacs/conversations/entities/Call.java index 18ef8b2e6..76aaf6114 100644 --- a/src/main/java/eu/siacs/conversations/entities/Call.java +++ b/src/main/java/eu/siacs/conversations/entities/Call.java @@ -1,9 +1,11 @@ package eu.siacs.conversations.entities; +import eu.siacs.conversations.services.AvatarService; +import eu.siacs.conversations.utils.UIHelper; import eu.siacs.conversations.xmpp.Jid; -public class Call { +public class Call implements AvatarService.Avatarable { private final String contact; private final Jid jid; @@ -44,4 +46,14 @@ public class Call { public boolean isSuccessful() { return successful; } + + @Override + public int getAvatarBackgroundColor() { + return UIHelper.getColorForName(getContact()); + } + + @Override + public String getAvatarName() { + return getContact(); + } } diff --git a/src/main/java/eu/siacs/conversations/ui/CallsFragment.java b/src/main/java/eu/siacs/conversations/ui/CallsFragment.java index 6d759f10a..5fc1df8cb 100644 --- a/src/main/java/eu/siacs/conversations/ui/CallsFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/CallsFragment.java @@ -1,15 +1,19 @@ package eu.siacs.conversations.ui; +import android.Manifest; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.ServiceConnection; +import android.content.pm.PackageManager; +import android.os.Build; import android.os.Bundle; import android.os.IBinder; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -18,23 +22,28 @@ import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; import java.util.List; import eu.siacs.conversations.R; -import eu.siacs.conversations.entities.Call; +import eu.siacs.conversations.entities.Account; import eu.siacs.conversations.entities.Conversation; import eu.siacs.conversations.entities.Message; +import eu.siacs.conversations.services.CallIntegrationConnectionService; import eu.siacs.conversations.services.XmppConnectionService; import eu.siacs.conversations.ui.adapter.CallsAdapter; -import eu.siacs.conversations.entities.RtpSessionStatus; - public class CallsFragment extends Fragment implements CallsAdapter.OnCallAgainClickListener { private XmppConnectionService xmppConnectionService; private RecyclerView recyclerView; private CallsAdapter adapter; - private List calls = new ArrayList<>(); + private List calls = new ArrayList<>(); + private Message mPendingCall; + + private static final int REQUEST_START_AUDIO_CALL = 0x213; + private static final int REQUEST_START_VIDEO_CALL = 0x214; private ServiceConnection mConnection = new ServiceConnection() { @Override @@ -71,7 +80,7 @@ public class CallsFragment extends Fragment implements CallsAdapter.OnCallAgainC recyclerView = view.findViewById(R.id.list); recyclerView.setLayoutManager(new LinearLayoutManager(getContext())); - adapter = new CallsAdapter(calls, this); + adapter = new CallsAdapter(calls, this, xmppConnectionService); recyclerView.setAdapter(adapter); return view; @@ -81,19 +90,21 @@ public class CallsFragment extends Fragment implements CallsAdapter.OnCallAgainC if (xmppConnectionService != null) { calls.clear(); calls.addAll(getCalls()); + adapter = new CallsAdapter(calls, this, xmppConnectionService); + recyclerView.setAdapter(adapter); adapter.notifyDataSetChanged(); } } - private List getCalls() { - List calls = new ArrayList<>(); + private List getCalls() { + List calls = new ArrayList<>(); + if (xmppConnectionService == null) { + return calls; + } for (Conversation conversation : xmppConnectionService.getConversations()) { for (Message message : xmppConnectionService.databaseBackend.getMessages(conversation, 100)) { // Limiting to last 100 messages per conversation for performance if (message.getType() == Message.TYPE_RTP_SESSION) { - RtpSessionStatus rtpSessionStatus = RtpSessionStatus.of(message.getBody()); - boolean isVideo = message.getBody().contains("video"); - Call call = new Call(conversation.getName().toString(), conversation.getJid(), message.getTimeSent(), message.getStatus(), isVideo, rtpSessionStatus.successful); - calls.add(call); + calls.add(message); } } } @@ -101,14 +112,99 @@ public class CallsFragment extends Fragment implements CallsAdapter.OnCallAgainC } @Override - public void onCallAgainClick(Call call) { - Intent intent = new Intent(getActivity(), RtpSessionActivity.class); - if (call.isVideoCall()) { - intent.setAction(RtpSessionActivity.ACTION_MAKE_VIDEO_CALL); + public void onCallAgainClick(Message call) { + mPendingCall = call; + if (call.getBody().contains("video")) { + checkPermissionAndTriggerVideoCall(); } else { - intent.setAction(RtpSessionActivity.ACTION_MAKE_VOICE_CALL); + checkPermissionAndTriggerAudioCall(); } - intent.putExtra("jid", call.getJid().toString()); - startActivity(intent); + } + + private void checkPermissionAndTriggerAudioCall() { + if (xmppConnectionService.useTorToConnect() || mPendingCall.getConversation().getAccount().isOnion()) { + Toast.makeText(getActivity(), R.string.disable_tor_to_make_call, Toast.LENGTH_SHORT).show(); + return; + } + if (xmppConnectionService.useI2PToConnect() || mPendingCall.getConversation().getAccount().isI2P()) { + Toast.makeText(getActivity(), R.string.no_i2p_calls, Toast.LENGTH_SHORT).show(); + return; + } + + final List permissions; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + permissions = Arrays.asList(Manifest.permission.RECORD_AUDIO, Manifest.permission.BLUETOOTH_CONNECT); + } else { + permissions = Collections.singletonList(Manifest.permission.RECORD_AUDIO); + } + if (hasPermissions(permissions, REQUEST_START_AUDIO_CALL)) { + triggerRtpSession(RtpSessionActivity.ACTION_MAKE_VOICE_CALL); + } + } + + private void checkPermissionAndTriggerVideoCall() { + if (xmppConnectionService.useTorToConnect() || mPendingCall.getConversation().getAccount().isOnion()) { + Toast.makeText(getActivity(), R.string.disable_tor_to_make_call, Toast.LENGTH_SHORT).show(); + return; + } + if (xmppConnectionService.useI2PToConnect() || mPendingCall.getConversation().getAccount().isI2P()) { + Toast.makeText(getActivity(), R.string.no_i2p_calls, Toast.LENGTH_SHORT).show(); + return; + } + final List permissions; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + permissions = Arrays.asList(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA, Manifest.permission.BLUETOOTH_CONNECT); + } else { + permissions = Arrays.asList(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA); + } + if (hasPermissions(permissions, REQUEST_START_VIDEO_CALL)) { + triggerRtpSession(RtpSessionActivity.ACTION_MAKE_VIDEO_CALL); + } + } + + private boolean hasPermissions(List permissions, int requestCode) { + final List missingPermissions = new ArrayList<>(); + for (String permission : permissions) { + if (getActivity().checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) { + missingPermissions.add(permission); + } + } + if (missingPermissions.size() == 0) { + return true; + } else { + requestPermissions(missingPermissions.toArray(new String[0]), requestCode); + return false; + } + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + if (mPendingCall != null) { + if(requestCode == REQUEST_START_AUDIO_CALL) { + triggerRtpSession(RtpSessionActivity.ACTION_MAKE_VOICE_CALL); + } else if (requestCode == REQUEST_START_VIDEO_CALL) { + triggerRtpSession(RtpSessionActivity.ACTION_MAKE_VIDEO_CALL); + } + } + } + } + + private void triggerRtpSession(final String action) { + if (xmppConnectionService.getJingleConnectionManager().isBusy()) { + Toast.makeText(getActivity(), R.string.only_one_call_at_a_time, Toast.LENGTH_LONG).show(); + return; + } + final Conversation conversation = (Conversation) mPendingCall.getConversation(); + final Account account = conversation.getAccount(); + if (account.setOption(Account.OPTION_SOFT_DISABLED, false)) { + xmppConnectionService.updateAccount(account); + } + CallIntegrationConnectionService.placeCall( + xmppConnectionService, + account, + conversation.getJid(), + RtpSessionActivity.actionToMedia(action)); + mPendingCall = null; } } diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/CallsAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/CallsAdapter.java index d3d45a7ef..31a3519ed 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/CallsAdapter.java +++ b/src/main/java/eu/siacs/conversations/ui/adapter/CallsAdapter.java @@ -10,28 +10,29 @@ import android.widget.TextView; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; -import com.bumptech.glide.Glide; - import java.util.List; import eu.siacs.conversations.R; -import eu.siacs.conversations.entities.Call; +import eu.siacs.conversations.entities.Message; +import eu.siacs.conversations.services.XmppConnectionService; import eu.siacs.conversations.ui.util.AvatarWorkerTask; import eu.siacs.conversations.ui.widget.AvatarView; import eu.siacs.conversations.utils.UIHelper; public class CallsAdapter extends RecyclerView.Adapter { - private final List calls; + private final List calls; private final OnCallAgainClickListener listener; + private final XmppConnectionService xmppConnectionService; public interface OnCallAgainClickListener { - void onCallAgainClick(Call call); + void onCallAgainClick(Message call); } - public CallsAdapter(List calls, OnCallAgainClickListener listener) { + public CallsAdapter(List calls, OnCallAgainClickListener listener, XmppConnectionService xmppConnectionService) { this.calls = calls; this.listener = listener; + this.xmppConnectionService = xmppConnectionService; } @NonNull @@ -43,8 +44,8 @@ public class CallsAdapter extends RecyclerView.Adapter listener.onCallAgainClick(call)); } } -- 2.39.5 From db5f3192812874304d8e52d7a6d43b34cda8b328 Mon Sep 17 00:00:00 2001 From: Arne Date: Sun, 4 Jan 2026 19:46:25 +0100 Subject: [PATCH 060/180] Fix correct avatar in call history --- .../java/eu/siacs/conversations/ui/adapter/CallsAdapter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/CallsAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/CallsAdapter.java index 31a3519ed..f4ddff7e8 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/CallsAdapter.java +++ b/src/main/java/eu/siacs/conversations/ui/adapter/CallsAdapter.java @@ -69,7 +69,7 @@ public class CallsAdapter extends RecyclerView.Adapter listener.onCallAgainClick(call)); -- 2.39.5 From c59779b098311686b47ff5f1e496fc12b5c9885d Mon Sep 17 00:00:00 2001 From: Arne Date: Sun, 4 Jan 2026 19:54:25 +0100 Subject: [PATCH 061/180] Display call date in call history --- .../ui/adapter/CallsAdapter.java | 3 ++ src/main/res/layout/item_call.xml | 29 ++++++++++++++----- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/CallsAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/CallsAdapter.java index f4ddff7e8..22446122e 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/CallsAdapter.java +++ b/src/main/java/eu/siacs/conversations/ui/adapter/CallsAdapter.java @@ -58,6 +58,7 @@ public class CallsAdapter extends RecyclerView.Adapter listener.onCallAgainClick(call)); } } diff --git a/src/main/res/layout/item_call.xml b/src/main/res/layout/item_call.xml index 47589161f..1edd5a731 100644 --- a/src/main/res/layout/item_call.xml +++ b/src/main/res/layout/item_call.xml @@ -7,13 +7,14 @@ - + + + + + + + -- 2.39.5 From 1b63d669b5be54f7e44915b2a7c187424ec43e8c Mon Sep 17 00:00:00 2001 From: Arne Date: Sun, 4 Jan 2026 21:50:51 +0100 Subject: [PATCH 062/180] Show audio/video call options in call log --- .../siacs/conversations/ui/CallsFragment.java | 6 +++-- .../ui/adapter/CallsAdapter.java | 21 ++++++++++++--- src/main/res/layout/item_call.xml | 27 ++++++++++++------- src/main/res/menu/call_again_context.xml | 11 ++++++++ 4 files changed, 50 insertions(+), 15 deletions(-) create mode 100644 src/main/res/menu/call_again_context.xml diff --git a/src/main/java/eu/siacs/conversations/ui/CallsFragment.java b/src/main/java/eu/siacs/conversations/ui/CallsFragment.java index 5fc1df8cb..1f9823e2e 100644 --- a/src/main/java/eu/siacs/conversations/ui/CallsFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/CallsFragment.java @@ -41,6 +41,7 @@ public class CallsFragment extends Fragment implements CallsAdapter.OnCallAgainC private CallsAdapter adapter; private List calls = new ArrayList<>(); private Message mPendingCall; + private boolean mPendingVideoCall; private static final int REQUEST_START_AUDIO_CALL = 0x213; private static final int REQUEST_START_VIDEO_CALL = 0x214; @@ -112,9 +113,10 @@ public class CallsFragment extends Fragment implements CallsAdapter.OnCallAgainC } @Override - public void onCallAgainClick(Message call) { + public void onCallAgainClick(Message call, boolean isVideoCall) { mPendingCall = call; - if (call.getBody().contains("video")) { + mPendingVideoCall = isVideoCall; + if (isVideoCall) { checkPermissionAndTriggerVideoCall(); } else { checkPermissionAndTriggerAudioCall(); diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/CallsAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/CallsAdapter.java index 22446122e..c01f5e042 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/CallsAdapter.java +++ b/src/main/java/eu/siacs/conversations/ui/adapter/CallsAdapter.java @@ -5,6 +5,7 @@ import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.ImageButton; +import android.widget.PopupMenu; import android.widget.TextView; import androidx.annotation.NonNull; @@ -26,7 +27,7 @@ public class CallsAdapter extends RecyclerView.Adapter calls, OnCallAgainClickListener listener, XmppConnectionService xmppConnectionService) { @@ -71,11 +72,25 @@ public class CallsAdapter extends RecyclerView.Adapter listener.onCallAgainClick(call)); + callAgainButton.setOnClickListener(v -> { + PopupMenu popup = new PopupMenu(v.getContext(), v); + popup.getMenuInflater().inflate(R.menu.call_again_context, popup.getMenu()); + popup.setOnMenuItemClickListener(item -> { + if (item.getItemId() == R.id.action_voice_call) { + listener.onCallAgainClick(call, false); + return true; + } else if (item.getItemId() == R.id.action_video_call) { + listener.onCallAgainClick(call, true); + return true; + } + return false; + }); + popup.show(); + }); } } } diff --git a/src/main/res/layout/item_call.xml b/src/main/res/layout/item_call.xml index 1edd5a731..e208d5cce 100644 --- a/src/main/res/layout/item_call.xml +++ b/src/main/res/layout/item_call.xml @@ -3,12 +3,12 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="wrap_content" - android:padding="16dp"> + android:padding="14dp"> @@ -30,24 +30,31 @@ android:layout_height="wrap_content" android:textStyle="bold" /> - + android:layout_height="wrap_content" + android:orientation="horizontal" + android:layout_marginEnd="4dp"> + android:layout_marginEnd="4dp" /> + + - + android:layout_marginStart="4dp" + android:layout_marginEnd="4dp" /> + diff --git a/src/main/res/menu/call_again_context.xml b/src/main/res/menu/call_again_context.xml new file mode 100644 index 000000000..7d6636fe8 --- /dev/null +++ b/src/main/res/menu/call_again_context.xml @@ -0,0 +1,11 @@ + + + + + -- 2.39.5 From b6b8156b431bf680a9b366e46e2dc189d3e31a77 Mon Sep 17 00:00:00 2001 From: Arne Date: Sun, 4 Jan 2026 21:53:47 +0100 Subject: [PATCH 063/180] Sort calls in CallsFragment by time in descending order --- src/main/java/eu/siacs/conversations/ui/CallsFragment.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/eu/siacs/conversations/ui/CallsFragment.java b/src/main/java/eu/siacs/conversations/ui/CallsFragment.java index 1f9823e2e..c3276a873 100644 --- a/src/main/java/eu/siacs/conversations/ui/CallsFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/CallsFragment.java @@ -109,6 +109,7 @@ public class CallsFragment extends Fragment implements CallsAdapter.OnCallAgainC } } } + Collections.sort(calls, (o1, o2) -> Long.compare(o2.getTimeSent(), o1.getTimeSent())); return calls; } -- 2.39.5 From 487577dba37ac2dd310ca4e5fbb68b1f5c979812 Mon Sep 17 00:00:00 2001 From: Arne Date: Sun, 4 Jan 2026 22:33:20 +0100 Subject: [PATCH 064/180] Color missed calls red in call history and add call status icons --- .../ui/adapter/CallsAdapter.java | 23 +++++++++++++++++++ src/main/res/layout/item_call.xml | 3 ++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/CallsAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/CallsAdapter.java index c01f5e042..9f390020c 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/CallsAdapter.java +++ b/src/main/java/eu/siacs/conversations/ui/adapter/CallsAdapter.java @@ -9,6 +9,7 @@ import android.widget.PopupMenu; import android.widget.TextView; import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; import androidx.recyclerview.widget.RecyclerView; import java.util.List; @@ -74,7 +75,29 @@ public class CallsAdapter extends RecyclerView.Adapter { PopupMenu popup = new PopupMenu(v.getContext(), v); diff --git a/src/main/res/layout/item_call.xml b/src/main/res/layout/item_call.xml index e208d5cce..2d78df012 100644 --- a/src/main/res/layout/item_call.xml +++ b/src/main/res/layout/item_call.xml @@ -3,7 +3,7 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="wrap_content" - android:padding="14dp"> + android:padding="12dp"> Date: Sun, 4 Jan 2026 23:13:26 +0100 Subject: [PATCH 065/180] Allow opening contact details from call history and move calls history list in a background thread --- .../siacs/conversations/ui/CallsFragment.java | 26 +++++++++----- .../ui/adapter/CallsAdapter.java | 35 ++++++++++++++----- src/main/res/layout/item_call.xml | 1 + 3 files changed, 45 insertions(+), 17 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/ui/CallsFragment.java b/src/main/java/eu/siacs/conversations/ui/CallsFragment.java index c3276a873..07ce86380 100644 --- a/src/main/java/eu/siacs/conversations/ui/CallsFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/CallsFragment.java @@ -28,13 +28,14 @@ import java.util.List; import eu.siacs.conversations.R; import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.entities.Contact; import eu.siacs.conversations.entities.Conversation; import eu.siacs.conversations.entities.Message; import eu.siacs.conversations.services.CallIntegrationConnectionService; import eu.siacs.conversations.services.XmppConnectionService; import eu.siacs.conversations.ui.adapter.CallsAdapter; -public class CallsFragment extends Fragment implements CallsAdapter.OnCallAgainClickListener { +public class CallsFragment extends Fragment implements CallsAdapter.OnCallAgainClickListener, CallsAdapter.OnContactClickListener { private XmppConnectionService xmppConnectionService; private RecyclerView recyclerView; @@ -81,19 +82,21 @@ public class CallsFragment extends Fragment implements CallsAdapter.OnCallAgainC recyclerView = view.findViewById(R.id.list); recyclerView.setLayoutManager(new LinearLayoutManager(getContext())); - adapter = new CallsAdapter(calls, this, xmppConnectionService); - recyclerView.setAdapter(adapter); return view; } private void loadCalls() { if (xmppConnectionService != null) { - calls.clear(); - calls.addAll(getCalls()); - adapter = new CallsAdapter(calls, this, xmppConnectionService); - recyclerView.setAdapter(adapter); - adapter.notifyDataSetChanged(); + new Thread(() -> { + final List loadedCalls = getCalls(); + if (getActivity() != null) { + getActivity().runOnUiThread(() -> { + adapter = new CallsAdapter(loadedCalls, this, this, xmppConnectionService); + recyclerView.setAdapter(adapter); + }); + } + }).start(); } } @@ -210,4 +213,11 @@ public class CallsFragment extends Fragment implements CallsAdapter.OnCallAgainC RtpSessionActivity.actionToMedia(action)); mPendingCall = null; } + + @Override + public void onContactClick(Contact contact) { + if (getActivity() instanceof XmppActivity) { + ((XmppActivity) getActivity()).switchToContactDetails(contact); + } + } } diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/CallsAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/CallsAdapter.java index 9f390020c..4c10d0b71 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/CallsAdapter.java +++ b/src/main/java/eu/siacs/conversations/ui/adapter/CallsAdapter.java @@ -1,10 +1,10 @@ - package eu.siacs.conversations.ui.adapter; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.ImageButton; +import android.widget.ImageView; import android.widget.PopupMenu; import android.widget.TextView; @@ -15,6 +15,7 @@ import androidx.recyclerview.widget.RecyclerView; import java.util.List; import eu.siacs.conversations.R; +import eu.siacs.conversations.entities.Contact; import eu.siacs.conversations.entities.Message; import eu.siacs.conversations.services.XmppConnectionService; import eu.siacs.conversations.ui.util.AvatarWorkerTask; @@ -24,16 +25,22 @@ import eu.siacs.conversations.utils.UIHelper; public class CallsAdapter extends RecyclerView.Adapter { private final List calls; - private final OnCallAgainClickListener listener; + private final OnCallAgainClickListener callAgainClickListener; + private final OnContactClickListener contactClickListener; private final XmppConnectionService xmppConnectionService; public interface OnCallAgainClickListener { void onCallAgainClick(Message call, boolean isVideoCall); } - public CallsAdapter(List calls, OnCallAgainClickListener listener, XmppConnectionService xmppConnectionService) { + public interface OnContactClickListener { + void onContactClick(Contact contact); + } + + public CallsAdapter(List calls, OnCallAgainClickListener callAgainClickListener, OnContactClickListener contactClickListener, XmppConnectionService xmppConnectionService) { this.calls = calls; - this.listener = listener; + this.callAgainClickListener = callAgainClickListener; + this.contactClickListener = contactClickListener; this.xmppConnectionService = xmppConnectionService; } @@ -47,7 +54,7 @@ public class CallsAdapter extends RecyclerView.Adapter contactClickListener.onContactClick(contact); + avatar.setOnClickListener(clickListener); + contactName.setOnClickListener(clickListener); + } else { + avatar.setOnClickListener(null); + contactName.setOnClickListener(null); + } + final eu.siacs.conversations.entities.RtpSessionStatus rtpSessionStatus = eu.siacs.conversations.entities.RtpSessionStatus.of(call.getBody()); final boolean received = call.getStatus() == Message.STATUS_RECEIVED; final boolean missed = received && !rtpSessionStatus.successful; @@ -104,10 +121,10 @@ public class CallsAdapter extends RecyclerView.Adapter { if (item.getItemId() == R.id.action_voice_call) { - listener.onCallAgainClick(call, false); + callAgainClickListener.onCallAgainClick(call, false); return true; } else if (item.getItemId() == R.id.action_video_call) { - listener.onCallAgainClick(call, true); + callAgainClickListener.onCallAgainClick(call, true); return true; } return false; @@ -116,4 +133,4 @@ public class CallsAdapter extends RecyclerView.Adapter -- 2.39.5 From 2036796360dfb488a24d98d6e791f4610b701f65 Mon Sep 17 00:00:00 2001 From: Arne Date: Mon, 5 Jan 2026 11:21:07 +0100 Subject: [PATCH 066/180] Introduce a dedicated database query to fetch only call messages and improve calls history performance --- .../persistance/DatabaseBackend.java | 49 +++++++++++++++++ .../siacs/conversations/ui/CallsFragment.java | 54 ++++++++++--------- 2 files changed, 79 insertions(+), 24 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java b/src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java index 48efc73ec..f6bf126ed 100644 --- a/src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java +++ b/src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java @@ -3354,4 +3354,53 @@ public class DatabaseBackend extends SQLiteOpenHelper { } }; } + + // New helper method that accepts an existing database connection + public Conversation findConversationByUuid(final String uuid, final SQLiteDatabase db) { + final String[] selectionArgs = {uuid}; + try (final Cursor cursor = + db.query( + Conversation.TABLENAME, + null, + Conversation.UUID + "=?", + selectionArgs, + null, + null, + null)) { + if (!cursor.moveToFirst()) { + return null; + } + final Conversation conversation = Conversation.fromCursor(cursor); + if (conversation.getJid() instanceof Jid.Invalid) { + return null; + } + return conversation; + } + } + + public ArrayList getMessages(Conversation conversation, int type, int limit) { + ArrayList list = new ArrayList<>(); + SQLiteDatabase db = this.getReadableDatabase(); + Cursor cursor = db.query(Message.TABLENAME, + null, + Message.CONVERSATION + "=? AND " + Message.TYPE + "=?", + new String[]{conversation.getUuid(), String.valueOf(type)}, + null, + null, + Message.TIME_SENT + " DESC", + String.valueOf(limit)); + if (cursor.getCount() > 0) { + cursor.moveToFirst(); + do { + try { + list.add(Message.fromCursor(cursor, conversation)); + } catch (final Exception e) { + Log.d(Config.LOGTAG,"unable to load message from database",e); + } + } while (cursor.moveToNext()); + } + cursor.close(); + return list; + } + } diff --git a/src/main/java/eu/siacs/conversations/ui/CallsFragment.java b/src/main/java/eu/siacs/conversations/ui/CallsFragment.java index 07ce86380..758fa3a96 100644 --- a/src/main/java/eu/siacs/conversations/ui/CallsFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/CallsFragment.java @@ -1,4 +1,3 @@ - package eu.siacs.conversations.ui; import android.Manifest; @@ -25,6 +24,8 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import eu.siacs.conversations.R; import eu.siacs.conversations.entities.Account; @@ -40,19 +41,23 @@ public class CallsFragment extends Fragment implements CallsAdapter.OnCallAgainC private XmppConnectionService xmppConnectionService; private RecyclerView recyclerView; private CallsAdapter adapter; - private List calls = new ArrayList<>(); + private final List calls = new ArrayList<>(); private Message mPendingCall; private boolean mPendingVideoCall; + private boolean mCallsLoaded = false; + private final ExecutorService executor = Executors.newSingleThreadExecutor(); private static final int REQUEST_START_AUDIO_CALL = 0x213; private static final int REQUEST_START_VIDEO_CALL = 0x214; - private ServiceConnection mConnection = new ServiceConnection() { + private final ServiceConnection mConnection = new ServiceConnection() { @Override public void onServiceConnected(ComponentName className, IBinder service) { XmppConnectionService.XmppConnectionBinder binder = (XmppConnectionService.XmppConnectionBinder) service; xmppConnectionService = binder.getService(); - loadCalls(); + if (!mCallsLoaded) { + loadCalls(); + } } @Override @@ -74,30 +79,35 @@ public class CallsFragment extends Fragment implements CallsAdapter.OnCallAgainC getActivity().unbindService(mConnection); } - @Nullable @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { View view = inflater.inflate(R.layout.fragment_calls, container, false); - recyclerView = view.findViewById(R.id.list); recyclerView.setLayoutManager(new LinearLayoutManager(getContext())); - return view; } private void loadCalls() { - if (xmppConnectionService != null) { - new Thread(() -> { - final List loadedCalls = getCalls(); - if (getActivity() != null) { - getActivity().runOnUiThread(() -> { - adapter = new CallsAdapter(loadedCalls, this, this, xmppConnectionService); - recyclerView.setAdapter(adapter); - }); - } - }).start(); + if (xmppConnectionService == null) { + return; } + executor.execute(() -> { + final List loadedCalls = getCalls(); + if (getActivity() != null) { + getActivity().runOnUiThread(() -> { + calls.clear(); + calls.addAll(loadedCalls); + if (recyclerView.getAdapter() == null) { + adapter = new CallsAdapter(calls, this, this, xmppConnectionService); + recyclerView.setAdapter(adapter); + } else { + adapter.notifyDataSetChanged(); + } + mCallsLoaded = true; + }); + } + }); } private List getCalls() { @@ -106,11 +116,8 @@ public class CallsFragment extends Fragment implements CallsAdapter.OnCallAgainC return calls; } for (Conversation conversation : xmppConnectionService.getConversations()) { - for (Message message : xmppConnectionService.databaseBackend.getMessages(conversation, 100)) { // Limiting to last 100 messages per conversation for performance - if (message.getType() == Message.TYPE_RTP_SESSION) { - calls.add(message); - } - } + // Limiting to last 100 messages per conversation for performance + calls.addAll(xmppConnectionService.databaseBackend.getMessages(conversation, Message.TYPE_RTP_SESSION, 100)); } Collections.sort(calls, (o1, o2) -> Long.compare(o2.getTimeSent(), o1.getTimeSent())); return calls; @@ -119,7 +126,6 @@ public class CallsFragment extends Fragment implements CallsAdapter.OnCallAgainC @Override public void onCallAgainClick(Message call, boolean isVideoCall) { mPendingCall = call; - mPendingVideoCall = isVideoCall; if (isVideoCall) { checkPermissionAndTriggerVideoCall(); } else { @@ -220,4 +226,4 @@ public class CallsFragment extends Fragment implements CallsAdapter.OnCallAgainC ((XmppActivity) getActivity()).switchToContactDetails(contact); } } -} +} \ No newline at end of file -- 2.39.5 From c9db106111a09848ef05ba027416e5b11e38bfab Mon Sep 17 00:00:00 2001 From: Arne Date: Mon, 5 Jan 2026 12:59:45 +0100 Subject: [PATCH 067/180] Show icons in "call again" context menu --- .../eu/siacs/conversations/ui/adapter/CallsAdapter.java | 5 ++++- src/main/res/menu/call_again_context.xml | 8 ++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/CallsAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/CallsAdapter.java index 4c10d0b71..6be35eb29 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/CallsAdapter.java +++ b/src/main/java/eu/siacs/conversations/ui/adapter/CallsAdapter.java @@ -1,10 +1,10 @@ package eu.siacs.conversations.ui.adapter; +import android.os.Build; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.ImageButton; -import android.widget.ImageView; import android.widget.PopupMenu; import android.widget.TextView; @@ -118,6 +118,9 @@ public class CallsAdapter extends RecyclerView.Adapter { PopupMenu popup = new PopupMenu(v.getContext(), v); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + popup.setForceShowIcon(true); + } popup.getMenuInflater().inflate(R.menu.call_again_context, popup.getMenu()); popup.setOnMenuItemClickListener(item -> { if (item.getItemId() == R.id.action_voice_call) { diff --git a/src/main/res/menu/call_again_context.xml b/src/main/res/menu/call_again_context.xml index 7d6636fe8..66afe0d88 100644 --- a/src/main/res/menu/call_again_context.xml +++ b/src/main/res/menu/call_again_context.xml @@ -2,10 +2,10 @@ + android:icon="@drawable/ic_call_24dp" + android:title="@string/audio_call" /> + android:icon="@drawable/ic_videocam_24dp" + android:title="@string/video_call" /> -- 2.39.5 From 0b887c4c1cc63a1fbae8ceeb72db74e9910f98a1 Mon Sep 17 00:00:00 2001 From: Arne Date: Mon, 5 Jan 2026 14:44:01 +0100 Subject: [PATCH 068/180] Show badge for missed calls in bottom navigation --- .../services/NotificationService.java | 6 +++ .../siacs/conversations/ui/CallsActivity.java | 38 +++++++++++++------ .../ui/ConversationsActivity.java | 5 +++ .../ui/StartConversationActivity.java | 5 +++ .../conversations/ui/StoriesActivity.java | 5 +++ 5 files changed, 47 insertions(+), 12 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/services/NotificationService.java b/src/main/java/eu/siacs/conversations/services/NotificationService.java index 26382f4fb..fb00a30b2 100644 --- a/src/main/java/eu/siacs/conversations/services/NotificationService.java +++ b/src/main/java/eu/siacs/conversations/services/NotificationService.java @@ -2468,4 +2468,10 @@ public class NotificationService { return lastTime; } } + + public boolean hasNewMissedCalls() { + synchronized (mMissedCalls) { + return !mMissedCalls.isEmpty(); + } + } } diff --git a/src/main/java/eu/siacs/conversations/ui/CallsActivity.java b/src/main/java/eu/siacs/conversations/ui/CallsActivity.java index 1bfdb58ef..fc9e06eec 100644 --- a/src/main/java/eu/siacs/conversations/ui/CallsActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/CallsActivity.java @@ -1,4 +1,3 @@ - package eu.siacs.conversations.ui; import static android.view.View.VISIBLE; @@ -12,7 +11,6 @@ import android.view.View; import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; -import androidx.appcompat.app.AppCompatActivity; import androidx.databinding.DataBindingUtil; import com.google.android.material.bottomnavigation.BottomNavigationView; @@ -22,50 +20,54 @@ import eu.siacs.conversations.databinding.ActivityCallsBinding; public class CallsActivity extends XmppActivity { + private ActivityCallsBinding binding; + private CallsFragment callsFragment; + @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); - ActivityCallsBinding binding = DataBindingUtil.setContentView(this, R.layout.activity_calls); + binding = DataBindingUtil.setContentView(this, R.layout.activity_calls); Activities.setStatusAndNavigationBarColors(this, findViewById(android.R.id.content)); setSupportActionBar(binding.toolbar); configureActionBar(getSupportActionBar()); if (savedInstanceState == null) { + callsFragment = new CallsFragment(); getSupportFragmentManager().beginTransaction() - .replace(R.id.fragment_container, new CallsFragment()) + .replace(R.id.fragment_container, callsFragment) .commit(); + } else { + callsFragment = (CallsFragment) getSupportFragmentManager().findFragmentById(R.id.fragment_container); } - - // Bottom Navigation Setup BottomNavigationView bottomNavigationView = findViewById(R.id.bottom_navigation); bottomNavigationView.setBackgroundColor(Color.TRANSPARENT); bottomNavigationView.setOnItemSelectedListener(item -> { switch (item.getItemId()) { - case R.id.chats -> { + case R.id.chats: { startActivity(new Intent(getApplicationContext(), ConversationsActivity.class)); overridePendingTransition(R.animator.fade_in, R.animator.fade_out); return true; } - case R.id.contactslist -> { + case R.id.contactslist: { Intent i = new Intent(getApplicationContext(), StartConversationActivity.class); i.putExtra("show_nav_bar", true); startActivity(i); overridePendingTransition(R.animator.fade_in, R.animator.fade_out); return true; } - case R.id.stories -> { + case R.id.stories: { Intent i = new Intent(getApplicationContext(), StoriesActivity.class); i.putExtra("show_nav_bar", true); startActivity(i); overridePendingTransition(R.animator.fade_in, R.animator.fade_out); return true; } - case R.id.calls -> { + case R.id.calls: { return true; } - default -> - throw new IllegalStateException("Unexpected value: " + item.getItemId()); + default: + throw new IllegalStateException("Unexpected value: " + item.getItemId()); } }); } @@ -93,6 +95,10 @@ public class CallsActivity extends XmppActivity { @Override protected void onBackendConnected() { refreshUiReal(); + // Clear missed call notifications and badge when the activity is displayed. + if (xmppConnectionService != null) { + xmppConnectionService.getNotificationService().clearMissedCalls(); + } } @Override @@ -108,6 +114,9 @@ public class CallsActivity extends XmppActivity { } protected void refreshUiReal() { + if (xmppConnectionService == null) { + return; + } ActionBar actionBar = getSupportActionBar(); // Show badge for unread message in bottom nav @@ -124,6 +133,11 @@ public class CallsActivity extends XmppActivity { var storiesBadge = bottomnav.getOrCreateBadge(R.id.stories); storiesBadge.setVisible(hasNewStories); + // Show badge for missed calls in bottom nav + boolean hasNewMissedCalls = xmppConnectionService.getNotificationService().hasNewMissedCalls(); + var callsBadge = bottomnav.getOrCreateBadge(R.id.calls); + callsBadge.setVisible(hasNewMissedCalls); + boolean showNavBar = bottomnav.getVisibility() == VISIBLE; if (actionBar != null) { actionBar.setHomeButtonEnabled(!showNavBar); diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java b/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java index 02e75d93c..a5c648816 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java @@ -259,6 +259,11 @@ public class ConversationsActivity extends XmppActivity var storiesBadge = bottomnav.getOrCreateBadge(R.id.stories); storiesBadge.setVisible(hasNewStories); + // Show badge for missed calls in bottom nav + boolean hasNewMissedCalls = xmppConnectionService.getNotificationService().hasNewMissedCalls(); + var callsBadge = bottomnav.getOrCreateBadge(R.id.calls); + callsBadge.setVisible(hasNewMissedCalls); + final var chatRequestsPref = xmppConnectionService.getStringPreference("chat_requests", R.string.default_chat_requests); final var accountUnreads = new HashMap(); binding.drawer.apply(dr -> { diff --git a/src/main/java/eu/siacs/conversations/ui/StartConversationActivity.java b/src/main/java/eu/siacs/conversations/ui/StartConversationActivity.java index 99275387d..1916c8621 100644 --- a/src/main/java/eu/siacs/conversations/ui/StartConversationActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/StartConversationActivity.java @@ -1223,6 +1223,11 @@ public class StartConversationActivity extends XmppActivity boolean hasNewStories = xmppConnectionService.getStories().stream().anyMatch(s -> s.getPublished() > lastRead); var storiesBadge = bottomnav.getOrCreateBadge(R.id.stories); storiesBadge.setVisible(hasNewStories); + + // Show badge for missed calls in bottom nav + boolean hasNewMissedCalls = xmppConnectionService.getNotificationService().hasNewMissedCalls(); + var callsBadge = bottomnav.getOrCreateBadge(R.id.calls); + callsBadge.setVisible(hasNewMissedCalls); } @Override diff --git a/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java b/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java index 568c9ff1e..6a29ea6a3 100644 --- a/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java @@ -332,6 +332,11 @@ public class StoriesActivity extends XmppActivity implements XmppConnectionServi var storiesBadge = bottomnav.getOrCreateBadge(R.id.stories); storiesBadge.setVisible(hasNewStories); + // Show badge for missed calls in bottom nav + boolean hasNewMissedCalls = xmppConnectionService.getNotificationService().hasNewMissedCalls(); + var callsBadge = bottomnav.getOrCreateBadge(R.id.calls); + callsBadge.setVisible(hasNewMissedCalls); + boolean showNavBar = bottomnav.getVisibility() == VISIBLE; if (actionBar != null) { actionBar.setHomeButtonEnabled(!showNavBar); -- 2.39.5 From e71c8b99a81472a5101a0e9235f2e199a126ab90 Mon Sep 17 00:00:00 2001 From: Arne Date: Tue, 6 Jan 2026 12:33:09 +0100 Subject: [PATCH 069/180] Add blank line after quote fallback and reduce quote length --- .../java/eu/siacs/conversations/entities/Message.java | 2 +- .../conversations/services/XmppConnectionService.java | 2 +- .../java/eu/siacs/conversations/ui/util/QuoteHelper.java | 6 +++++- .../java/eu/siacs/conversations/utils/MessageUtils.java | 8 ++++++++ 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/entities/Message.java b/src/main/java/eu/siacs/conversations/entities/Message.java index 6c5f078f2..c37bf8e32 100644 --- a/src/main/java/eu/siacs/conversations/entities/Message.java +++ b/src/main/java/eu/siacs/conversations/entities/Message.java @@ -491,7 +491,7 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable clearReplyReact(); if (body == null) body = new SpannableStringBuilder(getBody(false)); - setBody(QuoteHelper.quote(MessageUtils.prepareQuote(replyTo)) + "\n"); + setBody(QuoteHelper.quote(MessageUtils.prepareQuote(replyTo)) + "\n\n"); final String replyId = replyTo.replyId(); if (replyId == null) return; diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java index bd30512c9..ef9452d55 100644 --- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +++ b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java @@ -6831,7 +6831,7 @@ public class XmppConnectionService extends Service { final var packet = mMessageGenerator.reaction(reactTo, typeGroupChat, message, reactToId, reactions); - final var quote = QuoteHelper.quote(MessageUtils.prepareQuote(message)) + "\n"; + final var quote = QuoteHelper.quote(MessageUtils.prepareQuote(message, 1, 2)) + "\n\n"; final var body = quote + String.join(" ", newReactions); if (conversation.getNextEncryption() == Message.ENCRYPTION_AXOLOTL && newReactions.size() > 0) { FILE_ATTACHMENT_EXECUTOR.execute(() -> { diff --git a/src/main/java/eu/siacs/conversations/ui/util/QuoteHelper.java b/src/main/java/eu/siacs/conversations/ui/util/QuoteHelper.java index d835f862a..61536a183 100644 --- a/src/main/java/eu/siacs/conversations/ui/util/QuoteHelper.java +++ b/src/main/java/eu/siacs/conversations/ui/util/QuoteHelper.java @@ -98,6 +98,10 @@ public class QuoteHelper { } public static boolean isNestedTooDeeply(CharSequence line) { + return isNestedTooDeeply(line, Config.QUOTING_MAX_DEPTH); + } + + public static boolean isNestedTooDeeply(CharSequence line, int maxDepth) { if (isPositionQuoteStart(line, 0)) { int nestingDepth = 1; for (int i = 1; i < line.length(); i++) { @@ -107,7 +111,7 @@ public class QuoteHelper { break; } } - return nestingDepth >= (Config.QUOTING_MAX_DEPTH); + return nestingDepth >= maxDepth; } return false; } diff --git a/src/main/java/eu/siacs/conversations/utils/MessageUtils.java b/src/main/java/eu/siacs/conversations/utils/MessageUtils.java index ce8b62b3e..6d34af83a 100644 --- a/src/main/java/eu/siacs/conversations/utils/MessageUtils.java +++ b/src/main/java/eu/siacs/conversations/utils/MessageUtils.java @@ -31,6 +31,7 @@ package eu.siacs.conversations.utils; import com.google.common.base.Strings; +import eu.siacs.conversations.Config; import eu.siacs.conversations.R; import eu.siacs.conversations.entities.Conversational; import eu.siacs.conversations.entities.Message; @@ -49,6 +50,10 @@ public class MessageUtils { public static final String EMPTY_STRING = ""; public static String prepareQuote(final Message message) { + return prepareQuote(message, Config.QUOTING_MAX_DEPTH, -1); + } + + public static String prepareQuote(final Message message, int maxDepth, int maxLines) { final StringBuilder builder = new StringBuilder(); final String body; if (message.hasMeCommand()) { @@ -66,14 +71,17 @@ public class MessageUtils { } else { body = message.getQuoteableBody(); } + int lines = 0; for (String line : body.split("\n")) { if (!(line.length() <= 0) && QuoteHelper.isNestedTooDeeply(line)) { continue; } + if (maxLines > 0 && maxLines <= lines) break; if (builder.length() != 0) { builder.append('\n'); } builder.append(line.trim()); + lines++; } return builder.toString(); } -- 2.39.5 From ecb299ec1f91f10eef2b284fc62bd5b84ea2d69d Mon Sep 17 00:00:00 2001 From: Arne Date: Tue, 6 Jan 2026 19:21:30 +0100 Subject: [PATCH 070/180] Add option to disable calls per contact --- .../siacs/conversations/entities/Contact.java | 23 ++++++++++-- .../persistance/DatabaseBackend.java | 20 ++++++++++- .../services/XmppConnectionService.java | 20 +++++++++++ .../ui/ContactDetailsActivity.java | 11 +++++- .../xmpp/jingle/JingleConnectionManager.java | 35 +++++++++++++++++++ .../res/layout/activity_contact_details.xml | 6 ++++ src/main/res/values/strings.xml | 1 + 7 files changed, 111 insertions(+), 5 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/entities/Contact.java b/src/main/java/eu/siacs/conversations/entities/Contact.java index 4948a1f5e..bf9ebc7f6 100644 --- a/src/main/java/eu/siacs/conversations/entities/Contact.java +++ b/src/main/java/eu/siacs/conversations/entities/Contact.java @@ -63,6 +63,7 @@ public class Contact implements ListItem, Blockable { public static final String LAST_TIME = "last_time"; public static final String GROUPS = "groups"; public static final String RTP_CAPABILITY = "rtpCapability"; + public static final String CALLS_DISABLED = "callsDisabled"; private String accountUuid; private String systemName; private String serverName; @@ -84,9 +85,10 @@ public class Contact implements ListItem, Blockable { private String mLastPresence = null; private UserTune mUserTune = null; private RtpCapability.Capability rtpCapability; + private boolean callsDisabled; public Contact(Contact other) { - this(null, other.systemName, other.serverName, other.presenceName, other.jid, other.subscription, other.photoUri, other.systemAccount, other.keys == null ? null : other.keys.toString(), other.getAvatar() == null ? null : other.getAvatar().sha1sum, other.mLastseen, other.mLastPresence, other.groups == null ? null : other.groups.toString(), other.rtpCapability); + this(null, other.systemName, other.serverName, other.presenceName, other.jid, other.subscription, other.photoUri, other.systemAccount, other.keys == null ? null : other.keys.toString(), other.getAvatar() == null ? null : other.getAvatar().sha1sum, other.mLastseen, other.mLastPresence, other.groups == null ? null : other.groups.toString(), other.rtpCapability, other.callsDisabled); setAccount(other.getAccount()); } @@ -104,7 +106,8 @@ public class Contact implements ListItem, Blockable { final long lastseen, final String presence, final String groups, - final RtpCapability.Capability rtpCapability) { + final RtpCapability.Capability rtpCapability, + final boolean callsDisabled) { this.accountUuid = account; this.systemName = systemName; this.serverName = serverName; @@ -133,6 +136,7 @@ public class Contact implements ListItem, Blockable { this.mLastseen = lastseen; this.mLastPresence = presence; this.rtpCapability = rtpCapability; + this.callsDisabled = callsDisabled; } public Contact(final Jid jid) { @@ -169,7 +173,8 @@ public class Contact implements ListItem, Blockable { cursor.getString(cursor.getColumnIndex(LAST_PRESENCE)), cursor.getString(cursor.getColumnIndex(GROUPS)), RtpCapability.Capability.of( - cursor.getString(cursor.getColumnIndex(RTP_CAPABILITY)))); + cursor.getString(cursor.getColumnIndex(RTP_CAPABILITY))), + cursor.getInt(cursor.getColumnIndex(CALLS_DISABLED)) > 0); } public String getDisplayName() { @@ -295,6 +300,7 @@ public class Contact implements ListItem, Blockable { values.put(LAST_TIME, mLastseen); values.put(GROUPS, groups.toString()); values.put(RTP_CAPABILITY, rtpCapability == null ? null : rtpCapability.toString()); + values.put(CALLS_DISABLED, callsDisabled ? 1 : 0); return values; } } @@ -815,9 +821,20 @@ public class Contact implements ListItem, Blockable { } public RtpCapability.Capability getRtpCapability() { + if (this.callsDisabled) { + return null; + } return this.rtpCapability == null ? RtpCapability.Capability.NONE : this.rtpCapability; } + public void setCallsDisabled(boolean callsDisabled) { + this.callsDisabled = callsDisabled; + } + + public boolean areCallsDisabled() { + return callsDisabled; + } + public static final class Options { public static final int TO = 0; public static final int FROM = 1; diff --git a/src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java b/src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java index f6bf126ed..dae2dc093 100644 --- a/src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java +++ b/src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java @@ -109,7 +109,7 @@ import org.whispersystems.libsignal.state.SignedPreKeyRecord; public class DatabaseBackend extends SQLiteOpenHelper { private static final String DATABASE_NAME = "history"; - private static final int DATABASE_VERSION = 64; + private static final int DATABASE_VERSION = 65; private static boolean requiresMessageIndexRebuild = false; private static DatabaseBackend instance = null; @@ -139,6 +139,8 @@ public class DatabaseBackend extends SQLiteOpenHelper { + " TEXT, " + Contact.LAST_PRESENCE + " TEXT, " + + Contact.CALLS_DISABLED + + " boolean DEFAULT 0," + Contact.LAST_TIME + " NUMBER, " + Contact.RTP_CAPABILITY @@ -1303,6 +1305,9 @@ public class DatabaseBackend extends SQLiteOpenHelper { Log.e(Config.LOGTAG, "Failed to add ordering column to account table", e); } } + if (oldVersion < 65 && newVersion >= 65) { + db.execSQL("ALTER TABLE " + Contact.TABLENAME + " ADD COLUMN " + Contact.CALLS_DISABLED + " boolean DEFAULT 0"); + } } /** @@ -2200,6 +2205,19 @@ public class DatabaseBackend extends SQLiteOpenHelper { return message; } + public boolean updateContact(final Contact contact) { + final SQLiteDatabase db = this.getWritableDatabase();final ContentValues values = contact.getContentValues(); + final String[] args = {contact.getAccount().getUuid(), contact.getJid().asBareJid().toString()}; + try { + final int count = db.update(Contact.TABLENAME, values, + Contact.ACCOUNT + "=? AND " + Contact.JID + "=?", + args); + return count > 0; + } catch (Exception e) { + return false; + } + } + public static class FilePath { public final UUID uuid; public final String path; diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java index ef9452d55..b9739b3e2 100644 --- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +++ b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java @@ -8086,4 +8086,24 @@ public class XmppConnectionService extends Service { } } } + + public void updateContact(final Contact contact) { + // First, persist the change in the database + if (databaseBackend.updateContact(contact)) { + // Then, find the live account and contact objects + final Account account = findAccountByUuid(contact.getAccount().getUuid()); + if (account != null) { + final Contact rosterContact = account.getRoster().getContact(contact.getJid()); + // Update the live contact object with the new setting + if (rosterContact != null) { + rosterContact.setCallsDisabled(contact.areCallsDisabled()); + // Now, notify the UI about the change. + // This will cause activities like ConversationActivity to redraw their menus + // and hide/show the call button as appropriate. + updateConversationUi(); + updateConversationUi(true); + } + } + } + } } diff --git a/src/main/java/eu/siacs/conversations/ui/ContactDetailsActivity.java b/src/main/java/eu/siacs/conversations/ui/ContactDetailsActivity.java index ef03bcc2c..d8aaa56ca 100644 --- a/src/main/java/eu/siacs/conversations/ui/ContactDetailsActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/ContactDetailsActivity.java @@ -42,6 +42,7 @@ import de.monocles.chat.Util; import com.google.android.material.color.MaterialColors; import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.materialswitch.MaterialSwitch; import com.google.common.collect.ImmutableList; import com.google.common.primitives.Ints; @@ -118,6 +119,7 @@ public class ContactDetailsActivity extends OmemoActivity protected MenuItem save = null; private Contact contact; + private MaterialSwitch mDisableCallsSwitch; private final DialogInterface.OnClickListener removeFromRoster = new DialogInterface.OnClickListener() { @@ -309,7 +311,7 @@ public class ContactDetailsActivity extends OmemoActivity populateView(); }); binding.addContactButton.setOnClickListener(v -> showAddToRosterDialog(contact)); - + mDisableCallsSwitch = binding.disableCalls; mMediaAdapter = new MediaAdapter(this, R.dimen.media_size); this.binding.media.setAdapter(mMediaAdapter); GridManager.setupLayoutManager(this, this.binding.media, R.dimen.media_size); @@ -603,6 +605,13 @@ public class ContactDetailsActivity extends OmemoActivity binding.detailsSendPresence.setOnCheckedChangeListener(null); binding.detailsReceivePresence.setOnCheckedChangeListener(null); + mDisableCallsSwitch.setChecked(contact.areCallsDisabled()); + mDisableCallsSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { + contact.setCallsDisabled(isChecked); + xmppConnectionService.updateContact(contact); + }); + + List statusMessages = contact.getPresences().getStatusMessages(); if (statusMessages.isEmpty()) { binding.statusMessage.setVisibility(View.GONE); diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnectionManager.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnectionManager.java index 449023756..48bbc42b6 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnectionManager.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnectionManager.java @@ -104,6 +104,14 @@ public class JingleConnectionManager extends AbstractConnectionManager { connection = new JingleFileTransferConnection(this, id, from); } else if (Namespace.JINGLE_APPS_RTP.equals(descriptionNamespace) && isUsingClearNet(account)) { + // START of the fix for session-initiate + final Contact contact = account.getRoster().getContact(packet.getFrom()); + if (contact != null && contact.areCallsDisabled()) { + Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": rejecting session-initiate with disabled contact " + id.with); + sendDecline(account, packet, id); + return; + } + // END of the fix for session-initiate final boolean sessionEnded = this.terminatedSessions.asMap().containsKey(PersistableSessionId.of(id)); final boolean stranger = @@ -292,6 +300,21 @@ public class JingleConnectionManager extends AbstractConnectionManager { if (sessionId == null) { return; } + + if ("propose".equals(message.getName())) { + final Contact contact = account.getRoster().getContact(from); + if (contact != null && contact.areCallsDisabled()) { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": ignoring incoming call (propose) from disabled contact " + from); + final var rejection = new im.conversations.android.xmpp.model.stanza.Message(); + rejection.setType(im.conversations.android.xmpp.model.stanza.Message.Type.CHAT); + rejection.setTo(from); + rejection.addChild("reject", Namespace.JINGLE_MESSAGE).setAttribute("id", sessionId); + rejection.addChild("store", "urn:xmpp:hints"); + mXmppConnectionService.sendMessagePacket(account, rejection); + return; + } + } + if ("accept".equals(message.getName()) || "reject".equals(message.getName())) { for (AbstractJingleConnection connection : connections.values()) { if (connection instanceof JingleRtpConnection rtpConnection) { @@ -1290,4 +1313,16 @@ public class JingleConnectionManager extends AbstractConnectionManager { return false; } } + + private void sendDecline( + final Account account, final Iq request, final AbstractJingleConnection.Id id) { + mXmppConnectionService.sendIqPacket( + account, request.generateResponse(Iq.Type.RESULT), null); + final var iq = new Iq(Iq.Type.SET); + iq.setTo(id.with); + final var sessionTermination = + iq.addExtension(new Jingle(Jingle.Action.SESSION_TERMINATE, id.sessionId)); + sessionTermination.setReason(Reason.DECLINE, null); + mXmppConnectionService.sendIqPacket(account, iq, null); + } } diff --git a/src/main/res/layout/activity_contact_details.xml b/src/main/res/layout/activity_contact_details.xml index 0824aff37..ed2bfefea 100644 --- a/src/main/res/layout/activity_contact_details.xml +++ b/src/main/res/layout/activity_contact_details.xml @@ -175,6 +175,12 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/receive_presence_updates" /> + + of Could not create story Calls + Disable voice and video calls \ No newline at end of file -- 2.39.5 From 99144f43401e69ca96e8543f3ddb8262acc6b0eb Mon Sep 17 00:00:00 2001 From: Arne Date: Tue, 6 Jan 2026 20:04:50 +0100 Subject: [PATCH 071/180] Show empty view in calls fragment, improve avatar shape fallback and fix rare crash in calls log --- .../siacs/conversations/ui/CallsFragment.java | 14 ++++++++++++++ src/main/res/layout/fragment_calls.xml | 8 ++++++++ src/main/res/layout/item_call.xml | 5 ++--- src/main/res/values-land/dimens.xml | 1 + src/main/res/values/strings.xml | 1 + .../conversations/ui/widget/AvatarView.kt | 18 ++++++++++++------ 6 files changed, 38 insertions(+), 9 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/ui/CallsFragment.java b/src/main/java/eu/siacs/conversations/ui/CallsFragment.java index 758fa3a96..c36e5cc9c 100644 --- a/src/main/java/eu/siacs/conversations/ui/CallsFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/CallsFragment.java @@ -12,6 +12,7 @@ import android.os.IBinder; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.widget.TextView; import android.widget.Toast; import androidx.annotation.NonNull; @@ -40,6 +41,7 @@ public class CallsFragment extends Fragment implements CallsAdapter.OnCallAgainC private XmppConnectionService xmppConnectionService; private RecyclerView recyclerView; + private TextView emptyView; private CallsAdapter adapter; private final List calls = new ArrayList<>(); private Message mPendingCall; @@ -85,6 +87,7 @@ public class CallsFragment extends Fragment implements CallsAdapter.OnCallAgainC View view = inflater.inflate(R.layout.fragment_calls, container, false); recyclerView = view.findViewById(R.id.list); recyclerView.setLayoutManager(new LinearLayoutManager(getContext())); + emptyView = view.findViewById(R.id.empty_view); return view; } @@ -104,12 +107,23 @@ public class CallsFragment extends Fragment implements CallsAdapter.OnCallAgainC } else { adapter.notifyDataSetChanged(); } + updateViewStates(); mCallsLoaded = true; }); } }); } + private void updateViewStates() { + if (calls.isEmpty()) { + recyclerView.setVisibility(View.GONE); + emptyView.setVisibility(View.VISIBLE); + } else { + recyclerView.setVisibility(View.VISIBLE); + emptyView.setVisibility(View.GONE); + } + } + private List getCalls() { List calls = new ArrayList<>(); if (xmppConnectionService == null) { diff --git a/src/main/res/layout/fragment_calls.xml b/src/main/res/layout/fragment_calls.xml index 02f92bf4e..eb8122c39 100644 --- a/src/main/res/layout/fragment_calls.xml +++ b/src/main/res/layout/fragment_calls.xml @@ -8,4 +8,12 @@ android:layout_width="match_parent" android:layout_height="match_parent" /> + + diff --git a/src/main/res/layout/item_call.xml b/src/main/res/layout/item_call.xml index 6e2c5226b..486d492fa 100644 --- a/src/main/res/layout/item_call.xml +++ b/src/main/res/layout/item_call.xml @@ -1,6 +1,5 @@ @@ -47,7 +46,7 @@ android:layout_height="2dp" android:layout_gravity="center" android:padding="2dp" - android:background="?android:attr/textColorPrimary" /> + android:background="@color/sd_label_text_color" /> diff --git a/src/main/res/values-land/dimens.xml b/src/main/res/values-land/dimens.xml index 73ac76ef7..1c1e74926 100644 --- a/src/main/res/values-land/dimens.xml +++ b/src/main/res/values-land/dimens.xml @@ -2,4 +2,5 @@ 96dp 128dp 16dp + 8dp \ No newline at end of file diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index 522c0afc3..c925df370 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -1595,4 +1595,5 @@ Could not create story Calls Disable voice and video calls + No calls yet \ No newline at end of file diff --git a/src/monocleschat/java/eu/siacs/conversations/ui/widget/AvatarView.kt b/src/monocleschat/java/eu/siacs/conversations/ui/widget/AvatarView.kt index f98d77a85..787d829dd 100644 --- a/src/monocleschat/java/eu/siacs/conversations/ui/widget/AvatarView.kt +++ b/src/monocleschat/java/eu/siacs/conversations/ui/widget/AvatarView.kt @@ -2,6 +2,7 @@ package eu.siacs.conversations.ui.widget import android.content.Context import android.content.SharedPreferences +import android.content.res.Resources import android.graphics.Outline import androidx.preference.PreferenceManager import android.util.AttributeSet @@ -35,14 +36,19 @@ class AvatarView : AppCompatImageView { } private fun invalidateShape() { - val shape = PreferenceManager.getDefaultSharedPreferences(context).getString("avatar_shape", context.getString(R.string.avatar_shape)) + val defaultShape = try { + context.getString(R.string.avatar_shape) + } catch (e: Resources.NotFoundException) { + "oval" + } + val shape = PreferenceManager.getDefaultSharedPreferences(context).getString("avatar_shape", defaultShape) if (shape == currentShape) { return } - when { - shape == "oval" -> { + when (shape) { + "oval" -> { clipToOutline = true outlineProvider = object : ViewOutlineProvider() { override fun getOutline(view: View, outline: Outline) { @@ -50,7 +56,7 @@ class AvatarView : AppCompatImageView { } } } - shape == "rounded_square" -> { + "rounded_square" -> { clipToOutline = true outlineProvider = object : ViewOutlineProvider() { override fun getOutline(view: View, outline: Outline) { @@ -60,12 +66,12 @@ class AvatarView : AppCompatImageView { } } } - shape == "square" -> { + "square" -> { clipToOutline = false outlineProvider = ViewOutlineProvider.BACKGROUND } } - currentShape = shape!! + currentShape = shape ?: defaultShape } } \ No newline at end of file -- 2.39.5 From 1269de5d232524a7afe0bc2bb901fb276c231a5b Mon Sep 17 00:00:00 2001 From: Arne Date: Tue, 6 Jan 2026 22:33:15 +0100 Subject: [PATCH 072/180] Limit videos to one minute for stories --- .../conversations/ui/StoriesActivity.java | 29 +++++++++++++++---- src/main/res/values/strings.xml | 2 ++ 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java b/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java index 6a29ea6a3..d1188df64 100644 --- a/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java @@ -2,8 +2,6 @@ package eu.siacs.conversations.ui; import static android.view.View.VISIBLE; -import static eu.siacs.conversations.utils.AccountUtils.MANAGE_ACCOUNT_ACTIVITY; - import android.Manifest; import android.app.Activity; import android.app.PendingIntent; @@ -11,9 +9,11 @@ import android.content.ActivityNotFoundException; import android.content.Intent; import android.content.pm.PackageManager; import android.graphics.Color; +import android.media.MediaMetadataRetriever; import android.net.Uri; import android.os.Bundle; import android.provider.MediaStore; +import android.util.Log; import android.view.Menu; import android.view.MenuItem; import android.view.View; @@ -34,6 +34,7 @@ import java.util.List; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; +import eu.siacs.conversations.Config; import eu.siacs.conversations.R; import eu.siacs.conversations.databinding.ActivityStoriesBinding; import eu.siacs.conversations.entities.Account; @@ -210,7 +211,6 @@ public class StoriesActivity extends XmppActivity implements XmppConnectionServi } } - @Override public void onActivityResult(int requestCode, int resultCode, final Intent data) { super.onActivityResult(requestCode, resultCode, data); @@ -222,12 +222,29 @@ public class StoriesActivity extends XmppActivity implements XmppConnectionServi } else if (pendingTakePhotoUri.peek() != null) { uri = pendingTakePhotoUri.pop(); } else { - return; //No image was selected + return; } if (mSelectedAccount != null) { String mimeType = getContentResolver().getType(uri); if (mimeType != null && mimeType.startsWith("video/")) { - publish(uri, mimeType); + try { + MediaMetadataRetriever retriever = new MediaMetadataRetriever(); + retriever.setDataSource(this, uri); + String time = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION); + long durationMs = 0; + if (time != null) { + durationMs = Long.parseLong(time); + } + retriever.release(); + if (durationMs > 60000) { + Toast.makeText(this, R.string.video_too_long_for_story, Toast.LENGTH_SHORT).show(); + } else { + publish(uri, mimeType); + } + } catch (Exception e) { + Log.d(Config.LOGTAG, "Could not determine video duration", e); + Toast.makeText(this, R.string.error_retrieving_video_duration, Toast.LENGTH_SHORT).show(); + } } else { Intent intent = new Intent(this, EditActivity.class); intent.setData(uri); @@ -245,7 +262,7 @@ public class StoriesActivity extends XmppActivity implements XmppConnectionServi mimeType = getContentResolver().getType(uri); } if (mimeType == null) { - mimeType = "image/jpeg"; // Fallback for file URIs + mimeType = "image/jpeg"; } publish(uri, mimeType); } diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index c925df370..1d766e752 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -1596,4 +1596,6 @@ Calls Disable voice and video calls No calls yet + Too long. Maximum video length is one minute. + Could not retrieve video duration \ No newline at end of file -- 2.39.5 From ab5c20af458fc57b1624b5c766735761d42857ab Mon Sep 17 00:00:00 2001 From: Arne Date: Wed, 7 Jan 2026 06:08:05 +0100 Subject: [PATCH 073/180] Change stories node access model --- src/main/java/eu/siacs/conversations/generator/IqGenerator.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/eu/siacs/conversations/generator/IqGenerator.java b/src/main/java/eu/siacs/conversations/generator/IqGenerator.java index 392a1adeb..98ec1d916 100644 --- a/src/main/java/eu/siacs/conversations/generator/IqGenerator.java +++ b/src/main/java/eu/siacs/conversations/generator/IqGenerator.java @@ -723,7 +723,7 @@ public class IqGenerator extends AbstractGenerator { Bundle options = new Bundle(); options.putString("pubsub#node_type", "leaf"); options.putString("pubsub#type", Namespace.PUBSUB_STORIES); - options.putString("pubsub#access_model", "presence"); + options.putString("pubsub#access_model", "roster"); options.putString("pubsub#item_expire", "86400"); options.putString("pubsub#persist_items", "1"); options.putString("pubsub#notify_retract", "1"); -- 2.39.5 From 0ccf66cc16bfc425cb094e773bd8fe1c826ba7e0 Mon Sep 17 00:00:00 2001 From: Arne Date: Wed, 7 Jan 2026 13:03:36 +0100 Subject: [PATCH 074/180] Stop sending last published item on presence change --- src/main/java/eu/siacs/conversations/generator/IqGenerator.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/eu/siacs/conversations/generator/IqGenerator.java b/src/main/java/eu/siacs/conversations/generator/IqGenerator.java index 98ec1d916..44570bdb0 100644 --- a/src/main/java/eu/siacs/conversations/generator/IqGenerator.java +++ b/src/main/java/eu/siacs/conversations/generator/IqGenerator.java @@ -727,7 +727,7 @@ public class IqGenerator extends AbstractGenerator { options.putString("pubsub#item_expire", "86400"); options.putString("pubsub#persist_items", "1"); options.putString("pubsub#notify_retract", "1"); - options.putString("pubsub#send_last_published_item", "on_sub_and_presence"); + options.putString("pubsub#send_last_published_item", "on_sub"); options.putString("pubsub#publisher", "publishers"); return options; } -- 2.39.5 From 6be4f174ee4e0d2e99a316cc2be64cf61e171e55 Mon Sep 17 00:00:00 2001 From: Arne Date: Wed, 7 Jan 2026 14:38:37 +0100 Subject: [PATCH 075/180] Add null checks to isAudioCid to prevent crashes --- .../java/eu/siacs/conversations/ui/ConversationFragment.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java index c1a3689e0..f6f0acfbc 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java @@ -6719,7 +6719,6 @@ public class ConversationFragment extends XmppFragment if (cid == null) { return false; } - // Add null checks for activity and the service to prevent crashes. if (activity == null || activity.xmppConnectionService == null) { return false; } @@ -6735,6 +6734,9 @@ public class ConversationFragment extends XmppFragment private boolean isAudioCid(Cid cid) { if (cid == null) return false; + if (activity == null || activity.xmppConnectionService == null) { + return false; + } File file = activity.xmppConnectionService.getFileForCid(cid); if (file == null) return false; String lowerFilePath = file.getAbsolutePath(); -- 2.39.5 From 4a489d87446de191e674c9aa7bead6eb5884c1c3 Mon Sep 17 00:00:00 2001 From: Arne Date: Thu, 8 Jan 2026 09:01:03 +0100 Subject: [PATCH 076/180] Refactor story loading fixes compatibility for older devices --- .../siacs/conversations/ui/StoryFragment.java | 147 +++++++----------- 1 file changed, 57 insertions(+), 90 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/ui/StoryFragment.java b/src/main/java/eu/siacs/conversations/ui/StoryFragment.java index 8636d9a56..cc18adf51 100644 --- a/src/main/java/eu/siacs/conversations/ui/StoryFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/StoryFragment.java @@ -1,9 +1,9 @@ package eu.siacs.conversations.ui; import android.content.Context; +import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.Bundle; -import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -17,20 +17,16 @@ import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; import com.bumptech.glide.Glide; +import com.bumptech.glide.load.DataSource; +import com.bumptech.glide.load.engine.GlideException; +import com.bumptech.glide.request.RequestListener; +import com.bumptech.glide.request.target.CustomTarget; +import com.bumptech.glide.request.target.Target; +import com.bumptech.glide.request.transition.Transition; import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.UUID; -import eu.siacs.conversations.Config; import eu.siacs.conversations.R; -import eu.siacs.conversations.http.HttpConnectionManager; -import eu.siacs.conversations.utils.CryptoHelper; -import okhttp3.HttpUrl; public class StoryFragment extends Fragment { @@ -78,10 +74,7 @@ public class StoryFragment extends Fragment { } }); - final StoryViewActivity activity = (StoryViewActivity) getActivity(); - if (activity != null && activity.xmppConnectionService != null) { - loadStory(); - } + loadStory(); } public void loadStory() { @@ -106,95 +99,69 @@ public class StoryFragment extends Fragment { final String url = args.getString(ARG_URL); final String mimeType = args.getString(ARG_MIME_TYPE); - final StoryViewActivity activity = (StoryViewActivity) getActivity(); - if (activity == null) { - return; - } + progressBar.setVisibility(View.VISIBLE); - final File cacheFile = getStoryCacheFile(getContext(), url); - - new Thread(() -> { - try { - if (cacheFile != null && (!cacheFile.exists() || cacheFile.length() == 0)) { - activity.runOnUiThread(() -> progressBar.setVisibility(View.VISIBLE)); - Log.d(Config.LOGTAG, "Story not in cache. Downloading from: " + url); - final HttpUrl httpUrl = HttpUrl.get(url); - boolean useTor = false; - boolean useI2p = false; - if (activity.xmppConnectionService != null) { - useTor = activity.xmppConnectionService.useTorToConnect(); - useI2p = activity.xmppConnectionService.useI2PToConnect(); - } - - try (InputStream inputStream = HttpConnectionManager.open(httpUrl, useTor, useI2p); - FileOutputStream outputStream = new FileOutputStream(cacheFile)) { - byte[] buffer = new byte[4096]; - int bytesRead; - while ((bytesRead = inputStream.read(buffer)) != -1) { - outputStream.write(buffer, 0, bytesRead); - } - } - } else if (cacheFile != null) { - Log.d(Config.LOGTAG, "Loading story from cache: " + cacheFile.getName()); - } - - activity.runOnUiThread(() -> { - progressBar.setVisibility(View.GONE); - if (isAdded() && getContext() != null && cacheFile != null) { - videoView.stopPlayback(); - if (mimeType != null && mimeType.startsWith("video/")) { - imageView.setVisibility(View.GONE); - videoView.setVisibility(View.VISIBLE); - videoView.setVideoURI(Uri.fromFile(cacheFile)); + if (mimeType != null && mimeType.startsWith("video/")) { + imageView.setVisibility(View.GONE); + videoView.setVisibility(View.VISIBLE); + Glide.with(this) + .asFile() + .load(url) + .into(new CustomTarget() { + @Override + public void onResourceReady(@NonNull File resource, @Nullable Transition transition) { + if (!isAdded()) return; + progressBar.setVisibility(View.GONE); + videoView.setVideoURI(Uri.fromFile(resource)); videoView.setOnPreparedListener(mp -> { mp.setLooping(true); videoView.start(); }); - } else { - videoView.setVisibility(View.GONE); - imageView.setVisibility(View.VISIBLE); - Glide.with(getContext()).load(cacheFile).into(imageView); } - } - }); - } catch (Exception e) { - Log.e(Config.LOGTAG, "Failed to download or load story", e); - if (cacheFile != null && cacheFile.exists()) { - cacheFile.delete(); - } - activity.runOnUiThread(() -> { - if (isAdded() && getContext() != null) { - Toast.makeText(getContext(), R.string.download_failed_file_not_found, Toast.LENGTH_SHORT).show(); - } - }); - } - }).start(); - } + @Override + public void onLoadCleared(@Nullable Drawable placeholder) { + // Do nothing + } - private static File getStoryCacheFile(Context context, String url) { - if (context == null || url == null) { - return null; - } + @Override + public void onLoadFailed(@Nullable Drawable errorDrawable) { + if (!isAdded()) return; + progressBar.setVisibility(View.GONE); + Toast.makeText(getContext(), R.string.download_failed_file_not_found, Toast.LENGTH_SHORT).show(); + } + }); + } else { + videoView.setVisibility(View.GONE); + imageView.setVisibility(View.VISIBLE); + Glide.with(this) + .load(url) + .listener(new RequestListener() { + @Override + public boolean onLoadFailed(@Nullable GlideException e, Object model, Target target, boolean isFirstResource) { + if (isAdded()) { + progressBar.setVisibility(View.GONE); + Toast.makeText(getContext(), R.string.download_failed_file_not_found, Toast.LENGTH_SHORT).show(); + } + return false; + } - File cacheDir = context.getCacheDir(); - File storyCache = new File(cacheDir, "stories"); - if (!storyCache.exists()) { - storyCache.mkdirs(); - } - try { - MessageDigest digest = MessageDigest.getInstance("SHA-1"); - digest.update(url.getBytes()); - String sha1 = CryptoHelper.bytesToHex(digest.digest()); - return new File(storyCache, sha1); - } catch (NoSuchAlgorithmException e) { - return new File(storyCache, UUID.randomUUID().toString()); + @Override + public boolean onResourceReady(Drawable resource, Object model, Target target, DataSource dataSource, boolean isFirstResource) { + if (isAdded()) { + progressBar.setVisibility(View.GONE); + } + return false; + } + }) + .into(imageView); } } + @Override public void onDetach() { super.onDetach(); mListener = null; } -} \ No newline at end of file +} -- 2.39.5 From 58aade6d86520c7b2b92cb5ab6ebf620ab7b66ef Mon Sep 17 00:00:00 2001 From: Arne Date: Thu, 8 Jan 2026 09:18:40 +0100 Subject: [PATCH 077/180] Show own account JID in call history --- .../siacs/conversations/ui/adapter/CallsAdapter.java | 10 +++++++++- src/main/res/layout/item_call.xml | 6 ++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/CallsAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/CallsAdapter.java index 6be35eb29..16b5a8287 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/CallsAdapter.java +++ b/src/main/java/eu/siacs/conversations/ui/adapter/CallsAdapter.java @@ -111,7 +111,7 @@ public class CallsAdapter extends RecyclerView.Adapter + -- 2.39.5 From 9b5db72bf9613987af708c2efb40896a5c60ea79 Mon Sep 17 00:00:00 2001 From: Arne Date: Thu, 8 Jan 2026 10:08:26 +0100 Subject: [PATCH 078/180] Refresh call log after a call has ended --- .../services/XmppConnectionService.java | 25 +++++++++++++++++++ .../siacs/conversations/ui/CallsActivity.java | 9 +++---- .../siacs/conversations/ui/CallsFragment.java | 21 +++++++++++++--- .../xmpp/jingle/JingleRtpConnection.java | 1 + 4 files changed, 47 insertions(+), 9 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java index b9739b3e2..b2d59fde4 100644 --- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +++ b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java @@ -6364,6 +6364,31 @@ public class XmppConnectionService extends Service { } } + private final Set mOnCallLogUpdated = + Collections.newSetFromMap(new WeakHashMap()); + + public void setOnCallLogUpdatedListener(OnCallLogUpdated listener) { + synchronized (LISTENER_LOCK) { + this.mOnCallLogUpdated.add(listener); + } + } + + public void removeOnCallLogUpdatedListener(OnCallLogUpdated listener) { + synchronized (LISTENER_LOCK) { + this.mOnCallLogUpdated.remove(listener); + } + } + + public void updateCallLogUi() { + for (OnCallLogUpdated listener : threadSafeList(this.mOnCallLogUpdated)) { + listener.onCallLogUpdated(); + } + } + + public interface OnCallLogUpdated { + void onCallLogUpdated(); + } + public void notifyJingleRtpConnectionUpdate( final Account account, final Jid with, diff --git a/src/main/java/eu/siacs/conversations/ui/CallsActivity.java b/src/main/java/eu/siacs/conversations/ui/CallsActivity.java index fc9e06eec..d3ba6f7a6 100644 --- a/src/main/java/eu/siacs/conversations/ui/CallsActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/CallsActivity.java @@ -31,13 +31,12 @@ public class CallsActivity extends XmppActivity { setSupportActionBar(binding.toolbar); configureActionBar(getSupportActionBar()); - if (savedInstanceState == null) { + callsFragment = (CallsFragment) getSupportFragmentManager().findFragmentById(R.id.fragment_container); + if (callsFragment == null) { callsFragment = new CallsFragment(); getSupportFragmentManager().beginTransaction() .replace(R.id.fragment_container, callsFragment) .commit(); - } else { - callsFragment = (CallsFragment) getSupportFragmentManager().findFragmentById(R.id.fragment_container); } BottomNavigationView bottomNavigationView = findViewById(R.id.bottom_navigation); @@ -94,11 +93,11 @@ public class CallsActivity extends XmppActivity { @Override protected void onBackendConnected() { - refreshUiReal(); // Clear missed call notifications and badge when the activity is displayed. if (xmppConnectionService != null) { xmppConnectionService.getNotificationService().clearMissedCalls(); } + refreshUiReal(); } @Override @@ -159,4 +158,4 @@ public class CallsActivity extends XmppActivity { } return super.onOptionsItemSelected(item); } -} +} \ No newline at end of file diff --git a/src/main/java/eu/siacs/conversations/ui/CallsFragment.java b/src/main/java/eu/siacs/conversations/ui/CallsFragment.java index c36e5cc9c..3e0a07767 100644 --- a/src/main/java/eu/siacs/conversations/ui/CallsFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/CallsFragment.java @@ -37,7 +37,7 @@ import eu.siacs.conversations.services.CallIntegrationConnectionService; import eu.siacs.conversations.services.XmppConnectionService; import eu.siacs.conversations.ui.adapter.CallsAdapter; -public class CallsFragment extends Fragment implements CallsAdapter.OnCallAgainClickListener, CallsAdapter.OnContactClickListener { +public class CallsFragment extends Fragment implements CallsAdapter.OnCallAgainClickListener, CallsAdapter.OnContactClickListener, XmppConnectionService.OnCallLogUpdated { private XmppConnectionService xmppConnectionService; private RecyclerView recyclerView; @@ -45,7 +45,6 @@ public class CallsFragment extends Fragment implements CallsAdapter.OnCallAgainC private CallsAdapter adapter; private final List calls = new ArrayList<>(); private Message mPendingCall; - private boolean mPendingVideoCall; private boolean mCallsLoaded = false; private final ExecutorService executor = Executors.newSingleThreadExecutor(); @@ -57,13 +56,19 @@ public class CallsFragment extends Fragment implements CallsAdapter.OnCallAgainC public void onServiceConnected(ComponentName className, IBinder service) { XmppConnectionService.XmppConnectionBinder binder = (XmppConnectionService.XmppConnectionBinder) service; xmppConnectionService = binder.getService(); - if (!mCallsLoaded) { - loadCalls(); + if (xmppConnectionService != null) { + xmppConnectionService.setOnCallLogUpdatedListener(CallsFragment.this); + if (!mCallsLoaded) { + loadCalls(); + } } } @Override public void onServiceDisconnected(ComponentName arg0) { + if (xmppConnectionService != null) { + xmppConnectionService.removeOnCallLogUpdatedListener(CallsFragment.this); + } xmppConnectionService = null; } }; @@ -78,6 +83,9 @@ public class CallsFragment extends Fragment implements CallsAdapter.OnCallAgainC @Override public void onStop() { super.onStop(); + if (xmppConnectionService != null) { + xmppConnectionService.removeOnCallLogUpdatedListener(this); + } getActivity().unbindService(mConnection); } @@ -240,4 +248,9 @@ public class CallsFragment extends Fragment implements CallsAdapter.OnCallAgainC ((XmppActivity) getActivity()).switchToContactDetails(contact); } } + + @Override + public void onCallLogUpdated() { + loadCalls(); + } } \ No newline at end of file diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java index 307805dba..e41ff5dcb 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java @@ -2921,6 +2921,7 @@ public class JingleRtpConnection extends AbstractJingleConnection ((Conversation) conversational).add(this.message); xmppConnectionService.createMessageAsync(message); xmppConnectionService.updateConversationUi(); + xmppConnectionService.updateCallLogUi(); } else { throw new IllegalStateException("Somehow the conversation in a message was a stub"); } -- 2.39.5 From 45278505f8045bf71091c4d4c7e827a212d47f84 Mon Sep 17 00:00:00 2001 From: Stephen Paul Weber Date: Tue, 6 Jan 2026 10:32:57 -0500 Subject: [PATCH 079/180] Never fall back to iterative DNS for DNSSEC This can work around if your local resolver strips DNSSEC, but also it means resolution is bonkers slow and might even take forever / fail if DNS queries are blocked (because you're on TOR VPN or similar). So if recursive DNSSEC fails, just fail DNSSEC and fall back to regular DNS lookups. (cherry picked from commit d8152c4155ab5306f10ffbde7a7e0461ad98b656) --- src/main/java/eu/siacs/conversations/utils/Resolver.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/eu/siacs/conversations/utils/Resolver.java b/src/main/java/eu/siacs/conversations/utils/Resolver.java index fe5607e0d..45774a020 100644 --- a/src/main/java/eu/siacs/conversations/utils/Resolver.java +++ b/src/main/java/eu/siacs/conversations/utils/Resolver.java @@ -255,6 +255,8 @@ public class Resolver { final AbstractDnsClient dnssecclient = DnssecResolverApi.INSTANCE.getClient(); if (dnssecclient instanceof ReliableDnsClient) { ((ReliableDnsClient) dnssecclient).setUseHardcodedDnsServers(false); + // If your DNS server sucks, just don't do DNSSEC + ((ReliableDnsClient) dnssecclient).setMode(ReliableDnsClient.Mode.recursiveOnly); } } -- 2.39.5 From 60e0b713fff7ca04a469e2c1c7c766d23cc2ecdb Mon Sep 17 00:00:00 2001 From: Arne Date: Thu, 8 Jan 2026 11:27:37 +0100 Subject: [PATCH 080/180] Potentially fix double send files, fix setting allow unencrypted reactions and allow reply with file --- .../conversations/entities/Conversation.java | 8 +-- .../services/XmppConnectionService.java | 4 +- .../ui/ConversationFragment.java | 53 ++++++++++++------- 3 files changed, 40 insertions(+), 25 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/entities/Conversation.java b/src/main/java/eu/siacs/conversations/entities/Conversation.java index 771bafaa5..6b3a8666b 100644 --- a/src/main/java/eu/siacs/conversations/entities/Conversation.java +++ b/src/main/java/eu/siacs/conversations/entities/Conversation.java @@ -243,7 +243,7 @@ public class Conversation extends AbstractEntity protected boolean lockThread = false; protected boolean userSelectedThread = false; protected Message replyTo = null; - protected Message caption = null; + protected String caption = null; protected HashMap threads = new HashMap<>(); protected Multimap reactions = HashMultimap.create(); private String displayState = null; @@ -1060,11 +1060,11 @@ public class Conversation extends AbstractEntity return this.replyTo; } - public void setCaption(Message m) { - this.caption = m; + public void setCaption(String caption) { + this.caption = caption; } - public Message getCaption() { + public String getCaption() { return this.caption; } diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java index b2d59fde4..3b696e5f7 100644 --- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +++ b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java @@ -802,7 +802,7 @@ public class XmppConnectionService extends Service { message.setEncryption(conversation.getNextEncryption()); } if (conversation.getCaption() != null) { - message.appendBody(conversation.getCaption().getBody() + " "); + message.appendBody(conversation.getCaption() + " "); message.setEncryption(conversation.getNextEncryption()); } if (conversation.getNextEncryption() == Message.ENCRYPTION_PGP) { @@ -853,7 +853,7 @@ public class XmppConnectionService extends Service { message.setEncryption(conversation.getNextEncryption()); } if (conversation.getCaption() != null) { - message.appendBody(conversation.getCaption().getBody() + " "); + message.appendBody(conversation.getCaption() + " "); message.setEncryption(conversation.getNextEncryption()); } if (conversation.getNextEncryption() == Message.ENCRYPTION_PGP) { diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java index f6f0acfbc..e6d3a5379 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java @@ -1271,20 +1271,22 @@ public class ConversationFragment extends XmppFragment } private void sendMessage(Long sendAt) { - if (sendAt != null && sendAt < System.currentTimeMillis()) sendAt = null; // No sending in past plz - Editable body = this.binding.textinput.getText(); - if (mediaPreviewAdapter.getItemCount() > 1 || (mediaPreviewAdapter.getItemCount() == 1 && body == null)) { - commitAttachments(); - return; + if (sendAt != null && sendAt < System.currentTimeMillis()) { + sendAt = null; // No sending in past plz } - if (body == null) body = new SpannableStringBuilder(""); + Editable body = this.binding.textinput.getText(); + if (body == null) { + body = new SpannableStringBuilder(""); + } + final Conversation conversation = this.conversation; + final boolean hasAttachments = mediaPreviewAdapter.getItemCount() > 0; + final boolean hasSubject = binding.textinputSubject.getText().length() > 0; + if (body.length() > Config.MAX_DISPLAY_MESSAGE_CHARS) { Toast.makeText(activity, activity.getString(R.string.message_is_too_long), Toast.LENGTH_SHORT).show(); return; } - final Conversation conversation = this.conversation; - final boolean hasSubject = binding.textinputSubject.getText().length() > 0; - if (conversation == null || (body.length() == 0 && mediaPreviewAdapter.getItemCount() == 0 && (conversation.getThread() == null || !hasSubject))) { + if (conversation == null || (body.length() == 0 && !hasAttachments && (conversation.getThread() == null || !hasSubject))) { if (Build.VERSION.SDK_INT >= 24) { binding.textSendButton.showContextMenu(0, 0); } else { @@ -1295,16 +1297,29 @@ public class ConversationFragment extends XmppFragment if (trustKeysIfNeeded(conversation, REQUEST_TRUST_KEYS_TEXT)) { return; } + + if (hasAttachments) { + conversation.setCaption(body.toString()); + commitAttachments(); + messageSent(); + return; + } + final Message message; if (conversation.getCorrectingMessage() == null) { boolean attention = false; if (Pattern.compile("\\A@here\\s.*").matcher(body).find()) { attention = true; body.delete(0, 6); - while (body.length() > 0 && Character.isWhitespace(body.charAt(0))) body.delete(0, 1); + while (body.length() > 0 && Character.isWhitespace(body.charAt(0))) { + body.delete(0, 1); + } } + if (conversation.getReplyTo() != null) { - if (Emoticons.isEmoji(body.toString().replaceAll("\\s", "")) && conversation.getNextCounterpart() == null && !conversation.getReplyTo().isPrivateMessage()) { + if (((activity.getBooleanPreference("allow_unencrypted_reactions", R.bool.allow_unencrypted_reactions) && conversation.getNextEncryption() == Message.ENCRYPTION_AXOLOTL) || conversation.getNextEncryption() == Message.ENCRYPTION_NONE) + && Emoticons.isEmoji(body.toString().replaceAll("\\s", "")) + && conversation.getNextCounterpart() == null && !conversation.getReplyTo().isPrivateMessage()) { final var aggregated = conversation.getReplyTo().getAggregatedReactions(); final ImmutableSet.Builder reactionBuilder = new ImmutableSet.Builder<>(); reactionBuilder.addAll(aggregated.ourReactions); @@ -1345,14 +1360,11 @@ public class ConversationFragment extends XmppFragment } } } - // Set caption when only one attachment - if (mediaPreviewAdapter.getItemCount() == 1) { - conversation.setCaption(message); - commitAttachments(); - return; - } } - if (hasSubject) message.setSubject(binding.textinputSubject.getText().toString()); + + if (hasSubject) { + message.setSubject(binding.textinputSubject.getText().toString()); + } if (activity.xmppConnectionService != null && activity.xmppConnectionService.getBooleanPreference("show_thread_feature", R.bool.show_thread_feature)) { message.setThread(conversation.getThread()); } @@ -1360,9 +1372,12 @@ public class ConversationFragment extends XmppFragment message.addPayload(new Element("attention", "urn:xmpp:attention:0")); } Message.configurePrivateMessage(message); + } else { message = conversation.getCorrectingMessage(); - if (hasSubject) message.setSubject(binding.textinputSubject.getText().toString()); + if (hasSubject) { + message.setSubject(binding.textinputSubject.getText().toString()); + } if (activity.xmppConnectionService != null && activity.xmppConnectionService.getBooleanPreference("show_thread_feature", R.bool.show_thread_feature)) { message.setThread(conversation.getThread()); } -- 2.39.5 From 63b25c0a821ed15782d4421cf54f46aefadaec17 Mon Sep 17 00:00:00 2001 From: Arne Date: Thu, 8 Jan 2026 16:48:17 +0100 Subject: [PATCH 081/180] Improve and persist account order after drag-and-drop reordering --- .../siacs/conversations/entities/Account.java | 14 +++++-- .../persistance/DatabaseBackend.java | 11 ++---- .../ui/adapter/AccountAdapter.java | 4 -- .../ui/ManageAccountActivity.java | 39 +++++++------------ 4 files changed, 28 insertions(+), 40 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/entities/Account.java b/src/main/java/eu/siacs/conversations/entities/Account.java index 813a378a2..d3cb5ea72 100644 --- a/src/main/java/eu/siacs/conversations/entities/Account.java +++ b/src/main/java/eu/siacs/conversations/entities/Account.java @@ -74,6 +74,7 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable public static final String PINNED_CHANNEL_BINDING = "pinned_channel_binding"; public static final String FAST_MECHANISM = "fast_mechanism"; public static final String FAST_TOKEN = "fast_token"; + public static final String ORDERING = "ordering"; private int ordering = 0; public static final int OPTION_DISABLED = 1; @@ -146,7 +147,8 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable null, null, null, - null); + null, + 0); } private Account( @@ -165,7 +167,8 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable final String pinnedMechanism, final String pinnedChannelBinding, final String fastMechanism, - final String fastToken) { + final String fastToken, + final int ordering) { this.uuid = uuid; this.jid = jid; this.password = password; @@ -182,6 +185,7 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable this.pinnedChannelBinding = pinnedChannelBinding; this.fastMechanism = fastMechanism; this.fastToken = fastToken; + this.ordering = ordering; } public static JSONObject parseKeys(final String keys) { @@ -229,7 +233,8 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable cursor.getString(cursor.getColumnIndexOrThrow(PINNED_MECHANISM)), cursor.getString(cursor.getColumnIndexOrThrow(PINNED_CHANNEL_BINDING)), cursor.getString(cursor.getColumnIndexOrThrow(FAST_MECHANISM)), - cursor.getString(cursor.getColumnIndexOrThrow(FAST_TOKEN))); + cursor.getString(cursor.getColumnIndexOrThrow(FAST_TOKEN)), + cursor.getInt(cursor.getColumnIndexOrThrow(ORDERING))); } public void setMamPrefs(Element prefs) { @@ -590,6 +595,7 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable values.put(PINNED_CHANNEL_BINDING, pinnedChannelBinding); values.put(FAST_MECHANISM, this.fastMechanism); values.put(FAST_TOKEN, this.fastToken); + values.put(ORDERING, ordering); return values; } @@ -746,7 +752,7 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable public boolean areBookmarksLoaded() { // No way to tell if old PEP bookmarks are all loaded yet if they are empty - // because we don't manually fetch them... + // because we don\'t manually fetch them... if (getXmppConnection().getFeatures().bookmarksConversion()) return true; return bookmarksLoaded; diff --git a/src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java b/src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java index dae2dc093..6bba1d9e7 100644 --- a/src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java +++ b/src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java @@ -456,7 +456,8 @@ public class DatabaseBackend extends SQLiteOpenHelper { + " TEXT," + Account.FAST_TOKEN + " TEXT," - + "ordering INTEGER DEFAULT 0," + + Account.ORDERING + + " INTEGER DEFAULT 0," + Account.PORT + " NUMBER DEFAULT 5222)"); db.execSQL( @@ -1300,7 +1301,7 @@ public class DatabaseBackend extends SQLiteOpenHelper { } if (oldVersion < 64 && newVersion >= 64) { try { - db.execSQL("ALTER TABLE " + Account.TABLENAME + " ADD COLUMN ordering INTEGER DEFAULT 0"); + db.execSQL("ALTER TABLE " + Account.TABLENAME + " ADD COLUMN " + Account.ORDERING + " INTEGER DEFAULT 0"); } catch (Exception e) { Log.e(Config.LOGTAG, "Failed to add ordering column to account table", e); } @@ -1612,7 +1613,6 @@ public class DatabaseBackend extends SQLiteOpenHelper { public void createAccount(Account account) { final var db = this.getWritableDatabase(); final ContentValues values = account.getContentValues(); - values.put("ordering", account.getOrdering()); db.insert(Account.TABLENAME, null, values); } @@ -2357,13 +2357,11 @@ public class DatabaseBackend extends SQLiteOpenHelper { private List getAccounts(SQLiteDatabase db) { final List list = new ArrayList<>(); try (final Cursor cursor = - db.query(Account.TABLENAME, null, null, null, null, null, "ordering ASC")) { + db.query(Account.TABLENAME, null, null, null, null, null, Account.ORDERING + " ASC")) { // Use constant here while (cursor != null && cursor.moveToNext()) { list.add(Account.fromCursor(cursor)); } } - // Ensure you read the value if needed, though typically we just rely on the sort order - // account.setOrder(cursor.getInt(cursor.getColumnIndex("ordering"))); return list; } @@ -2371,7 +2369,6 @@ public class DatabaseBackend extends SQLiteOpenHelper { final var db = this.getWritableDatabase(); final String[] args = {account.getUuid()}; final ContentValues values = account.getContentValues(); - values.put("ordering", account.getOrdering()); final int rows = db.update(Account.TABLENAME, values, Account.UUID + "=?", args); return rows == 1; diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/AccountAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/AccountAdapter.java index c3002716f..a7c4cd794 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/AccountAdapter.java +++ b/src/main/java/eu/siacs/conversations/ui/adapter/AccountAdapter.java @@ -185,10 +185,6 @@ public class AccountAdapter extends RecyclerView.Adapter serviceAccounts = xmppConnectionService.getAccounts(); + synchronized (serviceAccounts) { + serviceAccounts.clear(); + serviceAccounts.addAll(accountList); + } + xmppConnectionService.updateAccountOrder(); + } + } + @Override public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) { } @@ -167,35 +179,12 @@ public class ManageAccountActivity extends XmppActivity implements XmppConnectio itemTouchHelper.attachToRecyclerView(accountListView); this.mAccountAdapter = new AccountAdapter(this, accountList, this::switchToAccount, itemTouchHelper::startDrag, - // Context Menu Listener account -> { this.selectedAccount = account; }, - // On Move Listener (Persistence Logic) - () -> { - // 1. Update the order in the service to match the adapter's list - if (xmppConnectionService != null) { - // We need to push the current 'accountList' order to the service - // This depends on how XmppConnectionService stores accounts. - // Usually, we just need to verify the service list matches, - // or force the service to update its internal list order if it's just in-memory for the session. - - // If the service loads from DB sorted by ID/Keys, we might need to add an 'order' column to the DB. - // However, for a quick fix if the service list is mutable: - List serviceAccounts = xmppConnectionService.getAccounts(); - synchronized (serviceAccounts) { - serviceAccounts.clear(); - serviceAccounts.addAll(accountList); - } - - // Ideally, trigger a database update here to save 'order' if supported. - // For now, updating the in-memory list prevents the reset on toggle. - xmppConnectionService.updateAccountOrder(); // You might need to implement this in Service if it doesn't exist - } - } + null ); - // --- Fix: Set the context listener to update selectedAccount --- this.mAccountAdapter.setContextAccountListener(account -> { this.selectedAccount = account; }); -- 2.39.5 From fadb15a33a951db310fac2a9d4d55bfba983f635 Mon Sep 17 00:00:00 2001 From: Arne Date: Thu, 8 Jan 2026 16:57:50 +0100 Subject: [PATCH 082/180] Remove default DNS servers --- src/main/res/values/defaults.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/res/values/defaults.xml b/src/main/res/values/defaults.xml index 730848790..e6cba8bab 100644 --- a/src/main/res/values/defaults.xml +++ b/src/main/res/values/defaults.xml @@ -62,8 +62,8 @@ true false disable - 194.242.2.2 - [2a07:e340::2] + + true 0 true -- 2.39.5 From 75c7f93e402a05567d796bf96cd33520b166cc97 Mon Sep 17 00:00:00 2001 From: Arne Date: Thu, 8 Jan 2026 17:21:59 +0100 Subject: [PATCH 083/180] Revert "Remove default DNS servers" This reverts commit e4da973884dbf4dafb03a8b7c7da3cf932e38e88. --- src/main/res/values/defaults.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/res/values/defaults.xml b/src/main/res/values/defaults.xml index e6cba8bab..730848790 100644 --- a/src/main/res/values/defaults.xml +++ b/src/main/res/values/defaults.xml @@ -62,8 +62,8 @@ true false disable - - + 194.242.2.2 + [2a07:e340::2] true 0 true -- 2.39.5 From a572fadf385c8b90e0ce85b9c549d58fd68f410f Mon Sep 17 00:00:00 2001 From: Arne Date: Thu, 8 Jan 2026 18:29:26 +0100 Subject: [PATCH 084/180] Remove hardcoded DNS servers completely and improve DNS server setting --- .../settings/ConnectionSettingsFragment.java | 10 +- src/main/java/org/minidns/DnsClient.java | 180 ++++++++---------- .../org/minidns/constants/DnsRootServer.java | 119 ++++++------ src/main/res/values/defaults.xml | 4 +- src/main/res/values/strings.xml | 6 +- 5 files changed, 156 insertions(+), 163 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/ui/fragment/settings/ConnectionSettingsFragment.java b/src/main/java/eu/siacs/conversations/ui/fragment/settings/ConnectionSettingsFragment.java index c522250d2..f96621a98 100644 --- a/src/main/java/eu/siacs/conversations/ui/fragment/settings/ConnectionSettingsFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/fragment/settings/ConnectionSettingsFragment.java @@ -59,14 +59,14 @@ public class ConnectionSettingsFragment extends XmppPreferenceFragment { final var dnsv4Server = (EditTextPreference) findPreference("dns_server_ipv4"); if (dnsv4Server != null) { - dnsv4Server.setText("194.242.2.2"); + dnsv4Server.setText(null); } final var dnsv6Server = (EditTextPreference) findPreference("dns_server_ipv6"); if (dnsv6Server != null) { - dnsv6Server.setText("[2a07:e340::2]"); + dnsv6Server.setText(null); } - + reconnectAccounts(); Toast.makeText(requireSettingsActivity(),R.string.dns_server_reset,Toast.LENGTH_LONG).show(); return true; }); @@ -105,7 +105,7 @@ public class ConnectionSettingsFragment extends XmppPreferenceFragment { reconnectAccounts(); requireService().reinitializeMuclumbusService(); } - case AppSettings.SHOW_CONNECTION_OPTIONS, AppSettings.PREFER_IPV6 -> { + case AppSettings.SHOW_CONNECTION_OPTIONS, AppSettings.PREFER_IPV6, "dns_server_ipv4", "dns_server_ipv6" -> { reconnectAccounts(); } } @@ -146,4 +146,4 @@ public class ConnectionSettingsFragment extends XmppPreferenceFragment { "%s is not %s", activity.getClass().getName(), SettingsActivity.class.getName())); } -} +} \ No newline at end of file diff --git a/src/main/java/org/minidns/DnsClient.java b/src/main/java/org/minidns/DnsClient.java index 6d0ae4592..692d9506f 100644 --- a/src/main/java/org/minidns/DnsClient.java +++ b/src/main/java/org/minidns/DnsClient.java @@ -12,6 +12,11 @@ package org.minidns; import static org.webrtc.ApplicationContextProvider.getApplicationContext; +import android.content.SharedPreferences; +import android.text.TextUtils; + +import androidx.preference.PreferenceManager; + import org.minidns.MiniDnsException.ErrorResponseException; import org.minidns.MiniDnsException.NoQueryPossibleException; import org.minidns.dnsmessage.DnsMessage; @@ -23,7 +28,6 @@ import org.minidns.dnsserverlookup.AndroidUsingReflection; import org.minidns.dnsserverlookup.DnsServerLookupMechanism; import org.minidns.dnsserverlookup.UnixUsingEtcResolvConf; import org.minidns.record.Record.TYPE; -import org.minidns.util.CollectionsUtil; import org.minidns.util.InetAddressUtil; import org.minidns.util.MultipleIoException; @@ -39,11 +43,8 @@ import java.util.List; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.CopyOnWriteArraySet; import java.util.logging.Level; -import eu.siacs.conversations.R; - /** * A minimal DNS client for SRV/A/AAAA/NS and CNAME lookups, with IDN support. * This circumvents the missing javax.naming package on android. @@ -52,37 +53,20 @@ public class DnsClient extends AbstractDnsClient { static final List LOOKUP_MECHANISMS = new CopyOnWriteArrayList<>(); - static final Set STATIC_IPV4_DNS_SERVERS = new CopyOnWriteArraySet<>(); - static final Set STATIC_IPV6_DNS_SERVERS = new CopyOnWriteArraySet<>(); - static { addDnsServerLookupMechanism(AndroidUsingExec.INSTANCE); addDnsServerLookupMechanism(AndroidUsingReflection.INSTANCE); addDnsServerLookupMechanism(UnixUsingEtcResolvConf.INSTANCE); - - try { - Inet4Address dnsforgeV4Dns = InetAddressUtil.ipv4From(eu.siacs.conversations.Conversations.getContext().getString(R.string.default_dns_server_ipv4)); - STATIC_IPV4_DNS_SERVERS.add(dnsforgeV4Dns); - } catch (IllegalArgumentException e) { - LOGGER.log(Level.WARNING, "Could not add static IPv4 DNS Server", e); - } - - try { - Inet6Address dnsforgeV6Dns = InetAddressUtil.ipv6From(eu.siacs.conversations.Conversations.getContext().getString(R.string.default_dns_server_ipv6)); - STATIC_IPV6_DNS_SERVERS.add(dnsforgeV6Dns); - } catch (IllegalArgumentException e) { - LOGGER.log(Level.WARNING, "Could not add static IPv6 DNS Server", e); - } } private static final Set blacklistedDnsServers = Collections.newSetFromMap(new ConcurrentHashMap(4)); private final Set nonRaServers = Collections.newSetFromMap(new ConcurrentHashMap(4)); - private boolean askForDnssec = false; + private boolean askForDnssec = true; private boolean disableResultFilter = false; - private boolean useHardcodedDnsServers = true; + private boolean useHardcodedDnsServers = false; /** * Create a new DNS client using the global default cache. @@ -103,36 +87,25 @@ public class DnsClient extends AbstractDnsClient { } private List getServerAddresses() { - List dnsServerAddresses = findDnsAddresses(); - + List hardcodedDnsServers = new ArrayList<>(); if (useHardcodedDnsServers) { - InetAddress primaryHardcodedDnsServer, secondaryHardcodedDnsServer = null; - switch (ipVersionSetting) { - case v4v6: - primaryHardcodedDnsServer = getRandomHardcodedIpv4DnsServer(); - secondaryHardcodedDnsServer = getRandomHarcodedIpv6DnsServer(); - break; - case v6v4: - primaryHardcodedDnsServer = getRandomHarcodedIpv6DnsServer(); - secondaryHardcodedDnsServer = getRandomHardcodedIpv4DnsServer(); - break; - case v4only: - primaryHardcodedDnsServer = getRandomHardcodedIpv4DnsServer(); - break; - case v6only: - primaryHardcodedDnsServer = getRandomHarcodedIpv6DnsServer(); - break; - default: - throw new AssertionError("Unknown ipVersionSetting: " + ipVersionSetting); + InetAddress primaryHardcodedDnsServer = getRandomHardcodedIpv4DnsServer(); + if (primaryHardcodedDnsServer != null) { + hardcodedDnsServers.add(primaryHardcodedDnsServer); } - - dnsServerAddresses.add(primaryHardcodedDnsServer); + InetAddress secondaryHardcodedDnsServer = getRandomHarcodedIpv6DnsServer(); if (secondaryHardcodedDnsServer != null) { - dnsServerAddresses.add(secondaryHardcodedDnsServer); + hardcodedDnsServers.add(secondaryHardcodedDnsServer); } } - return dnsServerAddresses; + if (!hardcodedDnsServers.isEmpty()) { + // If custom servers are defined, use them exclusively. + return hardcodedDnsServers; + } else { + // Otherwise, fall back to the system's DNS servers. + return findDnsAddresses(); + } } @Override @@ -150,6 +123,10 @@ public class DnsClient extends AbstractDnsClient { List dnsServerAddresses = getServerAddresses(); + if (dnsServerAddresses.isEmpty()) { + throw new NoQueryPossibleException(q); + } + List ioExceptions = new ArrayList<>(dnsServerAddresses.size()); for (InetAddress dns : dnsServerAddresses) { if (nonRaServers.contains(dns)) { @@ -179,22 +156,22 @@ public class DnsClient extends AbstractDnsClient { } switch (responseMessage.responseCode) { - case NO_ERROR: - case NX_DOMAIN: - break; - default: - String warning = "Response from " + dns + " asked for " + q.getQuestion() + " with error code: " - + responseMessage.responseCode + '.'; - if (!LOGGER.isLoggable(Level.FINE)) { - // Only append the responseMessage is log level is not fine. If it is fine or higher, the - // response has already been logged. - warning += "\n" + responseMessage; - } - LOGGER.warning(warning); + case NO_ERROR: + case NX_DOMAIN: + break; + default: + String warning = "Response from " + dns + " asked for " + q.getQuestion() + " with error code: " + + responseMessage.responseCode + '.'; + if (!LOGGER.isLoggable(Level.FINE)) { + // Only append the responseMessage is log level is not fine. If it is fine or higher, the + // response has already been logged. + warning += "\n" + responseMessage; + } + LOGGER.warning(warning); - ErrorResponseException exception = new ErrorResponseException(q, dnsQueryResult); - ioExceptions.add(exception); - continue; + ErrorResponseException exception = new ErrorResponseException(q, dnsQueryResult); + ioExceptions.add(exception); + continue; } return dnsQueryResult; @@ -259,7 +236,7 @@ public class DnsClient extends AbstractDnsClient { } catch (SecurityException exception) { LOGGER.log(Level.WARNING, "Could not lookup DNS server", exception); } - if (res == null) { + if (res == null || res.isEmpty()) { LOGGER.log(TRACE_LOG_LEVEL, "DnsServerLookupMechanism '" + mechanism.getName() + "' did not return any DNS server"); continue; } @@ -279,15 +256,7 @@ public class DnsClient extends AbstractDnsClient { new Object[] { mechanism.getName(), dnsServers }); } - assert !res.isEmpty(); - - // We could cache if res only contains IP addresses and avoid the verification in case. Not sure if its really that beneficial - // though, because the list returned by the server mechanism is rather short. - - // Verify the returned DNS servers: Ensure that only valid IP addresses are returned. We want to avoid that something else, - // especially a valid DNS name is returned, as this would cause the following String to InetAddress conversation using - // getByName(String) to cause a DNS lookup, which would be performed outside of the realm of MiniDNS and therefore also outside - // of its DNSSEC guarantees. + // Verify the returned DNS servers: Ensure that only valid IP addresses are returned. Iterator it = res.iterator(); while (it.hasNext()) { String potentialDnsServer = it.next(); @@ -297,7 +266,7 @@ public class DnsClient extends AbstractDnsClient { it.remove(); } else if (blacklistedDnsServers.contains(potentialDnsServer)) { LOGGER.fine("The DNS server lookup mechanism '" + mechanism.getName() - + "' returned a blacklisted result: '" + potentialDnsServer + "'"); + + "' returned a blacklisted result: '" + potentialDnsServer + "'"); it.remove(); } } @@ -307,7 +276,7 @@ public class DnsClient extends AbstractDnsClient { } LOGGER.warning("The DNS server lookup mechanism '" + mechanism.getName() - + "' returned not a single valid IP address after sanitazion"); + + "' returned not a single valid IP address after sanitazion"); res = null; } @@ -346,9 +315,7 @@ public class DnsClient extends AbstractDnsClient { int validServerAddresses = 0; for (String dnsServerString : res) { - // The following invariant must hold: "dnsServerString is a IP address". Therefore findDNS() must only return a List of Strings - // representing IP addresses. Otherwise the following call of getByName(String) may perform a DNS lookup without MiniDNS being - // involved. Something we want to avoid. + // The following invariant must hold: "dnsServerString is a IP address". assert InetAddressUtil.isIpAddress(dnsServerString); InetAddress dnsServerAddress; @@ -380,20 +347,20 @@ public class DnsClient extends AbstractDnsClient { List dnsServers = new ArrayList<>(validServerAddresses); switch (setting) { - case v4v6: - dnsServers.addAll(ipv4DnsServer); - dnsServers.addAll(ipv6DnsServer); - break; - case v6v4: - dnsServers.addAll(ipv6DnsServer); - dnsServers.addAll(ipv4DnsServer); - break; - case v4only: - dnsServers.addAll(ipv4DnsServer); - break; - case v6only: - dnsServers.addAll(ipv6DnsServer); - break; + case v4v6: + dnsServers.addAll(ipv4DnsServer); + dnsServers.addAll(ipv6DnsServer); + break; + case v6v4: + dnsServers.addAll(ipv6DnsServer); + dnsServers.addAll(ipv4DnsServer); + break; + case v4only: + dnsServers.addAll(ipv4DnsServer); + break; + case v6only: + dnsServers.addAll(ipv6DnsServer); + break; } return dnsServers; } @@ -404,15 +371,10 @@ public class DnsClient extends AbstractDnsClient { return; } synchronized (LOOKUP_MECHANISMS) { - // We can't use Collections.sort(CopyOnWriteArrayList) with Java 7. So we first create a temp array, sort it, and replace - // LOOKUP_MECHANISMS with the result. For more information about the Java 7 Collections.sort(CopyOnWriteArarayList) issue see - // http://stackoverflow.com/a/34827492/194894 - // TODO: Remove that workaround once MiniDNS is Java 8 only. ArrayList tempList = new ArrayList<>(LOOKUP_MECHANISMS.size() + 1); tempList.addAll(LOOKUP_MECHANISMS); tempList.add(dnsServerLookup); - // Sadly, this Collections.sort() does not with the CopyOnWriteArrayList on Java 7. Collections.sort(tempList); LOOKUP_MECHANISMS.clear(); @@ -459,11 +421,29 @@ public class DnsClient extends AbstractDnsClient { } public InetAddress getRandomHardcodedIpv4DnsServer() { - return CollectionsUtil.getRandomFrom(STATIC_IPV4_DNS_SERVERS, insecureRandom); + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getApplicationContext()); + String dnsServer = preferences.getString("dns_server_ipv4", null); + if (TextUtils.isEmpty(dnsServer)) { + return null; + } + try { + return InetAddressUtil.ipv4From(dnsServer); + } catch (IllegalArgumentException e) { + return null; + } } public InetAddress getRandomHarcodedIpv6DnsServer() { - return CollectionsUtil.getRandomFrom(STATIC_IPV6_DNS_SERVERS, insecureRandom); + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getApplicationContext()); + String dnsServer = preferences.getString("dns_server_ipv6", null); + if (TextUtils.isEmpty(dnsServer)) { + return null; + } + try { + return InetAddressUtil.ipv6From(dnsServer); + } catch (IllegalArgumentException e) { + return null; + } } private static Question getReverseIpLookupQuestionFor(DnsName dnsName) { @@ -491,6 +471,6 @@ public class DnsClient extends AbstractDnsClient { throw new IllegalArgumentException("The provided inetAddress '" + inetAddress + "' is neither of type Inet4Address nor Inet6Address"); } - } + } -} +} \ No newline at end of file diff --git a/src/main/java/org/minidns/constants/DnsRootServer.java b/src/main/java/org/minidns/constants/DnsRootServer.java index 2ff9e14dc..5a5583e27 100644 --- a/src/main/java/org/minidns/constants/DnsRootServer.java +++ b/src/main/java/org/minidns/constants/DnsRootServer.java @@ -12,7 +12,10 @@ package org.minidns.constants; import static org.webrtc.ApplicationContextProvider.getApplicationContext; -import android.content.res.Resources; +import android.content.SharedPreferences; +import android.text.TextUtils; + +import androidx.preference.PreferenceManager; import org.minidns.util.InetAddressUtil; @@ -46,9 +49,9 @@ public class DnsRootServer { rootServerInet4Address('k', 193, 0, 14, 129), rootServerInet4Address('l', 199, 7, 83, 42), rootServerInet4Address('m', 202, 12, 27, 33), - }; + }; - protected static final Inet6Address[] IPV6_ROOT_SERVERS = new Inet6Address[] { + protected static final Inet6Address[] IPV6_ROOT_SERVERS = new Inet6Address[] { rootServerInet6Address('a', 0x2001, 0x0503, 0xba3e, 0x0000, 0x0000, 0x000, 0x0002, 0x0030), rootServerInet6Address('b', 0x2001, 0x0500, 0x0084, 0x0000, 0x0000, 0x000, 0x0000, 0x000b), rootServerInet6Address('c', 0x2001, 0x0500, 0x0002, 0x0000, 0x0000, 0x000, 0x0000, 0x000c), @@ -59,65 +62,75 @@ public class DnsRootServer { rootServerInet6Address('j', 0x2001, 0x0503, 0x0c27, 0x0000, 0x0000, 0x000, 0x0002, 0x0030), rootServerInet6Address('l', 0x2001, 0x0500, 0x0003, 0x0000, 0x0000, 0x000, 0x0000, 0x0042), rootServerInet6Address('m', 0x2001, 0x0dc3, 0x0000, 0x0000, 0x0000, 0x000, 0x0000, 0x0035), - }; + }; - private static Inet4Address rootServerInet4Address(char rootServerId, int addr0, int addr1, int addr2, int addr3) { - Inet4Address inetAddress; - String name = rootServerId + ".root-servers.net"; - try { - inetAddress = (Inet4Address) InetAddress.getByAddress(name, new byte[] { (byte) addr0, (byte) addr1, (byte) addr2, - (byte) addr3 }); - IPV4_ROOT_SERVER_MAP.put(rootServerId, inetAddress); - } catch (UnknownHostException e) { - // This should never happen, if it does it's our fault! - throw new RuntimeException(e); - } - - return inetAddress; + private static Inet4Address rootServerInet4Address(char rootServerId, int addr0, int addr1, int addr2, int addr3) { + Inet4Address inetAddress; + String name = rootServerId + ".root-servers.net"; + try { + inetAddress = (Inet4Address) InetAddress.getByAddress(name, new byte[] { (byte) addr0, (byte) addr1, (byte) addr2, + (byte) addr3 }); + IPV4_ROOT_SERVER_MAP.put(rootServerId, inetAddress); + } catch (UnknownHostException e) { + // This should never happen, if it does it's our fault! + throw new RuntimeException(e); } - private static Inet6Address rootServerInet6Address(char rootServerId, int addr0, int addr1, int addr2, int addr3, int addr4, int addr5, int addr6, int addr7) { - Inet6Address inetAddress; - String name = rootServerId + ".root-servers.net"; - try { - inetAddress = (Inet6Address) InetAddress.getByAddress(name, new byte[] { - // @formatter:off - (byte) (addr0 >> 8), (byte) addr0, (byte) (addr1 >> 8), (byte) addr1, - (byte) (addr2 >> 8), (byte) addr2, (byte) (addr3 >> 8), (byte) addr3, - (byte) (addr4 >> 8), (byte) addr4, (byte) (addr5 >> 8), (byte) addr5, - (byte) (addr6 >> 8), (byte) addr6, (byte) (addr7 >> 8), (byte) addr7 - // @formatter:on - }); - IPV6_ROOT_SERVER_MAP.put(rootServerId, inetAddress); - } catch (UnknownHostException e) { - // This should never happen, if it does it's our fault! - throw new RuntimeException(e); - } - return inetAddress; - } + return inetAddress; + } - public static Inet4Address getRandomIpv4RootServer(Random random) { - if (getApplicationContext().getString(R.string.default_dns_server_ipv4).equals("194.242.2.2")) { - return IPV4_ROOT_SERVERS[random.nextInt(IPV4_ROOT_SERVERS.length)]; - } else { - return InetAddressUtil.ipv4From(eu.siacs.conversations.Conversations.getContext().getString(R.string.default_dns_server_ipv4)); + private static Inet6Address rootServerInet6Address(char rootServerId, int addr0, int addr1, int addr2, int addr3, int addr4, int addr5, int addr6, int addr7) { + Inet6Address inetAddress; + String name = rootServerId + ".root-servers.net"; + try { + inetAddress = (Inet6Address) InetAddress.getByAddress(name, new byte[] { + // @formatter:off + (byte) (addr0 >> 8), (byte) addr0, (byte) (addr1 >> 8), (byte) addr1, + (byte) (addr2 >> 8), (byte) addr2, (byte) (addr3 >> 8), (byte) addr3, + (byte) (addr4 >> 8), (byte) addr4, (byte) (addr5 >> 8), (byte) addr5, + (byte) (addr6 >> 8), (byte) addr6, (byte) (addr7 >> 8), (byte) addr7 + // @formatter:on + }); + IPV6_ROOT_SERVER_MAP.put(rootServerId, inetAddress); + } catch (UnknownHostException e) { + // This should never happen, if it does it's our fault! + throw new RuntimeException(e); + } + return inetAddress; + } + + public static Inet4Address getRandomIpv4RootServer(Random random) { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getApplicationContext()); + String dnsServer = preferences.getString("dns_server_ipv4", null); + if (!TextUtils.isEmpty(dnsServer)) { + try { + return InetAddressUtil.ipv4From(dnsServer); + } catch (IllegalArgumentException e) { + // Invalid format, do not fall back to root servers } } + return null; + } - public static Inet6Address getRandomIpv6RootServer(Random random) { - if (getApplicationContext().getString(R.string.default_dns_server_ipv6).equals("[2a07:e340::2]")) { - return IPV6_ROOT_SERVERS[random.nextInt(IPV6_ROOT_SERVERS.length)]; - } else { - return InetAddressUtil.ipv6From(eu.siacs.conversations.Conversations.getContext().getString(R.string.default_dns_server_ipv6)); + public static Inet6Address getRandomIpv6RootServer(Random random) { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getApplicationContext()); + String dnsServer = preferences.getString("dns_server_ipv6", null); + if (!TextUtils.isEmpty(dnsServer)) { + try { + return InetAddressUtil.ipv6From(dnsServer); + } catch (IllegalArgumentException e) { + // Invalid format, do not fall back to root servers } } + return null; + } - public static Inet4Address getIpv4RootServerById(char id) { - return IPV4_ROOT_SERVER_MAP.get(id); - } + public static Inet4Address getIpv4RootServerById(char id) { + return IPV4_ROOT_SERVER_MAP.get(id); + } - public static Inet6Address getIpv6RootServerById(char id) { - return IPV6_ROOT_SERVER_MAP.get(id); - } + public static Inet6Address getIpv6RootServerById(char id) { + return IPV6_ROOT_SERVER_MAP.get(id); + } -} +} \ No newline at end of file diff --git a/src/main/res/values/defaults.xml b/src/main/res/values/defaults.xml index 730848790..e6cba8bab 100644 --- a/src/main/res/values/defaults.xml +++ b/src/main/res/values/defaults.xml @@ -62,8 +62,8 @@ true false disable - 194.242.2.2 - [2a07:e340::2] + + true 0 true diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index 1d766e752..c85bfa8e6 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -1317,16 +1317,16 @@ Show text formatting toolbar in chat while keyboard is shown Text formatting toolbar Hide chats in Chat Requests area - Change the default privacy focused IPv4 DNS server (Mullvad). Empty the field to use the DNS server of your device or VPN + Add a custom IPv4 DNS server. Empty the field to use the DNS server of your device or VPN IPv4 DNS server IPv6 DNS server - Change the default privacy focused IPv6 DNS server (Mullvad). Empty the field to use the DNS server of your device or VPN + Add a custom IPv6 DNS server. Empty the field to use the DNS server of your device or VPN All chats Unread chats Direct messages Channels Manage account - Reset the default DNS servers to the privacy focused DNS provider Mullvad + Reset the custom DNS servers to the Android systems DNS server Reset DNS DNS reset Settings -- 2.39.5 From c6150583b67d8acad32e6490cce92acc2927d7b7 Mon Sep 17 00:00:00 2001 From: Arne Date: Thu, 8 Jan 2026 23:38:35 +0100 Subject: [PATCH 085/180] Make story titles with long text scrollable --- .../java/eu/siacs/conversations/ui/StoryViewActivity.java | 8 +++++++- src/main/res/layout/activity_story_view.xml | 2 ++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java b/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java index c64298749..6e27369c8 100644 --- a/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java @@ -1,6 +1,7 @@ package eu.siacs.conversations.ui; import android.os.Bundle; +import android.text.method.ScrollingMovementMethod; import android.text.format.DateUtils; import android.view.Menu; import android.view.MenuItem; @@ -74,6 +75,11 @@ public class StoryViewActivity extends XmppActivity implements StoryFragment.OnS viewPager = findViewById(R.id.view_pager); titleView = findViewById(R.id.story_title_view); + titleView.setMovementMethod(new ScrollingMovementMethod()); + titleView.setOnTouchListener((v, event) -> { + v.getParent().requestDisallowInterceptTouchEvent(true); + return false; + }); progressView = findViewById(R.id.story_progress_view); bottomPanel = findViewById(R.id.bottom_panel); @@ -292,4 +298,4 @@ public class StoryViewActivity extends XmppActivity implements StoryFragment.OnS invalidateOptionsMenu(); refreshUi(); } -} \ No newline at end of file +} diff --git a/src/main/res/layout/activity_story_view.xml b/src/main/res/layout/activity_story_view.xml index ee2f2cb73..89261587a 100644 --- a/src/main/res/layout/activity_story_view.xml +++ b/src/main/res/layout/activity_story_view.xml @@ -71,6 +71,8 @@ android:layout_height="wrap_content" android:autoLink="web" android:linksClickable="true" + android:maxLines="8" + android:scrollbars="vertical" android:textColor="@android:color/white" android:textSize="18sp" /> -- 2.39.5 From a806992b5c64f3746970f1cd2fb03686b0f8a707 Mon Sep 17 00:00:00 2001 From: Arne Date: Fri, 9 Jan 2026 01:00:39 +0100 Subject: [PATCH 086/180] Improve story creation dialog with media preview --- .../conversations/ui/StoriesActivity.java | 172 ++++++++++++------ src/main/res/layout/dialog_create_story.xml | 52 ++++++ src/main/res/values/strings.xml | 4 + 3 files changed, 168 insertions(+), 60 deletions(-) create mode 100644 src/main/res/layout/dialog_create_story.xml diff --git a/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java b/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java index d1188df64..bb8f2e84f 100644 --- a/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java @@ -13,20 +13,27 @@ import android.media.MediaMetadataRetriever; import android.net.Uri; import android.os.Bundle; import android.provider.MediaStore; +import android.text.Editable; +import android.text.TextWatcher; import android.util.Log; +import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuItem; import android.view.View; -import android.widget.EditText; +import android.widget.ImageView; +import android.widget.TextView; import android.widget.Toast; +import android.widget.VideoView; import androidx.annotation.NonNull; import androidx.appcompat.app.ActionBar; import androidx.databinding.DataBindingUtil; import androidx.recyclerview.widget.LinearLayoutManager; +import com.bumptech.glide.Glide; import com.google.android.material.bottomnavigation.BottomNavigationView; import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.textfield.TextInputEditText; import java.util.ArrayList; import java.util.Collections; @@ -39,8 +46,8 @@ import eu.siacs.conversations.R; import eu.siacs.conversations.databinding.ActivityStoriesBinding; import eu.siacs.conversations.entities.Account; import eu.siacs.conversations.entities.Story; -import eu.siacs.conversations.services.XmppConnectionService; import eu.siacs.conversations.medialib.activities.EditActivity; +import eu.siacs.conversations.services.XmppConnectionService; import eu.siacs.conversations.ui.adapter.StoryAdapter; import eu.siacs.conversations.ui.util.PendingItem; @@ -73,30 +80,26 @@ public class StoriesActivity extends XmppActivity implements XmppConnectionServi bottomNavigationView.setBackgroundColor(Color.TRANSPARENT); bottomNavigationView.setOnItemSelectedListener(item -> { switch (item.getItemId()) { - case R.id.chats -> { + case R.id.chats: startActivity(new Intent(getApplicationContext(), ConversationsActivity.class)); overridePendingTransition(R.animator.fade_in, R.animator.fade_out); return true; - } - case R.id.contactslist -> { + case R.id.contactslist: Intent i = new Intent(getApplicationContext(), StartConversationActivity.class); i.putExtra("show_nav_bar", true); startActivity(i); overridePendingTransition(R.animator.fade_in, R.animator.fade_out); return true; - } - case R.id.stories -> { + case R.id.stories: return true; - } - case R.id.calls -> { - Intent i = new Intent(getApplicationContext(), CallsActivity.class); - i.putExtra("show_nav_bar", true); - startActivity(i); + case R.id.calls: + Intent callsIntent = new Intent(getApplicationContext(), CallsActivity.class); + callsIntent.putExtra("show_nav_bar", true); + startActivity(callsIntent); overridePendingTransition(R.animator.fade_in, R.animator.fade_out); return true; - } - default -> - throw new IllegalStateException("Unexpected value: " + item.getItemId()); + default: + throw new IllegalStateException("Unexpected value: " + item.getItemId()); } }); } @@ -104,7 +107,7 @@ public class StoriesActivity extends XmppActivity implements XmppConnectionServi @Override public void onStart() { super.onStart(); - BottomNavigationView bottomNavigationView=findViewById(R.id.bottom_navigation); + BottomNavigationView bottomNavigationView = findViewById(R.id.bottom_navigation); bottomNavigationView.setSelectedItemId(R.id.stories); if (getBooleanPreference("show_nav_bar", R.bool.show_nav_bar) && getIntent().getBooleanExtra("show_nav_bar", false)) { @@ -164,51 +167,100 @@ public class StoriesActivity extends XmppActivity implements XmppConnectionServi } private void publish(Uri uri, String mimeType) { - if (uri != null && mSelectedAccount != null) { - final EditText input = new EditText(this); - input.setHint(R.string.title_optional); - new MaterialAlertDialogBuilder(this) - .setTitle(R.string.add_story_title) - .setView(input) - .setNegativeButton(R.string.cancel, null) - .setPositiveButton(R.string.publish, (dialog, which) -> { - final String title = input.getText().toString(); - Toast.makeText(this, R.string.uploading_story, Toast.LENGTH_SHORT).show(); - xmppConnectionService.uploadFileForUrl(mSelectedAccount, uri, mimeType, new UiCallback() { - @Override - public void success(String url) { - xmppConnectionService.publishStory(mSelectedAccount, url, mimeType, title, new UiCallback() { - @Override - public void success(Void aVoid) { - runOnUiThread(() -> Toast.makeText(StoriesActivity.this, R.string.story_published, Toast.LENGTH_SHORT).show()); - } - - @Override - public void error(int errorCode, Void object) { - runOnUiThread(() -> Toast.makeText(StoriesActivity.this, errorCode, Toast.LENGTH_SHORT).show()); - } - - @Override - public void userInputRequired(PendingIntent pi, Void object) { - // not used - } - }); - } - - @Override - public void error(int errorCode, String object) { - runOnUiThread(() -> Toast.makeText(StoriesActivity.this, errorCode, Toast.LENGTH_SHORT).show()); - } - - @Override - public void userInputRequired(PendingIntent pi, String object) { - // not used - } - }); - }) - .create() - .show(); + if (uri == null || mSelectedAccount == null) { + return; } + showCreateStoryDialog(uri, mimeType); + } + + private void showCreateStoryDialog(Uri uri, String mimeType) { + View dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_create_story, null); + ImageView storyPreviewImage = dialogView.findViewById(R.id.story_preview_image); + VideoView storyPreviewVideo = dialogView.findViewById(R.id.story_preview_video); + TextView publishInfoText = dialogView.findViewById(R.id.publish_info_text); + TextInputEditText titleEditText = dialogView.findViewById(R.id.title_edit_text); + + final TextWatcher textWatcher = new TextWatcher() { + private String before; + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + before = s.toString(); + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + // No action needed here + } + + @Override + public void afterTextChanged(Editable s) { + if (titleEditText.getLineCount() > 20) { + titleEditText.removeTextChangedListener(this); + titleEditText.setText(before); + titleEditText.setSelection(before.length()); + titleEditText.addTextChangedListener(this); + } + } + }; + titleEditText.addTextChangedListener(textWatcher); + + int rosterSize = mSelectedAccount.getRoster().getContacts().size(); + publishInfoText.setText(getResources().getQuantityString(R.plurals.publishing_to_x_contacts, rosterSize, rosterSize)); + + if (mimeType.startsWith("video/")) { + storyPreviewVideo.setVisibility(View.VISIBLE); + storyPreviewVideo.setVideoURI(uri); + storyPreviewVideo.setOnPreparedListener(mp -> { + mp.setLooping(true); + storyPreviewVideo.start(); + }); + } else { + storyPreviewImage.setVisibility(View.VISIBLE); + Glide.with(this).load(uri).into(storyPreviewImage); + } + + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.add_story_title) + .setView(dialogView) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.publish, (dialog2, which2) -> { + final String title = titleEditText.getText().toString(); + Toast.makeText(this, R.string.uploading_story, Toast.LENGTH_SHORT).show(); + xmppConnectionService.uploadFileForUrl(mSelectedAccount, uri, mimeType, new UiCallback() { + @Override + public void success(String url) { + xmppConnectionService.publishStory(mSelectedAccount, url, mimeType, title, new UiCallback() { + @Override + public void success(Void aVoid) { + runOnUiThread(() -> Toast.makeText(StoriesActivity.this, R.string.story_published, Toast.LENGTH_SHORT).show()); + } + + @Override + public void error(int errorCode, Void object) { + runOnUiThread(() -> Toast.makeText(StoriesActivity.this, errorCode, Toast.LENGTH_SHORT).show()); + } + + @Override + public void userInputRequired(PendingIntent pi, Void object) { + // not used + } + }); + } + + @Override + public void error(int errorCode, String object) { + runOnUiThread(() -> Toast.makeText(StoriesActivity.this, errorCode, Toast.LENGTH_SHORT).show()); + } + + @Override + public void userInputRequired(PendingIntent pi, String object) { + // not used + } + }); + }) + .create() + .show(); } @Override diff --git a/src/main/res/layout/dialog_create_story.xml b/src/main/res/layout/dialog_create_story.xml new file mode 100644 index 000000000..5d8471cf9 --- /dev/null +++ b/src/main/res/layout/dialog_create_story.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index c85bfa8e6..3d4d215c0 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -1598,4 +1598,8 @@ No calls yet Too long. Maximum video length is one minute. Could not retrieve video duration + + Publishing to %d contact + Publishing to %d contacts + \ No newline at end of file -- 2.39.5 From 3fd1516daa2dafae10a33bbabe80f8fc1e483456 Mon Sep 17 00:00:00 2001 From: Arne Date: Fri, 9 Jan 2026 10:34:30 +0100 Subject: [PATCH 087/180] Configure pubsub#publish_model and pubsub#max_items --- .../java/eu/siacs/conversations/generator/IqGenerator.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/eu/siacs/conversations/generator/IqGenerator.java b/src/main/java/eu/siacs/conversations/generator/IqGenerator.java index 44570bdb0..8e9d63f31 100644 --- a/src/main/java/eu/siacs/conversations/generator/IqGenerator.java +++ b/src/main/java/eu/siacs/conversations/generator/IqGenerator.java @@ -726,9 +726,10 @@ public class IqGenerator extends AbstractGenerator { options.putString("pubsub#access_model", "roster"); options.putString("pubsub#item_expire", "86400"); options.putString("pubsub#persist_items", "1"); + options.putString("pubsub#max_items", "120"); options.putString("pubsub#notify_retract", "1"); options.putString("pubsub#send_last_published_item", "on_sub"); - options.putString("pubsub#publisher", "publishers"); + options.putString("pubsub#publish_model", "publishers"); return options; } -- 2.39.5 From 6b1a117f070ef917db185fe548c01212bf8c1b65 Mon Sep 17 00:00:00 2001 From: Arne Date: Fri, 9 Jan 2026 10:59:43 +0100 Subject: [PATCH 088/180] Refine story publish count to subscribers only --- .../java/eu/siacs/conversations/ui/StoriesActivity.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java b/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java index bb8f2e84f..a452d38a6 100644 --- a/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java @@ -45,6 +45,7 @@ import eu.siacs.conversations.Config; import eu.siacs.conversations.R; import eu.siacs.conversations.databinding.ActivityStoriesBinding; import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.entities.Contact; import eu.siacs.conversations.entities.Story; import eu.siacs.conversations.medialib.activities.EditActivity; import eu.siacs.conversations.services.XmppConnectionService; @@ -205,8 +206,9 @@ public class StoriesActivity extends XmppActivity implements XmppConnectionServi }; titleEditText.addTextChangedListener(textWatcher); - int rosterSize = mSelectedAccount.getRoster().getContacts().size(); - publishInfoText.setText(getResources().getQuantityString(R.plurals.publishing_to_x_contacts, rosterSize, rosterSize)); + long subscriberCount = mSelectedAccount.getRoster().getContacts().stream() + .filter(c -> c.getOption(Contact.Options.FROM)) + .count();publishInfoText.setText(getResources().getQuantityString(R.plurals.publishing_to_x_contacts, (int) subscriberCount, (int) subscriberCount)); if (mimeType.startsWith("video/")) { storyPreviewVideo.setVisibility(View.VISIBLE); -- 2.39.5 From 540707527fec8ae5b7b02db4a2d6227083a15c6b Mon Sep 17 00:00:00 2001 From: Arne Date: Fri, 9 Jan 2026 12:16:31 +0100 Subject: [PATCH 089/180] Add initially basic microblog support --- src/main/AndroidManifest.xml | 6 + .../eu/siacs/conversations/entities/Post.java | 74 +++++++++ .../conversations/generator/IqGenerator.java | 29 ++++ .../services/XmppConnectionService.java | 75 +++++++++ .../ui/ConversationsOverviewFragment.java | 3 + .../conversations/ui/CreatePostActivity.java | 82 ++++++++++ .../siacs/conversations/ui/PostsActivity.java | 144 ++++++++++++++++++ .../ui/adapter/PostsAdapter.java | 61 ++++++++ .../conversations/utils/AccountUtils.java | 14 ++ .../res/drawable/outline_newspaper_24.xml | 5 + src/main/res/layout/activity_create_post.xml | 72 +++++++++ src/main/res/layout/activity_posts.xml | 43 ++++++ src/main/res/layout/item_post.xml | 71 +++++++++ src/main/res/menu/activity_posts.xml | 9 ++ .../menu/fragment_conversations_overview.xml | 7 + src/main/res/values/strings.xml | 8 + 16 files changed, 703 insertions(+) create mode 100644 src/main/java/eu/siacs/conversations/entities/Post.java create mode 100644 src/main/java/eu/siacs/conversations/ui/CreatePostActivity.java create mode 100644 src/main/java/eu/siacs/conversations/ui/PostsActivity.java create mode 100644 src/main/java/eu/siacs/conversations/ui/adapter/PostsAdapter.java create mode 100644 src/main/res/drawable/outline_newspaper_24.xml create mode 100644 src/main/res/layout/activity_create_post.xml create mode 100644 src/main/res/layout/activity_posts.xml create mode 100644 src/main/res/layout/item_post.xml create mode 100644 src/main/res/menu/activity_posts.xml diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index 4a4e22e09..7e8095219 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -471,5 +471,11 @@ + + diff --git a/src/main/java/eu/siacs/conversations/entities/Post.java b/src/main/java/eu/siacs/conversations/entities/Post.java new file mode 100644 index 000000000..131142be4 --- /dev/null +++ b/src/main/java/eu/siacs/conversations/entities/Post.java @@ -0,0 +1,74 @@ +package eu.siacs.conversations.entities; + +import static eu.siacs.conversations.parser.AbstractParser.parseTimestamp; + +import java.util.Date; + +import eu.siacs.conversations.xml.Element; +import eu.siacs.conversations.xml.Namespace; +import eu.siacs.conversations.xmpp.Jid; + +public class Post { + + private final String id; + private final String title; + private final String content; + private final Jid author; + private final Date published; + + public Post(String id, String title, String content, Jid author, Date published) { + this.id = id; + this.title = title; + this.content = content; + this.author = author; + this.published = published; + } + + public static Post fromElement(Element entry) { + String id = entry.findChildContent("id", Namespace.ATOM); + String title = entry.findChildContent("title", Namespace.ATOM); + String content = entry.findChildContent("content", Namespace.ATOM); + Element authorElement = entry.findChild("author", Namespace.ATOM); + Jid author = null; + if (authorElement != null) { + String uri = authorElement.findChildContent("uri", Namespace.ATOM); + if (uri != null && uri.startsWith("xmpp:")) { + try { + author = Jid.of(uri.substring(5)); + } catch (IllegalArgumentException e) { + // ignore + } + } + } + Date published = null; + final String publishedString = entry.findChildContent("published", Namespace.ATOM); + if (publishedString != null) { + try { + published = new Date(parseTimestamp(publishedString)); + } catch (Exception e) { + // ignore + } + } + return new Post(id, title, content, author, published); + } + + public String getId() { + return id; + } + + public String getTitle() { + return title; + } + + public String getContent() { + return content; + } + + public Jid getAuthor() { + return author; + } + + public Date getPublished() { + return published; + } +} diff --git a/src/main/java/eu/siacs/conversations/generator/IqGenerator.java b/src/main/java/eu/siacs/conversations/generator/IqGenerator.java index 8e9d63f31..370bf1ba2 100644 --- a/src/main/java/eu/siacs/conversations/generator/IqGenerator.java +++ b/src/main/java/eu/siacs/conversations/generator/IqGenerator.java @@ -818,4 +818,33 @@ public class IqGenerator extends AbstractGenerator { return response; } } + + public Iq retrievePubsubItems(final Jid server, final String node) { + final Iq iq = new Iq(Iq.Type.GET); + if (server != null) { + iq.setTo(server); + } + final Element pubsub = iq.addChild("pubsub", Namespace.PUBSUB); + final Element items = pubsub.addChild("items"); + items.setAttribute("node", node); + return iq; + } + + public Iq publishPost(final Account account, final String node, final String title, final String content) { + final Iq iq = new Iq(Iq.Type.SET); + iq.setTo(account.getJid().asBareJid()); + final Element pubsub = iq.addChild("pubsub", Namespace.PUBSUB); + final Element publish = pubsub.addChild("publish"); + publish.setAttribute("node", node); + final Element item = publish.addChild("item"); + final Element entry = item.addChild("entry", Namespace.ATOM); + entry.addChild("title").setContent(title); + entry.addChild("content").setContent(content); + final String id = "tag:" + account.getServer() + "," + AbstractGenerator.getTimestamp(System.currentTimeMillis()) + ":" + UUID.randomUUID().toString(); + entry.addChild("id").setContent(id); + final String now = AbstractGenerator.getTimestamp(System.currentTimeMillis()); + entry.addChild("published").setContent(now); + entry.addChild("updated").setContent(now); + return iq; + } } diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java index 3b696e5f7..4f86d5bcc 100644 --- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +++ b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java @@ -8131,4 +8131,79 @@ public class XmppConnectionService extends Service { } } } + + public void fetchPubsubItems(final Jid server, final String node, final OnPubsubItemsFetched callback) { + Account account = null; + if (server != null) { + account = findAccountByJid(server); + } + if (account == null) { + for (Account acc : getAccounts()) { + if (acc.isOnlineAndConnected()) { + account = acc; + break; + } + } + } + if (account == null) { + if (callback != null) { + callback.onPubsubItemsFetchFailed(); + } + return; + } + final Iq request = getIqGenerator().retrievePubsubItems(server, node); + sendIqPacket(account, request, response -> { + if (response.getType() == Iq.Type.RESULT) { + Element pubsub = response.findChild("pubsub", Namespace.PUBSUB); + if (pubsub != null && callback != null) { + callback.onPubsubItemsFetched(pubsub.toString()); + } else if (callback != null) { + callback.onPubsubItemsFetchFailed(); + } + } else { + if (callback != null) { + callback.onPubsubItemsFetchFailed(); + } + } + }); + } + + public interface OnPubsubItemsFetched { + void onPubsubItemsFetched(String feed); + + void onPubsubItemsFetchFailed(); + } + + public interface OnPostPublished { + void onPostPublished(); + void onPostPublishFailed(); + } + + public void publishPost(final String node, final String title, final String content, final OnPostPublished callback) { + Account account = null; + for (Account acc : getAccounts()) { + if (acc.isOnlineAndConnected()) { + account = acc; + break; + } + } + if (account == null) { + if (callback != null) { + callback.onPostPublishFailed(); + } + return; + } + final Iq request = getIqGenerator().publishPost(account, node, title, content); + sendIqPacket(account, request, response -> { + if (response.getType() == Iq.Type.RESULT) { + if (callback != null) { + callback.onPostPublished(); + } + } else { + if (callback != null) { + callback.onPostPublishFailed(); + } + } + }); + } } diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java b/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java index 4617c6b0f..2a44c82e3 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java @@ -538,6 +538,9 @@ public class ConversationsOverviewFragment extends XmppFragment { activity.switchToConversation(conversation); } return true; + case R.id.action_posts: + startActivity(new Intent(getActivity(), PostsActivity.class)); + return true; case R.id.action_stories: startActivity(new Intent(getActivity(), StoriesActivity.class)); return true; diff --git a/src/main/java/eu/siacs/conversations/ui/CreatePostActivity.java b/src/main/java/eu/siacs/conversations/ui/CreatePostActivity.java new file mode 100644 index 000000000..c4b7183a6 --- /dev/null +++ b/src/main/java/eu/siacs/conversations/ui/CreatePostActivity.java @@ -0,0 +1,82 @@ +package eu.siacs.conversations.ui; + +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuItem; +import android.widget.Toast; + +import androidx.databinding.DataBindingUtil; + +import eu.siacs.conversations.R; +import eu.siacs.conversations.databinding.ActivityCreatePostBinding; +import eu.siacs.conversations.services.XmppConnectionService; + +public class CreatePostActivity extends XmppActivity { + + private ActivityCreatePostBinding binding; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + binding = DataBindingUtil.setContentView(this, R.layout.activity_create_post); + setSupportActionBar(binding.toolbar); + if (getSupportActionBar() != null) { + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + } + + binding.publishButton.setOnClickListener(v -> publishPost()); + } + + @Override + protected void refreshUiReal() { + + } + + @Override + public void onBackendConnected() { + // do nothing + } + + private void publishPost() { + String title = binding.postTitleEditText.getText().toString(); + String content = binding.postContentEditText.getText().toString(); + + if (title.isEmpty() || content.isEmpty()) { + Toast.makeText(this, R.string.title_and_content_are_required, Toast.LENGTH_SHORT).show(); + return; + } + + if (xmppConnectionService != null) { + xmppConnectionService.publishPost("urn:xmpp:microblog:0", title, content, new XmppConnectionService.OnPostPublished() { + @Override + public void onPostPublished() { + runOnUiThread(() -> { + Toast.makeText(CreatePostActivity.this, R.string.post_published, Toast.LENGTH_SHORT).show(); + finish(); + }); + } + + @Override + public void onPostPublishFailed() { + runOnUiThread(() -> { + Toast.makeText(CreatePostActivity.this, R.string.error_publish_post, Toast.LENGTH_SHORT).show(); + }); + } + }); + } + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + finish(); + return true; + } + return super.onOptionsItemSelected(item); + } +} diff --git a/src/main/java/eu/siacs/conversations/ui/PostsActivity.java b/src/main/java/eu/siacs/conversations/ui/PostsActivity.java new file mode 100644 index 000000000..e32648f3c --- /dev/null +++ b/src/main/java/eu/siacs/conversations/ui/PostsActivity.java @@ -0,0 +1,144 @@ +package eu.siacs.conversations.ui; + +import android.content.Intent; +import android.os.Bundle; +import android.util.Log; +import android.view.Menu; +import android.view.MenuItem; +import android.widget.Toast; + +import androidx.databinding.DataBindingUtil; +import androidx.recyclerview.widget.LinearLayoutManager; + +import org.xmlpull.v1.XmlPullParserException; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import eu.siacs.conversations.Config; +import eu.siacs.conversations.R; +import eu.siacs.conversations.databinding.ActivityPostsBinding; +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.entities.Post; +import eu.siacs.conversations.services.XmppConnectionService; +import eu.siacs.conversations.ui.adapter.PostsAdapter; +import eu.siacs.conversations.utils.AccountUtils; +import eu.siacs.conversations.xml.Element; +import eu.siacs.conversations.xml.Namespace; +import eu.siacs.conversations.xml.XmlReader; + +public class PostsActivity extends XmppActivity { + + private ActivityPostsBinding binding; + private PostsAdapter postsAdapter; + private List postList = new ArrayList<>(); + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + binding = DataBindingUtil.setContentView(this, R.layout.activity_posts); + setSupportActionBar(binding.toolbar); + if (getSupportActionBar() != null) { + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + } + + binding.postsList.setLayoutManager(new LinearLayoutManager(this)); + postsAdapter = new PostsAdapter(postList); + binding.postsList.setAdapter(postsAdapter); + + binding.fabCreatePost.setOnClickListener(v -> { + Intent intent = new Intent(this, CreatePostActivity.class); + startActivity(intent); + }); + } + + @Override + protected void refreshUiReal() { + + } + + @Override + public void onStart() { + super.onStart(); + loadPosts(); + } + + @Override + public void onBackendConnected() { + loadPosts(); + } + + private void loadPosts() { + if (xmppConnectionService == null) { + return; + } + Account account = AccountUtils.getFirstEnabled(xmppConnectionService.getAccounts()); + if (account == null) { + Toast.makeText(this, R.string.no_active_account, Toast.LENGTH_SHORT).show(); + return; + } + xmppConnectionService.fetchPubsubItems(account.getJid().asBareJid(), "urn:xmpp:microblog:0", new XmppConnectionService.OnPubsubItemsFetched() { + @Override + public void onPubsubItemsFetched(String feedXml) { + try { + final XmlReader reader = new XmlReader(); + reader.setInputStream(new java.io.ByteArrayInputStream(feedXml.getBytes())); + final Element pubsub = reader.readElement(reader.readTag()); + final List posts = new ArrayList<>(); + if (pubsub != null && pubsub.getName().equals("pubsub")) { + final Element items = pubsub.findChild("items"); + if (items != null) { + for (Element item : items.getChildren()) { + if ("item".equals(item.getName())) { + Element entry = item.findChild("entry", Namespace.ATOM); + if (entry != null) { + posts.add(Post.fromElement(entry)); + } + } + } + } + } + runOnUiThread(() -> { + postList.clear(); + postList.addAll(posts); + postsAdapter.notifyDataSetChanged(); + if (posts.isEmpty()) { + Toast.makeText(PostsActivity.this, "No posts found.", Toast.LENGTH_SHORT).show(); + } + }); + } catch (IOException e) { + runOnUiThread(() -> { + Toast.makeText(PostsActivity.this, "Error parsing posts.", Toast.LENGTH_SHORT).show(); + Log.e(Config.LOGTAG,"error parsing posts",e); + }); + } + } + + @Override + public void onPubsubItemsFetchFailed() { + runOnUiThread(() -> { + Toast.makeText(PostsActivity.this, "Failed to fetch posts.", Toast.LENGTH_SHORT).show(); + }); + } + }); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.activity_posts, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + finish(); + return true; + } else if (item.getItemId() == R.id.action_refresh) { + loadPosts(); + return true; + } + return super.onOptionsItemSelected(item); + } +} diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/PostsAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/PostsAdapter.java new file mode 100644 index 000000000..67c04b5f4 --- /dev/null +++ b/src/main/java/eu/siacs/conversations/ui/adapter/PostsAdapter.java @@ -0,0 +1,61 @@ +package eu.siacs.conversations.ui.adapter; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import java.text.DateFormat; +import java.util.List; + +import eu.siacs.conversations.R; +import eu.siacs.conversations.databinding.ItemPostBinding; +import eu.siacs.conversations.entities.Post; + +public class PostsAdapter extends RecyclerView.Adapter { + + private final List posts; + + public PostsAdapter(List posts) { + this.posts = posts; + } + + @NonNull + @Override + public PostViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new PostViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.item_post, parent, false)); + } + + @Override + public void onBindViewHolder(@NonNull PostViewHolder holder, int position) { + holder.bind(posts.get(position)); + } + + @Override + public int getItemCount() { + return posts.size(); + } + + static class PostViewHolder extends RecyclerView.ViewHolder { + + private final ItemPostBinding binding; + + PostViewHolder(@NonNull View itemView) { + super(itemView); + binding = ItemPostBinding.bind(itemView); + } + + void bind(Post post) { + if (post.getAuthor() != null) { + binding.postAuthorName.setText(post.getAuthor().asBareJid().toString()); + } + binding.postTitle.setText(post.getTitle()); + binding.postContent.setText(post.getContent()); + if (post.getPublished() != null) { + binding.postTimestamp.setText(DateFormat.getDateTimeInstance().format(post.getPublished())); + } + } + } +} diff --git a/src/main/java/eu/siacs/conversations/utils/AccountUtils.java b/src/main/java/eu/siacs/conversations/utils/AccountUtils.java index bed96c24c..29dae1a49 100644 --- a/src/main/java/eu/siacs/conversations/utils/AccountUtils.java +++ b/src/main/java/eu/siacs/conversations/utils/AccountUtils.java @@ -132,4 +132,18 @@ public class AccountUtils { manageAccounts.setVisible(MANAGE_ACCOUNT_ACTIVITY != null); } } + + public static Account getFirstEnabled(List accounts) { + for (Account account : accounts) { + if (account.isOnlineAndConnected()) { + return account; + } + } + for (Account account : accounts) { + if (account.isEnabled()) { + return account; + } + } + return null; + } } diff --git a/src/main/res/drawable/outline_newspaper_24.xml b/src/main/res/drawable/outline_newspaper_24.xml new file mode 100644 index 000000000..02c74e71c --- /dev/null +++ b/src/main/res/drawable/outline_newspaper_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/layout/activity_create_post.xml b/src/main/res/layout/activity_create_post.xml new file mode 100644 index 000000000..6dabaa209 --- /dev/null +++ b/src/main/res/layout/activity_create_post.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + +