update fork #128

Manually merged
tristan merged 181 commits from mirror/monocles_chat_clean:master into master 2026-01-23 14:02:38 +01:00
135 changed files with 8599 additions and 811 deletions

View file

@ -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 <a href="https://translate.codeberg.org/projects/monocles_chat/">Codeberg Translate</a>. Merci beaucoup.
Vous pouvez améliorer ou créer des traductions sur <a href="https://translate.codeberg.org/projects/monocles_chat/">Codeberg Translate</a>. Merci beaucoup.
## Aidez-moi ! J'ai rencontré des problèmes !

View file

@ -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\""

View file

@ -567,5 +567,26 @@
<xmpp:note xml:lang='en'>Receiving retractions</xmpp:note>
</xmpp:SupportedXep>
</implements>
<implements>
<xmpp:SupportedXep>
<xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0501.html"/>
<xmpp:status>complete</xmpp:status>
<xmpp:version>0.2.0</xmpp:version>
</xmpp:SupportedXep>
</implements>
<implements>
<xmpp:SupportedXep>
<xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0277.html"/>
<xmpp:status>partial</xmpp:status>
<xmpp:version>0.6.5</xmpp:version>
</xmpp:SupportedXep>
</implements>
<implements>
<xmpp:SupportedXep>
<xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0472.html"/>
<xmpp:status>partial</xmpp:status>
<xmpp:version>0.2.1</xmpp:version>
</xmpp:SupportedXep>
</implements>
</Project>
</rdf:RDF>

View file

@ -396,6 +396,12 @@
<activity
android:name=".ui.MucUsersActivity"
android:label="@string/group_chat_members" />
<activity
android:name=".ui.StoriesActivity"
android:label="@string/stories" />
<activity
android:name=".ui.StoryViewActivity"
android:label="@string/story" />
<activity
android:name=".ui.ChannelDiscoveryActivity"
android:label="@string/discover_channels" />
@ -462,6 +468,14 @@
<data android:pathPattern=".*\\..*\\..*\\..*\\..*\\..*\\..*\\.ceb" />
</intent-filter>
</activity>
<activity
android:name=".ui.CallsActivity"
android:label="@string/calls" />
<activity
android:name=".ui.PostsActivity"
android:label="@string/feeds" />
<activity
android:name=".ui.CreatePostActivity"
android:label="@string/create_post" />
</application>
</manifest>

View file

@ -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";

View file

@ -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<Proxy> 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);
}
}

View file

@ -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;

View file

@ -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();
}
}

View file

@ -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;
}
}

View file

@ -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<String> 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);
}
}
}

View file

@ -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<String, Thread> threads = new HashMap<>();
protected Multimap<String, Reaction> 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;
}

View file

@ -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<MucOptions.User> counterparts;
private WeakReference<MucOptions.User> user;
private String retractId = null;
private androidx.core.util.Pair<Jid, String> 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<Jid, String> 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) {

View file

@ -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;
}
}

View file

@ -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<Element> 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<Story> parseFromPubSub(Element pubsub, Jid contact) {
List<Story> stories = new ArrayList<>();
if (pubsub == null) {
return stories;
}
Element items = pubsub.findChild("items");
if (items != null) {
final List<Element> 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;
}
}

View file

@ -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"

View file

@ -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;
}
}

View file

@ -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<String> callback) {
synchronized (this.uploadConnections) {
HttpUploadConnection connection = new HttpUploadConnection(account, file, Method.determine(account), this, callback);
connection.initForFile();
this.uploadConnections.add(connection);
}
}

View file

@ -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<SlotRequester.Slot> slotFuture;
private Runnable cb;
@Nullable
private final Runnable cb;
@Nullable
private final UiCallback<String> 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<String> 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<SlotRequester.Slot> 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<SlotRequester.Slot> 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<SlotRequester.Slot>() {
@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);
}
}
}

View file

@ -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");

View file

@ -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<Iq> {
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)) {

View file

@ -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<Element> 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")) {

View file

@ -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<Account> getAccounts(SQLiteDatabase db) {
final List<Account> 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<Message> getMessages(Conversation conversation, int type, int limit) {
ArrayList<Message> 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<eu.siacs.conversations.entities.Post> getPosts() {
final java.util.List<eu.siacs.conversations.entities.Post> 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);
}
}

View file

@ -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;

View file

@ -2468,4 +2468,10 @@ public class NotificationService {
return lastTime;
}
}
public boolean hasNewMissedCalls() {
synchronized (mMissedCalls) {
return !mMissedCalls.isEmpty();
}
}
}

View file

@ -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<Account> 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<OnCallLogUpdated> mOnCallLogUpdated =
Collections.newSetFromMap(new WeakHashMap<OnCallLogUpdated, Boolean>());
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<Void> 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<Void> 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<String> 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<Void> 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<String> wrapperCallback = new UiCallback<String>() {
@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<eu.siacs.conversations.entities.Story> stories = new java.util.concurrent.CopyOnWriteArrayList<>();
private final Set<OnStoriesUpdate> mOnStoriesUpdates =
java.util.Collections.newSetFromMap(new java.util.WeakHashMap<>());
public List<eu.siacs.conversations.entities.Story> 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<eu.siacs.conversations.entities.Story> 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<Void> callback) {
Iq iq = getIqGenerator().deleteItem(Namespace.PUBSUB_STORIES, storyId);
this.sendIqPacket(account, iq, response -> {
if (response.getType() == Iq.Type.RESULT) {
final List<eu.siacs.conversations.entities.Story> newStories = new ArrayList<>(stories);
for (Iterator<eu.siacs.conversations.entities.Story> 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<Jid, Account> 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<OnPostReceived> mOnPostReceivedListeners =
java.util.Collections.newSetFromMap(new java.util.WeakHashMap<>());
private final Set<OnPostRetracted> 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<Iq> 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<Iq> 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<OnCommentReceived> 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");
}
}
}
}
}

