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 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 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 payloads) { + if (payloads.contains("payload_time")) { + final Story story = stories.get(position); + holder.storyTime.setText(DateUtils.getRelativeTimeSpanString(story.getPublished(), System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS)); + } else { + super.onBindViewHolder(holder, position, payloads); + } + } + + @Override + public void onBindViewHolder(@NonNull StoryViewHolder holder, int position) { + final Story story = stories.get(position); + final Jid jid = story.getContact(); + final int avatarSize = activity.getResources().getDimensionPixelSize(R.dimen.avatar_story_size); + + Contact contact = null; + Account storyAccount = null; + + List contacts = activity.xmppConnectionService.findContacts(jid, null); + if (!contacts.isEmpty()) { + Contact bestContact = null; + int bestScore = -1; + for (Contact c : contacts) { + int score = 0; + if (c.getAccount().getStatus() == Account.State.ONLINE) { + score += 2; + } + Drawable d = activity.xmppConnectionService.getAvatarService().get(c, avatarSize); + if (!(d instanceof eu.siacs.conversations.services.AvatarService.TextDrawable)) { + score += 1; + } + if (score > bestScore) { + bestScore = score; + bestContact = c; + } + } + contact = bestContact; + } + + if (contact != null) { + storyAccount = contact.getAccount(); + } else if (activity.xmppConnectionService.findAccountByJid(jid) != null) { + storyAccount = activity.xmppConnectionService.findAccountByJid(jid); + if (storyAccount != null) { + contact = storyAccount.getSelfContact(); + } + } + + if (contact != null) { + holder.storyTitle.setText(contact.getDisplayName()); + Conversation conversation = activity.xmppConnectionService.findOrCreateConversation(storyAccount, contact.getJid(), false, false); + AvatarWorkerTask.loadAvatar(conversation, holder.storyImage, R.dimen.avatar_on_conversation_overview); + } else { + holder.storyTitle.setText(jid.asBareJid().toString()); + holder.storyImage.setImageResource(R.drawable.ic_person_black_48dp); + } + + holder.storyTime.setText(DateUtils.getRelativeTimeSpanString(story.getPublished(), System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS)); + + Glide.with(activity).load(story.getUrl()).into(holder.storyPreview); + + final Account finalStoryAccount = storyAccount; + holder.itemView.setOnClickListener(v -> { + Intent intent = new Intent(activity, StoryViewActivity.class); + ArrayList urls = new ArrayList<>(); + ArrayList titles = new ArrayList<>(); + ArrayList storyIds = new ArrayList<>(); + ArrayList mimeTypes = new ArrayList<>(); + for (Story s : activity.xmppConnectionService.getStories()) { + if (s.getContact().asBareJid().equals(story.getContact().asBareJid())) { + urls.add(s.getUrl()); + titles.add(s.getTitle()); + storyIds.add(s.getUuid()); + mimeTypes.add(s.getType()); + } + } + intent.putStringArrayListExtra(StoryViewActivity.EXTRA_URLS, urls); + intent.putStringArrayListExtra(StoryViewActivity.EXTRA_TITLES, titles); + intent.putStringArrayListExtra(StoryViewActivity.EXTRA_STORY_IDS, storyIds); + intent.putStringArrayListExtra(StoryViewActivity.EXTRA_MIME_TYPES, mimeTypes); + intent.putExtra(StoryViewActivity.EXTRA_CONTACT, story.getContact().asBareJid().toString()); + if (finalStoryAccount != null) { + intent.putExtra(StoryViewActivity.EXTRA_ACCOUNT, finalStoryAccount.getUuid()); + } + activity.startActivity(intent); + }); + } + + @Override + public int getItemCount() { + return stories.size(); + } + + static class StoryViewHolder extends RecyclerView.ViewHolder { + + final ImageView storyImage; + final TextView storyTitle; + final TextView storyTime; + final ImageView storyPreview; + + StoryViewHolder(@NonNull View itemView) { + super(itemView); + storyImage = itemView.findViewById(R.id.story_image); + storyTitle = itemView.findViewById(R.id.story_title); + storyTime = itemView.findViewById(R.id.story_time); + storyPreview = itemView.findViewById(R.id.story_preview); + } + } + + @Override + public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) { + super.onAttachedToRecyclerView(recyclerView); + this.recyclerView = recyclerView; + handler.post(refreshRunnable); + } + + @Override + public void onDetachedFromRecyclerView(@NonNull RecyclerView recyclerView) { + super.onDetachedFromRecyclerView(recyclerView); + handler.removeCallbacks(refreshRunnable); + this.recyclerView = null; + } +} diff --git a/src/main/java/eu/siacs/conversations/ui/fragment/settings/ConnectionSettingsFragment.java b/src/main/java/eu/siacs/conversations/ui/fragment/settings/ConnectionSettingsFragment.java index c522250d2..f96621a98 100644 --- a/src/main/java/eu/siacs/conversations/ui/fragment/settings/ConnectionSettingsFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/fragment/settings/ConnectionSettingsFragment.java @@ -59,14 +59,14 @@ public class ConnectionSettingsFragment extends XmppPreferenceFragment { final var dnsv4Server = (EditTextPreference) findPreference("dns_server_ipv4"); if (dnsv4Server != null) { - dnsv4Server.setText("194.242.2.2"); + dnsv4Server.setText(null); } final var dnsv6Server = (EditTextPreference) findPreference("dns_server_ipv6"); if (dnsv6Server != null) { - dnsv6Server.setText("[2a07:e340::2]"); + dnsv6Server.setText(null); } - + reconnectAccounts(); Toast.makeText(requireSettingsActivity(),R.string.dns_server_reset,Toast.LENGTH_LONG).show(); return true; }); @@ -105,7 +105,7 @@ public class ConnectionSettingsFragment extends XmppPreferenceFragment { reconnectAccounts(); requireService().reinitializeMuclumbusService(); } - case AppSettings.SHOW_CONNECTION_OPTIONS, AppSettings.PREFER_IPV6 -> { + case AppSettings.SHOW_CONNECTION_OPTIONS, AppSettings.PREFER_IPV6, "dns_server_ipv4", "dns_server_ipv6" -> { reconnectAccounts(); } } @@ -146,4 +146,4 @@ public class ConnectionSettingsFragment extends XmppPreferenceFragment { "%s is not %s", activity.getClass().getName(), SettingsActivity.class.getName())); } -} +} \ No newline at end of file diff --git a/src/main/java/eu/siacs/conversations/ui/fragment/settings/InterfaceSettingsFragment.java b/src/main/java/eu/siacs/conversations/ui/fragment/settings/InterfaceSettingsFragment.java index d2f708c86..b454c0d39 100644 --- a/src/main/java/eu/siacs/conversations/ui/fragment/settings/InterfaceSettingsFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/fragment/settings/InterfaceSettingsFragment.java @@ -1,9 +1,11 @@ package eu.siacs.conversations.ui.fragment.settings; +import android.app.Activity; import android.content.Intent; import android.content.pm.PackageManager; -import android.os.Bundle; +import android.net.Uri; import android.os.Build; +import android.os.Bundle; import android.preference.Preference; import android.widget.Toast; @@ -15,8 +17,8 @@ import com.google.android.material.color.DynamicColors; import java.io.File; import eu.siacs.conversations.AppSettings; -import eu.siacs.conversations.Conversations; import eu.siacs.conversations.R; +import eu.siacs.conversations.medialib.activities.EditActivity; import eu.siacs.conversations.ui.activity.SettingsActivity; import eu.siacs.conversations.ui.util.SettingsUtils; import eu.siacs.conversations.utils.ChatBackgroundHelper; @@ -24,6 +26,8 @@ import eu.siacs.conversations.utils.ThemeHelper; public class InterfaceSettingsFragment extends XmppPreferenceFragment { + private static final int REQUEST_EDIT_BACKGROUND = 9124; + @Override public void onCreatePreferences(@Nullable Bundle savedInstanceState, @Nullable String rootKey) { setPreferencesFromResource(R.xml.preferences_interface, rootKey); @@ -145,12 +149,33 @@ public class InterfaceSettingsFragment extends XmppPreferenceFragment { activity.getClass().getName(), SettingsActivity.class.getName())); } - @Override public void onActivityResult(int requestCode, int resultCode, Intent data) { - super.onActivityResult(requestCode, resultCode, data); + if (resultCode == Activity.RESULT_OK) { + if (requestCode == ChatBackgroundHelper.REQUEST_IMPORT_BACKGROUND) { + final Uri imageUri = data == null ? null : data.getData(); + if (imageUri != null) { + final Intent editIntent = new Intent(getActivity(), EditActivity.class); + editIntent.setData(imageUri); + editIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + startActivityForResult(editIntent, REQUEST_EDIT_BACKGROUND); + return; + } + } else if (requestCode == REQUEST_EDIT_BACKGROUND) { + Uri uri = data != null ? (Uri) data.getParcelableExtra(EditActivity.KEY_EDITED_URI) : null; + if (uri == null && data != null) { + uri = data.getData(); + } - ChatBackgroundHelper.onActivityResult(requireSettingsActivity(), requestCode, resultCode, data, null); + if (uri != null) { + Intent resultIntent = new Intent(); + resultIntent.setData(uri); + ChatBackgroundHelper.onActivityResult(requireSettingsActivity(), ChatBackgroundHelper.REQUEST_IMPORT_BACKGROUND, resultCode, resultIntent, null); + } + return; + } + } + super.onActivityResult(requestCode, resultCode, data); } @Override diff --git a/src/main/java/eu/siacs/conversations/ui/fragment/settings/PrivacySettingsFragment.java b/src/main/java/eu/siacs/conversations/ui/fragment/settings/PrivacySettingsFragment.java index c7fbf40a2..94e86f720 100644 --- a/src/main/java/eu/siacs/conversations/ui/fragment/settings/PrivacySettingsFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/fragment/settings/PrivacySettingsFragment.java @@ -79,9 +79,6 @@ public class PrivacySettingsFragment extends XmppPreferenceFragment { requireService().toggleScreenEventReceiver(); requireService().refreshAllPresences(); } - case AppSettings.CUSTOM_RESOURCE_NAME -> { - reconnectAccounts(); - } case AppSettings.CONFIRM_MESSAGES, AppSettings.BROADCAST_LAST_ACTIVITY, AppSettings.ALLOW_MESSAGE_CORRECTION, diff --git a/src/main/java/eu/siacs/conversations/ui/util/QuoteHelper.java b/src/main/java/eu/siacs/conversations/ui/util/QuoteHelper.java index d835f862a..61536a183 100644 --- a/src/main/java/eu/siacs/conversations/ui/util/QuoteHelper.java +++ b/src/main/java/eu/siacs/conversations/ui/util/QuoteHelper.java @@ -98,6 +98,10 @@ public class QuoteHelper { } public static boolean isNestedTooDeeply(CharSequence line) { + return isNestedTooDeeply(line, Config.QUOTING_MAX_DEPTH); + } + + public static boolean isNestedTooDeeply(CharSequence line, int maxDepth) { if (isPositionQuoteStart(line, 0)) { int nestingDepth = 1; for (int i = 1; i < line.length(); i++) { @@ -107,7 +111,7 @@ public class QuoteHelper { break; } } - return nestingDepth >= (Config.QUOTING_MAX_DEPTH); + return nestingDepth >= maxDepth; } return false; } diff --git a/src/main/java/eu/siacs/conversations/utils/AccountUtils.java b/src/main/java/eu/siacs/conversations/utils/AccountUtils.java index bed96c24c..29dae1a49 100644 --- a/src/main/java/eu/siacs/conversations/utils/AccountUtils.java +++ b/src/main/java/eu/siacs/conversations/utils/AccountUtils.java @@ -132,4 +132,18 @@ public class AccountUtils { manageAccounts.setVisible(MANAGE_ACCOUNT_ACTIVITY != null); } } + + public static Account getFirstEnabled(List accounts) { + for (Account account : accounts) { + if (account.isOnlineAndConnected()) { + return account; + } + } + for (Account account : accounts) { + if (account.isEnabled()) { + return account; + } + } + return null; + } } diff --git a/src/main/java/eu/siacs/conversations/utils/MessageUtils.java b/src/main/java/eu/siacs/conversations/utils/MessageUtils.java index ce8b62b3e..6d34af83a 100644 --- a/src/main/java/eu/siacs/conversations/utils/MessageUtils.java +++ b/src/main/java/eu/siacs/conversations/utils/MessageUtils.java @@ -31,6 +31,7 @@ package eu.siacs.conversations.utils; import com.google.common.base.Strings; +import eu.siacs.conversations.Config; import eu.siacs.conversations.R; import eu.siacs.conversations.entities.Conversational; import eu.siacs.conversations.entities.Message; @@ -49,6 +50,10 @@ public class MessageUtils { public static final String EMPTY_STRING = ""; public static String prepareQuote(final Message message) { + return prepareQuote(message, Config.QUOTING_MAX_DEPTH, -1); + } + + public static String prepareQuote(final Message message, int maxDepth, int maxLines) { final StringBuilder builder = new StringBuilder(); final String body; if (message.hasMeCommand()) { @@ -66,14 +71,17 @@ public class MessageUtils { } else { body = message.getQuoteableBody(); } + int lines = 0; for (String line : body.split("\n")) { if (!(line.length() <= 0) && QuoteHelper.isNestedTooDeeply(line)) { continue; } + if (maxLines > 0 && maxLines <= lines) break; if (builder.length() != 0) { builder.append('\n'); } builder.append(line.trim()); + lines++; } return builder.toString(); } diff --git a/src/main/java/eu/siacs/conversations/utils/Resolver.java b/src/main/java/eu/siacs/conversations/utils/Resolver.java index fe5607e0d..45774a020 100644 --- a/src/main/java/eu/siacs/conversations/utils/Resolver.java +++ b/src/main/java/eu/siacs/conversations/utils/Resolver.java @@ -255,6 +255,8 @@ public class Resolver { final AbstractDnsClient dnssecclient = DnssecResolverApi.INSTANCE.getClient(); if (dnssecclient instanceof ReliableDnsClient) { ((ReliableDnsClient) dnssecclient).setUseHardcodedDnsServers(false); + // If your DNS server sucks, just don't do DNSSEC + ((ReliableDnsClient) dnssecclient).setMode(ReliableDnsClient.Mode.recursiveOnly); } } diff --git a/src/main/java/eu/siacs/conversations/utils/UIHelper.java b/src/main/java/eu/siacs/conversations/utils/UIHelper.java index e3003e325..89b30c97b 100644 --- a/src/main/java/eu/siacs/conversations/utils/UIHelper.java +++ b/src/main/java/eu/siacs/conversations/utils/UIHelper.java @@ -1,3 +1,4 @@ + package eu.siacs.conversations.utils; import android.content.Context; @@ -38,6 +39,7 @@ import eu.siacs.conversations.Config; import eu.siacs.conversations.R; import eu.siacs.conversations.crypto.axolotl.AxolotlService; import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.entities.Call; import eu.siacs.conversations.entities.Contact; import eu.siacs.conversations.entities.Conversation; import eu.siacs.conversations.entities.Conversational; @@ -60,7 +62,7 @@ import java.util.Locale; public class UIHelper { - private static final List LOCATION_QUESTIONS = + private static final List LOCATION_QUESTIONS = Arrays.asList( "where are you", // en "where are you now", // en @@ -81,12 +83,12 @@ public class UIHelper { "donde estas" // es ); - private static final List PUNCTIONATION = + private static final List PUNCTIONATION = Arrays.asList('.', ',', '?', '!', ';', ':'); - private static final int SHORT_DATE_FLAGS = + private static final int SHORT_DATE_FLAGS = DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_NO_YEAR | DateUtils.FORMAT_ABBREV_ALL; - private static final int FULL_DATE_FLAGS = + private static final int FULL_DATE_FLAGS = DateUtils.FORMAT_SHOW_TIME | DateUtils.FORMAT_ABBREV_ALL | DateUtils.FORMAT_SHOW_DATE; public static String readableTimeDifference(Context context, long time, boolean allowRelative) { @@ -635,6 +637,15 @@ public class UIHelper { ContextCompat.getColor(textView.getContext(), color)))); } + public static String getCallInfo(Context context, Call call) { + final boolean received = call.getStatus() == Message.STATUS_RECEIVED; + if (!call.isSuccessful() && received) { + return context.getString(R.string.missed_call); + } else { + return context.getString(received ? R.string.incoming_call : R.string.outgoing_call); + } + } + public static String filesizeToString(long size) { if (size > (1.5 * 1024 * 1024)) { return Math.round(size * 1f / (1024 * 1024)) + " MiB"; diff --git a/src/main/java/eu/siacs/conversations/xml/Namespace.java b/src/main/java/eu/siacs/conversations/xml/Namespace.java index 210b8d417..aa2939e1f 100644 --- a/src/main/java/eu/siacs/conversations/xml/Namespace.java +++ b/src/main/java/eu/siacs/conversations/xml/Namespace.java @@ -111,6 +111,10 @@ public final class Namespace { public static final String HASHES = "urn:xmpp:hashes:2"; public static final String MDS_DISPLAYED = "urn:xmpp:mds:displayed:0"; public static final String MDS_SERVER_ASSIST = "urn:xmpp:mds:server-assist:0"; + public static final String PUBSUB_SOCIAL_FEED = "urn:xmpp:pubsub-social-feed:1"; + public static final String MICROBLOG = "urn:xmpp:microblog:0"; + public static final String PUBSUB_STORIES = "urn:xmpp:pubsub-social-feed:stories:0"; + public static final String ATOM = "http://www.w3.org/2005/Atom"; public static final String ENTITY_CAPABILITIES = "http://jabber.org/protocol/caps"; public static final String ENTITY_CAPABILITIES_2 = "urn:xmpp:caps"; diff --git a/src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java b/src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java index ef4d4f583..e36c297d5 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java +++ b/src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java @@ -3,7 +3,6 @@ package eu.siacs.conversations.xmpp; import static eu.siacs.conversations.utils.Random.SECURE_RANDOM; import android.content.Context; -import android.content.SharedPreferences; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.os.Build; @@ -15,7 +14,6 @@ import android.util.Pair; import android.util.SparseArray; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.preference.PreferenceManager; import com.google.common.base.MoreObjects; import com.google.common.base.Optional; @@ -2115,12 +2113,11 @@ public class XmppConnection implements Runnable { return; } clearIqCallbacks(); - - // New resource setting - Context context = mXmppConnectionService.getApplicationContext(); - String clientResource = getEffectiveClientResource(context); - account.setResource(clientResource); - + if (account.getJid().isBareJid()) { + account.setResource(createNewResource()); + } else { + fixResource(mXmppConnectionService, account); + } final Iq iq = new Iq(Iq.Type.SET); final String resource = Config.USE_RANDOM_RESOURCE_ON_EVERY_BIND @@ -2165,7 +2162,7 @@ public class XmppConnection implements Runnable { if (packet.getType() == Iq.Type.ERROR && error != null && error.hasChild("conflict")) { - account.setResource(clientResource); + account.setResource(createNewResource()); } throw new StateChangingError(Account.State.BIND_FAILURE); } @@ -2510,9 +2507,7 @@ public class XmppConnection implements Runnable { if (loginInfo.saslVersion == SaslMechanism.Version.SASL_2) { this.appSettings.resetInstallationId(); } - Context context = mXmppConnectionService.getApplicationContext(); - String clientResource = getEffectiveClientResource(context); - account.setResource(clientResource); + account.setResource(createNewResource()); Log.d( Config.LOGTAG, account.getJid().asBareJid() @@ -2642,6 +2637,10 @@ public class XmppConnection implements Runnable { tagWriter.writeTag(stream, flush); } + private static String createNewResource() { + return String.format("%s.%s", BuildConfig.APP_NAME, CryptoHelper.random(3)); + } + public String sendIqPacket(final Iq packet, final Consumer callback) { return sendIqPacket(packet, callback, null); } @@ -3351,31 +3350,4 @@ public class XmppConnection implements Runnable { return hasDiscoFeature(account.getJid().asBareJid(), Namespace.MDS_DISPLAYED); } } - - /** - * Retrieves the custom resource from SharedPreferences or use a default one - */ - private String getEffectiveClientResource(Context context) { - SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); - String customResource = sharedPreferences.getString("custom_resource_name", null); - - if (customResource != null && !customResource.trim().isEmpty()) { - Log.d(Config.LOGTAG, "Using custom resource: " + customResource); - return String.format("%s.%s", customResource, CryptoHelper.random(3)); - } else { - // Fallback to original default resource generation logic - String appName = "monocles chat"; // A sensible default - try { - // appName = context.getString(R.string.app_name); - appName = BuildConfig.APP_NAME + " " + BuildConfig.VERSION_NAME; - } catch (Exception e) { - Log.w(Config.LOGTAG, "Could not get appName from BuildConfig, using default.",e); - } - String defaultResource = String.format("%s.%s", appName, CryptoHelper.random(3)); - // String defaultResource = String.format("%s", appName); - Log.d(Config.LOGTAG, "Using default generated resource: " + defaultResource); - return defaultResource; - - } - } } diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnectionManager.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnectionManager.java index 449023756..48bbc42b6 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnectionManager.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnectionManager.java @@ -104,6 +104,14 @@ public class JingleConnectionManager extends AbstractConnectionManager { connection = new JingleFileTransferConnection(this, id, from); } else if (Namespace.JINGLE_APPS_RTP.equals(descriptionNamespace) && isUsingClearNet(account)) { + // START of the fix for session-initiate + final Contact contact = account.getRoster().getContact(packet.getFrom()); + if (contact != null && contact.areCallsDisabled()) { + Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": rejecting session-initiate with disabled contact " + id.with); + sendDecline(account, packet, id); + return; + } + // END of the fix for session-initiate final boolean sessionEnded = this.terminatedSessions.asMap().containsKey(PersistableSessionId.of(id)); final boolean stranger = @@ -292,6 +300,21 @@ public class JingleConnectionManager extends AbstractConnectionManager { if (sessionId == null) { return; } + + if ("propose".equals(message.getName())) { + final Contact contact = account.getRoster().getContact(from); + if (contact != null && contact.areCallsDisabled()) { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": ignoring incoming call (propose) from disabled contact " + from); + final var rejection = new im.conversations.android.xmpp.model.stanza.Message(); + rejection.setType(im.conversations.android.xmpp.model.stanza.Message.Type.CHAT); + rejection.setTo(from); + rejection.addChild("reject", Namespace.JINGLE_MESSAGE).setAttribute("id", sessionId); + rejection.addChild("store", "urn:xmpp:hints"); + mXmppConnectionService.sendMessagePacket(account, rejection); + return; + } + } + if ("accept".equals(message.getName()) || "reject".equals(message.getName())) { for (AbstractJingleConnection connection : connections.values()) { if (connection instanceof JingleRtpConnection rtpConnection) { @@ -1290,4 +1313,16 @@ public class JingleConnectionManager extends AbstractConnectionManager { return false; } } + + private void sendDecline( + final Account account, final Iq request, final AbstractJingleConnection.Id id) { + mXmppConnectionService.sendIqPacket( + account, request.generateResponse(Iq.Type.RESULT), null); + final var iq = new Iq(Iq.Type.SET); + iq.setTo(id.with); + final var sessionTermination = + iq.addExtension(new Jingle(Jingle.Action.SESSION_TERMINATE, id.sessionId)); + sessionTermination.setReason(Reason.DECLINE, null); + mXmppConnectionService.sendIqPacket(account, iq, null); + } } diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java index 307805dba..e41ff5dcb 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java @@ -2921,6 +2921,7 @@ public class JingleRtpConnection extends AbstractJingleConnection ((Conversation) conversational).add(this.message); xmppConnectionService.createMessageAsync(message); xmppConnectionService.updateConversationUi(); + xmppConnectionService.updateCallLogUi(); } else { throw new IllegalStateException("Somehow the conversation in a message was a stub"); } diff --git a/src/main/java/org/minidns/DnsClient.java b/src/main/java/org/minidns/DnsClient.java index 6d0ae4592..692d9506f 100644 --- a/src/main/java/org/minidns/DnsClient.java +++ b/src/main/java/org/minidns/DnsClient.java @@ -12,6 +12,11 @@ package org.minidns; import static org.webrtc.ApplicationContextProvider.getApplicationContext; +import android.content.SharedPreferences; +import android.text.TextUtils; + +import androidx.preference.PreferenceManager; + import org.minidns.MiniDnsException.ErrorResponseException; import org.minidns.MiniDnsException.NoQueryPossibleException; import org.minidns.dnsmessage.DnsMessage; @@ -23,7 +28,6 @@ import org.minidns.dnsserverlookup.AndroidUsingReflection; import org.minidns.dnsserverlookup.DnsServerLookupMechanism; import org.minidns.dnsserverlookup.UnixUsingEtcResolvConf; import org.minidns.record.Record.TYPE; -import org.minidns.util.CollectionsUtil; import org.minidns.util.InetAddressUtil; import org.minidns.util.MultipleIoException; @@ -39,11 +43,8 @@ import java.util.List; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.CopyOnWriteArraySet; import java.util.logging.Level; -import eu.siacs.conversations.R; - /** * A minimal DNS client for SRV/A/AAAA/NS and CNAME lookups, with IDN support. * This circumvents the missing javax.naming package on android. @@ -52,37 +53,20 @@ public class DnsClient extends AbstractDnsClient { static final List LOOKUP_MECHANISMS = new CopyOnWriteArrayList<>(); - static final Set STATIC_IPV4_DNS_SERVERS = new CopyOnWriteArraySet<>(); - static final Set STATIC_IPV6_DNS_SERVERS = new CopyOnWriteArraySet<>(); - static { addDnsServerLookupMechanism(AndroidUsingExec.INSTANCE); addDnsServerLookupMechanism(AndroidUsingReflection.INSTANCE); addDnsServerLookupMechanism(UnixUsingEtcResolvConf.INSTANCE); - - try { - Inet4Address dnsforgeV4Dns = InetAddressUtil.ipv4From(eu.siacs.conversations.Conversations.getContext().getString(R.string.default_dns_server_ipv4)); - STATIC_IPV4_DNS_SERVERS.add(dnsforgeV4Dns); - } catch (IllegalArgumentException e) { - LOGGER.log(Level.WARNING, "Could not add static IPv4 DNS Server", e); - } - - try { - Inet6Address dnsforgeV6Dns = InetAddressUtil.ipv6From(eu.siacs.conversations.Conversations.getContext().getString(R.string.default_dns_server_ipv6)); - STATIC_IPV6_DNS_SERVERS.add(dnsforgeV6Dns); - } catch (IllegalArgumentException e) { - LOGGER.log(Level.WARNING, "Could not add static IPv6 DNS Server", e); - } } private static final Set blacklistedDnsServers = Collections.newSetFromMap(new ConcurrentHashMap(4)); private final Set nonRaServers = Collections.newSetFromMap(new ConcurrentHashMap(4)); - private boolean askForDnssec = false; + private boolean askForDnssec = true; private boolean disableResultFilter = false; - private boolean useHardcodedDnsServers = true; + private boolean useHardcodedDnsServers = false; /** * Create a new DNS client using the global default cache. @@ -103,36 +87,25 @@ public class DnsClient extends AbstractDnsClient { } private List getServerAddresses() { - List dnsServerAddresses = findDnsAddresses(); - + List hardcodedDnsServers = new ArrayList<>(); if (useHardcodedDnsServers) { - InetAddress primaryHardcodedDnsServer, secondaryHardcodedDnsServer = null; - switch (ipVersionSetting) { - case v4v6: - primaryHardcodedDnsServer = getRandomHardcodedIpv4DnsServer(); - secondaryHardcodedDnsServer = getRandomHarcodedIpv6DnsServer(); - break; - case v6v4: - primaryHardcodedDnsServer = getRandomHarcodedIpv6DnsServer(); - secondaryHardcodedDnsServer = getRandomHardcodedIpv4DnsServer(); - break; - case v4only: - primaryHardcodedDnsServer = getRandomHardcodedIpv4DnsServer(); - break; - case v6only: - primaryHardcodedDnsServer = getRandomHarcodedIpv6DnsServer(); - break; - default: - throw new AssertionError("Unknown ipVersionSetting: " + ipVersionSetting); + InetAddress primaryHardcodedDnsServer = getRandomHardcodedIpv4DnsServer(); + if (primaryHardcodedDnsServer != null) { + hardcodedDnsServers.add(primaryHardcodedDnsServer); } - - dnsServerAddresses.add(primaryHardcodedDnsServer); + InetAddress secondaryHardcodedDnsServer = getRandomHarcodedIpv6DnsServer(); if (secondaryHardcodedDnsServer != null) { - dnsServerAddresses.add(secondaryHardcodedDnsServer); + hardcodedDnsServers.add(secondaryHardcodedDnsServer); } } - return dnsServerAddresses; + if (!hardcodedDnsServers.isEmpty()) { + // If custom servers are defined, use them exclusively. + return hardcodedDnsServers; + } else { + // Otherwise, fall back to the system's DNS servers. + return findDnsAddresses(); + } } @Override @@ -150,6 +123,10 @@ public class DnsClient extends AbstractDnsClient { List dnsServerAddresses = getServerAddresses(); + if (dnsServerAddresses.isEmpty()) { + throw new NoQueryPossibleException(q); + } + List ioExceptions = new ArrayList<>(dnsServerAddresses.size()); for (InetAddress dns : dnsServerAddresses) { if (nonRaServers.contains(dns)) { @@ -179,22 +156,22 @@ public class DnsClient extends AbstractDnsClient { } switch (responseMessage.responseCode) { - case NO_ERROR: - case NX_DOMAIN: - break; - default: - String warning = "Response from " + dns + " asked for " + q.getQuestion() + " with error code: " - + responseMessage.responseCode + '.'; - if (!LOGGER.isLoggable(Level.FINE)) { - // Only append the responseMessage is log level is not fine. If it is fine or higher, the - // response has already been logged. - warning += "\n" + responseMessage; - } - LOGGER.warning(warning); + case NO_ERROR: + case NX_DOMAIN: + break; + default: + String warning = "Response from " + dns + " asked for " + q.getQuestion() + " with error code: " + + responseMessage.responseCode + '.'; + if (!LOGGER.isLoggable(Level.FINE)) { + // Only append the responseMessage is log level is not fine. If it is fine or higher, the + // response has already been logged. + warning += "\n" + responseMessage; + } + LOGGER.warning(warning); - ErrorResponseException exception = new ErrorResponseException(q, dnsQueryResult); - ioExceptions.add(exception); - continue; + ErrorResponseException exception = new ErrorResponseException(q, dnsQueryResult); + ioExceptions.add(exception); + continue; } return dnsQueryResult; @@ -259,7 +236,7 @@ public class DnsClient extends AbstractDnsClient { } catch (SecurityException exception) { LOGGER.log(Level.WARNING, "Could not lookup DNS server", exception); } - if (res == null) { + if (res == null || res.isEmpty()) { LOGGER.log(TRACE_LOG_LEVEL, "DnsServerLookupMechanism '" + mechanism.getName() + "' did not return any DNS server"); continue; } @@ -279,15 +256,7 @@ public class DnsClient extends AbstractDnsClient { new Object[] { mechanism.getName(), dnsServers }); } - assert !res.isEmpty(); - - // We could cache if res only contains IP addresses and avoid the verification in case. Not sure if its really that beneficial - // though, because the list returned by the server mechanism is rather short. - - // Verify the returned DNS servers: Ensure that only valid IP addresses are returned. We want to avoid that something else, - // especially a valid DNS name is returned, as this would cause the following String to InetAddress conversation using - // getByName(String) to cause a DNS lookup, which would be performed outside of the realm of MiniDNS and therefore also outside - // of its DNSSEC guarantees. + // Verify the returned DNS servers: Ensure that only valid IP addresses are returned. Iterator it = res.iterator(); while (it.hasNext()) { String potentialDnsServer = it.next(); @@ -297,7 +266,7 @@ public class DnsClient extends AbstractDnsClient { it.remove(); } else if (blacklistedDnsServers.contains(potentialDnsServer)) { LOGGER.fine("The DNS server lookup mechanism '" + mechanism.getName() - + "' returned a blacklisted result: '" + potentialDnsServer + "'"); + + "' returned a blacklisted result: '" + potentialDnsServer + "'"); it.remove(); } } @@ -307,7 +276,7 @@ public class DnsClient extends AbstractDnsClient { } LOGGER.warning("The DNS server lookup mechanism '" + mechanism.getName() - + "' returned not a single valid IP address after sanitazion"); + + "' returned not a single valid IP address after sanitazion"); res = null; } @@ -346,9 +315,7 @@ public class DnsClient extends AbstractDnsClient { int validServerAddresses = 0; for (String dnsServerString : res) { - // The following invariant must hold: "dnsServerString is a IP address". Therefore findDNS() must only return a List of Strings - // representing IP addresses. Otherwise the following call of getByName(String) may perform a DNS lookup without MiniDNS being - // involved. Something we want to avoid. + // The following invariant must hold: "dnsServerString is a IP address". assert InetAddressUtil.isIpAddress(dnsServerString); InetAddress dnsServerAddress; @@ -380,20 +347,20 @@ public class DnsClient extends AbstractDnsClient { List dnsServers = new ArrayList<>(validServerAddresses); switch (setting) { - case v4v6: - dnsServers.addAll(ipv4DnsServer); - dnsServers.addAll(ipv6DnsServer); - break; - case v6v4: - dnsServers.addAll(ipv6DnsServer); - dnsServers.addAll(ipv4DnsServer); - break; - case v4only: - dnsServers.addAll(ipv4DnsServer); - break; - case v6only: - dnsServers.addAll(ipv6DnsServer); - break; + case v4v6: + dnsServers.addAll(ipv4DnsServer); + dnsServers.addAll(ipv6DnsServer); + break; + case v6v4: + dnsServers.addAll(ipv6DnsServer); + dnsServers.addAll(ipv4DnsServer); + break; + case v4only: + dnsServers.addAll(ipv4DnsServer); + break; + case v6only: + dnsServers.addAll(ipv6DnsServer); + break; } return dnsServers; } @@ -404,15 +371,10 @@ public class DnsClient extends AbstractDnsClient { return; } synchronized (LOOKUP_MECHANISMS) { - // We can't use Collections.sort(CopyOnWriteArrayList) with Java 7. So we first create a temp array, sort it, and replace - // LOOKUP_MECHANISMS with the result. For more information about the Java 7 Collections.sort(CopyOnWriteArarayList) issue see - // http://stackoverflow.com/a/34827492/194894 - // TODO: Remove that workaround once MiniDNS is Java 8 only. ArrayList tempList = new ArrayList<>(LOOKUP_MECHANISMS.size() + 1); tempList.addAll(LOOKUP_MECHANISMS); tempList.add(dnsServerLookup); - // Sadly, this Collections.sort() does not with the CopyOnWriteArrayList on Java 7. Collections.sort(tempList); LOOKUP_MECHANISMS.clear(); @@ -459,11 +421,29 @@ public class DnsClient extends AbstractDnsClient { } public InetAddress getRandomHardcodedIpv4DnsServer() { - return CollectionsUtil.getRandomFrom(STATIC_IPV4_DNS_SERVERS, insecureRandom); + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getApplicationContext()); + String dnsServer = preferences.getString("dns_server_ipv4", null); + if (TextUtils.isEmpty(dnsServer)) { + return null; + } + try { + return InetAddressUtil.ipv4From(dnsServer); + } catch (IllegalArgumentException e) { + return null; + } } public InetAddress getRandomHarcodedIpv6DnsServer() { - return CollectionsUtil.getRandomFrom(STATIC_IPV6_DNS_SERVERS, insecureRandom); + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getApplicationContext()); + String dnsServer = preferences.getString("dns_server_ipv6", null); + if (TextUtils.isEmpty(dnsServer)) { + return null; + } + try { + return InetAddressUtil.ipv6From(dnsServer); + } catch (IllegalArgumentException e) { + return null; + } } private static Question getReverseIpLookupQuestionFor(DnsName dnsName) { @@ -491,6 +471,6 @@ public class DnsClient extends AbstractDnsClient { throw new IllegalArgumentException("The provided inetAddress '" + inetAddress + "' is neither of type Inet4Address nor Inet6Address"); } - } + } -} +} \ No newline at end of file diff --git a/src/main/java/org/minidns/constants/DnsRootServer.java b/src/main/java/org/minidns/constants/DnsRootServer.java index 2ff9e14dc..5a5583e27 100644 --- a/src/main/java/org/minidns/constants/DnsRootServer.java +++ b/src/main/java/org/minidns/constants/DnsRootServer.java @@ -12,7 +12,10 @@ package org.minidns.constants; import static org.webrtc.ApplicationContextProvider.getApplicationContext; -import android.content.res.Resources; +import android.content.SharedPreferences; +import android.text.TextUtils; + +import androidx.preference.PreferenceManager; import org.minidns.util.InetAddressUtil; @@ -46,9 +49,9 @@ public class DnsRootServer { rootServerInet4Address('k', 193, 0, 14, 129), rootServerInet4Address('l', 199, 7, 83, 42), rootServerInet4Address('m', 202, 12, 27, 33), - }; + }; - protected static final Inet6Address[] IPV6_ROOT_SERVERS = new Inet6Address[] { + protected static final Inet6Address[] IPV6_ROOT_SERVERS = new Inet6Address[] { rootServerInet6Address('a', 0x2001, 0x0503, 0xba3e, 0x0000, 0x0000, 0x000, 0x0002, 0x0030), rootServerInet6Address('b', 0x2001, 0x0500, 0x0084, 0x0000, 0x0000, 0x000, 0x0000, 0x000b), rootServerInet6Address('c', 0x2001, 0x0500, 0x0002, 0x0000, 0x0000, 0x000, 0x0000, 0x000c), @@ -59,65 +62,75 @@ public class DnsRootServer { rootServerInet6Address('j', 0x2001, 0x0503, 0x0c27, 0x0000, 0x0000, 0x000, 0x0002, 0x0030), rootServerInet6Address('l', 0x2001, 0x0500, 0x0003, 0x0000, 0x0000, 0x000, 0x0000, 0x0042), rootServerInet6Address('m', 0x2001, 0x0dc3, 0x0000, 0x0000, 0x0000, 0x000, 0x0000, 0x0035), - }; + }; - private static Inet4Address rootServerInet4Address(char rootServerId, int addr0, int addr1, int addr2, int addr3) { - Inet4Address inetAddress; - String name = rootServerId + ".root-servers.net"; - try { - inetAddress = (Inet4Address) InetAddress.getByAddress(name, new byte[] { (byte) addr0, (byte) addr1, (byte) addr2, - (byte) addr3 }); - IPV4_ROOT_SERVER_MAP.put(rootServerId, inetAddress); - } catch (UnknownHostException e) { - // This should never happen, if it does it's our fault! - throw new RuntimeException(e); - } - - return inetAddress; + private static Inet4Address rootServerInet4Address(char rootServerId, int addr0, int addr1, int addr2, int addr3) { + Inet4Address inetAddress; + String name = rootServerId + ".root-servers.net"; + try { + inetAddress = (Inet4Address) InetAddress.getByAddress(name, new byte[] { (byte) addr0, (byte) addr1, (byte) addr2, + (byte) addr3 }); + IPV4_ROOT_SERVER_MAP.put(rootServerId, inetAddress); + } catch (UnknownHostException e) { + // This should never happen, if it does it's our fault! + throw new RuntimeException(e); } - private static Inet6Address rootServerInet6Address(char rootServerId, int addr0, int addr1, int addr2, int addr3, int addr4, int addr5, int addr6, int addr7) { - Inet6Address inetAddress; - String name = rootServerId + ".root-servers.net"; - try { - inetAddress = (Inet6Address) InetAddress.getByAddress(name, new byte[] { - // @formatter:off - (byte) (addr0 >> 8), (byte) addr0, (byte) (addr1 >> 8), (byte) addr1, - (byte) (addr2 >> 8), (byte) addr2, (byte) (addr3 >> 8), (byte) addr3, - (byte) (addr4 >> 8), (byte) addr4, (byte) (addr5 >> 8), (byte) addr5, - (byte) (addr6 >> 8), (byte) addr6, (byte) (addr7 >> 8), (byte) addr7 - // @formatter:on - }); - IPV6_ROOT_SERVER_MAP.put(rootServerId, inetAddress); - } catch (UnknownHostException e) { - // This should never happen, if it does it's our fault! - throw new RuntimeException(e); - } - return inetAddress; - } + return inetAddress; + } - public static Inet4Address getRandomIpv4RootServer(Random random) { - if (getApplicationContext().getString(R.string.default_dns_server_ipv4).equals("194.242.2.2")) { - return IPV4_ROOT_SERVERS[random.nextInt(IPV4_ROOT_SERVERS.length)]; - } else { - return InetAddressUtil.ipv4From(eu.siacs.conversations.Conversations.getContext().getString(R.string.default_dns_server_ipv4)); + private static Inet6Address rootServerInet6Address(char rootServerId, int addr0, int addr1, int addr2, int addr3, int addr4, int addr5, int addr6, int addr7) { + Inet6Address inetAddress; + String name = rootServerId + ".root-servers.net"; + try { + inetAddress = (Inet6Address) InetAddress.getByAddress(name, new byte[] { + // @formatter:off + (byte) (addr0 >> 8), (byte) addr0, (byte) (addr1 >> 8), (byte) addr1, + (byte) (addr2 >> 8), (byte) addr2, (byte) (addr3 >> 8), (byte) addr3, + (byte) (addr4 >> 8), (byte) addr4, (byte) (addr5 >> 8), (byte) addr5, + (byte) (addr6 >> 8), (byte) addr6, (byte) (addr7 >> 8), (byte) addr7 + // @formatter:on + }); + IPV6_ROOT_SERVER_MAP.put(rootServerId, inetAddress); + } catch (UnknownHostException e) { + // This should never happen, if it does it's our fault! + throw new RuntimeException(e); + } + return inetAddress; + } + + public static Inet4Address getRandomIpv4RootServer(Random random) { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getApplicationContext()); + String dnsServer = preferences.getString("dns_server_ipv4", null); + if (!TextUtils.isEmpty(dnsServer)) { + try { + return InetAddressUtil.ipv4From(dnsServer); + } catch (IllegalArgumentException e) { + // Invalid format, do not fall back to root servers } } + return null; + } - public static Inet6Address getRandomIpv6RootServer(Random random) { - if (getApplicationContext().getString(R.string.default_dns_server_ipv6).equals("[2a07:e340::2]")) { - return IPV6_ROOT_SERVERS[random.nextInt(IPV6_ROOT_SERVERS.length)]; - } else { - return InetAddressUtil.ipv6From(eu.siacs.conversations.Conversations.getContext().getString(R.string.default_dns_server_ipv6)); + public static Inet6Address getRandomIpv6RootServer(Random random) { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getApplicationContext()); + String dnsServer = preferences.getString("dns_server_ipv6", null); + if (!TextUtils.isEmpty(dnsServer)) { + try { + return InetAddressUtil.ipv6From(dnsServer); + } catch (IllegalArgumentException e) { + // Invalid format, do not fall back to root servers } } + return null; + } - public static Inet4Address getIpv4RootServerById(char id) { - return IPV4_ROOT_SERVER_MAP.get(id); - } + public static Inet4Address getIpv4RootServerById(char id) { + return IPV4_ROOT_SERVER_MAP.get(id); + } - public static Inet6Address getIpv6RootServerById(char id) { - return IPV6_ROOT_SERVER_MAP.get(id); - } + public static Inet6Address getIpv6RootServerById(char id) { + return IPV6_ROOT_SERVER_MAP.get(id); + } -} +} \ No newline at end of file diff --git a/src/main/res/drawable/calls_selected_black_24dp.xml b/src/main/res/drawable/calls_selected_black_24dp.xml new file mode 100644 index 000000000..fbd2b0a26 --- /dev/null +++ b/src/main/res/drawable/calls_selected_black_24dp.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/calls_selected_white_24dp.xml b/src/main/res/drawable/calls_selected_white_24dp.xml new file mode 100644 index 000000000..03b62ddc2 --- /dev/null +++ b/src/main/res/drawable/calls_selected_white_24dp.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/calls_unselected_black_24dp.xml b/src/main/res/drawable/calls_unselected_black_24dp.xml new file mode 100644 index 000000000..5a059c4dc --- /dev/null +++ b/src/main/res/drawable/calls_unselected_black_24dp.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/calls_unselected_white_24dp.xml b/src/main/res/drawable/calls_unselected_white_24dp.xml new file mode 100644 index 000000000..c24354227 --- /dev/null +++ b/src/main/res/drawable/calls_unselected_white_24dp.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/delete_18dp.xml b/src/main/res/drawable/delete_18dp.xml new file mode 100644 index 000000000..d24cd1c85 --- /dev/null +++ b/src/main/res/drawable/delete_18dp.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/favorite_border_24.xml b/src/main/res/drawable/favorite_border_24.xml new file mode 100644 index 000000000..1fa51aaec --- /dev/null +++ b/src/main/res/drawable/favorite_border_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/favorite_filled_24.xml b/src/main/res/drawable/favorite_filled_24.xml new file mode 100644 index 000000000..76ed0289d --- /dev/null +++ b/src/main/res/drawable/favorite_filled_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/feed_selected_black_24dp.xml b/src/main/res/drawable/feed_selected_black_24dp.xml new file mode 100644 index 000000000..5d38cf398 --- /dev/null +++ b/src/main/res/drawable/feed_selected_black_24dp.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/feed_selected_white_24dp.xml b/src/main/res/drawable/feed_selected_white_24dp.xml new file mode 100644 index 000000000..f6e9f660f --- /dev/null +++ b/src/main/res/drawable/feed_selected_white_24dp.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/feed_unselected_black_24dp.xml b/src/main/res/drawable/feed_unselected_black_24dp.xml new file mode 100644 index 000000000..5d2aadbd7 --- /dev/null +++ b/src/main/res/drawable/feed_unselected_black_24dp.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/feed_unselected_white_24dp.xml b/src/main/res/drawable/feed_unselected_white_24dp.xml new file mode 100644 index 000000000..610856eb9 --- /dev/null +++ b/src/main/res/drawable/feed_unselected_white_24dp.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/ic_refresh_24dp.xml b/src/main/res/drawable/ic_refresh_24dp.xml index 0bb6637a7..7ad131e91 100644 --- a/src/main/res/drawable/ic_refresh_24dp.xml +++ b/src/main/res/drawable/ic_refresh_24dp.xml @@ -1,4 +1,4 @@ - + diff --git a/src/main/res/drawable/outline_add_a_photo_24.xml b/src/main/res/drawable/outline_add_a_photo_24.xml new file mode 100644 index 000000000..6741aa5ae --- /dev/null +++ b/src/main/res/drawable/outline_add_a_photo_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/outline_amp_stories_24.xml b/src/main/res/drawable/outline_amp_stories_24.xml new file mode 100644 index 000000000..8822e8e19 --- /dev/null +++ b/src/main/res/drawable/outline_amp_stories_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/outline_heart_plus_24.xml b/src/main/res/drawable/outline_heart_plus_24.xml new file mode 100644 index 000000000..8818f040f --- /dev/null +++ b/src/main/res/drawable/outline_heart_plus_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/outline_keyboard_arrow_down_24.xml b/src/main/res/drawable/outline_keyboard_arrow_down_24.xml new file mode 100644 index 000000000..aa067aadc --- /dev/null +++ b/src/main/res/drawable/outline_keyboard_arrow_down_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/outline_newspaper_24.xml b/src/main/res/drawable/outline_newspaper_24.xml new file mode 100644 index 000000000..a2199feb8 --- /dev/null +++ b/src/main/res/drawable/outline_newspaper_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/outline_stories_24.xml b/src/main/res/drawable/outline_stories_24.xml new file mode 100644 index 000000000..c1237cf2c --- /dev/null +++ b/src/main/res/drawable/outline_stories_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/stories_selected_black_24.xml b/src/main/res/drawable/stories_selected_black_24.xml new file mode 100644 index 000000000..619df85e4 --- /dev/null +++ b/src/main/res/drawable/stories_selected_black_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/stories_selected_white_24.xml b/src/main/res/drawable/stories_selected_white_24.xml new file mode 100644 index 000000000..2f463ebd2 --- /dev/null +++ b/src/main/res/drawable/stories_selected_white_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/stories_unselected_black_24.xml b/src/main/res/drawable/stories_unselected_black_24.xml new file mode 100644 index 000000000..90f017dde --- /dev/null +++ b/src/main/res/drawable/stories_unselected_black_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/stories_unselected_white_24.xml b/src/main/res/drawable/stories_unselected_white_24.xml new file mode 100644 index 000000000..1d85a1060 --- /dev/null +++ b/src/main/res/drawable/stories_unselected_white_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/res/drawable/story_reply_background.xml b/src/main/res/drawable/story_reply_background.xml new file mode 100644 index 000000000..281d285c4 --- /dev/null +++ b/src/main/res/drawable/story_reply_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/main/res/layout/activity_calls.xml b/src/main/res/layout/activity_calls.xml new file mode 100644 index 000000000..a2780377f --- /dev/null +++ b/src/main/res/layout/activity_calls.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/res/layout/activity_contact_details.xml b/src/main/res/layout/activity_contact_details.xml index 0824aff37..a57fbee67 100644 --- a/src/main/res/layout/activity_contact_details.xml +++ b/src/main/res/layout/activity_contact_details.xml @@ -163,6 +163,12 @@ android:textColor="@color/black87" android:text="@string/add_contact" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +