diff --git a/README-fr.md b/README-fr.md
index 362ea6aa2..5c2534382 100644
--- a/README-fr.md
+++ b/README-fr.md
@@ -32,8 +32,7 @@ Il existe également un Support-MUC où vous pouvez poser des questions et obten
## Comment puis-je aider à la traduction ?
-Tu peux améliorer ou créer des traductions sur Codeberg Translate. Merci beaucoup.
-
+Vous pouvez améliorer ou créer des traductions sur Codeberg Translate. Merci beaucoup.
## Aidez-moi ! J'ai rencontré des problèmes !
diff --git a/build.gradle b/build.gradle
index c8833964d..e147d415b 100644
--- a/build.gradle
+++ b/build.gradle
@@ -116,6 +116,11 @@ dependencies {
implementation 'me.xdrop:fuzzywuzzy:1.4.0'
implementation 'net.fellbaum:jemoji:1.4.1'
implementation 'com.github.bumptech.glide:glide:4.15.1' // For photo editor compatibility
+ implementation('com.github.bumptech.glide:okhttp3-integration:4.15.1') {
+ exclude group: 'glide-parent'
+ }
+ implementation 'com.github.bumptech.glide:annotations:4.15.1'
+ annotationProcessor 'com.github.bumptech.glide:compiler:4.15.1'
implementation 'com.github.natario1:Autocomplete:v1.1.0'
implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
@@ -128,6 +133,7 @@ dependencies {
implementation "androidx.media3:media3-ui:1.3.1"
implementation "androidx.media3:media3-session:1.3.1"
implementation 'com.kizitonwose.calendar:view:2.5.4'
+ implementation "io.noties.markwon:core:4.6.2"
}
ext {
@@ -142,8 +148,8 @@ android {
defaultConfig {
minSdkVersion 23
targetSdkVersion 35
- versionCode 195
- versionName "2.0.18"
+ versionCode 196
+ versionName "2.1"
applicationId "de.monocles.chat"
def appName = "monocles chat"
resValue "string", "app_name", appName
@@ -209,8 +215,8 @@ android {
monocleschat {
dimension "mode"
applicationId = "de.monocles.chat"
- versionCode 195
- versionName "2.0.18"
+ versionCode 196
+ versionName "2.1"
def appName = "monocles chat"
resValue "string", "app_name", appName
buildConfigField "String", "APP_NAME", "\"$appName\""
diff --git a/monocles_chat.doap b/monocles_chat.doap
index 150a2e2f5..29ce5b0e9 100644
--- a/monocles_chat.doap
+++ b/monocles_chat.doap
@@ -567,5 +567,26 @@
Receiving retractions
+
+
+
+ complete
+ 0.2.0
+
+
+
+
+
+ partial
+ 0.6.5
+
+
+
+
+
+ partial
+ 0.2.1
+
+
diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml
index 6749e19f7..720e28b5d 100644
--- a/src/main/AndroidManifest.xml
+++ b/src/main/AndroidManifest.xml
@@ -396,6 +396,12 @@
+
+
@@ -462,6 +468,14 @@
+
+
+
-
diff --git a/src/main/java/eu/siacs/conversations/AppSettings.java b/src/main/java/eu/siacs/conversations/AppSettings.java
index 5f22879a6..7685b0149 100644
--- a/src/main/java/eu/siacs/conversations/AppSettings.java
+++ b/src/main/java/eu/siacs/conversations/AppSettings.java
@@ -43,7 +43,6 @@ public class AppSettings {
public static final String TRUST_SYSTEM_CA_STORE = "trust_system_ca_store";
public static final String DANE_ENFORCED = "enforce_dane";
- public static final String CUSTOM_RESOURCE_NAME = "custom_resource_name";
public static final String REQUIRE_CHANNEL_BINDING = "channel_binding_required";
public static final String REQUIRE_TLS_V1_3 = "require_tls_v1_3";
public static final String NOTIFICATION_RINGTONE = "notification_ringtone";
diff --git a/src/main/java/eu/siacs/conversations/CustomGlideModule.java b/src/main/java/eu/siacs/conversations/CustomGlideModule.java
new file mode 100644
index 000000000..b3446f9e1
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/CustomGlideModule.java
@@ -0,0 +1,77 @@
+package eu.siacs.conversations;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+
+import androidx.annotation.NonNull;
+import androidx.preference.PreferenceManager;
+
+import com.bumptech.glide.Glide;
+import com.bumptech.glide.Registry;
+import com.bumptech.glide.annotation.GlideModule;
+import com.bumptech.glide.integration.okhttp3.OkHttpUrlLoader;
+import com.bumptech.glide.load.model.GlideUrl;
+import com.bumptech.glide.module.AppGlideModule;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.Proxy;
+import java.net.ProxySelector;
+import java.net.SocketAddress;
+import java.net.URI;
+import java.util.Collections;
+import java.util.List;
+
+import eu.siacs.conversations.http.HttpConnectionManager;
+import okhttp3.OkHttpClient;
+
+@GlideModule
+public class CustomGlideModule extends AppGlideModule {
+
+ @Override
+ public void registerComponents(@NonNull Context context, @NonNull Glide glide, @NonNull Registry registry) {
+
+ // Start with the app's base OkHttpClient, which is already configured with the app's custom trust managers.
+ // This is the crucial step that makes direct connections (without a proxy) work correctly.
+ final OkHttpClient baseClient = HttpConnectionManager.okHttpClient(context);
+
+ final SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context);
+
+ // Create a dynamic ProxySelector. This selector's 'select' method will be called
+ // for each new network request, so it will always use the most current proxy settings.
+ ProxySelector proxySelector = new ProxySelector() {
+ @Override
+ public List select(URI uri) {
+ final boolean useI2p = preferences.getBoolean("use_i2p", false);
+ if (useI2p) {
+ return Collections.singletonList(HttpConnectionManager.getProxy(true));
+ }
+ final boolean useTor = preferences.getBoolean("use_tor", false);
+ if (useTor) {
+ return Collections.singletonList(HttpConnectionManager.getProxy(false));
+ }
+
+ // When no custom proxy is active, it is VITAL to return Proxy.NO_PROXY.
+ // Returning null or an empty list would cause OkHttp to fall back to system-wide proxies,
+ // which we want to avoid. This forces a direct connection using our
+ // correctly configured (and trusted) OkHttpClient.
+ return Collections.singletonList(Proxy.NO_PROXY);
+ }
+
+ @Override
+ public void connectFailed(URI uri, SocketAddress sa, IOException ioe) {
+ // This method is called if a connection to a proxy fails.
+ // You could add logging here for debugging if needed.
+ }
+ };
+
+ // Create a new client by cloning the base client and setting our dynamic proxy selector.
+ final OkHttpClient clientWithProxy = baseClient.newBuilder()
+ .proxySelector(proxySelector)
+ .build();
+
+ final OkHttpUrlLoader.Factory factory = new OkHttpUrlLoader.Factory(clientWithProxy);
+
+ registry.replace(GlideUrl.class, InputStream.class, factory);
+ }
+}
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/entities/Call.java b/src/main/java/eu/siacs/conversations/entities/Call.java
new file mode 100644
index 000000000..76aaf6114
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/entities/Call.java
@@ -0,0 +1,59 @@
+
+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 implements AvatarService.Avatarable {
+
+ 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;
+ }
+
+ @Override
+ public int getAvatarBackgroundColor() {
+ return UIHelper.getColorForName(getContact());
+ }
+
+ @Override
+ public String getAvatarName() {
+ return getContact();
+ }
+}
diff --git a/src/main/java/eu/siacs/conversations/entities/Comment.java b/src/main/java/eu/siacs/conversations/entities/Comment.java
new file mode 100644
index 000000000..045ff5457
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/entities/Comment.java
@@ -0,0 +1,79 @@
+package eu.siacs.conversations.entities;
+
+import static eu.siacs.conversations.parser.AbstractParser.parseTimestamp;
+import static eu.siacs.conversations.parser.AbstractParser.parseTimestampAtom;
+
+import android.util.Log;
+
+import java.text.ParseException;
+import java.util.Date;
+
+import eu.siacs.conversations.parser.AbstractParser;
+import eu.siacs.conversations.xml.Element;
+import eu.siacs.conversations.xmpp.Jid;
+
+public class Comment {
+
+ private final String id;
+ private final String title;
+ private final Jid author;
+ private final Date published;
+
+ public Comment(String id, String title, Jid author, Date published) {
+ this.id = id;
+ this.title = title;
+ this.author = author;
+ this.published = published;
+ }
+
+ public static Comment fromElement(Element item) {
+ Element entry = item.findChild("entry", "http://www.w3.org/2005/Atom");
+ if (entry == null) {
+ return null;
+ }
+ String id = item.getAttribute("id");
+ String title = entry.findChildContent("title");
+ Element authorElement = entry.findChild("author");
+ Jid author = null;
+ if (authorElement != null) {
+ String uri = authorElement.findChildContent("uri");
+ if (uri != null && uri.startsWith("xmpp:")) {
+ try {
+ author = Jid.of(uri.substring(5));
+ } catch (IllegalArgumentException e) {
+ //ignore
+ }
+ }
+ }
+ Date published = null;
+ String publishedString = entry.findChildContent("published");
+ if (publishedString != null) {
+ try {
+ published = new Date(parseTimestamp(publishedString));
+ } catch (Exception e) {
+ try {
+ published = new Date(parseTimestampAtom(publishedString));
+ } catch (ParseException error) {
+ Log.e("Feeds", "Couldn't parse timestamp " + publishedString);
+ }
+ }
+ }
+ return new Comment(id, title, author, published);
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ public Jid getAuthor() {
+ return author;
+ }
+
+ public Date getPublished() {
+ return published;
+ }
+}
diff --git a/src/main/java/eu/siacs/conversations/entities/Contact.java b/src/main/java/eu/siacs/conversations/entities/Contact.java
index 4948a1f5e..75a2f12c0 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;
@@ -829,10 +846,23 @@ public class Contact implements ListItem, Blockable {
public static final int DIRTY_DELETE = 7;
private static final int SYNCED_VIA_ADDRESS_BOOK = 8;
public static final int SYNCED_VIA_OTHER = 9;
+ public static final int FOLLOWED = 10;
}
// Method to update this Contact's presences/status messages
public void updatePresences(List newStatusMessages) {
this.presences.updateStatusMessages(newStatusMessages);
}
+
+ public boolean isFollowed() {
+ return getOption(Options.FOLLOWED);
+ }
+
+ public void setFollowed(boolean followed) {
+ if (followed) {
+ setOption(Options.FOLLOWED);
+ } else {
+ resetOption(Options.FOLLOWED);
+ }
+ }
}
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/entities/Message.java b/src/main/java/eu/siacs/conversations/entities/Message.java
index d2b47a2c5..c48df41be 100644
--- a/src/main/java/eu/siacs/conversations/entities/Message.java
+++ b/src/main/java/eu/siacs/conversations/entities/Message.java
@@ -107,6 +107,7 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
public static final int TYPE_PRIVATE = 4;
public static final int TYPE_PRIVATE_FILE = 5;
public static final int TYPE_RTP_SESSION = 6;
+ public static final int TYPE_STORY = 7;
public static final String CONVERSATION = "conversationUuid";
public static final String COUNTERPART = "counterpart";
@@ -188,6 +189,7 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
private List counterparts;
private WeakReference user;
private String retractId = null;
+ private androidx.core.util.Pair storyReference = null;
protected Message(Conversational conversation) {
this.conversation = conversation;
@@ -275,7 +277,7 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
final long timeSent,
final int encryption,
final int status,
- final int type,
+ int type,
final boolean carbon,
final String remoteMsgId,
final String relativeFilePath,
@@ -322,6 +324,31 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
if (payloads != null) this.payloads = payloads;
if (fileParams != null && getSims().isEmpty()) this.fileParams = new FileParams(fileParams);
this.retractId = retractId;
+
+ if (type == TYPE_TEXT || type == TYPE_PRIVATE) {
+ final FileParams fp = getFileParams();
+ if (fp != null && fp.url != null && fp.url.startsWith("xmpp:")) {
+ try {
+ final android.net.Uri uri = android.net.Uri.parse(fp.url);
+ String schemeSpecificPart = uri.getSchemeSpecificPart();
+ int queryStart = schemeSpecificPart.indexOf('?');
+ if (queryStart != -1) {
+ String queryString = schemeSpecificPart.substring(queryStart + 1);
+ String[] params = queryString.split(";");
+ for (String param : params) {
+ String[] keyValue = param.split("=", 2);
+ if (keyValue.length == 2 && "node".equals(keyValue[0]) && "urn:xmpp:pubsub-social-feed:stories:0".equals(keyValue[1])) {
+ type = TYPE_STORY;
+ break;
+ }
+ }
+ }
+ } catch (Exception e) {
+ // not a story URI
+ }
+ }
+ }
+ this.type = type;
}
public static Message fromCursor(Cursor cursor, Conversation conversation) throws IOException {
@@ -377,6 +404,44 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
return m;
}
+ public androidx.core.util.Pair getStoryReference() {
+ if (this.storyReference != null) {
+ return this.storyReference;
+ }
+ final FileParams fp = getFileParams();
+ if (fp == null || fp.url == null || !fp.url.startsWith("xmpp:")) {
+ return null;
+ }
+ try {
+ final String uriString = fp.url;
+ final android.net.Uri uri = android.net.Uri.parse(uriString);
+ String schemeSpecificPart = uri.getSchemeSpecificPart();
+ int queryStart = schemeSpecificPart.indexOf('?');
+ if (queryStart == -1) {
+ return null;
+ }
+ String jidString = schemeSpecificPart.substring(0, queryStart);
+ final Jid jid = Jid.of(jidString);
+ String queryString = schemeSpecificPart.substring(queryStart + 1);
+ String item = null;
+ String[] params = queryString.split(";");
+ for (String param : params) {
+ String[] keyValue = param.split("=", 2);
+ if (keyValue.length == 2 && "item".equals(keyValue[0])) {
+ item = keyValue[1];
+ break;
+ }
+ }
+ if (item != null) {
+ this.storyReference = new androidx.core.util.Pair<>(jid, item);
+ return this.storyReference;
+ }
+ } catch (Exception e) {
+ // Not a valid story URI
+ }
+ return null;
+ }
+
private static Jid fromString(String value) {
try {
if (value != null) {
@@ -491,7 +556,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;
@@ -1190,7 +1255,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) {
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..9123ac907
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/entities/Post.java
@@ -0,0 +1,178 @@
+package eu.siacs.conversations.entities;import static eu.siacs.conversations.parser.AbstractParser.parseTimestamp;
+import static eu.siacs.conversations.parser.AbstractParser.parseTimestampAtom;
+
+import android.util.Log;
+
+import java.text.ParseException;
+import java.util.Date;
+import java.util.Objects;
+
+import eu.siacs.conversations.xml.Element;
+import eu.siacs.conversations.xml.Namespace;
+import eu.siacs.conversations.xmpp.Jid;
+
+public class Post {
+
+ public static final String TABLENAME = "posts";
+ public static final String UUID = "uuid";
+ public static final String ACCOUNT_UUID = "account_uuid";
+ public static final String AUTHOR_JID = "author_jid";
+ public static final String TITLE = "title";
+ public static final String CONTENT = "content";
+ public static final String ATTACHMENT_URL = "attachment_url";
+ public static final String ATTACHMENT_TYPE = "attachment_type";
+ public static final String PUBLISHED = "published";
+ public static final String COMMENTS_NODE = "comments_node";
+
+ private final String id;
+ private final String title;
+ private final String content;
+ private final Jid author;
+ private final Date published;
+ private final String commentsNode;
+ private final String attachmentUrl;
+ private final String attachmentType;
+
+ public Post(String id, String title, String content, Jid author, Date published, String commentsNode, String attachmentUrl, String attachmentType) {
+ this.id = id;
+ this.title = title;
+ this.content = content;
+ this.author = author;
+ this.published = published;
+ this.commentsNode = commentsNode;
+ this.attachmentUrl = attachmentUrl;
+ this.attachmentType = attachmentType;
+ }
+
+ public static Post fromElement(Element item) {
+ final Element entry = item.findChild("entry", Namespace.ATOM);
+ if (entry == null) {
+ return null;
+ }
+ String id = item.getAttribute("id");
+ String title = entry.findChildContent("title");
+ String content = entry.findChildContent("content");
+ Element authorElement = entry.findChild("author");
+ Jid author = null;
+ if (authorElement != null) {
+ String uri = authorElement.findChildContent("uri");
+ 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");
+ if (publishedString != null) {
+ try {
+ published = new Date(parseTimestamp(publishedString));
+ } catch (Exception e) {
+ try {
+ published = new Date(parseTimestampAtom(publishedString));
+ } catch (ParseException error) {
+ Log.e("Feeds", "Couldn't parse timestamp " + publishedString);
+ }
+ }
+ }
+
+ String commentsNode = null;
+ String attachmentUrl = null;
+ String attachmentType = null;
+ for (Element child : entry.getChildren()) {
+ if ("link".equals(child.getName()) && Namespace.ATOM.equals(child.getNamespace())) {
+ String rel = child.getAttribute("rel");
+ if ("replies".equals(rel)) {
+ commentsNode = child.getAttribute("href");
+ } else if ("enclosure".equals(rel)) {
+ attachmentUrl = child.getAttribute("href");
+ attachmentType = child.getAttribute("type");
+ }
+ }
+ }
+
+ return new Post(id, title, content, author, published, commentsNode, attachmentUrl, attachmentType);
+ }
+
+ 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;
+ }
+
+ public String getCommentsNode() {
+ return commentsNode;
+ }
+
+ public String getAttachmentUrl() {
+ return attachmentUrl;
+ }
+
+ public String getAttachmentType() {
+ return attachmentType;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ Post post = (Post) o;
+ return Objects.equals(id, post.id);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id);
+ }
+
+
+ public static Post fromCursor(android.database.Cursor cursor) {
+ final String uuid = cursor.getString(cursor.getColumnIndex(UUID));
+ final String authorJidStr = cursor.getString(cursor.getColumnIndex(AUTHOR_JID));
+ eu.siacs.conversations.xmpp.Jid authorJid = null;
+ try {
+ if(authorJidStr != null) {
+ authorJid = eu.siacs.conversations.xmpp.Jid.of(authorJidStr);
+ }
+ } catch (IllegalArgumentException e) {
+ //ignore
+ }
+ final String title = cursor.getString(cursor.getColumnIndex(TITLE));
+ final String content = cursor.getString(cursor.getColumnIndex(CONTENT));
+ final String attachmentUrl = cursor.getString(cursor.getColumnIndex(ATTACHMENT_URL));
+ final String attachmentType = cursor.getString(cursor.getColumnIndex(ATTACHMENT_TYPE));
+ final long published = cursor.getLong(cursor.getColumnIndex(PUBLISHED));
+ final String commentsNode = cursor.getString(cursor.getColumnIndex(COMMENTS_NODE));
+ return new Post(uuid, title, content, authorJid, new java.util.Date(published), commentsNode, attachmentUrl, attachmentType);
+ }
+
+ public android.content.ContentValues getContentValues(eu.siacs.conversations.entities.Account account) {
+ android.content.ContentValues values = new android.content.ContentValues();
+ values.put(UUID, getId());
+ values.put(ACCOUNT_UUID, account.getUuid());
+ values.put(AUTHOR_JID, getAuthor() != null ? getAuthor().toString() : null);
+ values.put(TITLE, getTitle());
+ values.put(CONTENT, getContent());
+ values.put(ATTACHMENT_URL, getAttachmentUrl());
+ values.put(ATTACHMENT_TYPE, getAttachmentType());
+ values.put(PUBLISHED, getPublished() != null ? getPublished().getTime() : 0);
+ values.put(COMMENTS_NODE, getCommentsNode());
+ return values;
+ }
+}
\ No newline at end of file
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..fce0e045e
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/entities/Story.java
@@ -0,0 +1,202 @@
+package eu.siacs.conversations.entities;
+
+import static eu.siacs.conversations.parser.AbstractParser.parseTimestamp;
+import static eu.siacs.conversations.parser.AbstractParser.parseTimestampAtom;
+
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.util.Log;
+
+import java.text.ParseException;
+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 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) {
+ 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;
+ }
+
+ 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) {
+ 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) {
+ Log.e("Story", "Couldn't parse timestamp " + publishedContent);
+ }
+ }
+ if (timestamp == 0) {
+ try {
+ timestamp = parseTimestampAtom(publishedContent);
+ } catch (ParseException e) {
+ Log.e("Story", "Couldn't parse timestamp " + publishedContent);
+ }
+ }
+ }
+
+ 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) {
+ try {
+ timestamp = parseTimestampAtom(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", "http://www.w3.org/2005/Atom"),
+ timestamp
+ );
+ }
+
+ 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) {
+ 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);
+ }
+ }
+ }
+ }
+ }
+ 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;
+ }
+}
diff --git a/src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java b/src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java
index c1ba8e09f..b26e4193e 100644
--- a/src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java
+++ b/src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java
@@ -34,6 +34,7 @@ public abstract class AbstractGenerator {
Namespace.OOB,
"http://jabber.org/protocol/caps",
"http://jabber.org/protocol/disco#info",
+ Namespace.PUBSUB,
"urn:xmpp:avatar:metadata+notify",
Namespace.NICK + "+notify",
"urn:xmpp:ping",
@@ -41,6 +42,12 @@ public abstract class AbstractGenerator {
"http://jabber.org/protocol/chatstates",
Namespace.REACTIONS,
Namespace.USER_TUNE + "+notify",
+ Namespace.PUBSUB_SOCIAL_FEED,
+ Namespace.PUBSUB_SOCIAL_FEED + "+notify",
+ Namespace.MICROBLOG,
+ Namespace.MICROBLOG + "+notify",
+ Namespace.PUBSUB_STORIES,
+ Namespace.PUBSUB_STORIES + "+notify",
};
private final String[] MESSAGE_CONFIRMATION_FEATURES = {
"urn:xmpp:chat-markers:0", "urn:xmpp:receipts"
diff --git a/src/main/java/eu/siacs/conversations/generator/IqGenerator.java b/src/main/java/eu/siacs/conversations/generator/IqGenerator.java
index f4acd27ce..3aceb6971 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;
@@ -244,6 +246,47 @@ public class IqGenerator extends AbstractGenerator {
return publish(namespace, item, options);
}
+ public Iq publishStory(final Account account, final String url, final String type, final String title, Bundle options) {
+ final Element item = new Element("item");
+ final String storyId = UUID.randomUUID().toString();
+ item.setAttribute("id", storyId);
+ final Element entry = item.addChild("entry", Namespace.ATOM);
+
+ entry.addChild("id").setContent("urn:uuid:" + storyId);
+
+ 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);
+
+ final String timestamp = getTimestamp(System.currentTimeMillis());
+ entry.addChild("updated").setContent(timestamp);
+ entry.addChild("published").setContent(timestamp);
+
+ if (account != null) {
+ entry.addChild("author").addChild("uri").setContent("xmpp:" + account.getJid().asBareJid());
+ }
+
+ final Element link = entry.addChild("link");
+ link.setAttribute("rel", "enclosure");
+ link.setAttribute("href", url);
+ link.setAttribute("type", type);
+ link.setAttribute("title", effectiveTitle);
+
+ 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 +715,31 @@ 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", "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_and_presence");
+ options.putString("pubsub#publish_model", "publishers");
+ return options;
+ }
+
+ public Iq createStoriesNode() {
+ 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) {
return pubsubConfiguration(jid, node, null);
}
@@ -746,4 +814,164 @@ 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 title, final String content, final String attachmentUrl, final String attachmentType, final String postId) {
+ final Element item = new Element("item");
+ item.setAttribute("id", postId);
+ final Element entry = item.addChild("entry", Namespace.ATOM);
+
+ entry.addChild("id").setContent("urn:uuid:" + postId);
+
+ entry.addChild("link")
+ .setAttribute("rel", "replies")
+ .setAttribute("title", "comments")
+ .setAttribute("href", "xmpp:" + account.getJid().asBareJid() + "?;node=urn:xmpp:microblog:0:comments/" + postId);
+
+ if (title != null) {
+ entry.addChild("title").setContent(title);
+ }
+ if (content != null) {
+ entry.addChild("content").setContent(content);
+ }
+ if (attachmentUrl != null && attachmentType != null) {
+ entry.addChild("link")
+ .setAttribute("rel", "enclosure")
+ .setAttribute("href", attachmentUrl)
+ .setAttribute("type", attachmentType);
+ }
+ final Element author = entry.addChild("author");
+ String name = account.getDisplayName();
+ if (name == null || name.trim().isEmpty()) {
+ name = account.getJid().asBareJid().toString();
+ }
+ author.addChild("name").setContent(name);
+ author.addChild("uri").setContent("xmpp:" + account.getJid().asBareJid().toString());
+ final String now = AbstractGenerator.getTimestamp(System.currentTimeMillis());
+ entry.addChild("published").setContent(now);
+ entry.addChild("updated").setContent(now);
+
+ return publish(Namespace.MICROBLOG, item, null);
+ }
+
+
+ public static Bundle defaultPostConfiguration() {
+ Bundle options = new Bundle();
+ options.putString("pubsub#node_type", "leaf");
+ options.putString("pubsub#type", Namespace.PUBSUB_SOCIAL_FEED);
+ options.putString("pubsub#access_model", "roster");
+ options.putString("pubsub#persist_items", "1");
+ options.putString("pubsub#deliver_payloads", "1");
+ options.putString("pubsub#send_last_published_item", "on_sub");
+ options.putString("pubsub#max_items", "max");
+ options.putString("pubsub#notify_retract", "1");
+ options.putString("pubsub#deliver_notifications", "1");
+ options.putString("pubsub#publish_model", "publishers");
+ return options;
+ }
+
+ public static Bundle defaultCommentsConfiguration() {
+ Bundle options = new Bundle();
+ options.putString("pubsub#node_type", "leaf");
+ options.putString("pubsub#type", "urn:xmpp:microblog:0:comments");
+ options.putString("pubsub#access_model", "open");
+ options.putString("pubsub#persist_items", "1");
+ options.putString("pubsub#max_items", "max");
+ options.putString("pubsub#notify_retract", "1");
+ options.putString("pubsub#deliver_notifications", "1");
+ options.putString("pubsub#deliver_payloads", "1");
+ options.putString("pubsub#send_last_published_item", "on_sub");
+ options.putString("pubsub#publish_model", "open");
+ options.putString("pubsub#itemreply", "publisher");
+ return options;
+ }
+
+ private Iq createNode(String node, Bundle options) {
+ final Iq iq = new Iq(Iq.Type.SET);
+ final Element pubsub = iq.addChild("pubsub", Namespace.PUBSUB);
+ pubsub.addChild("create").setAttribute("node", node);
+ final Element configure = pubsub.addChild("configure");
+ final Data data = Data.create("http://jabber.org/protocol/pubsub#node_config", options);
+ configure.addChild(data);
+ return iq;
+ }
+
+ public Iq createSocialFeedNode() {
+ final Iq iq = new Iq(Iq.Type.SET);
+ final Element pubsub = iq.addChild("pubsub", Namespace.PUBSUB);
+ pubsub.addChild("create").setAttribute("node", Namespace.MICROBLOG);
+ final Element configure = pubsub.addChild("configure");
+ final Data data = Data.create("http://jabber.org/protocol/pubsub#node_config", defaultPostConfiguration());
+ configure.addChild(data);
+ return iq;
+ }
+
+ public Iq publishComment(final Account account, final String node, final String title) {
+ 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);
+ final Element author = entry.addChild("author");
+ String name = account.getDisplayName();
+ if (name == null || name.trim().isEmpty()) {
+ name = account.getJid().asBareJid().toString();
+ }
+ author.addChild("name").setContent(name);
+ author.addChild("uri").setContent("xmpp:" + account.getJid().asBareJid().toString());
+ 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;
+ }
+
+
+ public Iq createCommentsNode(String postId) {
+ return createNode("urn:xmpp:microblog:0:comments/" + postId, defaultCommentsConfiguration());
+ }
+
+ public Iq retractPost(final String node, final String id) {
+ final var packet = new Iq(Iq.Type.SET);
+ final Element pubsub = packet.addChild("pubsub", Namespace.PUBSUB);
+ final Element retract = pubsub.addChild("retract");
+ retract.setAttribute("node", node);
+ retract.setAttribute("notify", "true");
+ retract.addChild("item").setAttribute("id", id);
+ return packet;
+ }
+
+ public Iq generateSubscriptionIq(final Jid to, final String node, final Jid from) {
+ final Iq iq = new Iq(Iq.Type.SET);
+ iq.setTo(to);
+ final Element pubsub = iq.addChild("pubsub", Namespace.PUBSUB);
+ final Element subscribe = pubsub.addChild("subscribe");
+ subscribe.setAttribute("node", node);
+ subscribe.setAttribute("jid", from.asBareJid().toString());
+ return iq;
+ }
+
+ public Iq generateUnsubscriptionIq(final Jid to, final String node, final Jid from) {
+ final Iq iq = new Iq(Iq.Type.SET);
+ iq.setTo(to);
+ final Element pubsub = iq.addChild("pubsub", Namespace.PUBSUB);
+ final Element unsubscribe = pubsub.addChild("unsubscribe");
+ unsubscribe.setAttribute("node", node);
+ unsubscribe.setAttribute("jid", from.asBareJid().toString());
+ return iq;
+ }
}
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/parser/AbstractParser.java b/src/main/java/eu/siacs/conversations/parser/AbstractParser.java
index 1cf6bb104..368432541 100644
--- a/src/main/java/eu/siacs/conversations/parser/AbstractParser.java
+++ b/src/main/java/eu/siacs/conversations/parser/AbstractParser.java
@@ -92,6 +92,26 @@ public abstract class AbstractParser {
return Math.min(dateFormat.parse(timestamp).getTime() + ms, System.currentTimeMillis());
}
+ public static long parseTimestampAtom(String timestamp) throws ParseException {
+ timestamp = timestamp.replace("+00:00", "+0000");
+ SimpleDateFormat dateFormat;
+ long ms;
+ if (timestamp.length() >= 25 && timestamp.charAt(19) == '.') {
+ String millis = timestamp.substring(19, timestamp.length() - 5);
+ try {
+ double fractions = Double.parseDouble("0" + millis);
+ ms = Math.round(1000 * fractions);
+ } catch (NumberFormatException e) {
+ ms = 0;
+ }
+ } else {
+ ms = 0;
+ }
+ timestamp = timestamp.substring(0, 19) + timestamp.substring(timestamp.length() - 5);
+ dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US);
+ return Math.min(dateFormat.parse(timestamp).getTime() + ms, System.currentTimeMillis());
+ }
+
public static long getTimestamp(final String input) throws ParseException {
if (input == null) {
throw new IllegalArgumentException("timestamp should not be 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..682290cd1 100644
--- a/src/main/java/eu/siacs/conversations/parser/IqParser.java
+++ b/src/main/java/eu/siacs/conversations/parser/IqParser.java
@@ -9,8 +9,11 @@ import com.google.common.io.BaseEncoding;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.crypto.axolotl.AxolotlService;
import eu.siacs.conversations.entities.Account;
+import eu.siacs.conversations.entities.Comment;
import eu.siacs.conversations.entities.Contact;
+import eu.siacs.conversations.entities.Post;
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 +441,57 @@ 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 (node != null && node.equals(Namespace.ATOM) || node != null && node.startsWith("urn:xmpp:microblog:0") || node != null && node.startsWith(Namespace.PUBSUB_SOCIAL_FEED)) {
+ for (Element child : items.getChildren()) {
+ if ("item".equals(child.getName())) {
+ final String postId = child.getAttribute("id");
+ Element entry = child.findChild("entry", Namespace.ATOM);
+ if (entry != null) {
+ try {
+ Element inReplyTo = entry.findChild("in-reply-to", "http://purl.org/syndication/thread/1.0");
+ if (inReplyTo != null) {
+ Comment comment = Comment.fromElement(entry);
+ String originalPostUuid = inReplyTo.getAttribute("ref");
+ if (originalPostUuid != null && originalPostUuid.startsWith("urn:uuid:")) {
+ originalPostUuid = originalPostUuid.substring(9);
+ }
+ mXmppConnectionService.notifyOnCommentReceived(originalPostUuid, comment);
+ } else {
+ Post post = Post.fromElement(entry);
+ mXmppConnectionService.onPostReceived(post, account);
+ }
+ } catch (Exception e) {
+ Log.d(Config.LOGTAG, "error creating post/comment from pubsub item in iq", e);
+ }
+ } else if (postId != null) {
+ mXmppConnectionService.onPostRetracted(postId);
+ }
+ } else if ("retract".equals(child.getName())) {
+ final String postId = child.getAttribute("id");
+ if (postId != null) {
+ mXmppConnectionService.onPostRetracted(postId);
+ }
+ }
+ }
+ }
+ }
} 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..06007cae2 100644
--- a/src/main/java/eu/siacs/conversations/parser/MessageParser.java
+++ b/src/main/java/eu/siacs/conversations/parser/MessageParser.java
@@ -35,8 +35,11 @@ import java.util.function.Consumer;
import java.util.stream.Collectors;
import eu.siacs.conversations.crypto.OtrService;
+import eu.siacs.conversations.entities.Comment;
+import eu.siacs.conversations.entities.Post;
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 +467,26 @@ 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 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);
+ }
+ }
+ }
+ }
+ }
} else if (Namespace.USER_TUNE.equals(node)) {
final Conversation conversation =
mXmppConnectionService.find(account, from.asBareJid());
@@ -1732,11 +1755,41 @@ public class MessageParser extends AbstractParser
packet);
}
- final Element event =
- original.findChild("event", "http://jabber.org/protocol/pubsub#event");
- if (event != null && Jid.Invalid.hasValidFrom(original) && original.getFrom().isBareJid()) {
- if (event.hasChild("items")) {
- parseEvent(event, original.getFrom(), account);
+ final Element event = original.findChild("event", "http://jabber.org/protocol/pubsub#event");
+ if (event != null) {
+ final Element items = event.findChild("items");
+ if (items != null) {
+ final String node = items.getAttribute("node");
+ if (node != null && node.equals(Namespace.ATOM) || node != null && node.startsWith("urn:xmpp:microblog:0") || node != null && node.startsWith(Namespace.PUBSUB_SOCIAL_FEED)) {
+ for (Element child : items.getChildren()) {
+ if ("item".equals(child.getName())) {
+ Element entry = child.findChild("entry", Namespace.ATOM);
+ if (entry != null) {
+ try {
+ Element inReplyTo = entry.findChild("in-reply-to", "http://purl.org/syndication/thread/1.0");
+ if (inReplyTo != null) {
+ Comment comment = Comment.fromElement(entry);
+ String originalPostUuid = inReplyTo.getAttribute("ref");
+ if (originalPostUuid != null && originalPostUuid.startsWith("urn:uuid:")) {
+ originalPostUuid = originalPostUuid.substring(9);
+ }
+ mXmppConnectionService.notifyOnCommentReceived(originalPostUuid, comment);
+ } else {
+ Post post = Post.fromElement(entry);
+ mXmppConnectionService.onPostReceived(post, account);
+ }
+ } catch (Exception e) {
+ Log.d(Config.LOGTAG, "error creating post/comment from pubsub item in message", e);
+ }
+ }
+ } else if ("retract".equals(child.getName())) {
+ final String postId = child.getAttribute("id");
+ mXmppConnectionService.onPostRetracted(postId);
+ }
+ }
+ } else {
+ parseEvent(event, original.getFrom(), account);
+ }
} else if (event.hasChild("delete")) {
parseDeleteEvent(event, original.getFrom(), account);
} else if (event.hasChild("purge")) {
diff --git a/src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java b/src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java
index 48efc73ec..13ae0282a 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 = 66;
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
@@ -374,6 +376,21 @@ public class DatabaseBackend extends SQLiteOpenHelper {
private static final String COPY_PREEXISTING_ENTRIES =
"INSERT INTO messages_index(messages_index) VALUES('rebuild');";
+ private static final String CREATE_POSTS_TABLE =
+ "CREATE TABLE " + eu.siacs.conversations.entities.Post.TABLENAME + " ("
+ + eu.siacs.conversations.entities.Post.UUID + " TEXT PRIMARY KEY,"
+ + eu.siacs.conversations.entities.Post.ACCOUNT_UUID + " TEXT,"
+ + eu.siacs.conversations.entities.Post.AUTHOR_JID + " TEXT,"
+ + eu.siacs.conversations.entities.Post.TITLE + " TEXT,"
+ + eu.siacs.conversations.entities.Post.CONTENT + " TEXT,"
+ + eu.siacs.conversations.entities.Post.ATTACHMENT_URL + " TEXT,"
+ + eu.siacs.conversations.entities.Post.ATTACHMENT_TYPE + " TEXT,"
+ + eu.siacs.conversations.entities.Post.PUBLISHED + " NUMBER,"
+ + eu.siacs.conversations.entities.Post.COMMENTS_NODE + " TEXT,"
+ + "FOREIGN KEY(" + eu.siacs.conversations.entities.Post.ACCOUNT_UUID + ") REFERENCES "
+ + eu.siacs.conversations.entities.Account.TABLENAME
+ + "(" + eu.siacs.conversations.entities.Account.UUID + ") ON DELETE CASCADE);";
+
protected Context context;
private DatabaseBackend(Context context) {
@@ -454,7 +471,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(
@@ -565,6 +583,7 @@ public class DatabaseBackend extends SQLiteOpenHelper {
db.execSQL(CREATE_MESSAGE_INSERT_TRIGGER);
db.execSQL(CREATE_MESSAGE_UPDATE_TRIGGER);
db.execSQL(CREATE_MESSAGE_DELETE_TRIGGER);
+ db.execSQL(CREATE_POSTS_TABLE);
monoclesDatabase(db);
}
@@ -1298,11 +1317,17 @@ 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);
}
}
+ if (oldVersion < 65 && newVersion >= 65) {
+ db.execSQL("ALTER TABLE " + Contact.TABLENAME + " ADD COLUMN " + Contact.CALLS_DISABLED + " boolean DEFAULT 0");
+ }
+ if (oldVersion < 66 && newVersion >= 66) {
+ db.execSQL(CREATE_POSTS_TABLE);
+ }
}
/**
@@ -1607,7 +1632,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);
}
@@ -2200,6 +2224,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;
@@ -2339,13 +2376,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;
}
@@ -2353,7 +2388,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;
@@ -3354,4 +3388,78 @@ 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;
+ }
+
+ public void createPost(eu.siacs.conversations.entities.Post post, eu.siacs.conversations.entities.Account account) {
+ final android.database.sqlite.SQLiteDatabase db = this.getWritableDatabase();
+ db.insertWithOnConflict(eu.siacs.conversations.entities.Post.TABLENAME, null, post.getContentValues(account), android.database.sqlite.SQLiteDatabase.CONFLICT_REPLACE);
+ }
+
+ public java.util.List getPosts() {
+ final java.util.List list = new java.util.ArrayList<>();
+ final android.database.sqlite.SQLiteDatabase db = this.getReadableDatabase();
+ android.database.Cursor cursor = db.query(eu.siacs.conversations.entities.Post.TABLENAME, null, null, null, null, null, eu.siacs.conversations.entities.Post.PUBLISHED + " DESC");
+ while (cursor.moveToNext()) {
+ list.add(eu.siacs.conversations.entities.Post.fromCursor(cursor));
+ }
+ cursor.close();
+ return list;
+ }
+
+ public void deletePost(String uuid) {
+ final android.database.sqlite.SQLiteDatabase db = this.getWritableDatabase();
+ db.delete(eu.siacs.conversations.entities.Post.TABLENAME, eu.siacs.conversations.entities.Post.UUID + "=?", new String[]{uuid});
+ }
+
+ public void clearPosts() {
+ final android.database.sqlite.SQLiteDatabase db = this.getWritableDatabase();
+ db.delete(eu.siacs.conversations.entities.Post.TABLENAME, null, null);
+ }
}
diff --git a/src/main/java/eu/siacs/conversations/persistance/FileBackend.java b/src/main/java/eu/siacs/conversations/persistance/FileBackend.java
index 071d75d33..e5c222455 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();
@@ -2428,6 +2435,46 @@ 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());
+ }
+ }
+
+ public Uri getTakeVideoUri() {
+ final String filename =
+ String.format("IMG_%s.%s", IMAGE_DATE_FORMAT.format(new Date()), "mp4");
+ final File directory;
+ if (Config.ONLY_INTERNAL_STORAGE) {
+ directory = new File(mXmppConnectionService.getCacheDir(), "Camera");
+ } else {
+ directory =
+ new File(
+ Environment.getExternalStoragePublicDirectory(
+ Environment.DIRECTORY_DCIM),
+ "Camera");
+ }
+ final File file = new File(directory, filename);
+ file.getParentFile().mkdirs();
+ return getUriForFile(mXmppConnectionService, file, filename);
+ }
+
private static class Dimensions {
public final int width;
public final int height;
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/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java
index 858289dc0..6b77c4dae 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;
@@ -106,11 +108,13 @@ 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;
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;
@@ -122,6 +126,11 @@ import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import eu.siacs.conversations.Conversations;
+import eu.siacs.conversations.entities.Comment;
+import eu.siacs.conversations.entities.Post;
+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;
@@ -290,6 +299,9 @@ public class XmppConnectionService extends Service {
private final ScheduledExecutorService userTuneUpdateExecutor =
Executors.newSingleThreadScheduledExecutor();
private ScheduledFuture> pendingUserTuneUpdate;
+
+ 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 =
@@ -344,19 +356,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 =
@@ -791,7 +804,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) {
@@ -842,7 +855,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) {
@@ -1845,8 +1858,11 @@ public class XmppConnectionService extends Service {
toggleForegroundService();
rescanStickers();
cleanupCache();
+
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(
@@ -1938,6 +1954,8 @@ public class XmppConnectionService extends Service {
destroyed = false;
fileObserver.stopWatching();
internalPingExecutor.shutdown();
+ storyRetractionExecutor.shutdown();
+ storyCacheExecutor.shutdown();
super.onDestroy();
}
@@ -4199,7 +4217,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() {
@@ -6347,6 +6366,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,
@@ -6814,7 +6858,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(() -> {
@@ -7758,4 +7802,626 @@ 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;
+ }
+ // This is the corrected publish request. It sends NO configuration options.
+ final Iq packet = getIqGenerator().publishStory(account, 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");
+ // 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) {
+ // 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);
+ }
+ }
+ });
+ }
+
+ 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;}
+
+ final DownloadableFile file = getFileBackend().getTemporaryFile(mimeType);
+
+ Runnable runnable = () -> {
+ try {
+ if (mimeType != null && mimeType.startsWith("video/")) {
+ 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 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");
+ }
+ }
+ } finally {
+ stopOngoingVideoTranscodingForegroundNotification();
+ }
+ } else if (mimeType != null && mimeType.startsWith("image/")) {
+ getFileBackend().copyImageToPrivateStorage(file, uri);
+ } else {
+ 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(file, uri);
+ } catch (FileBackend.FileCopyException ex) {
+ callback.error(ex.getResId(), null);
+ return;
+ }
+ } catch (final FileBackend.FileCopyException e) {
+ callback.error(e.getResId(), null);
+ return;
+ }
+
+ UiCallback wrapperCallback = new UiCallback() {
+ @Override
+ public void success(String url) {
+ try {
+ callback.success(url);
+ } finally {
+ getFileBackend().deleteFile(file);
+ }
+ }
+
+ @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) {
+ try {
+ callback.userInputRequired(pi, object);
+ } finally {
+ getFileBackend().deleteFile(file);
+ }
+ }
+ };
+ mHttpConnectionManager.createNewUploadConnection(file, account, false, wrapperCallback);
+ };
+
+ 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<>());
+
+ 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);
+ }
+ }
+ }
+ });
+ }
+
+ 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) {
+ 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);
+ }
+ } else {
+ if (callback != null) {
+ callback.error(R.string.error_deleting_story, null);
+ }
+ }
+ });
+ }
+
+ 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);
+ }
+ }
+
+ 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);
+ }
+ }
+ }
+ }
+
+ 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());
+ }
+ }
+ }
+ }
+ }
+ }
+
+ 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);
+ }
+ }
+ }
+ }
+
+ 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 void publishPost(final Account account, final String title, final String content, final String attachmentUrl, final String attachmentType, final String postId, final OnPostPublished callback) {
+ if (account == null) {
+ if (callback != null) {
+ callback.onPostPublishFailed();
+ }
+ return;
+ }
+
+ final boolean isEdit = postId != null;
+ final String idToPublish = isEdit ? postId : UUID.randomUUID().toString();
+
+ final Runnable publicationRunnable = () -> {
+ final Iq request = getIqGenerator().publishPost(account, title, content, attachmentUrl, attachmentType, idToPublish);
+ sendIqPacket(account, request, response2 -> {
+ if (response2.getType() == Iq.Type.RESULT) {
+ if (!isEdit) {
+ sendIqPacket(account, getIqGenerator().createCommentsNode(idToPublish), response3 -> {
+ if (response3.getType() != Iq.Type.RESULT) {
+ Log.d(Config.LOGTAG, "could not create comments node for post " + idToPublish + ". " + response3);
+ }
+ });
+ }
+ if (callback != null) {
+ callback.onPostPublished();
+ }
+ } else {
+ Log.e(Config.LOGTAG, "Could not publish post. Server responded with: " + response2);
+ if (callback != null) {
+ callback.onPostPublishFailed();
+ }
+ }
+ });
+ };
+
+ if (!isEdit) {
+ final Iq createRequest = getIqGenerator().createSocialFeedNode();
+ sendIqPacket(account, createRequest, response -> {
+ if (response.getType() != Iq.Type.RESULT) {
+ Element error = response.findChild("error");
+ if (error == null || !error.hasChild("conflict")) {
+ Log.d(Config.LOGTAG, "could not create social feed node " + response);
+ }
+ }
+ publicationRunnable.run();
+ });
+ } else {
+ publicationRunnable.run();
+ }
+ }
+
+ public void publishComment(final Account account, final String nodeUri, final String title, final OnPostPublished callback) {
+ if (account == null) {
+ if (callback != null) {
+ callback.onPostPublishFailed();
+ }
+ return;
+ }
+ final Jid to;
+ final String targetNode;
+ try {
+ final eu.siacs.conversations.utils.XmppUri uri = new eu.siacs.conversations.utils.XmppUri(nodeUri);
+ to = uri.getJid();
+ targetNode = uri.getParameter("node");
+ } catch (Exception e) {
+ Log.e(Config.LOGTAG, "Invalid URI in publishComment: " + nodeUri, e);
+ if (callback != null) {
+ callback.onPostPublishFailed();
+ }
+ return;
+ }
+
+ if (to == null || targetNode == null) {
+ Log.e(Config.LOGTAG, "Could not determine target for publishing comment");
+ if (callback != null) {
+ callback.onPostPublishFailed();
+ }
+ return;
+ }
+
+ final Iq createRequest = getIqGenerator().createCommentsNode(targetNode);
+ createRequest.setTo(to); // Comments node might be on a different server
+ sendIqPacket(account, createRequest, response -> {
+ if (response.getType() != Iq.Type.RESULT) {
+ Element error = response.findChild("error");
+ if (error == null || !error.hasChild("conflict")) {
+ Log.d(Config.LOGTAG, "could not create comments node " + response);
+ }
+ }
+ final Iq publishRequest = getIqGenerator().publishComment(account, targetNode, title);
+ publishRequest.setTo(to);
+ sendIqPacket(account, publishRequest, publishResponse -> {
+ if (publishResponse.getType() == Iq.Type.RESULT) {
+ if (callback != null) {
+ callback.onPostPublished();
+ }
+ } else {
+ Log.e(Config.LOGTAG, "Could not publish comment. Server responded with: " + publishResponse);
+ if (callback != null) {
+ callback.onPostPublishFailed();
+ }
+ }
+ });
+ });
+ }
+
+ public void retractPost(final Account account, final String node, final String id, final OnPostRetracted callback) {
+ if (account == null) {
+ if (callback != null) {
+ callback.onPostRetractionFailed();
+ }
+ return;
+ }
+ final Iq request = getIqGenerator().retractPost(node, id);
+ sendIqPacket(account, request, response -> {
+ if (response.getType() == Iq.Type.RESULT) {
+ if (callback != null) {
+ callback.onPostRetracted(id);
+ }
+ } else {
+ if (callback != null) {
+ callback.onPostRetractionFailed();
+ }
+ }
+ });
+ }
+
+
+ public void retractPost(final Account account, final Jid to, final String node, final String id, final OnPostRetracted callback) {
+ final Iq packet = getIqGenerator().retractPost(node, id);
+ packet.setTo(to);
+ this.sendIqPacket(account, packet, response -> {
+ if (response.getType() == Iq.Type.RESULT) {
+ if (callback != null) {
+ callback.onPostRetracted(id);
+ }
+ } else {
+ if (callback != null) {
+ callback.onPostRetractionFailed();
+ }
+ }
+ });
+ }
+
+ public interface OnPostPublished {
+ void onPostPublished();
+ void onPostPublishFailed();
+ }
+
+ public interface OnPostReceived {
+ void onPostReceived(Post post);
+ }
+
+ public interface OnPostRetracted {
+ void onPostRetracted(String postId);
+ void onPostRetractionFailed();
+ }
+
+ private final Set mOnPostReceivedListeners =
+ java.util.Collections.newSetFromMap(new java.util.WeakHashMap<>());
+ private final Set mOnPostRetractedListeners =
+ java.util.Collections.newSetFromMap(new java.util.WeakHashMap<>());
+
+
+ public void addOnPostReceivedListener(OnPostReceived listener) {
+ synchronized (LISTENER_LOCK) {
+ mOnPostReceivedListeners.add(listener);
+ }
+ }
+
+ public void removeOnPostReceivedListener(OnPostReceived listener) {
+ synchronized (LISTENER_LOCK) {
+ mOnPostReceivedListeners.remove(listener);
+ }
+ }
+
+ public void addOnPostRetractedListener(OnPostRetracted listener) {
+ synchronized (LISTENER_LOCK) {
+ mOnPostRetractedListeners.add(listener);
+ }
+ }
+
+ public void removeOnPostRetractedListener(OnPostRetracted listener) {
+ synchronized (LISTENER_LOCK) {
+ mOnPostRetractedListeners.remove(listener);
+ }
+ }
+
+ public void onPostReceived(Post post, Account account) {
+ if (post == null || account == null) {
+ return;
+ }
+ databaseBackend.createPost(post, account);
+ for (OnPostReceived listener : threadSafeList(mOnPostReceivedListeners)) {
+ listener.onPostReceived(post);
+ }
+ }
+
+ public void onPostRetracted(String postId) {
+ if (postId == null) {
+ return;
+ }
+ databaseBackend.deletePost(postId);
+ for (OnPostRetracted listener : threadSafeList(mOnPostRetractedListeners)) {
+ listener.onPostRetracted(postId);
+ }
+ }
+
+ public void subscribeTo(final Account account, final Jid to, final String node, final Consumer callback) {
+ final Iq iq = getIqGenerator().generateSubscriptionIq(to.asBareJid(), node, account.getJid().asBareJid());
+ sendIqPacket(account, iq, callback);
+ }
+
+ public void unsubscribeFrom(final Account account, final Jid to, final String node, final Consumer callback) {
+ final Iq iq = getIqGenerator().generateUnsubscriptionIq(to.asBareJid(), node, account.getJid().asBareJid());
+ sendIqPacket(account, iq, callback);
+ }
+
+ public interface OnCommentReceived {
+ void onCommentReceived(String originalPostUuid, Comment comment);
+ }
+
+ private final List mOnCommentReceivedListeners = new ArrayList<>();
+
+ public void addOnCommentReceivedListener(OnCommentReceived listener) {
+ synchronized (mOnCommentReceivedListeners) {
+ if (!mOnCommentReceivedListeners.contains(listener)) {
+ mOnCommentReceivedListeners.add(listener);
+ }
+ }
+ }
+
+ public void removeOnCommentReceivedListener(OnCommentReceived listener) {
+ synchronized (mOnCommentReceivedListeners) {
+ mOnCommentReceivedListeners.remove(listener);
+ }
+ }
+
+ public void notifyOnCommentReceived(String originalPostUuid, Comment comment) {
+ synchronized (mOnCommentReceivedListeners) {
+ for (OnCommentReceived listener : mOnCommentReceivedListeners) {
+ try {
+ listener.onCommentReceived(originalPostUuid, comment);
+ } catch (Exception e) {
+ Log.d(Config.LOGTAG, "safe to ignore, listener has been removed");
+ }
+ }
+ }
+ }
}
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..f1a2c3a64
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/ui/CallsActivity.java
@@ -0,0 +1,161 @@
+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.databinding.DataBindingUtil;
+
+import com.google.android.material.bottomnavigation.BottomNavigationView;
+
+import eu.siacs.conversations.R;
+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);
+ binding = DataBindingUtil.setContentView(this, R.layout.activity_calls);
+ Activities.setStatusAndNavigationBarColors(this, findViewById(android.R.id.content));
+ setSupportActionBar(binding.toolbar);
+ configureActionBar(getSupportActionBar());
+
+ callsFragment = (CallsFragment) getSupportFragmentManager().findFragmentById(R.id.fragment_container);
+ if (callsFragment == null) {
+ callsFragment = new CallsFragment();
+ getSupportFragmentManager().beginTransaction()
+ .replace(R.id.fragment_container, callsFragment)
+ .commit();
+ }
+
+ 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.feeds: {
+ Intent i = new Intent(getApplicationContext(), PostsActivity.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
+ public boolean onSupportNavigateUp() {
+ 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() {
+ // Clear missed call notifications and badge when the activity is displayed.
+ if (xmppConnectionService != null) {
+ xmppConnectionService.getNotificationService().clearMissedCalls();
+ }
+ 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() {
+ if (xmppConnectionService == null) {
+ return;
+ }
+ 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);
+
+ // 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);
+ 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);
+ }
+}
\ 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
new file mode 100644
index 000000000..3e0a07767
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/ui/CallsFragment.java
@@ -0,0 +1,256 @@
+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.TextView;
+import android.widget.Toast;
+
+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.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;
+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, CallsAdapter.OnContactClickListener, XmppConnectionService.OnCallLogUpdated {
+
+ private XmppConnectionService xmppConnectionService;
+ private RecyclerView recyclerView;
+ private TextView emptyView;
+ private CallsAdapter adapter;
+ private final List calls = new ArrayList<>();
+ private Message mPendingCall;
+ 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 final ServiceConnection mConnection = new ServiceConnection() {
+ @Override
+ public void onServiceConnected(ComponentName className, IBinder service) {
+ XmppConnectionService.XmppConnectionBinder binder = (XmppConnectionService.XmppConnectionBinder) service;
+ xmppConnectionService = binder.getService();
+ 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;
+ }
+ };
+
+ @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();
+ if (xmppConnectionService != null) {
+ xmppConnectionService.removeOnCallLogUpdatedListener(this);
+ }
+ 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()));
+ emptyView = view.findViewById(R.id.empty_view);
+ return view;
+ }
+
+ private void loadCalls() {
+ 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();
+ }
+ 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) {
+ return calls;
+ }
+ for (Conversation conversation : xmppConnectionService.getConversations()) {
+ // 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;
+ }
+
+ @Override
+ public void onCallAgainClick(Message call, boolean isVideoCall) {
+ mPendingCall = call;
+ if (isVideoCall) {
+ checkPermissionAndTriggerVideoCall();
+ } else {
+ checkPermissionAndTriggerAudioCall();
+ }
+ }
+
+ 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;
+ }
+
+ @Override
+ public void onContactClick(Contact contact) {
+ if (getActivity() instanceof XmppActivity) {
+ ((XmppActivity) getActivity()).switchToContactDetails(contact);
+ }
+ }
+
+ @Override
+ public void onCallLogUpdated() {
+ loadCalls();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/eu/siacs/conversations/ui/ContactDetailsActivity.java b/src/main/java/eu/siacs/conversations/ui/ContactDetailsActivity.java
index ef03bcc2c..5b40be884 100644
--- a/src/main/java/eu/siacs/conversations/ui/ContactDetailsActivity.java
+++ b/src/main/java/eu/siacs/conversations/ui/ContactDetailsActivity.java
@@ -1,6 +1,7 @@
package eu.siacs.conversations.ui;
import android.Manifest;
+import android.app.Activity;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.DialogInterface;
@@ -42,6 +43,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;
@@ -99,6 +101,8 @@ import eu.siacs.conversations.xmpp.Jid;
import eu.siacs.conversations.xmpp.OnKeyStatusUpdated;
import eu.siacs.conversations.xmpp.OnUpdateBlocklist;
import eu.siacs.conversations.xmpp.XmppConnection;
+import im.conversations.android.xmpp.model.stanza.Iq;
+
import java.util.Collection;
import java.util.Collections;
import java.util.List;
@@ -118,6 +122,8 @@ public class ContactDetailsActivity extends OmemoActivity
protected MenuItem save = null;
private Contact contact;
+ private MaterialSwitch mDisableCallsSwitch;
+ private MaterialSwitch mFollowFeedSwitch;
private final DialogInterface.OnClickListener removeFromRoster =
new DialogInterface.OnClickListener() {
@@ -178,6 +184,9 @@ public class ContactDetailsActivity extends OmemoActivity
}
}
};
+
+ private OnCheckedChangeListener mOnFollowFeedCheckedChange;
+
private Jid accountJid;
private Jid contactJid;
private boolean showDynamicTags = false;
@@ -309,6 +318,76 @@ public class ContactDetailsActivity extends OmemoActivity
populateView();
});
binding.addContactButton.setOnClickListener(v -> showAddToRosterDialog(contact));
+ mDisableCallsSwitch = binding.disableCalls;
+ mFollowFeedSwitch = binding.followFeedSwitch;
+
+ this.mOnFollowFeedCheckedChange =
+ (buttonView, isChecked) -> {
+ if (contact != null) {
+ // Disable the switch to show an operation is in progress
+ mFollowFeedSwitch.setEnabled(false);
+ if (isChecked) {
+ xmppConnectionService.subscribeTo(
+ contact.getAccount(),
+ contact.getJid(),
+ Namespace.MICROBLOG,
+ packet -> {
+ runOnUiThread(
+ () -> {
+ // Always re-enable the switch
+ mFollowFeedSwitch.setEnabled(true);
+ if (packet.getType() == Iq.Type.RESULT) {
+ contact.setFollowed(true);
+ xmppConnectionService.updateContact(contact);
+ setResult(Activity.RESULT_OK);
+ } else {
+ // Revert switch on failure, detaching listener to prevent loop
+ mFollowFeedSwitch.setOnCheckedChangeListener(null);
+ mFollowFeedSwitch.setChecked(false);
+ mFollowFeedSwitch.setOnCheckedChangeListener(
+ this.mOnFollowFeedCheckedChange);
+ Toast.makeText(
+ ContactDetailsActivity.this,
+ R.string
+ .error_subscribing_to_feed,
+ Toast.LENGTH_SHORT)
+ .show();
+ }
+ });
+ });
+ } else {
+ xmppConnectionService.unsubscribeFrom(
+ contact.getAccount(),
+ contact.getJid(),
+ Namespace.MICROBLOG,
+ packet -> {
+ runOnUiThread(
+ () -> {
+ // Always re-enable the switch
+ mFollowFeedSwitch.setEnabled(true);
+ if (packet.getType() == Iq.Type.RESULT) {
+ contact.setFollowed(false);
+ xmppConnectionService.updateContact(contact);
+ setResult(Activity.RESULT_OK);
+ } else {
+ // Revert switch on failure, detaching listener to prevent loop
+ mFollowFeedSwitch.setOnCheckedChangeListener(null);
+ mFollowFeedSwitch.setChecked(true);
+ mFollowFeedSwitch.setOnCheckedChangeListener(
+ this.mOnFollowFeedCheckedChange);
+ Toast.makeText(
+ ContactDetailsActivity.this,
+ R.string
+ .error_unsubscribing_from_feed,
+ Toast.LENGTH_SHORT)
+ .show();
+ }
+ });
+ });
+ }
+ }
+ };
+ mFollowFeedSwitch.setOnCheckedChangeListener(mOnFollowFeedCheckedChange);
mMediaAdapter = new MediaAdapter(this, R.dimen.media_size);
this.binding.media.setAdapter(mMediaAdapter);
@@ -603,6 +682,15 @@ 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);
+ });
+ mFollowFeedSwitch.setVisibility(View.VISIBLE);
+ mFollowFeedSwitch.setOnCheckedChangeListener(mOnFollowFeedCheckedChange);
+ mFollowFeedSwitch.setChecked(contact.isFollowed());
+
List statusMessages = contact.getPresences().getStatusMessages();
if (statusMessages.isEmpty()) {
binding.statusMessage.setVisibility(View.GONE);
@@ -685,6 +773,7 @@ public class ContactDetailsActivity extends OmemoActivity
binding.detailsSendPresence.setVisibility(View.GONE);
binding.detailsReceivePresence.setVisibility(View.GONE);
binding.statusMessage.setVisibility(View.GONE);
+ mFollowFeedSwitch.setVisibility(View.GONE);
}
if (contact.isBlocked() && !this.showDynamicTags) {
@@ -880,6 +969,9 @@ public class ContactDetailsActivity extends OmemoActivity
this.binding.recentThreadsWrapper.setVisibility(View.VISIBLE);
Util.justifyListViewHeightBasedOnChildren(binding.recentThreads);
}
+ if (contact != null) {
+ mFollowFeedSwitch.setChecked(contact.isFollowed());
+ }
}
private void onBadgeClick(final View view) {
diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java
index 6deb80e78..e6d3a5379 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 =
@@ -1270,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 {
@@ -1294,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);
@@ -1344,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());
}
@@ -1359,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());
}
@@ -1707,7 +1723,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();
@@ -6696,7 +6734,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;
}
@@ -6712,6 +6749,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();
diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java b/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java
index 67a96b506..85cf3ee14 100644
--- a/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java
+++ b/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java
@@ -253,6 +253,17 @@ 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);
+
+ // 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 -> {
@@ -1128,16 +1139,23 @@ public class ConversationsActivity extends XmppActivity
case R.id.chats -> {
return true;
}
- case R.id.contactslist -> {
- Intent i = new Intent(getApplicationContext(), StartConversationActivity.class);
+ case R.id.feeds -> {
+ Intent i = new Intent(getApplicationContext(), PostsActivity.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);
+ 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 -> {
+ 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 8f3eedc94..92094f626 100644
--- a/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java
+++ b/src/main/java/eu/siacs/conversations/ui/ConversationsOverviewFragment.java
@@ -57,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;
@@ -66,7 +66,6 @@ import eu.siacs.conversations.entities.Contact;
import eu.siacs.conversations.entities.Conversation;
import eu.siacs.conversations.entities.Conversational;
import eu.siacs.conversations.services.XmppConnectionService;
-import eu.siacs.conversations.services.QuickConversationsService;
import eu.siacs.conversations.ui.adapter.ConversationAdapter;
import eu.siacs.conversations.ui.interfaces.OnConversationArchived;
import eu.siacs.conversations.ui.interfaces.OnConversationSelected;
@@ -77,12 +76,8 @@ 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.List;
import java.util.concurrent.atomic.AtomicReference;
@@ -91,8 +86,7 @@ public class ConversationsOverviewFragment extends XmppFragment {
private static final String STATE_SCROLL_POSITION =
ConversationsOverviewFragment.class.getName() + ".scroll_state";
-
- private final List conversations = new ArrayList<>();
+ private final List conversations = new ArrayList<>();
private final PendingItem swipedConversation = new PendingItem<>();
private final PendingItem pendingScrollState = new PendingItem<>();
private FragmentConversationsOverviewBinding binding;
@@ -228,7 +222,7 @@ public class ConversationsOverviewFragment extends XmppFragment {
pendingActionHelper.push(
() -> {
- if (snackbar.isShownOrQueued()) {
+ if (snackbar.isShownOrQueued()) {
snackbar.dismiss();
}
final Conversation conversation = swipedConversation.pop();
@@ -338,7 +332,8 @@ public class ConversationsOverviewFragment extends XmppFragment {
inflater, R.layout.fragment_conversations_overview, container, false);
this.binding.fab.setOnClickListener(
(view) -> StartConversationActivity.launch(getActivity()));
-
+ this.binding.fabStartConversation.setOnClickListener(
+ (view) -> StartConversationActivity.launch(getActivity()));
this.conversationsAdapter = new ConversationAdapter(this.activity, this.conversations);
this.conversationsAdapter.setConversationClickListener(
(view, conversation) -> {
@@ -360,24 +355,29 @@ public class ConversationsOverviewFragment extends XmppFragment {
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);
-
- 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);
- }
- }
+ @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);
+ 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);
+ final MenuItem feeds = menu.findItem(R.id.action_feeds);
+ 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 (feeds != null) feeds.setVisible(false);
+ }
+ if (activity == null || activity.xmppConnectionService == null || activity.xmppConnectionService.getAccounts().size() != 1) {
+ noteToSelf.setVisible(false);
+ }
+ }
@Override
public void onCreateContextMenu(ContextMenu menu, View view, ContextMenu.ContextMenuInfo menuInfo) {
@@ -486,11 +486,13 @@ 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);
+ MenuItem feeds = menu.findItem(R.id.action_feeds);
if (navBarVisible) {
- manageAccount.setVisible(false);
- manageAccounts.setVisible(false);
+ stories.setVisible(false);
+ calls.setVisible(false);
+ feeds.setVisible(false);
} else {
AccountUtils.showHideMenuItems(menu);
}
@@ -508,9 +510,11 @@ public class ConversationsOverviewFragment extends XmppFragment {
if (showed) {
this.binding.fab.setVisibility(View.GONE);
+ this.binding.fabStartConversation.setVisibility(View.VISIBLE);
} else {
this.binding.fab.setVisibility(View.VISIBLE);
- }
+ this.binding.fabStartConversation.setVisibility(View.GONE);
+ }
}
}
@@ -520,30 +524,39 @@ public class ConversationsOverviewFragment extends XmppFragment {
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_feeds:
+ startActivity(new Intent(getActivity(), PostsActivity.class));
+ return true;
+ 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);
+ }
private void selectAccountToStartEasyInvite() {
final List accounts =
EasyOnboardingInvite.getSupportingAccounts(activity.xmppConnectionService);
@@ -578,85 +591,86 @@ 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;
+ }
+ 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);
+ binding.fabStartConversation.setVisibility(View.GONE);
- if (activity.xmppConnectionService != null && activity.xmppConnectionService.isOnboarding()) {
- binding.fab.setVisibility(View.GONE);
+ 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.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 (showed) {
+ this.binding.fab.setVisibility(View.GONE);
+ binding.fabStartConversation.setVisibility(View.VISIBLE);
+ } else {
+ this.binding.fab.setVisibility(View.VISIBLE);
+ binding.fabStartConversation.setVisibility(View.GONE);
+ }
+ }
+ }
+ if (activity.getPreferences().getBoolean("swipe_to_archive", true)) setupSwipe();
- 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();
+ });
- 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;
- }
- }
- }
+ 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 +680,5 @@ public class ConversationsOverviewFragment extends XmppFragment {
scrollPosition.position, scrollPosition.offset);
}
}
+
}
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..0f03ede40
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/ui/CreatePostActivity.java
@@ -0,0 +1,368 @@
+package eu.siacs.conversations.ui;
+
+import android.Manifest;
+import android.app.PendingIntent;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.net.Uri;
+import android.os.Bundle;
+import android.preference.PreferenceManager;
+import android.provider.MediaStore;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.WindowManager;
+import android.widget.AdapterView;
+import android.widget.ArrayAdapter;
+import android.widget.Toast;
+
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
+import androidx.core.content.ContextCompat;
+import androidx.databinding.DataBindingUtil;
+
+import com.bumptech.glide.Glide;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import eu.siacs.conversations.R;
+import eu.siacs.conversations.databinding.ActivityCreatePostBinding;
+import eu.siacs.conversations.entities.Account;
+import eu.siacs.conversations.services.XmppConnectionService;
+import eu.siacs.conversations.xml.Namespace;
+
+public class CreatePostActivity extends XmppActivity {
+
+ private ActivityCreatePostBinding binding;
+ private String inReplyToId;
+ private String inReplyToNode;
+ private String postId;
+ private Uri attachmentUri;
+ private String attachmentType;
+ private Uri mCameraUri;
+ private String accountUuid;
+ private String postTitle;
+ private String postContent;
+
+ private List onlineAccounts = new ArrayList<>();
+
+ private final ActivityResultLauncher requestPermissionLauncher =
+ registerForActivityResult(new ActivityResultContracts.RequestPermission(), isGranted -> {
+ if (isGranted) {
+ openCamera();
+ } else {
+ Toast.makeText(this, R.string.no_camera_permission, Toast.LENGTH_SHORT).show();
+ }
+ });
+
+ private final ActivityResultLauncher takePictureLauncher =
+ registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> {
+ if (result.getResultCode() == RESULT_OK) {
+ attachmentUri = mCameraUri;
+ attachmentType = "image/jpeg";
+ binding.attachmentPreview.setImageURI(attachmentUri);
+ binding.attachmentPreview.setVisibility(View.VISIBLE);
+ binding.attachmentVideoView.setVisibility(View.GONE);
+ }
+ });
+
+ private final ActivityResultLauncher takeVideoLauncher =
+ registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> {
+ if (result.getResultCode() == RESULT_OK) {
+ attachmentUri = mCameraUri;
+ attachmentType = "video/mp4";
+ binding.attachmentVideoView.setVideoURI(attachmentUri);
+ binding.attachmentVideoView.setOnPreparedListener(mp -> {
+ mp.setLooping(true);
+ binding.attachmentVideoView.start();
+ });
+ binding.attachmentVideoView.setVisibility(View.VISIBLE);
+ binding.attachmentPreview.setVisibility(View.GONE);
+ }
+ });
+
+ private final ActivityResultLauncher attachFileLauncher = registerForActivityResult(
+ new ActivityResultContracts.GetContent(),
+ uri -> {
+ if (uri != null) {
+ this.attachmentUri = uri;
+ this.attachmentType = getContentResolver().getType(attachmentUri);
+ if (this.attachmentType != null && this.attachmentType.startsWith("image/")) {
+ binding.attachmentPreview.setImageURI(attachmentUri);
+ binding.attachmentPreview.setVisibility(View.VISIBLE);
+ binding.attachmentVideoView.setVisibility(View.GONE);
+ } else if (this.attachmentType != null && this.attachmentType.startsWith("video/")) {
+ binding.attachmentVideoView.setVideoURI(attachmentUri);
+ binding.attachmentVideoView.setOnPreparedListener(mp -> {
+ mp.setLooping(true);
+ binding.attachmentVideoView.start();
+ });
+ binding.attachmentVideoView.setVisibility(View.VISIBLE);
+ binding.attachmentPreview.setVisibility(View.GONE);
+ }
+ }
+ }
+ );
+
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ binding = DataBindingUtil.setContentView(this, R.layout.activity_create_post);
+ Activities.setStatusAndNavigationBarColors(this, binding.getRoot());
+ setSupportActionBar(binding.toolbar);
+ configureActionBar(getSupportActionBar());
+ if (getSupportActionBar() != null) {
+ getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+ }
+ getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);
+ inReplyToId = getIntent().getStringExtra("in_reply_to_id");
+ inReplyToNode = getIntent().getStringExtra("in_reply_to_node");
+ postId = getIntent().getStringExtra("post_id");
+ postTitle = getIntent().getStringExtra("post_title");
+ postContent = getIntent().getStringExtra("post_content");
+ accountUuid = getIntent().getStringExtra("account");
+ if (inReplyToNode != null) {
+ setTitle(R.string.add_a_comment);
+ binding.postTitleEditText.setVisibility(View.GONE);
+ binding.postContentEditText.setHint(R.string.comment);
+ binding.attachFileButton.setVisibility(View.GONE);
+ binding.attachImageButton.setVisibility(View.GONE);
+ binding.attachVideoButton.setVisibility(View.GONE);
+ binding.postPreview.setVisibility(View.VISIBLE);
+ binding.postPreviewTitle.setText(postTitle);
+ binding.postPreviewContent.setText(postContent);
+ } else {
+ binding.postTitleEditText.setVisibility(View.VISIBLE);
+ binding.postContentEditText.setHint(R.string.post_content);
+ }
+ if (postId != null) {
+ binding.postTitleEditText.setText(getIntent().getStringExtra("title"));
+ binding.postContentEditText.setText(getIntent().getStringExtra("content"));
+ if (accountUuid != null) {
+ binding.accountSpinner.setVisibility(View.GONE);
+ }
+ final String attachmentUrl = getIntent().getStringExtra("attachment_url");
+ this.attachmentType = getIntent().getStringExtra("attachment_type");
+ if (attachmentUrl != null && this.attachmentType != null) {
+ this.attachmentUri = Uri.parse(attachmentUrl);
+ if (this.attachmentType.startsWith("image/")) {
+ binding.attachmentPreview.setVisibility(View.VISIBLE);
+ Glide.with(this).load(attachmentUrl).into(binding.attachmentPreview);
+ } else if (this.attachmentType.startsWith("video/")) {
+ binding.attachmentVideoView.setVisibility(View.VISIBLE);
+ binding.attachmentVideoView.setVideoURI(Uri.parse(attachmentUrl));
+ binding.attachmentVideoView.setOnPreparedListener(mp -> {
+ mp.setLooping(true);
+ binding.attachmentVideoView.start();
+ });
+ }
+ }
+ }
+
+ binding.publishButton.setOnClickListener(v -> publishPost());
+ binding.attachFileButton.setOnClickListener(v -> attachFileLauncher.launch("*/*"));
+ binding.attachImageButton.setOnClickListener(v -> {
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
+ openCamera();
+ } else {
+ requestPermissionLauncher.launch(Manifest.permission.CAMERA);
+ }
+ });
+ binding.attachVideoButton.setOnClickListener(v -> {
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
+ openVideoCamera();
+ } else {
+ requestPermissionLauncher.launch(Manifest.permission.CAMERA);
+ }
+ });
+ }
+
+ private void openCamera() {
+ if (xmppConnectionService == null) {
+ Toast.makeText(this, R.string.not_connected_try_again, Toast.LENGTH_SHORT).show();
+ return;
+ }
+ mCameraUri = xmppConnectionService.getFileBackend().getTakePhotoUri();
+ Intent takePictureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
+ takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, mCameraUri);
+ takePictureLauncher.launch(takePictureIntent);
+ }
+
+ private void openVideoCamera() {
+ if (xmppConnectionService == null) {
+ Toast.makeText(this, R.string.not_connected_try_again, Toast.LENGTH_SHORT).show();
+ return;
+ }
+ mCameraUri = xmppConnectionService.getFileBackend().getTakeVideoUri();
+ Intent takeVideoIntent = new Intent(MediaStore.ACTION_VIDEO_CAPTURE);
+ takeVideoIntent.putExtra(MediaStore.EXTRA_OUTPUT, mCameraUri);
+ takeVideoLauncher.launch(takeVideoIntent);
+ }
+
+ @Override
+ protected void refreshUiReal() {
+
+ }
+
+ @Override
+ public void onBackendConnected() {
+ if (xmppConnectionService != null && accountUuid == null) {
+ onlineAccounts.clear();
+ List accountJids = new ArrayList<>();
+ for (Account account : xmppConnectionService.getAccounts()) {
+ if (account.isOnlineAndConnected()) {
+ onlineAccounts.add(account);
+ accountJids.add(account.getJid().asBareJid().toString());
+ }
+ }
+ ArrayAdapter adapter = new ArrayAdapter<>(this, android.R.layout.simple_spinner_item, accountJids);
+ adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+ binding.accountSpinner.setAdapter(adapter);
+
+ int persistedPosition = getPersistedItem();
+ if (!onlineAccounts.isEmpty() && persistedPosition < onlineAccounts.size()) {
+ binding.accountSpinner.setSelection(persistedPosition);
+ }
+
+ binding.accountSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
+ @Override
+ public void onItemSelected(AdapterView> parentView, View view, int position, long itemId) {
+ setPersistedItem(position);
+ }
+ @Override
+ public void onNothingSelected(AdapterView> arg0) {
+ // Do nothing
+ }
+ });
+ }
+ }
+
+ private void publishPost() {
+ String title = binding.postTitleEditText.getText().toString();
+ String content = binding.postContentEditText.getText().toString();
+
+ if (title.isEmpty() && content.isEmpty() && attachmentUri == null) {
+ Toast.makeText(this, R.string.title_or_content_or_attachment_required, Toast.LENGTH_SHORT).show();
+ return;
+ }
+
+ if (xmppConnectionService != null) {
+ Account selectedAccount;
+ if (accountUuid != null) {
+ selectedAccount = xmppConnectionService.findAccountByUuid(accountUuid);
+ if (selectedAccount == null) {
+ Toast.makeText(this, getString(R.string.account_not_found), Toast.LENGTH_SHORT).show();
+ return;
+ }
+ } else {
+ if (binding.accountSpinner.getSelectedItemPosition() < 0 || binding.accountSpinner.getSelectedItemPosition() >= onlineAccounts.size()) {
+ Toast.makeText(this, R.string.no_active_account, Toast.LENGTH_SHORT).show();
+ return;
+ }
+ selectedAccount = onlineAccounts.get(binding.accountSpinner.getSelectedItemPosition());
+ }
+
+ if (!selectedAccount.isOnlineAndConnected()) {
+ Toast.makeText(this, R.string.account_not_connected, Toast.LENGTH_SHORT).show();
+ return;
+ }
+
+ if (inReplyToNode != null) {
+ xmppConnectionService.publishComment(selectedAccount, inReplyToNode, content, new XmppConnectionService.OnPostPublished() {
+ @Override
+ public void onPostPublished() {
+ runOnUiThread(() -> {
+ Toast.makeText(CreatePostActivity.this, R.string.comment_published, Toast.LENGTH_SHORT).show();
+ setResult(RESULT_OK);
+ finish();
+ });
+ }
+
+ @Override
+ public void onPostPublishFailed() {
+ runOnUiThread(() -> {
+ Toast.makeText(CreatePostActivity.this, R.string.error_publish_comment, Toast.LENGTH_SHORT).show();
+ });
+ }
+ });
+ } else if (attachmentUri != null) {
+ final String mimeType = this.attachmentType != null ? this.attachmentType : getContentResolver().getType(attachmentUri);
+ final String scheme = attachmentUri.getScheme();
+ if (scheme != null && (scheme.equals("http") || scheme.equals("https"))) {
+ publish(selectedAccount, title, content, attachmentUri.toString(), mimeType);
+ } else {
+ Toast.makeText(this, R.string.uploading_attachment, Toast.LENGTH_SHORT).show();
+ setResult(RESULT_OK);
+ finish();
+ xmppConnectionService.uploadFileForUrl(selectedAccount, attachmentUri, mimeType, new UiCallback() {
+ @Override
+ public void success(String url) {
+ publish(selectedAccount, title, content, url, mimeType);
+ }
+
+ @Override
+ public void error(int errorCode, String object) {
+ runOnUiThread(() -> Toast.makeText(CreatePostActivity.this, errorCode, Toast.LENGTH_SHORT).show());
+ }
+
+ @Override
+ public void userInputRequired(PendingIntent pi, String object) {
+
+ }
+ });
+ }
+ } else {
+ publish(selectedAccount, title, content, null, null);
+ }
+ }
+ }
+
+ private void publish(Account account, String title, String content, String attachmentUrl, String attachmentType) {
+ xmppConnectionService.publishPost(account, title, content, attachmentUrl, attachmentType, postId, new XmppConnectionService.OnPostPublished() {
+ @Override
+ public void onPostPublished() {
+ runOnUiThread(() -> {
+ Toast.makeText(CreatePostActivity.this, R.string.post_published, Toast.LENGTH_SHORT).show();
+ setResult(RESULT_OK);
+ finish();
+ });
+ }
+
+ @Override
+ public void onPostPublishFailed() {
+ runOnUiThread(() -> {
+ Toast.makeText(CreatePostActivity.this, R.string.error_publish_post, Toast.LENGTH_SHORT).show();
+ });
+ }
+ });
+ }
+
+ private int getPersistedItem() {
+ return PreferenceManager.getDefaultSharedPreferences(this).getInt(makePersistedItemKeyName(), 0);
+ }
+
+ protected void setPersistedItem(int position) {
+ PreferenceManager.getDefaultSharedPreferences(this).edit().putInt(makePersistedItemKeyName(), position).apply();
+ }
+
+ private String makePersistedItemKeyName() {
+ return "create_post_selected_account_position";
+ }
+
+
+ @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/EnterJidDialog.java b/src/main/java/eu/siacs/conversations/ui/EnterJidDialog.java
index 037696ee4..d024d98a9 100644
--- a/src/main/java/eu/siacs/conversations/ui/EnterJidDialog.java
+++ b/src/main/java/eu/siacs/conversations/ui/EnterJidDialog.java
@@ -294,6 +294,7 @@ public class EnterJidDialog extends DialogFragment implements OnBackendConnected
if (mListener != null) {
try {
if (mListener.onEnterJidDialogPositive(accountJid, contactJid, secondary, binding.bookmark.isChecked())) {
+ context.setResult(Activity.RESULT_OK);
dialog.dismiss();
}
} catch (JidError error) {
diff --git a/src/main/java/eu/siacs/conversations/ui/OnSearchPerformed.java b/src/main/java/eu/siacs/conversations/ui/OnSearchPerformed.java
new file mode 100644
index 000000000..ce01bc4ea
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/ui/OnSearchPerformed.java
@@ -0,0 +1,5 @@
+package eu.siacs.conversations.ui;
+
+public interface OnSearchPerformed {
+ void onSearchPerformed(String query);
+}
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..f5e78b43c
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/ui/PostsActivity.java
@@ -0,0 +1,448 @@
+package eu.siacs.conversations.ui;
+
+import static android.view.View.VISIBLE;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.graphics.Color;
+import android.os.Bundle;
+import android.util.Log;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.Toast;
+
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
+import androidx.appcompat.app.ActionBar;
+import androidx.appcompat.widget.SearchView;
+import androidx.databinding.DataBindingUtil;
+import androidx.recyclerview.widget.LinearLayoutManager;
+
+import com.google.android.material.bottomnavigation.BottomNavigationView;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
+
+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.Contact;
+import eu.siacs.conversations.entities.Post;
+import eu.siacs.conversations.services.XmppConnectionService;
+import eu.siacs.conversations.ui.adapter.FollowSuggestionAdapter;
+import eu.siacs.conversations.ui.adapter.PostsAdapter;
+import eu.siacs.conversations.xml.Element;
+import eu.siacs.conversations.xml.Namespace;
+import eu.siacs.conversations.xml.XmlReader;
+import eu.siacs.conversations.xmpp.Jid;
+
+public class PostsActivity extends XmppActivity implements XmppConnectionService.OnPostReceived, XmppConnectionService.OnPostRetracted, OnSearchPerformed {
+
+ private ActivityPostsBinding binding;
+ private PostsAdapter postsAdapter;
+ private List postList = new ArrayList<>();
+ private List allPosts = new ArrayList<>();
+ private String mCurrentQuery = "";
+ private SearchView mSearchView;
+
+ private FollowSuggestionAdapter mFollowSuggestionAdapter;
+ private List mFollowSuggestions = new ArrayList<>();
+ private boolean mSuggestionsVisible = true;
+
+ private final ActivityResultLauncher postResultLauncher = registerForActivityResult(
+ new ActivityResultContracts.StartActivityForResult(),
+ result -> {
+ if (result.getResultCode() == Activity.RESULT_OK) {
+ loadPosts();
+ }
+ });
+
+ private void toggleSuggestionsVisibility() {
+ mSuggestionsVisible = !mSuggestionsVisible;
+ binding.followSuggestionsList.setVisibility(mSuggestionsVisible ? View.VISIBLE : View.GONE);
+ binding.toggleSuggestionsButton.animate().rotation(mSuggestionsVisible ? -180 : 0).setDuration(300).start();
+ getPreferences(MODE_PRIVATE).edit().putBoolean("suggestions_visible", mSuggestionsVisible).apply();
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ mSuggestionsVisible = getPreferences(MODE_PRIVATE).getBoolean("suggestions_visible", true);
+ binding = DataBindingUtil.setContentView(this, R.layout.activity_posts);
+ Activities.setStatusAndNavigationBarColors(this, binding.getRoot());
+ setSupportActionBar(binding.toolbar);
+ configureActionBar(getSupportActionBar());
+
+ binding.postsList.setLayoutManager(new LinearLayoutManager(this));
+ postsAdapter = new PostsAdapter(this, postList, postResultLauncher, this);
+ binding.postsList.setAdapter(postsAdapter);
+
+ binding.fabCreatePost.setOnClickListener(v -> {
+ Intent intent = new Intent(this, CreatePostActivity.class);
+ postResultLauncher.launch(intent);
+ });
+
+ binding.swipeContainer.setOnRefreshListener(() -> {
+ loadPosts();
+ binding.swipeContainer.setRefreshing(false);
+ });
+
+ binding.followSuggestionsHeader.setOnClickListener(v -> toggleSuggestionsVisibility());
+ binding.toggleSuggestionsButton.setOnClickListener(v -> toggleSuggestionsVisibility());
+
+ 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.feeds: {
+ 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: {
+ Intent i = new Intent(getApplicationContext(), CallsActivity.class);
+ 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());
+ }
+ });
+
+ handleIntent(getIntent());
+ }
+
+ @Override
+ public void onStart() {
+ super.onStart();
+ if (xmppConnectionService != null) {
+ xmppConnectionService.addOnPostReceivedListener(this);
+ xmppConnectionService.addOnPostRetractedListener(this);
+ }
+ if (postList.isEmpty()) {
+ loadPosts();
+ }
+ BottomNavigationView bottomNavigationView=findViewById(R.id.bottom_navigation);
+ bottomNavigationView.setSelectedItemId(R.id.feeds);
+
+ 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 onStop() {
+ super.onStop();
+ if (xmppConnectionService != null) {
+ xmppConnectionService.removeOnPostReceivedListener(this);
+ xmppConnectionService.removeOnPostRetractedListener(this);
+ }
+ }
+
+ @Override
+ public void onBackendConnected() {
+ if (xmppConnectionService != null) {
+ xmppConnectionService.addOnPostReceivedListener(this);
+ xmppConnectionService.addOnPostRetractedListener(this);
+ if (mFollowSuggestionAdapter == null) {
+ mFollowSuggestionAdapter = new FollowSuggestionAdapter(this, xmppConnectionService, mFollowSuggestions);
+ binding.followSuggestionsList.setAdapter(mFollowSuggestionAdapter);
+ }
+ }
+ refreshUiReal();
+ }
+
+ @Override
+ public void onPostReceived(final Post post) {
+ runOnUiThread(() -> {
+ if (post == null || post.getId() == null) {
+ return;
+ }
+
+ int existingPostIndex = -1;
+ for (int i = 0; i < allPosts.size(); i++) {
+ Post existingPost = allPosts.get(i);
+ if (post.getId().equals(existingPost.getId())) {
+ existingPostIndex = i;
+ break;
+ }
+ }
+
+ if (existingPostIndex != -1) {
+ allPosts.set(existingPostIndex, post);
+ } else {
+ allPosts.add(0, post);
+ }
+ filterAndDisplayPosts(mCurrentQuery);
+ });
+ }
+
+ @Override
+ protected void refreshUiReal() {
+ if (postList.isEmpty()) {
+ loadPosts();
+ }
+
+ // 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);
+
+ // Show badge for missed calls in bottom nav
+ boolean hasNewMissedCalls = xmppConnectionService.getNotificationService().hasNewMissedCalls();
+ var callsBadge = bottomnav.getOrCreateBadge(R.id.calls);
+ callsBadge.setVisible(hasNewMissedCalls);
+ ActionBar actionBar = getSupportActionBar();
+ boolean showNavBar = binding.bottomNavigation.getVisibility() == VISIBLE;
+ if (actionBar != null) {
+ actionBar.setHomeButtonEnabled(!showNavBar);
+ actionBar.setDisplayHomeAsUpEnabled(!showNavBar);
+ }
+ }
+
+ public void loadPosts() {
+ if (xmppConnectionService == null) {
+ return;
+ }
+ allPosts.clear();
+ allPosts.addAll(xmppConnectionService.databaseBackend.getPosts());
+ filterAndDisplayPosts(mCurrentQuery);
+
+ mFollowSuggestions.clear();
+ for(final Account account : xmppConnectionService.getAccounts()) {
+ if(account.isOnlineAndConnected()) {
+ final List sourcesToFetch = new ArrayList<>();
+ sourcesToFetch.add(account.getJid().asBareJid());
+ for (Contact contact : account.getRoster().getContacts()) {
+ if (contact.isFollowed()) {
+ sourcesToFetch.add(contact.getJid().asBareJid());
+ } else if (contact.showInRoster()) {
+ mFollowSuggestions.add(contact);
+ }
+ }
+
+ if (mFollowSuggestions.isEmpty()) {
+ binding.followSuggestionsHeader.setVisibility(View.GONE);
+ binding.followSuggestionsList.setVisibility(View.GONE);
+ } else {
+ binding.followSuggestionsHeader.setVisibility(View.VISIBLE);
+ binding.followSuggestionsList.setVisibility(mSuggestionsVisible ? View.VISIBLE : View.GONE);
+ binding.toggleSuggestionsButton.setRotation(mSuggestionsVisible ? -180 : 0);
+ }
+ if (mFollowSuggestionAdapter != null) {
+ mFollowSuggestionAdapter.notifyDataSetChanged();
+ }
+
+ for (Jid source : sourcesToFetch) {
+ xmppConnectionService.fetchPubsubItems(source, Namespace.MICROBLOG, 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 newPosts = new ArrayList<>();
+ if (pubsub != null) {
+ 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) {
+ Post p = Post.fromElement(item);
+ newPosts.add(p);
+ xmppConnectionService.databaseBackend.createPost(p, account);
+ }
+ }
+ }
+ runOnUiThread(() -> {
+ for(Post newPost : newPosts) {
+ boolean found = false;
+ for(Post existingPost : allPosts) {
+ if (existingPost.getId() != null && existingPost.getId().equals(newPost.getId())) {
+ found = true;
+ break;
+ }
+ }
+ if (!found) {
+ allPosts.add(newPost);
+ }
+ }
+ filterAndDisplayPosts(mCurrentQuery);
+ });
+ }
+ }
+ } catch (Exception 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() {
+ // Silently ignore for now
+ }
+ });
+ }
+ }
+ }
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ getMenuInflater().inflate(R.menu.activity_posts, menu);
+ MenuItem searchItem = menu.findItem(R.id.action_search);
+ mSearchView = (SearchView) searchItem.getActionView();
+ mSearchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() {
+ @Override
+ public boolean onQueryTextSubmit(String query) {
+ filterAndDisplayPosts(query);
+ return true;
+ }
+
+ @Override
+ public boolean onQueryTextChange(String newText) {
+ filterAndDisplayPosts(newText);
+ return true;
+ }
+ });
+ searchItem.setOnActionExpandListener(new MenuItem.OnActionExpandListener() {
+ @Override
+ public boolean onMenuItemActionExpand(MenuItem item) {
+ return true;
+ }
+
+ @Override
+ public boolean onMenuItemActionCollapse(MenuItem item) {
+ filterAndDisplayPosts("");
+ return true;
+ }
+ });
+ 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) {
+ xmppConnectionService.databaseBackend.clearPosts();
+ postList.clear();
+ postsAdapter.notifyDataSetChanged();
+ loadPosts();
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ @Override
+ public void onBackPressed() {
+ if (mSearchView != null && !mSearchView.isIconified()) {
+ mSearchView.setIconified(true);
+ return;
+ }
+ if (mCurrentQuery != null && !mCurrentQuery.isEmpty()) {
+ filterAndDisplayPosts("");
+ return;
+ }
+
+ 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 onPostRetracted(final String postId) {
+ runOnUiThread(() -> {
+ if (postId == null) {
+ return;
+ }
+ allPosts.removeIf(p -> p.getId().equals(postId));
+ filterAndDisplayPosts(mCurrentQuery);
+ });
+ }
+
+ @Override
+ public void onPostRetractionFailed() {
+ runOnUiThread(() -> Toast.makeText(this, R.string.error_retract_post, Toast.LENGTH_SHORT).show());
+ }
+
+ @Override
+ public void onSearchPerformed(String query) {
+ if (mSearchView != null) {
+ mSearchView.setIconified(false);
+ mSearchView.setQuery(query, true);
+ }
+ }
+
+ private void filterAndDisplayPosts(String query) {
+ this.mCurrentQuery = query;
+ postList.clear();
+ if (query.isEmpty()) {
+ postList.addAll(allPosts);
+ } else {
+ postList.addAll(allPosts.stream().filter(p -> (p.getContent() != null && p.getContent().contains(query)) || (p.getTitle() != null && p.getTitle().contains(query))).collect(Collectors.toList()));
+ }
+ java.util.Collections.sort(postList, (p1, p2) -> {
+ if (p1.getPublished() == null && p2.getPublished() == null) return 0;
+ if (p1.getPublished() == null) return 1;
+ if (p2.getPublished() == null) return -1;
+ return p2.getPublished().compareTo(p1.getPublished());
+ });
+ postsAdapter.notifyDataSetChanged();
+ }
+
+ private void handleIntent(Intent intent) {
+ if (intent != null) {
+ final String postUuid = intent.getStringExtra("post_uuid");
+ if (postUuid != null) {
+ //This is a rough search. A better way would be to scroll to the item
+ filterAndDisplayPosts(postUuid);
+ }
+ final String hashtag = intent.getStringExtra("hashtag");
+ if (hashtag != null) {
+ onSearchPerformed(hashtag);
+ }
+ }
+ }
+
+ @Override
+ protected void onNewIntent(Intent intent) {
+ super.onNewIntent(intent);
+ handleIntent(intent);
+ }
+}
diff --git a/src/main/java/eu/siacs/conversations/ui/StartConversationActivity.java b/src/main/java/eu/siacs/conversations/ui/StartConversationActivity.java
index f881fb6b3..b58701e6c 100644
--- a/src/main/java/eu/siacs/conversations/ui/StartConversationActivity.java
+++ b/src/main/java/eu/siacs/conversations/ui/StartConversationActivity.java
@@ -1,8 +1,5 @@
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.annotation.SuppressLint;
import android.app.Dialog;
@@ -13,7 +10,6 @@ import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.content.res.ColorStateList;
-import android.graphics.Color;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
@@ -60,9 +56,6 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import androidx.viewpager.widget.PagerAdapter;
import androidx.viewpager.widget.ViewPager;
-import de.monocles.chat.FinishOnboarding;
-
-import com.google.android.material.bottomnavigation.BottomNavigationView;
import com.google.android.material.color.MaterialColors;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.google.android.material.textfield.TextInputLayout;
@@ -439,30 +432,6 @@ public class StartConversationActivity extends XmppActivity
return false;
});
- 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 -> {
- 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());
- }
- });
mRequestedContactsPermission.set(savedInstanceState != null && savedInstanceState.getBoolean("requested_contacts_permission", false));
mOpenedFab.set(savedInstanceState != null && savedInstanceState.getBoolean("opened_fab", false));
binding.speedDial.setOnActionSelectedListener(actionItem -> {
@@ -565,15 +534,6 @@ public class StartConversationActivity extends XmppActivity
}
requestNotificationPermissionIfNeeded();
}
-
- BottomNavigationView bottomNavigationView=findViewById(R.id.bottom_navigation);
- bottomNavigationView.setSelectedItemId(R.id.contactslist);
-
- if (getBooleanPreference("show_nav_bar", R.bool.show_nav_bar) && getIntent().getBooleanExtra("show_nav_bar", false)) {
- bottomNavigationView.setVisibility(VISIBLE);
- } else {
- bottomNavigationView.setVisibility(View.GONE);
- }
}
private void requestNotificationPermissionIfNeeded() {
@@ -965,16 +925,10 @@ public class StartConversationActivity extends XmppActivity
@Override
public boolean onPrepareOptionsMenu(Menu menu) {
boolean res = super.onPrepareOptionsMenu(menu);
- boolean navBarVisible = binding.bottomNavigation.getVisibility() == VISIBLE;
MenuItem manageAccount = menu.findItem(R.id.action_account);
MenuItem manageAccounts = menu.findItem(R.id.action_accounts);
MenuItem noteToSelf = menu.findItem(R.id.action_note_to_self);
- if (navBarVisible) {
- manageAccount.setVisible(false);
- manageAccounts.setVisible(false);
- } else {
- AccountUtils.showHideMenuItems(menu);
- }
+ AccountUtils.showHideMenuItems(menu);
if (xmppConnectionService != null && xmppConnectionService.getAccounts().size() != 1) {
noteToSelf.setVisible(false);
@@ -1199,17 +1153,9 @@ public class StartConversationActivity extends XmppActivity
}
boolean openConversations =
!createdByViewIntent && !xmppConnectionService.isConversationsListEmpty(null);
- boolean showNavBar = binding.bottomNavigation.getVisibility() == VISIBLE;
- actionBar.setDisplayHomeAsUpEnabled(openConversations && !showNavBar);
- actionBar.setDisplayHomeAsUpEnabled(openConversations && !showNavBar);
- // 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);
+ actionBar.setDisplayHomeAsUpEnabled(openConversations);
+ actionBar.setDisplayHomeAsUpEnabled(openConversations);
}
@Override
@@ -1543,9 +1489,6 @@ public class StartConversationActivity extends XmppActivity
Intent intent = new Intent(this, ConversationsActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION);
startActivity(intent);
- if (binding.bottomNavigation.getVisibility() == VISIBLE) {
- overridePendingTransition(R.animator.fade_in, R.animator.fade_out);
- }
}
finish();
}
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..0c2978605
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/ui/StoriesActivity.java
@@ -0,0 +1,456 @@
+package eu.siacs.conversations.ui;
+
+import static android.view.View.VISIBLE;
+
+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.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.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;
+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.Contact;
+import eu.siacs.conversations.entities.Story;
+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;
+
+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 static final int REQUEST_EDIT_STORY_IMAGE = 0x2b04;
+ 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);
+ Activities.setStatusAndNavigationBarColors(this, findViewById(android.R.id.content));
+ 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.feeds:
+ Intent i = new Intent(getApplicationContext(), PostsActivity.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.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());
+ }
+ });
+ }
+
+ @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);
+ getPreferences().edit().putLong("last_read_story_timestamp", System.currentTimeMillis()).apply();
+ 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);
+ }
+
+ private void publish(Uri uri, String mimeType) {
+ 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);
+
+ 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);
+ 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
+ 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 {
+ return;
+ }
+ if (mSelectedAccount != null) {
+ String mimeType = getContentResolver().getType(uri);
+ if (mimeType != null && mimeType.startsWith("video/")) {
+ 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);
+ 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) {
+ String mimeType = data.getType();
+ if (mimeType == null) {
+ mimeType = getContentResolver().getType(uri);
+ }
+ if (mimeType == null) {
+ mimeType = "image/jpeg";
+ }
+ publish(uri, mimeType);
+ }
+ }
+ } else {
+ pendingTakePhotoUri.pop();
+ }
+ }
+
+ private void selectAccountToPublishStory() {
+ 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) {
+ 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/*,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();
+ 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, videoIntent});
+
+ 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);
+
+ // 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);
+
+ // 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);
+ actionBar.setDisplayHomeAsUpEnabled(!showNavBar);
+ }
+ refresh();
+ }
+
+ private void refresh() {
+ if (xmppConnectionService == null) {
+ 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,
+ (a, b) -> a.getPublished() > b.getPublished() ? a : b
+ ))
+ .values()
+ );
+ Collections.sort(this.stories, (a, b) -> Long.compare(b.getPublished(), a.getPublished()));
+ 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
+ public void onStoriesUpdate() {
+ runOnUiThread(this::refresh);
+ }
+}
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..10525429b
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/ui/StoryFragment.java
@@ -0,0 +1,296 @@
+package eu.siacs.conversations.ui;
+
+import android.animation.Animator;
+import android.animation.AnimatorListenerAdapter;
+import android.animation.ObjectAnimator;
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.media.MediaPlayer;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.view.LayoutInflater;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.ProgressBar;
+import android.widget.Toast;
+import android.widget.VideoView;
+
+import androidx.annotation.NonNull;
+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.util.ArrayList;
+
+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";
+ private static final long STORY_DURATION_MS = 6000;
+
+ private OnStoryInteractionListener mListener;
+ private VideoView videoView;
+ private ObjectAnimator currentAnimator;
+ private LinearLayout progressBarContainer;
+ private ArrayList urls;
+ private final Handler videoProgressHandler = new Handler(Looper.getMainLooper());
+ private Runnable videoProgressRunnable;
+
+ public interface OnStoryInteractionListener {
+ void onNextStory();
+
+ void pauseStory();
+
+ void resumeStory();
+ }
+
+ public static StoryFragment newInstance(String url, String mimeType, ArrayList urls) {
+ StoryFragment fragment = new StoryFragment();
+ Bundle args = new Bundle();
+ args.putString(ARG_URL, url);
+ args.putString(ARG_MIME_TYPE, mimeType);
+ args.putStringArrayList("urls", urls);
+ fragment.setArguments(args);
+ return fragment;
+ }
+
+ @Override
+ public void onAttach(@NonNull Context context) {
+ super.onAttach(context);
+ if (context instanceof OnStoryInteractionListener) {
+ mListener = (OnStoryInteractionListener) context;
+ } else {
+ throw new RuntimeException(context.toString() + " must implement OnStoryInteractionListener");
+ }
+ }
+
+ @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);
+
+ view.setOnTouchListener((v, event) -> {
+ switch (event.getAction()) {
+ case MotionEvent.ACTION_DOWN:
+ pauseStory();
+ return true;
+ case MotionEvent.ACTION_UP:
+ resumeStory();
+ return true;
+ }
+ return false;
+ });
+
+ progressBarContainer = view.findViewById(R.id.progress_bar_container);
+ if (getArguments() != null) {
+ urls = getArguments().getStringArrayList("urls");
+ }
+ setupProgressBars();
+ loadStory();
+ }
+
+ private void setupProgressBars() {
+ if (urls == null) {
+ return;
+ }
+ progressBarContainer.removeAllViews();
+ for (int i = 0; i < urls.size(); i++) {
+ ProgressBar progressBar = (ProgressBar) LayoutInflater.from(getContext()).inflate(R.layout.story_progress_bar, progressBarContainer, false);
+ progressBar.setMax(1000);
+ LinearLayout.LayoutParams params = (LinearLayout.LayoutParams) progressBar.getLayoutParams();
+ params.weight = 1;
+ progressBarContainer.addView(progressBar);
+ }
+ }
+
+ public void loadStory() {
+ if (!isAdded()) {
+ return;
+ }
+
+ final View view = getView();
+ if (view == null) {
+ return;
+ }
+
+ final ImageView imageView = view.findViewById(R.id.story_image_view);
+ 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);
+
+ progressBar.setVisibility(View.VISIBLE);
+
+ final int currentPosition = urls.indexOf(url);
+
+ for (int i = 0; i < currentPosition; i++) {
+ ((ProgressBar) progressBarContainer.getChildAt(i)).setProgress(1000);
+ }
+ for (int i = currentPosition; i < urls.size(); i++) {
+ ((ProgressBar) progressBarContainer.getChildAt(i)).setProgress(0);
+ }
+
+
+ if (mimeType != null && mimeType.startsWith("video/")) {
+ imageView.setVisibility(View.GONE);
+ videoView.setVisibility(View.VISIBLE);
+ videoView.setOnCompletionListener(mp -> {
+ if (mListener != null) {
+ mListener.onNextStory();
+ }
+ });
+ Glide.with(this)
+ .asFile()
+ .load(url)
+ .into(new CustomTarget() {
+ @Override
+ public void onResourceReady(@NonNull File resource, @Nullable Transition super File> transition) {
+ if (!isAdded()) return;
+ videoView.setVideoURI(Uri.fromFile(resource));
+ videoView.setOnPreparedListener(mp -> {
+ progressBar.setVisibility(View.GONE);
+ mp.setLooping(false);
+ videoView.start();
+ final int duration = mp.getDuration();
+ final ProgressBar currentStoryProgressBar = (ProgressBar) progressBarContainer.getChildAt(currentPosition);
+ if (currentStoryProgressBar != null) {
+ videoProgressRunnable = new Runnable() {
+ @Override
+ public void run() {
+ if (videoView != null && videoView.isPlaying()) {
+ int currentPosition = videoView.getCurrentPosition();
+ int progress = (int) (((float) currentPosition / duration) * 1000);
+ currentStoryProgressBar.setProgress(progress);
+ videoProgressHandler.postDelayed(this, 50);
+ }
+ }
+ };
+ videoProgressHandler.post(videoProgressRunnable);
+ }
+ });
+ }
+
+ @Override
+ public void onLoadCleared(@Nullable Drawable placeholder) {
+ // Do nothing
+ }
+
+ @Override
+ public void onLoadFailed(@Nullable Drawable errorDrawable) {
+ super.onLoadFailed(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;
+ }
+
+ @Override
+ public boolean onResourceReady(Drawable resource, Object model, Target target, DataSource
+ dataSource, boolean isFirstResource) {
+ if (isAdded()) {
+ progressBar.setVisibility(View.GONE);
+ }
+ return false;
+ }
+})
+ .into(imageView);
+ final ProgressBar currentProgressBar = (ProgressBar) progressBarContainer.getChildAt(currentPosition);
+ currentAnimator = ObjectAnimator.ofInt(currentProgressBar, "progress", 0, 1000);
+ currentAnimator.setDuration(STORY_DURATION_MS);
+ currentAnimator.addListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ super.onAnimationEnd(animation);
+ if (mListener != null) {
+ mListener.onNextStory();
+ }
+ }
+ });
+ currentAnimator.start();
+ }
+ }
+
+ public void pauseVideo() {
+ if (videoView != null && videoView.isPlaying()) {
+ videoView.pause();
+ if (videoProgressRunnable != null) {
+ videoProgressHandler.removeCallbacks(videoProgressRunnable);
+ }
+ }
+ }
+
+ public void resumeVideo() {
+ if (videoView != null && !videoView.isPlaying()) {
+ videoView.start();
+ if (videoProgressRunnable != null) {
+ videoProgressHandler.post(videoProgressRunnable);
+ }
+ }
+ }
+
+ public void pauseStory() {
+ if (currentAnimator != null && currentAnimator.isRunning()) {
+ currentAnimator.pause();
+ }
+ mListener.pauseStory();
+ pauseVideo();
+ }
+
+ public void resumeStory() {
+ if (currentAnimator != null && currentAnimator.isPaused()) {
+ currentAnimator.resume();
+ }
+ mListener.resumeStory();
+ resumeVideo();
+ }
+
+
+ @Override
+ public void onDetach() {
+ super.onDetach();
+ if (videoProgressRunnable != null) {
+ videoProgressHandler.removeCallbacks(videoProgressRunnable);
+ }
+ mListener = null;
+ }
+}
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..bc52deaab
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/ui/StoryViewActivity.java
@@ -0,0 +1,379 @@
+package eu.siacs.conversations.ui;
+
+import android.os.Bundle;
+import android.text.format.DateUtils;
+import android.text.method.ScrollingMovementMethod;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.LinearLayout;
+import android.widget.ProgressBar;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AlertDialog;
+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 com.google.android.material.textfield.TextInputEditText;
+
+import java.util.ArrayList;
+import java.util.Collections;
+
+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.entities.Story;
+import eu.siacs.conversations.parser.AbstractParser;
+import eu.siacs.conversations.ui.util.AvatarWorkerTask;
+import eu.siacs.conversations.ui.widget.AvatarView;
+import eu.siacs.conversations.xml.Element;
+import eu.siacs.conversations.xml.Namespace;
+import eu.siacs.conversations.xmpp.Jid;
+
+public class StoryViewActivity extends XmppActivity implements StoryFragment.OnStoryInteractionListener {
+
+ 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_CONTACT = "contact";
+ public static final String EXTRA_MIME_TYPES = "story_mime_types";
+
+ private ViewPager2 viewPager;
+ private TextView titleView;
+ private View bottomPanel;
+ private AppBarLayout appBarLayout;
+ private AvatarView toolbarAvatar;
+ private TextView toolbarTitle;
+ private TextView toolbarSubtitle;
+ private LinearLayout progressBarContainer;
+
+ private ArrayList urls;
+ private ArrayList titles;
+ private ArrayList storyIds;
+ private ArrayList mimeTypes;
+ 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);
+ 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);
+ progressBarContainer = findViewById(R.id.progress_bar_container);
+
+ viewPager = findViewById(R.id.view_pager);
+ titleView = findViewById(R.id.story_title_view);
+ titleView.setMovementMethod(new ScrollingMovementMethod());
+
+ 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);
+
+ try {
+ contact = Jid.of(getIntent().getStringExtra(EXTRA_CONTACT));
+ } catch (final Exception e) {
+ //ignore
+ }
+
+ setupProgressBars();
+
+ StoryPagerAdapter adapter = new StoryPagerAdapter(this);
+ viewPager.setAdapter(adapter);
+ viewPager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
+ @Override
+ public void onPageSelected(int position) {
+ super.onPageSelected(position);
+ updateUiForPosition(position);
+ }
+ });
+ updateUiForPosition(0);
+ showSystemUi();
+ }
+
+ private void setupProgressBars() {
+ progressBarContainer.removeAllViews();
+ for (int i = 0; i < urls.size(); i++) {
+ ProgressBar progressBar = (ProgressBar) LayoutInflater.from(this).inflate(R.layout.story_progress_bar, progressBarContainer, false);
+ progressBar.setMax(1000);
+ LinearLayout.LayoutParams params = (LinearLayout.LayoutParams) progressBar.getLayoutParams();
+ params.weight = 1;
+ progressBarContainer.addView(progressBar);
+ }
+ }
+
+ public void onNextStory() {
+ showSystemUi();
+ final int currentItem = viewPager.getCurrentItem();
+ final int nextItem = currentItem + 1;
+ if (nextItem < urls.size()) {
+ viewPager.setCurrentItem(nextItem, false);
+ } else {
+ finish();
+ }
+ }
+
+ 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 (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();
+ if (mAccount != null) {
+ 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) {
+ long twentyFourHoursAgo = System.currentTimeMillis() - 86400000;
+ for (Story story : xmppConnectionService.getStories()) {
+ if (story.getUuid().equals(currentStoryId) && story.getPublished() >= twentyFourHoursAgo) {
+ 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));
+ }
+
+ private void hideSystemUi() {
+ 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() {
+ 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);
+ }
+
+ @Override
+ protected void refreshUiReal() {
+ updateUiForPosition(viewPager.getCurrentItem());
+ }
+
+ @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) {
+ // Pause the story progress before showing the dialog
+ Fragment currentFragment = getSupportFragmentManager().findFragmentByTag("f" + viewPager.getCurrentItem());
+ if (currentFragment instanceof StoryFragment) {
+ ((StoryFragment) currentFragment).pauseStory();
+ }
+
+ int currentPos = viewPager.getCurrentItem();
+ if (mAccount != null) {
+ final LinearLayout container = new LinearLayout(this);
+ container.setOrientation(LinearLayout.VERTICAL);
+ final LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT);
+ int margin = (int) (16 * getResources().getDisplayMetrics().density);
+ params.setMargins(margin, 0, margin, 0);
+ final TextInputEditText input = new TextInputEditText(this);
+ input.setLayoutParams(params);
+ input.setHint(R.string.compose_message_hint);
+ container.addView(input);
+
+ final AlertDialog dialog = new MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.reply_to_story)
+ .setView(container)
+ .setNegativeButton(R.string.cancel, null)
+ .setPositiveButton(R.string.send, (d, which) -> {
+ final String text = input.getText() != null ? input.getText().toString() : "";
+ Conversation conversation = xmppConnectionService.findOrCreateConversation(mAccount, contact, false, false);
+ String storyId = storyIds.get(currentPos);
+ if (storyId == null) {
+ return;
+ }
+ String storyUri = "xmpp:" + contact.asBareJid().toString() + "?;node=urn:xmpp:pubsub-social-feed:stories:0;item=" + storyId;
+ final String messageBody = text.isEmpty() ? getString(R.string.reply_to_story) : text;
+
+ Message storyMessage = new Message(
+ conversation,
+ messageBody,
+ conversation.getNextEncryption(),
+ Message.STATUS_SEND
+ );
+ Element reference = new Element("reference", "urn:xmpp:reference:0");
+ reference.setAttribute("type", "data");
+ reference.setAttribute("uri", storyUri);
+ storyMessage.addPayload(reference);
+ storyMessage.setType(Message.TYPE_STORY);
+
+ Message.FileParams storyParams = new Message.FileParams();
+ storyParams.url = storyUri;
+ storyMessage.setFileParams(storyParams);
+
+ xmppConnectionService.sendMessage(storyMessage);
+ switchToConversation(conversation);
+ })
+ .create();
+
+ dialog.setOnDismissListener(d -> {
+ // Resume story progress when the dialog is dismissed
+ Fragment fragment = getSupportFragmentManager().findFragmentByTag("f" + viewPager.getCurrentItem());
+ if (fragment instanceof StoryFragment) {
+ ((StoryFragment) fragment).resumeStory();
+ }
+ });
+ dialog.show();
+ }
+ 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), urls);
+ }
+
+ @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();
+ refreshUi();
+ }
+
+ @Override
+ public void pauseStory() {
+ hideSystemUi();
+ }
+
+ @Override
+ public void resumeStory() {
+ showSystemUi();
+ }
+}
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 {
+
+ private final List calls;
+ private final OnCallAgainClickListener callAgainClickListener;
+ private final OnContactClickListener contactClickListener;
+ private final XmppConnectionService xmppConnectionService;
+
+ public interface OnCallAgainClickListener {
+ void onCallAgainClick(Message call, boolean isVideoCall);
+ }
+
+ public interface OnContactClickListener {
+ void onContactClick(Contact contact);
+ }
+
+ public CallsAdapter(List calls, OnCallAgainClickListener callAgainClickListener, OnContactClickListener contactClickListener, XmppConnectionService xmppConnectionService) {
+ this.calls = calls;
+ this.callAgainClickListener = callAgainClickListener;
+ this.contactClickListener = contactClickListener;
+ this.xmppConnectionService = xmppConnectionService;
+ }
+
+ @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) {
+ Message call = calls.get(position);
+ holder.bind(call, callAgainClickListener, contactClickListener, xmppConnectionService);
+ }
+
+ @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 TextView callDate;
+ 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);
+ callDate = itemView.findViewById(R.id.call_date);
+ callAgainButton = itemView.findViewById(R.id.call_again);
+ }
+
+ public void bind(final Message call, final OnCallAgainClickListener callAgainClickListener, final OnContactClickListener contactClickListener, final XmppConnectionService xmppConnectionService) {
+ AvatarWorkerTask.loadAvatar(call.getConversation().getContact(), avatar, R.dimen.bubble_avatar_size);
+ contactName.setText(call.getConversation().getContact().getDisplayName());
+
+ final Contact contact = call.getConversation().getContact();
+ if (contact != null && !contact.isSelf()) {
+ View.OnClickListener clickListener = v -> 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;
+
+ int color;
+ if (missed) {
+ color = ContextCompat.getColor(itemView.getContext(), R.color.red_700);
+ } else {
+ color = contactName.getCurrentTextColor();
+ }
+ callInfo.setTextColor(color);
+ android.graphics.drawable.Drawable drawable = ContextCompat.getDrawable(itemView.getContext(), eu.siacs.conversations.entities.RtpSessionStatus.getDrawable(received, rtpSessionStatus.successful));
+ if (drawable != null) {
+ drawable.setTint(color);
+ final int size = (int) (18 * itemView.getContext().getResources().getDisplayMetrics().density);
+ drawable.setBounds(0, 0, size, size);
+ }
+
+ if (xmppConnectionService != null) callInfo.setText(UIHelper.getMessagePreview(xmppConnectionService, call).first);
+ callInfo.setCompoundDrawables(drawable, null, null, null);
+ callInfo.setCompoundDrawablePadding((int) (6 * itemView.getContext().getResources().getDisplayMetrics().density));
+
+ callDate.setText(UIHelper.readableTimeDifference(itemView.getContext(), call.getTimeSent(), false));
+ callAgainButton.setOnClickListener(v -> {
+ 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) {
+ callAgainClickListener.onCallAgainClick(call, false);
+ return true;
+ } else if (item.getItemId() == R.id.action_video_call) {
+ callAgainClickListener.onCallAgainClick(call, true);
+ return true;
+ }
+ return false;
+ });
+ popup.show();
+ });
+ TextView accountInfo = itemView.findViewById(R.id.account_info);
+ if (xmppConnectionService != null && xmppConnectionService.getBooleanPreference("show_own_accounts", R.bool.show_own_accounts)) {
+ accountInfo.setText(call.getConversation().getAccount().getJid().asBareJid().toString());
+ accountInfo.setVisibility(View.VISIBLE);
+ } else {
+ accountInfo.setVisibility(View.GONE);
+ }
+
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/CommentsAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/CommentsAdapter.java
new file mode 100644
index 000000000..efc5a8a45
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/ui/adapter/CommentsAdapter.java
@@ -0,0 +1,218 @@
+package eu.siacs.conversations.ui.adapter;
+
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import java.text.DateFormat;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+
+import eu.siacs.conversations.Config;
+import eu.siacs.conversations.R;
+import eu.siacs.conversations.databinding.ItemCommentBinding;
+import eu.siacs.conversations.entities.Account;
+import eu.siacs.conversations.entities.Comment;
+import eu.siacs.conversations.entities.Contact;
+import eu.siacs.conversations.entities.Post;
+import eu.siacs.conversations.services.XmppConnectionService;
+import eu.siacs.conversations.ui.XmppActivity;
+import eu.siacs.conversations.ui.util.AvatarWorkerTask;
+import eu.siacs.conversations.utils.AccountUtils;
+import eu.siacs.conversations.utils.XmppUri;
+import eu.siacs.conversations.xmpp.Jid;
+
+public class CommentsAdapter extends RecyclerView.Adapter {
+
+ private final Post mPost;
+ private final List comments;
+ private final XmppActivity mActivity;
+
+ private XmppConnectionService.OnCommentReceived mOnCommentReceived;
+
+ public CommentsAdapter(XmppActivity activity, Post post, List comments) {
+ this.mActivity = activity;
+ this.mPost = post;
+ this.comments = comments;
+ }
+
+ @Override
+ public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) {
+ super.onAttachedToRecyclerView(recyclerView);
+ if (mActivity.xmppConnectionService != null) {
+ this.mOnCommentReceived = (postUuid, comment) -> {
+ if (mPost.getId().equals(postUuid)) {
+ mActivity.runOnUiThread(() -> {
+ boolean found = false;
+ for (Comment c : comments) {
+ if (c.getId().equals(comment.getId())) {
+ found = true;
+ break;
+ }
+ }
+ if (!found) {
+ comments.add(comment);
+ Collections.sort(comments, (c1, c2) -> {
+ Date d1 = c1.getPublished();
+ Date d2 = c2.getPublished();
+ if (d1 == null && d2 == null) return 0;
+ if (d1 == null) return -1;
+ if (d2 == null) return 1;
+ return d1.compareTo(d2);
+ });
+ notifyDataSetChanged();
+ }
+ });
+ }
+ };
+ mActivity.xmppConnectionService.addOnCommentReceivedListener(this.mOnCommentReceived);
+ }
+ }
+
+ @Override
+ public void onDetachedFromRecyclerView(@NonNull RecyclerView recyclerView) {
+ super.onDetachedFromRecyclerView(recyclerView);
+ if (mActivity.xmppConnectionService != null && this.mOnCommentReceived != null) {
+ mActivity.xmppConnectionService.removeOnCommentReceivedListener(this.mOnCommentReceived);
+ }
+ }
+
+
+ @NonNull
+ @Override
+ public CommentViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ return new CommentViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.item_comment, parent, false));
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull CommentViewHolder holder, int position) {
+ holder.bind(comments.get(position));
+ }
+
+ @Override
+ public int getItemCount() {
+ return comments.size();
+ }
+
+ class CommentViewHolder extends RecyclerView.ViewHolder {
+
+ private final ItemCommentBinding binding;
+
+ CommentViewHolder(@NonNull View itemView) {
+ super(itemView);
+ binding = ItemCommentBinding.bind(itemView);
+ }
+
+ void bind(Comment comment) {
+ if (comment.getAuthor() != null) {
+ final Jid authorJid = comment.getAuthor();
+ if (mActivity.xmppConnectionService != null) {
+
+ final Account postAuthorAccount = mActivity.xmppConnectionService.findAccountByJid(mPost.getAuthor());
+ Account contextAccount = postAuthorAccount != null ? postAuthorAccount : AccountUtils.getFirstEnabled(mActivity.xmppConnectionService.getAccounts());
+
+ if (contextAccount != null) {
+ if (authorJid.asBareJid().equals(contextAccount.getJid().asBareJid())) {
+ if (contextAccount.getDisplayName() == null) {
+ binding.commentAuthorName.setText(authorJid.asBareJid().toString());
+ } else {
+ binding.commentAuthorName.setText(contextAccount.getDisplayName());
+ }
+ final Account self = contextAccount;
+ binding.commentAuthorAvatar.setOnClickListener(v -> mActivity.switchToAccount(self));
+ binding.commentAuthorName.setOnClickListener(v -> mActivity.switchToAccount(self));
+ AvatarWorkerTask.loadAvatar(contextAccount, binding.commentAuthorAvatar, R.dimen.posts_comments_avatar_size);
+ } else {
+ Contact contact = contextAccount.getRoster().getContact(authorJid);
+ if (contact != null) {
+ if (contact.getDisplayName() == null) {
+ binding.commentAuthorName.setText(contact.getJid().asBareJid().toString());
+ } else {
+ binding.commentAuthorName.setText(contact.getDisplayName());
+ }
+ binding.commentAuthorAvatar.setOnClickListener(v -> mActivity.switchToContactDetails(contact));
+ binding.commentAuthorName.setOnClickListener(v -> mActivity.switchToContactDetails(contact));
+ AvatarWorkerTask.loadAvatar(contact, binding.commentAuthorAvatar, R.dimen.posts_comments_avatar_size);
+ } else {
+ binding.commentAuthorName.setText(authorJid.asBareJid().toString());
+ binding.commentAuthorAvatar.setImageResource(R.drawable.ic_person_24dp);
+ }
+ }
+
+ final Account commentAuthorAccount = mActivity.xmppConnectionService.findAccountByJid(comment.getAuthor());
+ boolean iAmPostAuthor = postAuthorAccount != null && postAuthorAccount.isOnlineAndConnected();
+ boolean iAmCommentAuthor = commentAuthorAccount != null && commentAuthorAccount.isOnlineAndConnected();
+
+ if (iAmPostAuthor || iAmCommentAuthor) {
+ binding.retractButton.setVisibility(View.VISIBLE);
+
+ final Account accountToSendFrom = iAmCommentAuthor ? commentAuthorAccount : postAuthorAccount;
+ binding.retractButton.setOnClickListener(v -> {
+ new MaterialAlertDialogBuilder(mActivity)
+ .setTitle(R.string.retract_comment)
+ .setMessage(R.string.retract_comment_confirm)
+ .setPositiveButton(R.string.retract, (dialog, which) -> retractComment(accountToSendFrom, comment))
+ .setNegativeButton(R.string.cancel, null)
+ .show();
+ });
+ } else {
+ binding.retractButton.setVisibility(View.GONE);
+ }
+ } else {
+ binding.commentAuthorName.setText(authorJid.asBareJid().toString());
+ binding.commentAuthorAvatar.setImageResource(R.drawable.ic_person_24dp);
+ }
+ }
+ } else {
+ binding.commentAuthorName.setText(null);
+ binding.commentAuthorAvatar.setImageResource(R.drawable.ic_person_24dp);
+ }
+ binding.commentContent.setText(comment.getTitle());
+ if (comment.getPublished() != null) {
+ binding.commentTimestamp.setText(DateFormat.getDateTimeInstance().format(comment.getPublished()));
+ } else {
+ binding.commentTimestamp.setText(null);
+ }
+ }
+
+ private void retractComment(Account account, Comment comment) {
+ if (mActivity.xmppConnectionService == null) {
+ return;
+ }
+ try {
+ final XmppUri uri = new XmppUri(mPost.getCommentsNode());
+ final Jid jid = uri.getJid();
+ final String node = uri.getParameter("node");
+
+ mActivity.xmppConnectionService.retractPost(account, jid, node, comment.getId(), new XmppConnectionService.OnPostRetracted() {
+ @Override
+ public void onPostRetracted(String postId) {
+ mActivity.runOnUiThread(() -> {
+ int pos = getAdapterPosition();
+ if (pos != RecyclerView.NO_POSITION) {
+ comments.remove(pos);
+ notifyItemRemoved(pos);
+ }
+ });
+ }
+
+ @Override
+ public void onPostRetractionFailed() {
+ mActivity.runOnUiThread(() -> Toast.makeText(mActivity, R.string.error_retracting_comment, Toast.LENGTH_SHORT).show());
+ }
+ });
+ } catch (Exception e) {
+ Log.e(Config.LOGTAG, "error retracting comment", e);
+ mActivity.runOnUiThread(() -> Toast.makeText(mActivity, R.string.error_retracting_comment, Toast.LENGTH_SHORT).show());
+ }
+ }
+ }
+}
diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/FollowSuggestionAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/FollowSuggestionAdapter.java
new file mode 100644
index 000000000..6af0e21c4
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/ui/adapter/FollowSuggestionAdapter.java
@@ -0,0 +1,86 @@
+package eu.siacs.conversations.ui.adapter;
+
+import android.graphics.drawable.Drawable;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.ImageView;
+import android.widget.TextView;
+import android.widget.Toast;
+
+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.services.XmppConnectionService;
+import eu.siacs.conversations.ui.PostsActivity;
+import eu.siacs.conversations.ui.util.AvatarWorkerTask;
+import eu.siacs.conversations.xml.Namespace;
+import im.conversations.android.xmpp.model.stanza.Iq;
+
+public class FollowSuggestionAdapter extends RecyclerView.Adapter {
+
+ private final PostsActivity mPostsActivity;
+ private final List mContacts;
+ private final XmppConnectionService mXmppConnectionService;
+
+ public FollowSuggestionAdapter(PostsActivity activity, XmppConnectionService service, List contacts) {
+ this.mPostsActivity = activity;
+ this.mXmppConnectionService = service;
+ this.mContacts = contacts;
+ }
+
+ @NonNull
+ @Override
+ public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_follow_suggestion, parent, false);
+ return new ViewHolder(view);
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
+ final Contact contact = mContacts.get(position);
+ holder.mContactName.setText(contact.getDisplayName());
+ AvatarWorkerTask.loadAvatar(contact, holder.mAvatar, R.dimen.feed_suggestions_avatar_size);
+ holder.mAvatar.setOnClickListener(v -> mPostsActivity.switchToContactDetails(contact));
+ holder.mFollowButton.setOnClickListener(v -> {
+ final Account account = contact.getAccount();
+ mXmppConnectionService.subscribeTo(account, contact.getJid(), Namespace.MICROBLOG, packet -> {
+ mPostsActivity.runOnUiThread(() -> {
+ if (packet.getType() == Iq.Type.RESULT) {
+ contact.setFollowed(true);
+ mXmppConnectionService.updateContact(contact);
+ mContacts.remove(contact);
+ notifyDataSetChanged();
+ mPostsActivity.loadPosts();
+ } else {
+ Toast.makeText(mPostsActivity, R.string.error_subscribing_to_feed, Toast.LENGTH_SHORT).show();
+ }
+ });
+ });
+ });
+ }
+
+ @Override
+ public int getItemCount() {
+ return mContacts.size();
+ }
+
+ public static class ViewHolder extends RecyclerView.ViewHolder {
+ final ImageView mAvatar;
+ final TextView mContactName;
+ final Button mFollowButton;
+
+ ViewHolder(View view) {
+ super(view);
+ mAvatar = view.findViewById(R.id.contact_avatar);
+ mContactName = view.findViewById(R.id.contact_name);
+ mFollowButton = view.findViewById(R.id.follow_button);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java
index 2b000b901..be294c8d2 100644
--- a/src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java
+++ b/src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java
@@ -54,6 +54,7 @@ import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import androidx.core.content.res.ResourcesCompat;
+import androidx.core.util.Pair;
import androidx.core.view.ViewCompat;
import androidx.core.widget.ImageViewCompat;
import androidx.customview.widget.ViewDragHelper;
@@ -91,7 +92,10 @@ import com.google.common.collect.ImmutableSet;
import com.lelloman.identicon.view.GithubIdenticonView;
import de.monocles.chat.ui.DraggableListView;
+import eu.siacs.conversations.entities.Story;
+import eu.siacs.conversations.services.XmppConnectionService;
import eu.siacs.conversations.ui.AddReactionActivity;
+import eu.siacs.conversations.ui.StoryViewActivity;
import io.ipfs.cid.Cid;
import java.io.IOException;
@@ -99,6 +103,8 @@ import java.lang.ref.WeakReference;
import java.net.URI;
import java.net.URISyntaxException;
import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -574,6 +580,7 @@ public class MessageAdapter extends ArrayAdapter implements DraggableLi
BubbleMessageItemViewHolder viewHolder,
CharSequence text,
final BubbleColor bubbleColor) {
+ viewHolder.storyPreview().setVisibility(View.GONE);
viewHolder.downloadButton().setVisibility(View.GONE);
viewHolder.audioPlayer().setVisibility(View.GONE);
viewHolder.image().setVisibility(View.GONE);
@@ -593,6 +600,52 @@ public class MessageAdapter extends ArrayAdapter implements DraggableLi
viewHolder.messageBody().setTextIsSelectable(false);
}
+ private void displayPubSubMessage(
+ final BubbleMessageItemViewHolder viewHolder,
+ final Message message,
+ final BubbleColor bubbleColor) {
+
+ // First, handle the text part of the message.
+ // This ensures the text is always displayed for a story reply.
+ displayTextMessage(viewHolder, message, bubbleColor);
+
+ // Now, find and display the story preview.
+ final Pair storyReference = message.getStoryReference();
+ Story story = null;
+ if (storyReference != null) {
+ final XmppConnectionService xmppService = activity.xmppConnectionService;
+ if (xmppService != null) {
+ for (final Story s : activity.xmppConnectionService.getStories()) {
+ if (s.getUuid().equals(storyReference.second)) {
+ story = s;
+ break;
+ }
+ }
+ }
+ }
+
+ // Only show the preview if a valid story was actually found.
+ if (story != null) {
+ viewHolder.storyPreview().setVisibility(View.VISIBLE);
+ viewHolder.storyTitle().setText(story.getTitle());
+ Glide.with(activity).load(story.getUrl()).into(viewHolder.storyThumbnail());
+ final Story finalStory = story;
+ viewHolder.storyPreview().setOnClickListener(v -> {
+ final Intent intent = new Intent(activity, StoryViewActivity.class);
+ intent.putExtra(StoryViewActivity.EXTRA_URLS, new ArrayList<>(Collections.singletonList(finalStory.getUrl())));
+ intent.putExtra(StoryViewActivity.EXTRA_TITLES, new ArrayList<>(Collections.singletonList(finalStory.getTitle())));
+ intent.putExtra(StoryViewActivity.EXTRA_STORY_IDS, new ArrayList<>(Collections.singletonList(finalStory.getUuid())));
+ intent.putExtra(StoryViewActivity.EXTRA_MIME_TYPES, new ArrayList<>(Collections.singletonList(finalStory.getType())));
+ intent.putExtra(StoryViewActivity.EXTRA_CONTACT, finalStory.getContact().asBareJid().toString());
+ intent.putExtra(StoryViewActivity.EXTRA_ACCOUNT, message.getConversation().getAccount().getUuid());
+ activity.startActivity(intent);
+ });
+ } else {
+ // If no story is found, we MUST hide the preview to prevent recycling issues.
+ viewHolder.storyPreview().setVisibility(View.GONE);
+ }
+ }
+
private void displayEmojiMessage(
final BubbleMessageItemViewHolder viewHolder,
final Message message,
@@ -737,6 +790,8 @@ public class MessageAdapter extends ArrayAdapter implements DraggableLi
final BubbleMessageItemViewHolder viewHolder,
final Message message,
final BubbleColor bubbleColor) {
+ if (message.getType() != Message.TYPE_STORY)
+ viewHolder.storyPreview().setVisibility(View.GONE);
viewHolder.inReplyToQuote().setVisibility(GONE);
viewHolder.downloadButton().setVisibility(GONE);
viewHolder.image().setVisibility(GONE);
@@ -1517,6 +1572,7 @@ public class MessageAdapter extends ArrayAdapter implements DraggableLi
final int position,
final Message message,
final BubbleMessageItemViewHolder viewHolder) {
+ viewHolder.storyPreview().setVisibility(View.GONE); //reset view state
final boolean omemoEncryption = message.getEncryption() == Message.ENCRYPTION_AXOLOTL;
final boolean isInValidSession =
message.isValidInSession() && (!omemoEncryption || message.isTrusted());
@@ -1638,7 +1694,6 @@ public class MessageAdapter extends ArrayAdapter implements DraggableLi
}
});
- boolean footerWrap = false;
final Transferable transferable = message.getTransferable();
final boolean unInitiatedButKnownSize = MessageUtils.unInitiatedButKnownSize(message);
@@ -1646,6 +1701,8 @@ public class MessageAdapter extends ArrayAdapter implements DraggableLi
if (muted) {
// Muted MUC participant
displayInfoMessage(viewHolder, "Muted", bubbleColor);
+ } else if (message.getType() == Message.TYPE_STORY || message.getStoryReference() != null) {
+ displayPubSubMessage(viewHolder, message, bubbleColor);
} else if (unInitiatedButKnownSize || message.isDeleted() || (transferable != null && transferable.getStatus() != Transferable.STATUS_UPLOADING)) {
if (unInitiatedButKnownSize || (message.isDeleted() && message.getModerated() == null) || transferable != null && transferable.getStatus() == Transferable.STATUS_OFFER) {
displayDownloadableMessage(viewHolder, message, activity.getString(R.string.download_x_file, UIHelper.getFileDescriptionString(activity, message)), bubbleColor);
@@ -2643,6 +2700,9 @@ public class MessageAdapter extends ArrayAdapter implements DraggableLi
protected abstract TextView username();
protected abstract TextView showMore();
+ protected abstract LinearLayout storyPreview();
+ protected abstract ShapeableImageView storyThumbnail();
+ protected abstract TextView storyTitle();
}
private static class StartBubbleMessageItemViewHolder extends BubbleMessageItemViewHolder {
@@ -2771,6 +2831,21 @@ public class MessageAdapter extends ArrayAdapter implements DraggableLi
protected TextView showMore() {
return this.binding.messageContent.showMore;
}
+
+ @Override
+ protected LinearLayout storyPreview() {
+ return this.binding.messageContent.storyPreview;
+ }
+
+ @Override
+ protected ShapeableImageView storyThumbnail() {
+ return this.binding.messageContent.storyThumbnail;
+ }
+
+ @Override
+ protected TextView storyTitle() {
+ return this.binding.messageContent.storyTitle;
+ }
}
private static class EndBubbleMessageItemViewHolder extends BubbleMessageItemViewHolder {
@@ -2797,6 +2872,21 @@ public class MessageAdapter extends ArrayAdapter implements DraggableLi
return this.binding.messageContent.showMore;
}
+ @Override
+ protected LinearLayout storyPreview() {
+ return this.binding.messageContent.storyPreview;
+ }
+
+ @Override
+ protected ShapeableImageView storyThumbnail() {
+ return this.binding.messageContent.storyThumbnail;
+ }
+
+ @Override
+ protected TextView storyTitle() {
+ return this.binding.messageContent.storyTitle;
+ }
+
@Override
protected ImageView indicatorEdit() {
return this.binding.editIndicator;
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..8f0ba316b
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/ui/adapter/PostsAdapter.java
@@ -0,0 +1,673 @@
+package eu.siacs.conversations.ui.adapter;
+
+import android.app.Dialog;
+import android.content.Intent;
+import android.graphics.Color;
+import android.graphics.drawable.ColorDrawable;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+import android.text.Spannable;
+import android.text.SpannableString;
+import android.text.style.ClickableSpan;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ArrayAdapter;
+import android.widget.ImageView;
+import android.widget.MediaController;
+import android.widget.Toast;
+import android.widget.VideoView;
+
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.bumptech.glide.Glide;
+import com.bumptech.glide.request.target.CustomTarget;
+import com.bumptech.glide.request.transition.Transition;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import java.io.File;
+import java.text.DateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import eu.siacs.conversations.Config;
+import eu.siacs.conversations.R;
+import eu.siacs.conversations.databinding.ItemPostBinding;
+import eu.siacs.conversations.entities.Account;
+import eu.siacs.conversations.entities.Comment;
+import eu.siacs.conversations.entities.Contact;
+import eu.siacs.conversations.entities.Message;
+import eu.siacs.conversations.entities.Post;
+import eu.siacs.conversations.entities.StubConversation;
+import eu.siacs.conversations.services.XmppConnectionService;
+import eu.siacs.conversations.ui.CreatePostActivity;
+import eu.siacs.conversations.ui.OnSearchPerformed;
+import eu.siacs.conversations.ui.UiCallback;
+import eu.siacs.conversations.ui.XmppActivity;
+import eu.siacs.conversations.ui.util.AvatarWorkerTask;
+import eu.siacs.conversations.utils.AccountUtils;
+import eu.siacs.conversations.utils.XmppUri;
+import eu.siacs.conversations.xml.Element;
+import eu.siacs.conversations.xml.Namespace;
+import eu.siacs.conversations.xml.XmlReader;
+import eu.siacs.conversations.xmpp.Jid;
+import io.noties.markwon.Markwon;
+
+public class PostsAdapter extends RecyclerView.Adapter {
+
+ private final List posts;
+ private final XmppActivity mActivity;
+ private final Set expandedPosts = new HashSet<>();
+ private final ActivityResultLauncher postResultLauncher;
+ private final Markwon markwon;
+ private final OnSearchPerformed mOnSearchPerformed;
+
+ public PostsAdapter(XmppActivity activity, List posts, ActivityResultLauncher launcher, OnSearchPerformed onSearchPerformed) {
+ this.mActivity = activity;
+ this.posts = posts;
+ this.postResultLauncher = launcher;
+ this.markwon = Markwon.create(activity);
+ this.mOnSearchPerformed = onSearchPerformed;
+ }
+
+ @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();
+ }
+
+ private void showImagePreviewDialog(String url) {
+ if (mActivity == null || url == null) {
+ return;
+ }
+ final Dialog dialog = new Dialog(mActivity);
+ dialog.setContentView(R.layout.dialog_image_preview);
+ ImageView imageView = dialog.findViewById(R.id.image_view);
+ Glide.with(mActivity).load(url).into(imageView);
+ imageView.setOnClickListener(v -> dialog.dismiss());
+ if (dialog.getWindow() != null) {
+ dialog.getWindow().setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
+ }
+ dialog.show();
+ }
+
+ private void showVideoPreviewDialog (String url) {
+ if (mActivity == null || url == null) {
+ return;
+ }
+ final Dialog dialog = new Dialog(mActivity);
+ dialog.setContentView(R.layout.dialog_video_preview);
+ VideoView videoView = dialog.findViewById(R.id.video_view);
+ android.widget.FrameLayout frameLayout = dialog.findViewById(R.id.video_frame);
+ Glide.with(mActivity)
+ .asFile()
+ .load(url)
+ .into(new CustomTarget() {
+ @Override
+ public void onResourceReady(@NonNull File resource, @Nullable Transition super File> transition) {
+ videoView.setVideoURI(Uri.fromFile(resource));
+ videoView.setOnPreparedListener(mp -> {
+ mp.setLooping(true);
+ videoView.start();
+ });
+ }
+
+ @Override
+ public void onLoadCleared(@Nullable Drawable placeholder) {
+ // Do nothing
+ }
+
+ @Override
+ public void onLoadFailed(@Nullable Drawable errorDrawable) {
+ Toast.makeText(mActivity, R.string.download_failed_file_not_found, Toast.LENGTH_SHORT).show();
+ }
+ });
+ MediaController controller = new MediaController(mActivity);
+ controller.setAnchorView(frameLayout);
+ videoView.setMediaController(controller);
+ if (dialog.getWindow() != null) {
+ dialog.getWindow().setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
+ dialog.getWindow().setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
+ }
+ dialog.show();
+ }
+
+ class PostViewHolder extends RecyclerView.ViewHolder {
+
+ private final ItemPostBinding binding;
+
+ PostViewHolder(@NonNull View itemView) {
+ super(itemView);
+ binding = ItemPostBinding.bind(itemView);
+ }
+
+ void bind(Post post) {
+ binding.commentsList.setVisibility(View.GONE);
+ binding.commentsList.setAdapter(null);
+ binding.likeButton.setOnClickListener(null);
+ binding.likeCount.setText("");
+ binding.likeButton.setEnabled(false);
+ binding.likeButton.setCompoundDrawablesWithIntrinsicBounds(R.drawable.favorite_border_24, 0, 0, 0);
+
+ final boolean isExpanded = expandedPosts.contains(post);
+
+ binding.postContentSummary.setVisibility(isExpanded ? View.GONE : View.VISIBLE);
+ binding.postContentFull.setVisibility(isExpanded ? View.VISIBLE : View.GONE);
+ binding.postActions.setVisibility(isExpanded ? View.VISIBLE : View.GONE);
+
+ if (isExpanded) {
+ loadCommentsAndLikes(post, this);
+ }
+
+ final View.OnClickListener expandClickListener = v -> {
+ if (expandedPosts.contains(post)) {
+ expandedPosts.remove(post);
+ } else {
+ expandedPosts.add(post);}
+ notifyItemChanged(getAdapterPosition());
+ };
+
+ setupPostView(post, expandClickListener);
+ }
+
+ private void setupPostView(final Post post, final View.OnClickListener expandClickListener) {
+ final boolean isExpanded = expandedPosts.contains(post);
+ final boolean hasAttachment = post.getAttachmentUrl() != null;
+ final boolean isImage = hasAttachment && post.getAttachmentType() != null && post.getAttachmentType().startsWith("image/");
+ final boolean isVideo = hasAttachment && post.getAttachmentType() != null && post.getAttachmentType().startsWith("video/");
+
+ binding.attachmentHint.setVisibility(hasAttachment && !isExpanded ? View.VISIBLE : View.GONE);
+ binding.postImage.setVisibility(isExpanded && (isImage || isVideo) ? View.VISIBLE : View.GONE);
+ binding.videoOverlayIcon.setVisibility(isExpanded && isVideo ? View.VISIBLE : View.GONE);
+ binding.downloadButton.setVisibility(isExpanded && hasAttachment && !isImage && !isVideo ? View.VISIBLE : View.GONE);
+
+ if (isExpanded && (isImage || isVideo)) {
+ binding.attachmentProgress.setVisibility(View.VISIBLE);
+ Glide.with(mActivity)
+ .load(post.getAttachmentUrl()) .listener(new com.bumptech.glide.request.RequestListener() {
+ @Override
+ public boolean onLoadFailed(@Nullable com.bumptech.glide.load.engine.GlideException e, @Nullable Object model, @NonNull com.bumptech.glide.request.target.Target target, boolean isFirstResource) {
+ binding.attachmentProgress.setVisibility(View.GONE);
+ return false;
+ }
+
+ @Override
+ public boolean onResourceReady(@NonNull Drawable resource, @NonNull Object model, @NonNull com.bumptech.glide.request.target.Target target, @NonNull com.bumptech.glide.load.DataSource dataSource, boolean isFirstResource) {
+ binding.attachmentProgress.setVisibility(View.GONE);
+ return false;
+ }
+ })
+ .into(binding.postImage);binding.postImage.setOnClickListener(v -> {
+ if (isImage) showImagePreviewDialog(post.getAttachmentUrl());
+ else showVideoPreviewDialog(post.getAttachmentUrl());
+ });
+ }
+
+ final List postAccounts = new ArrayList<>();
+ if (post.getAuthor() != null && mActivity.xmppConnectionService != null) {
+ for (Account account : mActivity.xmppConnectionService.getAccounts()) {
+ if (account.isOnlineAndConnected()) {
+ if (account.getJid().asBareJid().equals(post.getAuthor().asBareJid()) || (account.getRoster() != null && account.getRoster().getContact(post.getAuthor().asBareJid()) != null)) {
+ postAccounts.add(account);
+ }
+ }
+ }
+ }
+ if (mActivity.xmppConnectionService == null) {
+ binding.editButton.setVisibility(View.GONE);
+ binding.deleteButton.setVisibility(View.GONE);
+ binding.replyButton.setVisibility(View.GONE);
+ binding.commentButton.setVisibility(View.GONE);
+ binding.downloadButton.setVisibility(View.GONE);
+ binding.likeButton.setVisibility(View.GONE);
+ } else {
+ final Account ownAccount = post.getAuthor() != null ? mActivity.xmppConnectionService.findAccountByJid(post.getAuthor().asBareJid()) : null;
+
+ binding.downloadButton.setOnClickListener(v -> {
+ if (postAccounts.size() == 1) {
+ downloadAttachment(postAccounts.get(0), post);
+ } else { showAccountSelectionDialog(mActivity.getString(R.string.choose_account_for_download), postAccounts, account -> downloadAttachment(account, post));
+ }
+ });
+
+ if (post.getContent() != null) {
+ final CharSequence markdown = markwon.toMarkdown(post.getContent());
+ binding.postContentSummary.setText(markdown);
+ binding.postContentSummary.setMovementMethod(null);
+ binding.postContentSummary.setOnClickListener(expandClickListener);
+
+ final SpannableString spannable = new SpannableString(markdown);
+ final Matcher matcher = Pattern.compile("#[\\p{L}\\p{N}]+").matcher(spannable);
+ while (matcher.find()) {
+ final String hashtag = matcher.group(0);
+ spannable.setSpan(new ClickableSpan() {
+ @Override
+ public void onClick(@NonNull View widget) {
+ mOnSearchPerformed.onSearchPerformed(hashtag);
+ }
+ }, matcher.start(), matcher.end(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+
+ binding.postContentFull.setText(spannable);
+ binding.postContentFull.setMovementMethod(android.text.method.LinkMovementMethod.getInstance());
+ binding.postContentFull.setOnClickListener(null);
+
+ } else {
+ binding.postContentSummary.setText("");
+ binding.postContentFull.setText("");
+ }
+
+ itemView.setOnClickListener(expandClickListener);
+ binding.postTitle.setOnClickListener(expandClickListener);
+
+ binding.commentButton.setOnClickListener(v -> {
+ if (post.getAuthor() != null) {
+ if (postAccounts.size() == 1) {
+ replyToPost(postAccounts.get(0), post);
+ } else {
+ showAccountSelectionDialog(mActivity.getString(R.string.choose_account_for_reply), postAccounts, account -> replyToPost(account, post));
+ }
+ }
+ });
+
+ binding.replyButton.setVisibility(View.GONE);
+
+ binding.shareButton.setOnClickListener(v -> {
+ Intent intent = new Intent(Intent.ACTION_SEND);
+ intent.setType("text/plain");
+ intent.putExtra(Intent.EXTRA_TEXT, post.getTitle() + "\n" + post.getContent());
+ mActivity.startActivity(Intent.createChooser(intent, mActivity.getString(R.string.share_post_with)));
+ });
+
+ if (ownAccount != null && ownAccount.isOnlineAndConnected()) {
+ binding.editButton.setVisibility(View.VISIBLE);
+ binding.deleteButton.setVisibility(View.VISIBLE);
+ binding.editButton.setOnClickListener(v -> {
+ editPost(ownAccount, post);
+ });
+ binding.deleteButton.setOnClickListener(v -> {
+ new MaterialAlertDialogBuilder(mActivity)
+ .setTitle(R.string.retract_post)
+ .setMessage(R.string.retract_post_confirm)
+ .setPositiveButton(R.string.retract, (dialog, which) -> {
+ mActivity.xmppConnectionService.retractPost(ownAccount, Namespace.MICROBLOG, post.getId(),
+ new XmppConnectionService.OnPostRetracted() {
+
+ @Override
+ public void onPostRetracted(String postId) {
+ mActivity.runOnUiThread(() -> {
+ mActivity.xmppConnectionService.databaseBackend.deletePost(postId);
+ int pos = getAdapterPosition();
+ if (pos != RecyclerView.NO_POSITION) {
+ posts.remove(pos);
+ notifyItemRemoved(pos);
+ }
+ });
+ }
+
+ @Override
+ public void onPostRetractionFailed() {
+ mActivity.runOnUiThread(() -> Toast.makeText(mActivity, R.string.error_retract_post, Toast.LENGTH_SHORT).show());
+ }
+ });
+ })
+ .setNegativeButton(R.string.cancel, null)
+ .show();
+ });
+ } else {
+ binding.editButton.setVisibility(View.GONE);
+ binding.deleteButton.setVisibility(View.GONE);
+ }
+
+ if (post.getAuthor() != null) {
+ final Jid authorJid = post.getAuthor();
+ Account displayAccount = ownAccount;
+ if (displayAccount == null && postAccounts.size() > 0) {
+ displayAccount = postAccounts.get(0);
+ }
+ if (displayAccount == null) {
+ displayAccount = AccountUtils.getFirstEnabled(mActivity.xmppConnectionService.getAccounts());
+ }
+
+ if (displayAccount != null) {
+ if (authorJid.asBareJid().equals(displayAccount.getJid().asBareJid())) {
+ final String displayName = displayAccount.getDisplayName();
+ binding.postAuthorName.setText(displayName != null && !displayName.isEmpty() ? displayName : displayAccount.getJid().asBareJid().toString());
+ AvatarWorkerTask.loadAvatar(displayAccount, binding.postAuthorAvatar, R.dimen.bubble_avatar_size);
+ final Account self = displayAccount;
+ binding.postAuthorAvatar.setOnClickListener(v -> mActivity.switchToAccount(self));
+ binding.postAuthorName.setOnClickListener(v -> mActivity.switchToAccount(self));
+ } else {
+ Contact contact = displayAccount.getRoster().getContact(authorJid);
+ if (contact != null) {
+ final String displayName = contact.getDisplayName();
+ binding.postAuthorName.setText(displayName != null && !displayName.isEmpty() ? displayName : contact.getJid().asBareJid().toString());
+ AvatarWorkerTask.loadAvatar(contact, binding.postAuthorAvatar, R.dimen.bubble_avatar_size);
+ binding.postAuthorAvatar.setOnClickListener(v -> mActivity.switchToContactDetails(contact));
+ binding.postAuthorName.setOnClickListener(v -> mActivity.switchToContactDetails(contact));
+ } else {
+ binding.postAuthorName.setText(authorJid.asBareJid().toString());
+ binding.postAuthorAvatar.setImageResource(R.drawable.ic_person_24dp);
+ binding.postAuthorAvatar.setOnClickListener(null);
+ binding.postAuthorName.setOnClickListener(null);
+ }
+ }
+ } else {
+ binding.postAuthorName.setText(authorJid.asBareJid().toString());
+ binding.postAuthorAvatar.setImageResource(R.drawable.ic_person_24dp);
+ binding.postAuthorAvatar.setOnClickListener(null);
+ binding.postAuthorName.setOnClickListener(null);
+ }
+ } else {
+ binding.postAuthorName.setText(null);
+ binding.postAuthorAvatar.setImageResource(R.drawable.ic_person_24dp);
+ binding.postAuthorAvatar.setOnClickListener(null);
+ binding.postAuthorName.setOnClickListener(null);
+ }
+ binding.postTitle.setText(post.getTitle());
+ if (post.getPublished() != null) {
+ binding.postTimestamp.setText(DateFormat.getDateTimeInstance().format(post.getPublished()));
+ } else {
+ binding.postTimestamp.setText(null);
+ }
+ }
+ }
+
+ private void loadCommentsAndLikes(final Post post, final PostViewHolder holder) {
+ if (mActivity.xmppConnectionService == null) {
+ return;
+ }
+ try {
+ final XmppUri uri = new XmppUri(post.getCommentsNode());
+ final Jid jid = uri.getJid();
+ final String node = uri.getParameter("node");
+ if (jid != null && node != null) {
+ mActivity.xmppConnectionService.fetchPubsubItems(jid, node, 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 comments = new ArrayList<>();
+ final List likes = new ArrayList<>();
+ if (pubsub != null) {
+ final Element items = pubsub.findChild("items");
+ if (items != null) {
+ for (Element item : items.getChildren()) {
+ if ("item".equals(item.getName())) {
+ final Comment comment = Comment.fromElement(item);
+ if (comment != null) {
+ if ("♥".equals(comment.getTitle())) {
+ likes.add(comment);
+ } else {
+ comments.add(comment);
+ }
+ }
+ }
+ }
+ }
+ }
+ mActivity.runOnUiThread(() -> {
+ if (holder.getAdapterPosition() == RecyclerView.NO_POSITION || posts.get(holder.getAdapterPosition()) != post) {
+ return;
+ }
+ if (!comments.isEmpty()) {
+ java.util.Collections.sort(comments, (c1, c2) -> {
+ Date d1 = c1.getPublished();
+ Date d2 = c2.getPublished();
+ if (d1 == null && d2 == null) return 0;
+ if (d1 == null) return -1;
+ if (d2 == null) return 1;
+ return d1.compareTo(d2);
+ });
+ CommentsAdapter commentsAdapter = new CommentsAdapter(mActivity, post, comments);
+ holder.binding.commentsList.setLayoutManager(new LinearLayoutManager(mActivity));
+ holder.binding.commentsList.setAdapter(commentsAdapter);
+ holder.binding.commentsList.setVisibility(View.VISIBLE);
+ } else {
+ holder.binding.commentsList.setVisibility(View.GONE);
+ }
+ // Update like button UI and logic
+ setupLikeButton(post, holder, likes);
+ });
+ } catch (Exception e) {
+ Log.e(Config.LOGTAG, "error parsing comments", e);
+ }
+ }
+
+ @Override
+ public void onPubsubItemsFetchFailed() {
+ Log.e(Config.LOGTAG, "failed to fetch comments for post " + post.getId());
+ }
+ });
+ }
+ } catch (final Exception e) {
+ Log.e(Config.LOGTAG, "error parsing comments node uri", e);
+ }
+ }
+
+ private void setupLikeButton(final Post post, final PostViewHolder holder, final List likes) {
+ final List onlineAccounts = mActivity.xmppConnectionService.getAccounts().stream()
+ .filter(Account::isOnlineAndConnected)
+ .collect(java.util.stream.Collectors.toList());
+
+ if (onlineAccounts.isEmpty()) {
+ holder.binding.likeButton.setEnabled(false);
+ holder.binding.likeCount.setText(String.valueOf(likes.size()));
+ return;
+ }
+ holder.binding.likeButton.setEnabled(true);
+ holder.binding.likeCount.setText(String.valueOf(likes.size()));
+
+ holder.binding.likeButton.setOnLongClickListener(v -> {
+ showLikesDialog(likes);
+ return true;
+ });
+
+ Comment myLike = null;
+ Account myLikerAccount = null;
+ for (Account acc : onlineAccounts) {
+ for (Comment like : likes) {
+ if (like.getAuthor() != null && like.getAuthor().asBareJid().equals(acc.getJid().asBareJid())) {
+ myLike = like;
+ myLikerAccount = acc;
+ break;
+ }
+ }
+ if (myLike != null) break;
+ }
+
+ final boolean hasLiked = myLike != null;
+ holder.binding.likeButton.setCompoundDrawablesWithIntrinsicBounds(
+ hasLiked ? R.drawable.favorite_filled_24 : R.drawable.favorite_border_24, 0, 0, 0);
+
+ final Comment finalMyLike = myLike;
+ final Account finalMyLikerAccount = myLikerAccount;
+ holder.binding.likeButton.setOnClickListener(v -> {
+ if (hasLiked && finalMyLikerAccount != null) {
+ retractLike(finalMyLikerAccount, finalMyLike, post, holder);
+ } else {
+ if (onlineAccounts.size() > 1) {
+ showAccountSelectionDialog(mActivity.getString(R.string.choose_account_for_like), onlineAccounts, selectedAccount -> {
+ publishLike(selectedAccount, post, holder);
+ });
+ } else {
+ publishLike(onlineAccounts.get(0), post, holder);
+ }
+ }
+ });
+ }
+
+ private void showLikesDialog(List likes) {
+ if (likes.isEmpty()) {
+ return;
+ }
+ final ArrayList likerDisplayNames = new ArrayList<>();
+ final List accounts = mActivity.xmppConnectionService.getAccounts();
+ for (Comment like : likes) {
+ if (like.getAuthor() != null) {
+ final Jid authorJid = like.getAuthor().asBareJid();
+ String displayName = null;
+ for (Account account : accounts) {
+ if (account.getRoster() != null) {
+ final Contact contact = account.getRoster().getContact(authorJid);
+ if (contact != null && contact.getDisplayName() != null && !contact.getDisplayName().isEmpty()) {
+ displayName = contact.getDisplayName();
+ break;
+ }
+ }
+ }
+ if (displayName != null) {
+ likerDisplayNames.add(displayName);
+ } else {
+ likerDisplayNames.add(authorJid.toString());
+ }
+ }
+ }
+ final ArrayAdapter adapter = new ArrayAdapter<>(mActivity, android.R.layout.simple_list_item_1, likerDisplayNames);
+
+ new MaterialAlertDialogBuilder(mActivity)
+ .setTitle(mActivity.getResources().getQuantityString(R.plurals.liked_by_title, likes.size(), likes.size()))
+ .setAdapter(adapter, null)
+ .setPositiveButton(R.string.action_close, null)
+ .create()
+ .show();
+ }
+
+ private void publishLike(final Account account, final Post post, final PostViewHolder holder) {
+ mActivity.xmppConnectionService.publishComment(account, post.getCommentsNode(), "♥", new XmppConnectionService.OnPostPublished() {
+ @Override
+ public void onPostPublished() {
+ mActivity.runOnUiThread(() -> loadCommentsAndLikes(post, holder));
+ }
+ @Override
+ public void onPostPublishFailed() {
+ mActivity.runOnUiThread(() -> Toast.makeText(mActivity, R.string.error_liking_post, Toast.LENGTH_SHORT).show());
+ }
+ });
+ }
+
+ private void retractLike(final Account account, final Comment like, final Post post, final PostViewHolder holder) {
+ try {
+ final XmppUri uri = new XmppUri(post.getCommentsNode());
+ final Jid jid = uri.getJid();
+ final String node = uri.getParameter("node");
+ mActivity.xmppConnectionService.retractPost(account, jid, node, like.getId(), new XmppConnectionService.OnPostRetracted() {
+ @Override
+ public void onPostRetracted(String postId) {
+ mActivity.runOnUiThread(() -> loadCommentsAndLikes(post, holder));
+ }
+ @Override
+ public void onPostRetractionFailed() {
+ mActivity.runOnUiThread(() -> Toast.makeText(mActivity, R.string.error_removing_like, Toast.LENGTH_SHORT).show());
+ }
+ });
+ } catch (Exception e) {
+ Log.e(Config.LOGTAG, "error retracting like", e);
+ mActivity.runOnUiThread(() -> Toast.makeText(mActivity, R.string.error_removing_like, Toast.LENGTH_SHORT).show());
+ }
+ }
+ }
+
+ private void downloadAttachment(Account account, Post post) {
+ if (mActivity.xmppConnectionService == null) {
+ return;
+ }
+ final var message = new Message(new StubConversation(account, "", null, 0), null, Message.ENCRYPTION_NONE);
+ message.setType(Message.TYPE_FILE);
+ Message.FileParams params = new Message.FileParams();
+ params.url = post.getAttachmentUrl();
+ message.setFileParams(params);
+
+ mActivity.xmppConnectionService.getHttpConnectionManager().createNewDownloadConnection(message, false, (file) -> {
+ mActivity.xmppConnectionService.copyAttachmentToDownloadsFolder(message, new UiCallback() {
+ @Override
+ public void success(Integer object) {
+ mActivity.runOnUiThread(() -> Toast.makeText(mActivity, R.string.save_to_downloads_success, Toast.LENGTH_SHORT).show());
+ }
+
+ @Override
+ public void error(int errorCode, Integer object) {
+ mActivity.runOnUiThread(() -> Toast.makeText(mActivity, errorCode, Toast.LENGTH_SHORT).show());
+ }
+
+ @Override
+ public void userInputRequired(android.app.PendingIntent pi, Integer object) {
+
+ }
+ });
+ });
+ Toast.makeText(mActivity, R.string.download_started, Toast.LENGTH_SHORT).show();
+ }
+
+ private void showAccountSelectionDialog(String title, List accounts, java.util.function.Consumer onAccountSelected) {
+ if (accounts.size() == 0) {
+ Toast.makeText(mActivity, R.string.no_active_account, Toast.LENGTH_SHORT).show();
+ return;
+ }
+ if (accounts.size() == 1) {
+ onAccountSelected.accept(accounts.get(0));
+ return;
+ } final ArrayAdapter adapter = new ArrayAdapter<>(mActivity, android.R.layout.simple_list_item_1);
+ final java.util.Map accountMap = new java.util.HashMap<>();
+ for (Account account : accounts) {
+ String jid = account.getJid().asBareJid().toString();
+ adapter.add(jid);
+ accountMap.put(jid, account);
+ }
+
+ new MaterialAlertDialogBuilder(mActivity)
+ .setTitle(title)
+ .setAdapter(adapter, (dialog, which) -> {
+ String jid = adapter.getItem(which);
+ onAccountSelected.accept(accountMap.get(jid));
+ })
+ .create()
+ .show();
+ }
+
+ private void replyToPost(Account account, Post post) {
+ Intent intent = new Intent(mActivity, CreatePostActivity.class);
+ intent.putExtra("in_reply_to_id", post.getId());
+ intent.putExtra("in_reply_to_node", post.getCommentsNode());
+ intent.putExtra("post_id", post.getId());
+ intent.putExtra("account", account.getUuid());
+ intent.putExtra("post_title", post.getTitle());
+ intent.putExtra("post_content", post.getContent());
+ postResultLauncher.launch(intent);
+ }
+
+ private void editPost(Account account, Post post) {
+ Intent intent = new Intent(mActivity, CreatePostActivity.class);
+ intent.putExtra("post_id", post.getId());
+ intent.putExtra("title", post.getTitle());
+ intent.putExtra("content", post.getContent());
+ intent.putExtra("account", account.getUuid());
+ if (post.getAttachmentUrl() != null) {
+ intent.putExtra("attachment_url", post.getAttachmentUrl());
+ intent.putExtra("attachment_type", post.getAttachmentType());
+ }
+ postResultLauncher.launch(intent);
+ }
+}
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..5c9713c89
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/ui/adapter/StoryAdapter.java
@@ -0,0 +1,191 @@
+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;
+import android.view.ViewGroup;
+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;
+
+import java.util.ArrayList;
+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 {
+
+ private final XmppActivity activity;
+ private final List 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
+ @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, @NonNull List