View file

@ -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);
}
}

View file

@ -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<Message> 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<Message> 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<Message> getCalls() {
List<Message> 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<String> 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<String> 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<String> permissions, int requestCode) {
final List<String> 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();
}
}

View file

@ -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<String> 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) {

View file

@ -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<String> 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();

View file

@ -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<Account, Integer>();
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);

View file

@ -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<Conversation> conversations = new ArrayList<>();
private final List<Conversation> conversations = new ArrayList<>();
private final PendingItem<Conversation> swipedConversation = new PendingItem<>();
private final PendingItem<ScrollState> 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<Account> 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<Account> 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<Account> 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);
}
}
}

View file

@ -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<Account> onlineAccounts = new ArrayList<>();
private final ActivityResultLauncher<String> 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<Intent> 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<Intent> 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<String> 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<String> accountJids = new ArrayList<>();
for (Account account : xmppConnectionService.getAccounts()) {
if (account.isOnlineAndConnected()) {
onlineAccounts.add(account);
accountJids.add(account.getJid().asBareJid().toString());
}
}
ArrayAdapter<String> 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<String>() {
@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);
}
}

View file

@ -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) {

View file

@ -0,0 +1,5 @@
package eu.siacs.conversations.ui;
public interface OnSearchPerformed {
void onSearchPerformed(String query);
}

View file

@ -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<Post> postList = new ArrayList<>();
private List<Post> allPosts = new ArrayList<>();
private String mCurrentQuery = "";
private SearchView mSearchView;
private FollowSuggestionAdapter mFollowSuggestionAdapter;
private List<Contact> mFollowSuggestions = new ArrayList<>();
private boolean mSuggestionsVisible = true;
private final ActivityResultLauncher<Intent> 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<Jid> 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<Post> 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);
}
}

View file

@ -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();
}

View file

@ -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<Uri> pendingTakePhotoUri = new PendingItem<>();
private ActivityStoriesBinding binding;
private StoryAdapter storyAdapter;
private final List<Story> 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<String>() {
@Override
public void success(String url) {
xmppConnectionService.publishStory(mSelectedAccount, url, mimeType, title, new UiCallback<Void>() {
@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<Account> 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<Account> 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);
}
}

View file

@ -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<String> 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<String> 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<File>() {
@Override
public void onResourceReady(@NonNull File resource, @Nullable Transition<? super File> transition) {
if (!isAdded()) return;
videoView.setVideoURI(Uri.fromFile(resource));
videoView.setOnPreparedListener(mp -> {
progressBar.setVisibility(View.GONE);
mp.setLooping(false);
videoView.start();
final int duration = mp.getDuration();
final ProgressBar currentStoryProgressBar = (ProgressBar) progressBarContainer.getChildAt(currentPosition);
if (currentStoryProgressBar != null) {
videoProgressRunnable = new Runnable() {
@Override
public void run() {
if (videoView != null && videoView.isPlaying()) {
int currentPosition = videoView.getCurrentPosition();
int progress = (int) (((float) currentPosition / duration) * 1000);
currentStoryProgressBar.setProgress(progress);
videoProgressHandler.postDelayed(this, 50);
}
}
};
videoProgressHandler.post(videoProgressRunnable);
}
});
}
@Override
public void onLoadCleared(@Nullable Drawable placeholder) {
// Do nothing
}
@Override
public void onLoadFailed(@Nullable Drawable errorDrawable) {
super.onLoadFailed(errorDrawable);
if (!isAdded()) return;
progressBar.setVisibility(View.GONE);
Toast.makeText(getContext(), R.string.download_failed_file_not_found, Toast.LENGTH_SHORT).show();
}
});
} else {
videoView.setVisibility(View.GONE);
imageView.setVisibility(View.VISIBLE);
Glide.with(this)
.load(url)
.listener(new RequestListener<Drawable>() {
@Override
public boolean onLoadFailed(@Nullable GlideException e, Object model, Target<Drawable> 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<Drawable> 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;
}
}

View file

@ -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<String> urls;
private ArrayList<String> titles;
private ArrayList<String> storyIds;
private ArrayList<String> 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<Void>() {
@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();
}
}

View file

@ -185,10 +185,6 @@ public class AccountAdapter extends RecyclerView.Adapter<AccountAdapter.ViewHold
}
notifyItemMoved(fromPosition, toPosition);
// Notify that data has changed so we can persist
if (mOnAccountMovedListener != null) {
mOnAccountMovedListener.onAccountMoved();
}
return true;
}

View file

@ -0,0 +1,147 @@
package eu.siacs.conversations.ui.adapter;
import android.os.Build;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageButton;
import android.widget.PopupMenu;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat;
import androidx.recyclerview.widget.RecyclerView;
import java.util.List;
import eu.siacs.conversations.R;
import eu.siacs.conversations.entities.Contact;
import eu.siacs.conversations.entities.Message;
import eu.siacs.conversations.services.XmppConnectionService;
import eu.siacs.conversations.ui.util.AvatarWorkerTask;
import eu.siacs.conversations.ui.widget.AvatarView;
import eu.siacs.conversations.utils.UIHelper;
public class CallsAdapter extends RecyclerView.Adapter<CallsAdapter.CallViewHolder> {
private final List<Message> 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<Message> 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);
}
}
}
}

View file

@ -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<CommentsAdapter.CommentViewHolder> {
private final Post mPost;
private final List<Comment> comments;
private final XmppActivity mActivity;
private XmppConnectionService.OnCommentReceived mOnCommentReceived;
public CommentsAdapter(XmppActivity activity, Post post, List<Comment> 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());
}
}
}
}

View file

@ -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<FollowSuggestionAdapter.ViewHolder> {
private final PostsActivity mPostsActivity;
private final List<Contact> mContacts;
private final XmppConnectionService mXmppConnectionService;
public FollowSuggestionAdapter(PostsActivity activity, XmppConnectionService service, List<Contact> 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);
}
}
}

View file

@ -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<Message> 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<Message> 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<Jid, String> 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<Message> 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<Message> 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<Message> 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<Message> 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<Message> 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<Message> 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<Message> 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;

View file

@ -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<PostsAdapter.PostViewHolder> {
private final List<Post> posts;
private final XmppActivity mActivity;
private final Set<Post> expandedPosts = new HashSet<>();
private final ActivityResultLauncher<Intent> postResultLauncher;
private final Markwon markwon;
private final OnSearchPerformed mOnSearchPerformed;
public PostsAdapter(XmppActivity activity, List<Post> posts, ActivityResultLauncher<Intent> 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<File>() {
@Override
public void onResourceReady(@NonNull File resource, @Nullable Transition<? super File> transition) {
videoView.setVideoURI(Uri.fromFile(resource));
videoView.setOnPreparedListener(mp -> {
mp.setLooping(true);
videoView.start();
});
}
@Override
public void onLoadCleared(@Nullable Drawable placeholder) {
// Do nothing
}
@Override
public void onLoadFailed(@Nullable Drawable errorDrawable) {
Toast.makeText(mActivity, R.string.download_failed_file_not_found, Toast.LENGTH_SHORT).show();
}
});
MediaController controller = new MediaController(mActivity);
controller.setAnchorView(frameLayout);
videoView.setMediaController(controller);
if (dialog.getWindow() != null) {
dialog.getWindow().setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
dialog.getWindow().setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
}
dialog.show();
}
class PostViewHolder extends RecyclerView.ViewHolder {
private final ItemPostBinding binding;
PostViewHolder(@NonNull View itemView) {
super(itemView);
binding = ItemPostBinding.bind(itemView);
}
void bind(Post post) {
binding.commentsList.setVisibility(View.GONE);
binding.commentsList.setAdapter(null);
binding.likeButton.setOnClickListener(null);
binding.likeCount.setText("");
binding.likeButton.setEnabled(false);
binding.likeButton.setCompoundDrawablesWithIntrinsicBounds(R.drawable.favorite_border_24, 0, 0, 0);
final boolean isExpanded = expandedPosts.contains(post);
binding.postContentSummary.setVisibility(isExpanded ? View.GONE : View.VISIBLE);
binding.postContentFull.setVisibility(isExpanded ? View.VISIBLE : View.GONE);
binding.postActions.setVisibility(isExpanded ? View.VISIBLE : View.GONE);
if (isExpanded) {
loadCommentsAndLikes(post, this);
}
final View.OnClickListener expandClickListener = v -> {
if (expandedPosts.contains(post)) {
expandedPosts.remove(post);
} else {
expandedPosts.add(post);}
notifyItemChanged(getAdapterPosition());
};
setupPostView(post, expandClickListener);
}
private void setupPostView(final Post post, final View.OnClickListener expandClickListener) {
final boolean isExpanded = expandedPosts.contains(post);
final boolean hasAttachment = post.getAttachmentUrl() != null;
final boolean isImage = hasAttachment && post.getAttachmentType() != null && post.getAttachmentType().startsWith("image/");
final boolean isVideo = hasAttachment && post.getAttachmentType() != null && post.getAttachmentType().startsWith("video/");
binding.attachmentHint.setVisibility(hasAttachment && !isExpanded ? View.VISIBLE : View.GONE);
binding.postImage.setVisibility(isExpanded && (isImage || isVideo) ? View.VISIBLE : View.GONE);
binding.videoOverlayIcon.setVisibility(isExpanded && isVideo ? View.VISIBLE : View.GONE);
binding.downloadButton.setVisibility(isExpanded && hasAttachment && !isImage && !isVideo ? View.VISIBLE : View.GONE);
if (isExpanded && (isImage || isVideo)) {
binding.attachmentProgress.setVisibility(View.VISIBLE);
Glide.with(mActivity)
.load(post.getAttachmentUrl()) .listener(new com.bumptech.glide.request.RequestListener<Drawable>() {
@Override
public boolean onLoadFailed(@Nullable com.bumptech.glide.load.engine.GlideException e, @Nullable Object model, @NonNull com.bumptech.glide.request.target.Target<Drawable> 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<Drawable> 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<Account> 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<Comment> comments = new ArrayList<>();
final List<Comment> 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<Comment> likes) {
final List<Account> 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<Comment> likes) {
if (likes.isEmpty()) {
return;
}
final ArrayList<String> likerDisplayNames = new ArrayList<>();
final List<Account> 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<String> 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<Integer>() {
@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<Account> accounts, java.util.function.Consumer<Account> 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<String> adapter = new ArrayAdapter<>(mActivity, android.R.layout.simple_list_item_1);
final java.util.Map<String, Account> 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);
}
}

View file

@ -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<StoryAdapter.StoryViewHolder> {
private final XmppActivity activity;
private final List<Story> stories;
private final Handler handler = new Handler();
private final Runnable refreshRunnable;
private RecyclerView recyclerView;
public StoryAdapter(XmppActivity activity, List<Story> 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<Object> 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<Contact> 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<String> urls = new ArrayList<>();
ArrayList<String> titles = new ArrayList<>();
ArrayList<String> storyIds = new ArrayList<>();
ArrayList<String> 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;
}
}

View file

@ -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()));
}
}
}

View file

@ -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

View file

@ -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,

View file

@ -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;
}

View file

@ -132,4 +132,18 @@ public class AccountUtils {
manageAccounts.setVisible(MANAGE_ACCOUNT_ACTIVITY != null);
}
}
public static Account getFirstEnabled(List<Account> accounts) {
for (Account account : accounts) {
if (account.isOnlineAndConnected()) {
return account;
}
}
for (Account account : accounts) {
if (account.isEnabled()) {
return account;
}
}
return null;
}
}

View file

@ -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();
}

View file

@ -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);
}
}

View file

@ -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<String> LOCATION_QUESTIONS =
private static final List<String> 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<Character> PUNCTIONATION =
private static final List<Character> 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";

View file

@ -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";

View file

@ -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<Iq> 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;
}
}
}

View file

@ -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);
}
}

View file

@ -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");
}

View file

@ -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<DnsServerLookupMechanism> LOOKUP_MECHANISMS = new CopyOnWriteArrayList<>();
static final Set<Inet4Address> STATIC_IPV4_DNS_SERVERS = new CopyOnWriteArraySet<>();
static final Set<Inet6Address> 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<String> blacklistedDnsServers = Collections.newSetFromMap(new ConcurrentHashMap<String, Boolean>(4));
private final Set<InetAddress> nonRaServers = Collections.newSetFromMap(new ConcurrentHashMap<InetAddress, Boolean>(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<InetAddress> getServerAddresses() {
List<InetAddress> dnsServerAddresses = findDnsAddresses();
List<InetAddress> 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<InetAddress> dnsServerAddresses = getServerAddresses();
if (dnsServerAddresses.isEmpty()) {
throw new NoQueryPossibleException(q);
}
List<IOException> 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<String> 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<InetAddress> 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<DnsServerLookupMechanism> 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");
}
}
}
}
}

View file

@ -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);
}
}
}

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M20.01,15.38c-1.23,0 -2.42,-0.2 -3.53,-0.56 -0.35,-0.12 -0.74,-0.03 -1.01,0.24l-1.57,1.97c-2.83,-1.35 -5.48,-3.9 -6.89,-6.83l1.95,-1.66c0.27,-0.28 0.35,-0.67 0.24,-1.02 -0.37,-1.11 -0.56,-2.3 -0.56,-3.53 0,-0.54 -0.45,-0.99 -0.99,-0.99H4.19C3.65,3 3,3.24 3,3.99 3,13.28 10.73,21 20.01,21c0.71,0 0.99,-0.63 0.99,-1.18v-3.45c0,-0.54 -0.45,-0.99 -0.99,-0.99z"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M20.01,15.38c-1.23,0 -2.42,-0.2 -3.53,-0.56 -0.35,-0.12 -0.74,-0.03 -1.01,0.24l-1.57,1.97c-2.83,-1.35 -5.48,-3.9 -6.89,-6.83l1.95,-1.66c0.27,-0.28 0.35,-0.67 0.24,-1.02 -0.37,-1.11 -0.56,-2.3 -0.56,-3.53 0,-0.54 -0.45,-0.99 -0.99,-0.99H4.19C3.65,3 3,3.24 3,3.99 3,13.28 10.73,21 20.01,21c0.71,0 0.99,-0.63 0.99,-1.18v-3.45c0,-0.54 -0.45,-0.99 -0.99,-0.99z"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M6.54,5c0.06,0.89 0.21,1.76 0.45,2.59l-1.2,1.2c-0.41,-1.2 -0.67,-2.47 -0.76,-3.79h1.51m9.86,12.02c0.85,0.24 1.72,0.39 2.6,0.45v1.49c-1.32,-0.09 -2.59,-0.35 -3.8,-0.75l1.2,-1.19M7.5,3H4c-0.55,0 -1,0.45 -1,1 0,9.39 7.61,17 17,17 0.55,0 1,-0.45 1,-1v-3.49c0,-0.55 -0.45,-1 -1,-1 -1.24,0 -2.45,-0.2 -3.57,-0.57 -0.1,-0.04 -0.21,-0.05 -0.31,-0.05 -0.26,0 -0.51,0.1 -0.71,0.29l-2.2,2.2c-2.83,-1.45 -5.15,-3.76 -6.59,-6.59l2.2,-2.2c0.28,-0.28 0.36,-0.67 0.25,-1.02C8.7,6.45 8.5,5.25 8.5,4c0,-0.55 -0.45,-1 -1,-1z"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M6.54,5c0.06,0.89 0.21,1.76 0.45,2.59l-1.2,1.2c-0.41,-1.2 -0.67,-2.47 -0.76,-3.79h1.51m9.86,12.02c0.85,0.24 1.72,0.39 2.6,0.45v1.49c-1.32,-0.09 -2.59,-0.35 -3.8,-0.75l1.2,-1.19M7.5,3H4c-0.55,0 -1,0.45 -1,1 0,9.39 7.61,17 17,17 0.55,0 1,-0.45 1,-1v-3.49c0,-0.55 -0.45,-1 -1,-1 -1.24,0 -2.45,-0.2 -3.57,-0.57 -0.1,-0.04 -0.21,-0.05 -0.31,-0.05 -0.26,0 -0.51,0.1 -0.71,0.29l-2.2,2.2c-2.83,-1.45 -5.15,-3.76 -6.59,-6.59l2.2,-2.2c0.28,-0.28 0.36,-0.67 0.25,-1.02C8.7,6.45 8.5,5.25 8.5,4c0,-0.55 -0.45,-1 -1,-1z"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="18dp" android:tint="?colorControlNormal" android:viewportHeight="960" android:viewportWidth="960" android:width="18dp">
<path android:fillColor="@android:color/white" android:pathData="M280,840Q247,840 223.5,816.5Q200,793 200,760L200,240L160,240L160,160L360,160L360,120L600,120L600,160L800,160L800,240L760,240L760,760Q760,793 736.5,816.5Q713,840 680,840L280,840ZM680,240L280,240L280,760Q280,760 280,760Q280,760 280,760L680,760Q680,760 680,760Q680,760 680,760L680,240ZM360,680L440,680L440,320L360,320L360,680ZM520,680L600,680L600,320L520,320L520,680ZM280,240L280,240L280,760Q280,760 280,760Q280,760 280,760L280,760Q280,760 280,760Q280,760 280,760L280,240Z"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="?attr/colorControlNormal" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M16.5,3c-1.74,0 -3.41,0.81 -4.5,2.09C10.91,3.81 9.24,3 7.5,3 4.42,3 2,5.42 2,8.5c0,3.78 3.4,6.86 8.55,11.54L12,21.35l1.45,-1.32C18.6,15.36 22,12.28 22,8.5 22,5.42 19.58,3 16.5,3zM12.1,18.55l-0.1,0.1 -0.1,-0.1C7.14,14.24 4,11.39 4,8.5 4,6.5 5.5,5 7.5,5c1.54,0 3.04,0.99 3.57,2.36h1.87C13.46,5.99 14.96,5 16.5,5c2,0 3.5,1.5 3.5,3.5 0,2.89 -3.14,5.74 -7.9,10.05z"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="?attr/colorControlNormal" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M16,3H5C3.9,3 3,3.9 3,5v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2V8L16,3zM7,7h5v2H7V7zM17,17H7v-2h10V17zM17,13H7v-2h10V13zM15,9V5l4,4H15z"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M16,3H5C3.9,3 3,3.9 3,5v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2V8L16,3zM7,7h5v2H7V7zM17,17H7v-2h10V17zM17,13H7v-2h10V13zM15,9V5l4,4H15z"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M16,3H5C3.9,3 3,3.9 3,5v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2V8L16,3zM19,19H5V5h10v4h4V19zM7,17h10v-2H7V17zM12,7H7v2h5V7zM7,13h10v-2H7V13z"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M16,3H5C3.9,3 3,3.9 3,5v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2V8L16,3zM19,19H5V5h10v4h4V19zM7,17h10v-2H7V17zM12,7H7v2h5V7zM7,13h10v-2H7V13z"/>
</vector>

View file

@ -1,4 +1,4 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="?attr/colorControlNormal" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="?colorControlNormal" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M440,520L440,520L440,520L440,520L440,520L440,520Q440,520 440,520Q440,520 440,520L440,520Q440,520 440,520Q440,520 440,520L440,520Q440,520 440,520Q440,520 440,520L440,520L440,520ZM120,840Q87,840 63.5,816.5Q40,793 40,760L40,280Q40,247 63.5,223.5Q87,200 120,200L246,200L320,120L560,120L560,200L355,200L282,280L120,280Q120,280 120,280Q120,280 120,280L120,760Q120,760 120,760Q120,760 120,760L760,760Q760,760 760,760Q760,760 760,760L760,400L840,400L840,760Q840,793 816.5,816.5Q793,840 760,840L120,840ZM760,280L760,200L680,200L680,120L760,120L760,40L840,40L840,120L920,120L920,200L840,200L840,280L760,280ZM440,700Q515,700 567.5,647.5Q620,595 620,520Q620,445 567.5,392.5Q515,340 440,340Q365,340 312.5,392.5Q260,445 260,520Q260,595 312.5,647.5Q365,700 440,700ZM440,620Q398,620 369,591Q340,562 340,520Q340,478 369,449Q398,420 440,420Q482,420 511,449Q540,478 540,520Q540,562 511,591Q482,620 440,620Z"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="?attr/colorControlNormal" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M280,800L280,160L680,160L680,800L280,800ZM120,720L120,240L200,240L200,720L120,720ZM760,720L760,240L840,240L840,720L760,720ZM360,720L600,720L600,240L360,240L360,720ZM360,720L360,240L360,240L360,720Z"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M440,459Q440,459 440,459Q440,459 440,459Q440,459 440,459Q440,459 440,459Q440,459 440,459Q440,459 440,459Q440,459 440,459Q440,459 440,459L440,459Q440,459 440,459Q440,459 440,459Q440,459 440,459Q440,459 440,459Q440,459 440,459Q440,459 440,459Q440,459 440,459Q440,459 440,459Q440,459 440,459Q440,459 440,459Q440,459 440,459Q440,459 440,459ZM440,840L313,726Q241,661 189.5,610Q138,559 104.5,514Q71,469 55.5,427Q40,385 40,339Q40,245 103,182.5Q166,120 260,120Q312,120 359,142Q406,164 440,204Q474,164 521,142Q568,120 620,120Q701,120 756,165.5Q811,211 831,280Q831,280 817.5,280Q804,280 788.5,280Q773,280 759.5,280Q746,280 746,280Q728,240 693,220Q658,200 620,200Q569,200 532,227.5Q495,255 463,300L417,300Q386,255 346.5,227.5Q307,200 260,200Q203,200 161.5,239.5Q120,279 120,339Q120,372 134,406Q148,440 184,484.5Q220,529 282,588.5Q344,648 440,732Q466,709 501,679Q536,649 557,629Q557,629 566,638Q575,647 585.5,657.5Q596,668 605,677Q614,686 614,686Q592,706 558,735.5Q524,765 498,788L440,840ZM720,680L720,560L600,560L600,480L720,480L720,360L800,360L800,480L920,480L920,560L800,560L800,680L720,680Z"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="?colorControlNormal" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M480,616L240,376L296,320L480,504L664,320L720,376L480,616Z"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="?attr/colorControlNormal" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M16,3H5C3.9,3 3,3.9 3,5v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2V8L16,3zM19,19H5V5h10v4h4V19zM7,17h10v-2H7V17zM12,7H7v2h5V7zM7,13h10v-2H7V13z"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="?attr/colorControlNormal" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M14,2H4C2.9,2 2,2.9 2,4v10h2V4h10V2zM18,6H8C6.9,6 6,6.9 6,8v10h2V8h10V6zM20,10h-8c-1.1,0 -2,0.9 -2,2v8c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2v-8C22,10.9 21.1,10 20,10zM20,20h-8v-8h8V20z"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M14,2L4,2c-1.11,0 -2,0.9 -2,2v10h2L4,4h10L14,2zM18,6L8,6c-1.11,0 -2,0.9 -2,2v10h2L8,8h10L18,6zM20,10h-8c-1.11,0 -2,0.9 -2,2v8c0,1.1 0.89,2 2,2h8c1.1,0 2,-0.9 2,-2v-8c0,-1.1 -0.9,-2 -2,-2z"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M14,2L4,2c-1.11,0 -2,0.9 -2,2v10h2L4,4h10L14,2zM18,6L8,6c-1.11,0 -2,0.9 -2,2v10h2L8,8h10L18,6zM20,10h-8c-1.11,0 -2,0.9 -2,2v8c0,1.1 0.89,2 2,2h8c1.1,0 2,-0.9 2,-2v-8c0,-1.1 -0.9,-2 -2,-2z"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M14,2H4C2.9,2 2,2.9 2,4v10h2V4h10V2zM18,6H8C6.9,6 6,6.9 6,8v10h2V8h10V6zM20,10h-8c-1.1,0 -2,0.9 -2,2v8c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2v-8C22,10.9 21.1,10 20,10zM20,20h-8v-8h8V20z"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M14,2H4C2.9,2 2,2.9 2,4v10h2V4h10V2zM18,6H8C6.9,6 6,6.9 6,8v10h2V8h10V6zM20,10h-8c-1.1,0 -2,0.9 -2,2v8c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2v-8C22,10.9 21.1,10 20,10zM20,20h-8v-8h8V20z"/>
</vector>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="?attr/colorSurfaceVariant" />
<corners android:radius="12dp" />
</shape>

View file

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:fitsSystemWindows="true">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_above="@id/bottom_navigation">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:title="@string/calls" />
</com.google.android.material.appbar.AppBarLayout>
<FrameLayout
android:id="@+id/fragment_container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
</RelativeLayout>
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottom_navigation"
android:layout_width="match_parent"
android:layout_height="@dimen/nav_bar_height"
android:layout_alignParentBottom="true"
android:background="?colorSurfaceContainerLowest"
app:labelVisibilityMode="labeled"
app:menu="@menu/bottom_navigation_menu_calls" />
</RelativeLayout>
</layout>

View file

@ -163,6 +163,12 @@
android:textColor="@color/black87"
android:text="@string/add_contact" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/follow_feed_switch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/follow_feed" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/details_send_presence"
android:layout_width="match_parent"
@ -175,6 +181,14 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/receive_presence_updates" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/disable_calls"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/disable_voice_and_video_calls" />
</LinearLayout>
<TextView

View file

@ -0,0 +1,209 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
tools:context=".ui.CreatePostActivity">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/app_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:fillViewport="true"
app:layout_constraintTop_toBottomOf="@id/app_bar"
app:layout_constraintBottom_toTopOf="@+id/attachment_options">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingBottom="16dp">
<com.google.android.material.card.MaterialCardView
android:id="@+id/post_preview"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="8dp">
<TextView
android:id="@+id/post_preview_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.Material3.TitleSmall"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="This is the original post title" />
<TextView
android:id="@+id/post_preview_content"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:maxLines="3"
android:ellipsize="end"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/post_preview_title"
tools:text="This is a preview of the original post content that you are replying to." />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
<LinearLayout
android:id="@+id/posts_attachment_preview_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:orientation="vertical"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/post_preview">
<ImageView
android:id="@+id/attachment_preview"
android:layout_width="match_parent"
android:layout_height="150dp"
android:layout_gravity="center_horizontal"
android:visibility="gone"
android:scaleType="fitCenter"
tools:src="@tools:sample/avatars"
tools:visibility="visible" />
<VideoView
android:id="@+id/attachment_video_view"
android:layout_width="match_parent"
android:layout_height="150dp"
android:layout_gravity="center_horizontal"
android:visibility="gone"
tools:src="@tools:sample/avatars"
tools:visibility="visible" />
</LinearLayout>
<Spinner
android:id="@+id/account_spinner"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/posts_attachment_preview_layout" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/post_title_layout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/account_spinner">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/post_title_edit_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/post_title"
android:textColor="?android:attr/textColorPrimary"
android:textColorHint="?android:attr/textColorHint"
android:textColorHighlight="?attr/colorControlHighlight"
android:textColorLink="?attr/colorAccent" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/post_content_layout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/post_title_layout">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/post_content_edit_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="top"
android:minLines="5"
android:textColor="?android:attr/textColorPrimary"
android:textColorHint="?android:attr/textColorHint"
android:textColorHighlight="?attr/colorControlHighlight"
android:textColorLink="?attr/colorAccent" />
</com.google.android.material.textfield.TextInputLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.core.widget.NestedScrollView>
<LinearLayout
android:id="@+id/attachment_options"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="16dp"
android:paddingEnd="16dp"
app:layout_constraintBottom_toTopOf="@+id/publish_button">
<ImageButton
android:id="@+id/attach_file_button"
style="?android:attr/buttonBarButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ic_attach_file_24dp" />
<ImageButton
android:id="@+id/attach_image_button"
style="?android:attr/buttonBarButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ic_camera_alt_24dp" />
<ImageButton
android:id="@+id/attach_video_button"
style="?android:attr/buttonBarButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ic_videocam_24dp"
android:contentDescription="@string/attach_record_video" />
</LinearLayout>
<Button
android:id="@+id/publish_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:text="@string/publish"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View file

@ -9,7 +9,6 @@
<LinearLayout
android:layout_width="fill_parent"
android:layout_height="fill_parent"
android:layout_above="@id/bottom_navigation"
android:orientation="vertical">
<com.google.android.material.appbar.AppBarLayout
@ -85,16 +84,6 @@
android:src="@drawable/ic_settings_24dp"
app:shapeAppearance="@style/ShapeAppearanceOverlay.Photo" />
</RelativeLayout>
</LinearLayout>
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottom_navigation"
android:layout_width="match_parent"
android:layout_height="@dimen/nav_bar_height"
android:layout_alignParentBottom="true"
android:background="?colorSurfaceContainerLowest"
app:labelVisibilityMode="labeled"
app:menu="@menu/bottom_navigation_menu_accounts" />
</RelativeLayout>
</layout>

View file

@ -0,0 +1,112 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true" >
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_above="@id/bottom_navigation">
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.PostsActivity">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/action_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="?attr/actionBarSize" />
</com.google.android.material.appbar.AppBarLayout>
<LinearLayout
android:id="@+id/follow_suggestions_header"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:visibility="gone"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
tools:visibility="visible"
android:animateLayoutChanges="true">
<TextView
android:id="@+id/follow_suggestions_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/follow_suggestions" />
<ImageButton
android:id="@+id/toggle_suggestions_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/toggle_suggestions"
android:src="@drawable/outline_keyboard_arrow_down_24" />
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/follow_suggestions_list"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:visibility="gone"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipe_container"
android:layout_width="match_parent"
android:layout_height="match_parent" >
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/posts_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:listitem="@layout/item_post" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</LinearLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab_create_post"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:src="@drawable/ic_edit_24dp"
app:layout_anchor="@id/posts_list"
app:layout_anchorGravity="bottom|end" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</RelativeLayout>
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottom_navigation"
android:layout_width="match_parent"
android:layout_height="@dimen/nav_bar_height"
android:layout_alignParentBottom="true"
android:background="?colorSurfaceContainerLowest"
app:labelVisibilityMode="labeled"
app:menu="@menu/bottom_navigation_menu_feeds" />
</RelativeLayout>
</layout>

View file

@ -7,11 +7,6 @@
android:layout_height="match_parent"
android:fitsSystemWindows="true">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_above="@id/bottom_navigation">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/app_bar_layout"
android:layout_width="match_parent"
@ -65,16 +60,6 @@
app:sdMainFabOpenedIconColor="?colorOnPrimaryContainer"
app:sdOverlayLayout="@id/overlay"
app:sdUseReverseAnimationOnClose="true" />
</RelativeLayout>
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottom_navigation"
android:layout_width="match_parent"
android:layout_height="@dimen/nav_bar_height"
android:layout_alignParentBottom="true"
android:background="?colorSurfaceContainerLowest"
app:labelVisibilityMode="labeled"
app:menu="@menu/bottom_navigation_menu_contacts" />
</RelativeLayout>
</layout>

View file

@ -0,0 +1,69 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:fitsSystemWindows="true">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_above="@id/bottom_navigation">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="?attr/actionBarSize" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/stories_list"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<TextView
android:id="@+id/placeholder"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:visibility="gone" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</LinearLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab_add_story"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_alignParentBottom="true"
android:layout_margin="16dp"
app:srcCompat="@drawable/outline_add_a_photo_24" />
</RelativeLayout>
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottom_navigation"
android:layout_width="match_parent"
android:layout_height="@dimen/nav_bar_height"
android:layout_alignParentBottom="true"
android:background="?colorSurfaceContainerLowest"
app:labelVisibilityMode="labeled"
app:menu="@menu/bottom_navigation_menu_stories" />
</RelativeLayout>
</layout>

View file

@ -0,0 +1,92 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/black"
android:fitsSystemWindows="true">
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/view_pager"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/app_bar_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#80000000"
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
app:popupTheme="@style/ThemeOverlay.AppCompat.Light">
<LinearLayout
android:id="@+id/progress_bar_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:indeterminate="true"
android:paddingTop="8dp"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingBottom="4dp" />
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="?attr/actionBarSize" >
<eu.siacs.conversations.ui.widget.AvatarView
android:id="@+id/toolbar_avatar"
android:layout_width="38dp"
android:layout_height="38dp"
android:layout_marginEnd="8dp"
android:scaleType="centerCrop" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/toolbar_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxLines="1"
android:textSize="@dimen/actionbar_text_size" />
<TextView
android:id="@+id/toolbar_subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
android:maxLines="1" />
</LinearLayout>
</com.google.android.material.appbar.MaterialToolbar>
</com.google.android.material.appbar.AppBarLayout>
<LinearLayout
android:id="@+id/bottom_panel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:background="#80000000"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/story_title_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:autoLink="web"
android:linksClickable="true"
android:maxLines="8"
android:scrollbars="vertical"
android:textColor="@android:color/white"
android:textSize="18sp" />
</LinearLayout>
</FrameLayout>

View file

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:tools="http://schemas.android.com/tools"
android:orientation="vertical"
android:padding="16dp">
<ImageView
android:id="@+id/story_preview_image"
android:layout_width="match_parent"
android:layout_height="250dp"
android:layout_gravity="center_horizontal"
android:scaleType="fitCenter"
android:visibility="gone" />
<VideoView
android:id="@+id/story_preview_video"
android:layout_width="match_parent"
android:layout_height="250dp"
android:layout_gravity="center_horizontal"
android:visibility="gone"
tools:visibility="visible" />
<TextView
android:id="@+id/publish_info_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="8dp"
android:textAppearance="?attr/textAppearanceCaption" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/title_text_input_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:counterEnabled="true"
app:counterMaxLength="500">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/title_edit_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="top"
android:hint="@string/title_optional"
android:inputType="textMultiLine"
android:lines="3"
android:maxLength="500" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/image_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_centerInParent="true"
android:scaleType="fitCenter" />
</RelativeLayout>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/video_frame"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/transparent">
<VideoView
android:id="@+id/video_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center" />
</FrameLayout>

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/list"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<TextView
android:id="@+id/empty_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/no_calls_yet"
android:visibility="gone" />
</FrameLayout>

View file

@ -6,57 +6,65 @@
android:layout_height="match_parent">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical">
<RelativeLayout
android:id="@+id/overview_snackbar"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:layout_above="@+id/context_preview"
android:layout_marginLeft="8dp"
android:layout_marginRight="8dp"
android:layout_marginBottom="4dp"
android:background="@drawable/snackbar"
android:minHeight="48dp"
android:visibility="gone">
<TextView
android:id="@+id/overview_snackbar_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:layout_marginStart="24dp"
android:layout_toStartOf="@+id/overview_snackbar_action"
android:textColor="?colorOnSurfaceInverse"
android:text="Warning" />
<TextView
android:id="@+id/overview_snackbar_action"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:paddingLeft="24dp"
android:paddingTop="16dp"
android:paddingRight="24dp"
android:paddingBottom="16dp"
android:textAllCaps="true"
android:textColor="?colorOnSurfaceInverse"
android:textStyle="bold"
android:text="@string/action_fix" />
</RelativeLayout>
<de.monocles.chat.ContextMenuRecyclerView
android:id="@+id/list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="vertical" />
android:orientation="vertical">
<RelativeLayout
android:id="@+id/overview_snackbar"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:layout_above="@+id/context_preview"
android:layout_marginLeft="8dp"
android:layout_marginRight="8dp"
android:layout_marginBottom="4dp"
android:background="@drawable/snackbar"
android:minHeight="48dp"
android:visibility="gone">
<TextView
android:id="@+id/overview_snackbar_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:layout_marginStart="24dp"
android:layout_toStartOf="@+id/overview_snackbar_action"
android:textColor="?colorOnSurfaceInverse"
android:text="Warning" />
<TextView
android:id="@+id/overview_snackbar_action"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:paddingLeft="24dp"
android:paddingTop="16dp"
android:paddingRight="24dp"
android:paddingBottom="16dp"
android:textAllCaps="true"
android:textColor="?colorOnSurfaceInverse"
android:textStyle="bold"
android:text="@string/action_fix" />
</RelativeLayout>
<de.monocles.chat.ContextMenuRecyclerView
android:id="@+id/list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="vertical" />
</LinearLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab_start_conversation"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end|bottom"
android:layout_margin="16dp"
android:src="@drawable/ic_edit_24dp" />
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/fab"
android:layout_width="wrap_content"
@ -65,6 +73,7 @@
android:layout_margin="16dp"
android:text="@string/start_chat"
app:icon="@drawable/ic_chat_24dp" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout>

View file

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ProgressBar
android:id="@+id/story_progress"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
<VideoView
android:id="@+id/story_video_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"
android:visibility="gone" />
<ImageView
android:id="@+id/story_image_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="fitCenter" />
<LinearLayout
android:id="@+id/progress_bar_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingTop="8dp"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingBottom="4dp"/>
</FrameLayout>

View file

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="12dp">
<eu.siacs.conversations.ui.widget.AvatarView
android:id="@+id/avatar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:scaleType="centerCrop" />
<LinearLayout
android:id="@+id/center_layout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_toEndOf="@id/avatar"
android:layout_toStartOf="@+id/call_again"
android:layout_centerVertical="true"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:orientation="vertical">
<TextView
android:id="@+id/contact_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginEnd="4dp">
<TextView
android:id="@+id/call_info"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="4dp" />
<View
android:layout_width="2dp"
android:layout_height="2dp"
android:layout_gravity="center"
android:padding="2dp"
android:background="@color/sd_label_text_color" />
<TextView
android:id="@+id/call_date"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp" />
</LinearLayout>
<TextView
android:id="@+id/account_info"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="11sp"
android:visibility="gone" />
</LinearLayout>
<ImageButton
android:id="@+id/call_again"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:padding="4dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_call_24dp" />
</RelativeLayout>

View file

@ -0,0 +1,76 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="16dp"
android:paddingTop="8dp"
android:paddingEnd="16dp"
android:paddingBottom="8dp">
<eu.siacs.conversations.ui.widget.AvatarView
android:id="@+id/comment_author_avatar"
android:layout_width="32dp"
android:layout_height="32dp"
android:scaleType="centerCrop"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@tools:sample/avatars" />
<ImageButton
android:id="@+id/retract_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/retract_comment"
android:src="@drawable/delete_18dp"
app:layout_constraintBottom_toBottomOf="@+id/comment_author_name"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/comment_author_name"
app:tint="?android:attr/textColorSecondary" />
<TextView
android:id="@+id/comment_author_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:textAppearance="@style/TextAppearance.AppCompat.Body2"
app:layout_constraintEnd_toStartOf="@+id/retract_button"
app:layout_constraintStart_toEndOf="@+id/comment_author_avatar"
app:layout_constraintTop_toTopOf="@+id/comment_author_avatar"
tools:text="Jane Doe" />
<TextView
android:id="@+id/comment_timestamp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:textColor="?android:attr/textColorSecondary"
android:textSize="10sp"
app:layout_constraintStart_toEndOf="@+id/comment_author_avatar"
app:layout_constraintTop_toBottomOf="@+id/comment_author_name"
tools:text="1 hour ago" />
<TextView
android:id="@+id/comment_content"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="4dp"
android:autoLink="web"
android:textIsSelectable="true"
android:textColorHighlight="?attr/colorControlHighlight"
android:textColorLink="?attr/colorAccent"
android:textColorHint="?android:attr/textColorHint"
android:textColor="?android:attr/textColorPrimary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/comment_author_avatar"
app:layout_constraintTop_toBottomOf="@+id/comment_timestamp"
tools:text="This is a comment." />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:orientation="vertical"
android:padding="2dp">
<eu.siacs.conversations.ui.widget.AvatarView
android:id="@+id/contact_avatar"
android:layout_width="42dp"
android:layout_height="42dp" />
<TextView
android:id="@+id/contact_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:ellipsize="end"
android:maxWidth="70dp"
android:maxLines="1"
android:textAppearance="?textAppearanceCaption"
tools:text="Contact Name" />
<Button
android:id="@+id/follow_button"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/follow" />
</LinearLayout>

Some files were not shown because too many files have changed in this diff Show more