Merge remote-tracking branch 'upstream/dev' into feature/read_status_updates

# Conflicts:
#	app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt
#	app/src/main/res/values/strings.xml
This commit is contained in:
Morgan Pretty 2023-01-24 14:10:14 +11:00
commit bc20811431
104 changed files with 1816 additions and 1711 deletions

View File

@ -95,7 +95,8 @@ dependencies {
implementation 'com.takisoft.fix:colorpicker:1.0.1' implementation 'com.takisoft.fix:colorpicker:1.0.1'
implementation 'com.codewaves.stickyheadergrid:stickyheadergrid:0.9.4' implementation 'com.codewaves.stickyheadergrid:stickyheadergrid:0.9.4'
implementation 'com.github.dmytrodanylyk.circular-progress-button:library:1.1.3-S2' implementation 'com.github.dmytrodanylyk.circular-progress-button:library:1.1.3-S2'
implementation 'org.signal:android-database-sqlcipher:3.5.9-S3' implementation 'androidx.sqlite:sqlite-ktx:2.2.0'
implementation 'net.zetetic:sqlcipher-android:4.5.3@aar'
implementation ('com.googlecode.ez-vcard:ez-vcard:0.9.11') { implementation ('com.googlecode.ez-vcard:ez-vcard:0.9.11') {
exclude group: 'com.fasterxml.jackson.core' exclude group: 'com.fasterxml.jackson.core'
exclude group: 'org.freemarker' exclude group: 'org.freemarker'
@ -158,7 +159,7 @@ dependencies {
testImplementation 'org.robolectric:shadows-multidex:4.4' testImplementation 'org.robolectric:shadows-multidex:4.4'
} }
def canonicalVersionCode = 321 def canonicalVersionCode = 323
def canonicalVersionName = "1.16.3" def canonicalVersionName = "1.16.3"
def postFixSize = 10 def postFixSize = 10

View File

@ -47,6 +47,7 @@ import org.session.libsession.utilities.Util;
import org.session.libsession.utilities.WindowDebouncer; import org.session.libsession.utilities.WindowDebouncer;
import org.session.libsession.utilities.dynamiclanguage.DynamicLanguageContextWrapper; import org.session.libsession.utilities.dynamiclanguage.DynamicLanguageContextWrapper;
import org.session.libsession.utilities.dynamiclanguage.LocaleParser; import org.session.libsession.utilities.dynamiclanguage.LocaleParser;
import org.session.libsignal.utilities.HTTP;
import org.session.libsignal.utilities.JsonUtil; import org.session.libsignal.utilities.JsonUtil;
import org.session.libsignal.utilities.Log; import org.session.libsignal.utilities.Log;
import org.session.libsignal.utilities.ThreadUtils; import org.session.libsignal.utilities.ThreadUtils;
@ -57,6 +58,7 @@ import org.thoughtcrime.securesms.database.EmojiSearchDatabase;
import org.thoughtcrime.securesms.database.JobDatabase; import org.thoughtcrime.securesms.database.JobDatabase;
import org.thoughtcrime.securesms.database.LokiAPIDatabase; import org.thoughtcrime.securesms.database.LokiAPIDatabase;
import org.thoughtcrime.securesms.database.Storage; import org.thoughtcrime.securesms.database.Storage;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.database.model.EmojiSearchData; import org.thoughtcrime.securesms.database.model.EmojiSearchData;
import org.thoughtcrime.securesms.dependencies.DatabaseComponent; import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
import org.thoughtcrime.securesms.dependencies.DatabaseModule; import org.thoughtcrime.securesms.dependencies.DatabaseModule;
@ -66,6 +68,7 @@ import org.thoughtcrime.securesms.groups.OpenGroupMigrator;
import org.thoughtcrime.securesms.home.HomeActivity; import org.thoughtcrime.securesms.home.HomeActivity;
import org.thoughtcrime.securesms.jobmanager.JobManager; import org.thoughtcrime.securesms.jobmanager.JobManager;
import org.thoughtcrime.securesms.jobmanager.impl.JsonDataSerializer; import org.thoughtcrime.securesms.jobmanager.impl.JsonDataSerializer;
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
import org.thoughtcrime.securesms.jobs.FastJobStorage; import org.thoughtcrime.securesms.jobs.FastJobStorage;
import org.thoughtcrime.securesms.jobs.JobManagerFactories; import org.thoughtcrime.securesms.jobs.JobManagerFactories;
import org.thoughtcrime.securesms.logging.AndroidLogger; import org.thoughtcrime.securesms.logging.AndroidLogger;
@ -236,6 +239,9 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
resubmitProfilePictureIfNeeded(); resubmitProfilePictureIfNeeded();
loadEmojiSearchIndexIfNeeded(); loadEmojiSearchIndexIfNeeded();
EmojiSource.refresh(); EmojiSource.refresh();
NetworkConstraint networkConstraint = new NetworkConstraint.Factory(this).create();
HTTP.INSTANCE.setConnectedToNetwork(networkConstraint::isMet);
} }
@Override @Override
@ -244,6 +250,12 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
Log.i(TAG, "App is now visible."); Log.i(TAG, "App is now visible.");
KeyCachingService.onAppForegrounded(this); KeyCachingService.onAppForegrounded(this);
// If the user account hasn't been created or onboarding wasn't finished then don't start
// the pollers
if (TextSecurePreferences.getLocalNumber(this) == null || !TextSecurePreferences.hasSeenWelcomeScreen(this)) {
return;
}
ThreadUtils.queue(()->{ ThreadUtils.queue(()->{
if (poller != null) { if (poller != null) {
poller.setCaughtUp(false); poller.setCaughtUp(false);
@ -537,7 +549,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
TextSecurePreferences.setProfileName(this, displayName); TextSecurePreferences.setProfileName(this, displayName);
} }
getSharedPreferences(PREFERENCES_NAME, 0).edit().clear().commit(); getSharedPreferences(PREFERENCES_NAME, 0).edit().clear().commit();
if (!deleteDatabase("signal.db")) { if (!deleteDatabase(SQLCipherOpenHelper.DATABASE_NAME)) {
Log.d("Loki", "Failed to delete database."); Log.d("Loki", "Failed to delete database.");
} }
Util.runOnMain(() -> new Handler().postDelayed(ApplicationContext.this::restartApplication, 200)); Util.runOnMain(() -> new Handler().postDelayed(ApplicationContext.this::restartApplication, 200));

View File

@ -114,7 +114,7 @@ class MediaGalleryAdapter extends StickyHeaderGridAdapter {
Slide slide = MediaUtil.getSlideForAttachment(context, mediaRecord.getAttachment()); Slide slide = MediaUtil.getSlideForAttachment(context, mediaRecord.getAttachment());
if (slide != null) { if (slide != null) {
thumbnailView.setImageResource(glideRequests, slide, false, false); thumbnailView.setImageResource(glideRequests, slide, false, null);
} }
thumbnailView.setOnClickListener(view -> itemClickListener.onMediaClicked(mediaRecord)); thumbnailView.setOnClickListener(view -> itemClickListener.onMediaClicked(mediaRecord));

View File

@ -176,6 +176,11 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper)
return messageDB.getMessageID(serverId, threadId) return messageDB.getMessageID(serverId, threadId)
} }
override fun getMessageIDs(serverIds: List<Long>, threadId: Long): Pair<List<Long>, List<Long>> {
val messageDB = DatabaseComponent.get(context).lokiMessageDatabase()
return messageDB.getMessageIDs(serverIds, threadId)
}
override fun deleteMessage(messageID: Long, isSms: Boolean) { override fun deleteMessage(messageID: Long, isSms: Boolean) {
val messagingDatabase: MessagingDatabase = if (isSms) DatabaseComponent.get(context).smsDatabase() val messagingDatabase: MessagingDatabase = if (isSms) DatabaseComponent.get(context).smsDatabase()
else DatabaseComponent.get(context).mmsDatabase() else DatabaseComponent.get(context).mmsDatabase()
@ -184,6 +189,15 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper)
DatabaseComponent.get(context).lokiMessageDatabase().deleteMessageServerHash(messageID) DatabaseComponent.get(context).lokiMessageDatabase().deleteMessageServerHash(messageID)
} }
override fun deleteMessages(messageIDs: List<Long>, threadId: Long, isSms: Boolean) {
val messagingDatabase: MessagingDatabase = if (isSms) DatabaseComponent.get(context).smsDatabase()
else DatabaseComponent.get(context).mmsDatabase()
messagingDatabase.deleteMessages(messageIDs.toLongArray(), threadId)
DatabaseComponent.get(context).lokiMessageDatabase().deleteMessages(messageIDs)
DatabaseComponent.get(context).lokiMessageDatabase().deleteMessageServerHashes(messageIDs)
}
override fun updateMessageAsDeleted(timestamp: Long, author: String) { override fun updateMessageAsDeleted(timestamp: Long, author: String) {
val database = DatabaseComponent.get(context).mmsSmsDatabase() val database = DatabaseComponent.get(context).mmsSmsDatabase()
val address = Address.fromSerialized(author) val address = Address.fromSerialized(author)

View File

@ -13,6 +13,11 @@ class ScreenshotObserver(private val context: Context, handler: Handler, private
override fun onChange(selfChange: Boolean, uri: Uri?) { override fun onChange(selfChange: Boolean, uri: Uri?) {
super.onChange(selfChange, uri) super.onChange(selfChange, uri)
uri ?: return uri ?: return
// There is an odd bug where we can get notified for changes to 'content://media/external'
// directly which is a protected folder, this code is to prevent that crash
if (uri.scheme == "content" && uri.host == "media" && uri.path == "/external") { return }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
queryRelativeDataColumn(uri) queryRelativeDataColumn(uri)
} else { } else {

View File

@ -8,7 +8,7 @@ import androidx.annotation.WorkerThread
import com.annimon.stream.function.Consumer import com.annimon.stream.function.Consumer
import com.annimon.stream.function.Predicate import com.annimon.stream.function.Predicate
import com.google.protobuf.ByteString import com.google.protobuf.ByteString
import net.sqlcipher.database.SQLiteDatabase import net.zetetic.database.sqlcipher.SQLiteDatabase
import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.EventBus
import org.session.libsession.avatars.AvatarHelper import org.session.libsession.avatars.AvatarHelper
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId

View File

@ -5,7 +5,7 @@ import android.content.ContentValues
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import net.sqlcipher.database.SQLiteDatabase import net.zetetic.database.sqlcipher.SQLiteDatabase
import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.EventBus
import org.session.libsession.avatars.AvatarHelper import org.session.libsession.avatars.AvatarHelper
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId

View File

@ -1,158 +0,0 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.TextView;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.ImageSlide;
import org.thoughtcrime.securesms.mms.SlidesClickedListener;
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview;
import network.loki.messenger.R;
import okhttp3.HttpUrl;
public class LinkPreviewView extends FrameLayout {
private static final int TYPE_CONVERSATION = 0;
private static final int TYPE_COMPOSE = 1;
private ViewGroup container;
private OutlinedThumbnailView thumbnail;
private TextView title;
private TextView site;
private View divider;
private View closeButton;
private View spinner;
private int type;
private int defaultRadius;
private CornerMask cornerMask;
private Outliner outliner;
private CloseClickedListener closeClickedListener;
public LinkPreviewView(Context context) {
super(context);
init(null);
}
public LinkPreviewView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init(attrs);
}
private void init(@Nullable AttributeSet attrs) {
inflate(getContext(), R.layout.link_preview, this);
container = findViewById(R.id.linkpreview_container);
thumbnail = findViewById(R.id.linkpreview_thumbnail);
title = findViewById(R.id.linkpreview_title);
site = findViewById(R.id.linkpreview_site);
divider = findViewById(R.id.linkpreview_divider);
spinner = findViewById(R.id.linkpreview_progress_wheel);
closeButton = findViewById(R.id.linkpreview_close);
defaultRadius = getResources().getDimensionPixelSize(R.dimen.thumbnail_default_radius);
cornerMask = new CornerMask(this);
outliner = new Outliner();
outliner.setColor(getResources().getColor(R.color.transparent));
if (attrs != null) {
TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.LinkPreviewView, 0, 0);
type = typedArray.getInt(R.styleable.LinkPreviewView_linkpreview_type, 0);
typedArray.recycle();
}
if (type == TYPE_COMPOSE) {
container.setBackgroundColor(Color.TRANSPARENT);
container.setPadding(0, 0, 0, 0);
divider.setVisibility(VISIBLE);
closeButton.setOnClickListener(v -> {
if (closeClickedListener != null) {
closeClickedListener.onCloseClicked();
}
});
}
setWillNotDraw(false);
}
@Override
protected void dispatchDraw(Canvas canvas) {
super.dispatchDraw(canvas);
if (type == TYPE_COMPOSE) return;
cornerMask.mask(canvas);
outliner.draw(canvas);
}
public void setLoading() {
title.setVisibility(GONE);
site.setVisibility(GONE);
thumbnail.setVisibility(GONE);
spinner.setVisibility(VISIBLE);
closeButton.setVisibility(GONE);
}
public void setLinkPreview(@NonNull GlideRequests glideRequests, @NonNull LinkPreview linkPreview, boolean showThumbnail, boolean showCloseButton) {
setLinkPreview(glideRequests, linkPreview, showThumbnail);
if (showCloseButton) {
closeButton.setVisibility(VISIBLE);
} else {
closeButton.setVisibility(GONE);
}
}
public void setLinkPreview(@NonNull GlideRequests glideRequests, @NonNull LinkPreview linkPreview, boolean showThumbnail) {
title.setVisibility(VISIBLE);
site.setVisibility(VISIBLE);
thumbnail.setVisibility(VISIBLE);
spinner.setVisibility(GONE);
closeButton.setVisibility(VISIBLE);
title.setText(linkPreview.getTitle());
HttpUrl url = HttpUrl.parse(linkPreview.getUrl());
if (url != null) {
site.setText(url.topPrivateDomain());
}
if (showThumbnail && linkPreview.getThumbnail().isPresent()) {
thumbnail.setVisibility(VISIBLE);
thumbnail.setImageResource(glideRequests, new ImageSlide(getContext(), linkPreview.getThumbnail().get()), type == TYPE_CONVERSATION, false);
thumbnail.showDownloadText(false);
} else {
thumbnail.setVisibility(GONE);
}
}
public void setCorners(int topLeft, int topRight) {
cornerMask.setRadii(topLeft, topRight, 0, 0);
outliner.setRadii(topLeft, topRight, 0, 0);
thumbnail.setCorners(topLeft, defaultRadius, defaultRadius, defaultRadius);
postInvalidate();
}
public void setCloseClickedListener(@Nullable CloseClickedListener closeClickedListener) {
this.closeClickedListener = closeClickedListener;
}
public void setDownloadClickedListener(SlidesClickedListener listener) {
thumbnail.setDownloadClickListener(listener);
}
public interface CloseClickedListener {
void onCloseClicked();
}
}

View File

@ -1,48 +0,0 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.graphics.Canvas;
import android.util.AttributeSet;
import org.session.libsession.utilities.ThemeUtil;
import org.thoughtcrime.securesms.conversation.v2.utilities.ThumbnailView;
import network.loki.messenger.R;
public class OutlinedThumbnailView extends ThumbnailView {
private CornerMask cornerMask;
private Outliner outliner;
public OutlinedThumbnailView(Context context) {
super(context);
init();
}
public OutlinedThumbnailView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
private void init() {
cornerMask = new CornerMask(this);
outliner = new Outliner();
outliner.setColor(ThemeUtil.getThemedColor(getContext(), R.attr.conversation_item_image_outline_color));
setWillNotDraw(false);
}
@Override
protected void dispatchDraw(Canvas canvas) {
super.dispatchDraw(canvas);
cornerMask.mask(canvas);
outliner.draw(canvas);
}
public void setCorners(int topLeft, int topRight, int bottomRight, int bottomLeft) {
cornerMask.setRadii(topLeft, topRight, bottomRight, bottomLeft);
outliner.setRadii(topLeft, topRight, bottomRight, bottomLeft);
postInvalidate();
}
}

View File

@ -34,6 +34,8 @@ class ProfilePictureView @JvmOverloads constructor(
private val profilePicturesCache = mutableMapOf<String, String?>() private val profilePicturesCache = mutableMapOf<String, String?>()
private val unknownRecipientDrawable = ResourceContactPhoto(R.drawable.ic_profile_default) private val unknownRecipientDrawable = ResourceContactPhoto(R.drawable.ic_profile_default)
.asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false) .asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false)
private val unknownOpenGroupDrawable = ResourceContactPhoto(R.drawable.ic_notification)
.asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false)
// endregion // endregion
@ -43,10 +45,8 @@ class ProfilePictureView @JvmOverloads constructor(
val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithSessionID(publicKey) val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithSessionID(publicKey)
return contact?.displayName(Contact.ContactContext.REGULAR) ?: publicKey return contact?.displayName(Contact.ContactContext.REGULAR) ?: publicKey
} }
fun isOpenGroupWithProfilePicture(recipient: Recipient): Boolean {
return recipient.isOpenGroupRecipient && recipient.groupAvatarId != null if (recipient.isClosedGroupRecipient) {
}
if (recipient.isGroupRecipient && !isOpenGroupWithProfilePicture(recipient)) {
val members = DatabaseComponent.get(context).groupDatabase() val members = DatabaseComponent.get(context).groupDatabase()
.getGroupMemberAddresses(recipient.address.toGroupString(), true) .getGroupMemberAddresses(recipient.address.toGroupString(), true)
.sorted() .sorted()
@ -107,7 +107,7 @@ class ProfilePictureView @JvmOverloads constructor(
if (profilePicturesCache.containsKey(publicKey) && profilePicturesCache[publicKey] == recipient.profileAvatar) return if (profilePicturesCache.containsKey(publicKey) && profilePicturesCache[publicKey] == recipient.profileAvatar) return
val signalProfilePicture = recipient.contactPhoto val signalProfilePicture = recipient.contactPhoto
val avatar = (signalProfilePicture as? ProfileContactPhoto)?.avatarObject val avatar = (signalProfilePicture as? ProfileContactPhoto)?.avatarObject
val placeholder = PlaceholderAvatarPhoto(context, publicKey, displayName ?: "${publicKey.take(4)}...${publicKey.takeLast(4)}")
if (signalProfilePicture != null && avatar != "0" && avatar != "") { if (signalProfilePicture != null && avatar != "0" && avatar != "") {
glide.clear(imageView) glide.clear(imageView)
glide.load(signalProfilePicture) glide.load(signalProfilePicture)
@ -117,7 +117,12 @@ class ProfilePictureView @JvmOverloads constructor(
.diskCacheStrategy(DiskCacheStrategy.NONE) .diskCacheStrategy(DiskCacheStrategy.NONE)
.circleCrop() .circleCrop()
.into(imageView) .into(imageView)
} else if (recipient.isOpenGroupRecipient && recipient.groupAvatarId == null) {
glide.clear(imageView)
imageView.setImageDrawable(unknownOpenGroupDrawable)
} else { } else {
val placeholder = PlaceholderAvatarPhoto(context, publicKey, displayName ?: "${publicKey.take(4)}...${publicKey.takeLast(4)}")
glide.clear(imageView) glide.clear(imageView)
glide.load(placeholder) glide.load(placeholder)
.placeholder(unknownRecipientDrawable) .placeholder(unknownRecipientDrawable)

View File

@ -52,19 +52,4 @@ public class StickerView extends FrameLayout {
public void setOnLongClickListener(@Nullable OnLongClickListener l) { public void setOnLongClickListener(@Nullable OnLongClickListener l) {
image.setOnLongClickListener(l); image.setOnLongClickListener(l);
} }
public void setSticker(@NonNull GlideRequests glideRequests, @NonNull Slide stickerSlide) {
boolean showControls = stickerSlide.asAttachment().getDataUri() == null;
image.setImageResource(glideRequests, stickerSlide, showControls, false);
missingShade.setVisibility(showControls ? View.VISIBLE : View.GONE);
}
public void setThumbnailClickListener(@NonNull SlideClickListener listener) {
image.setThumbnailClickListener(listener);
}
public void setDownloadClickListener(@NonNull SlidesClickedListener listener) {
image.setDownloadClickListener(listener);
}
} }

View File

@ -24,7 +24,7 @@ public class EmojiTextView extends AppCompatTextView {
private static final char ELLIPSIS = '…'; private static final char ELLIPSIS = '…';
private CharSequence previousText; private CharSequence previousText;
private BufferType previousBufferType; private BufferType previousBufferType = BufferType.NORMAL;
private float originalFontSize; private float originalFontSize;
private boolean useSystemEmoji; private boolean useSystemEmoji;
private boolean sizeChangeInProgress; private boolean sizeChangeInProgress;
@ -49,6 +49,15 @@ public class EmojiTextView extends AppCompatTextView {
} }
@Override public void setText(@Nullable CharSequence text, BufferType type) { @Override public void setText(@Nullable CharSequence text, BufferType type) {
// No need to do anything special if the text is null or empty
if (text == null || text.length() == 0) {
previousText = text;
previousOverflowText = overflowText;
previousBufferType = type;
super.setText(text, type);
return;
}
EmojiParser.CandidateList candidates = EmojiProvider.getCandidates(text); EmojiParser.CandidateList candidates = EmojiProvider.getCandidates(text);
if (scaleEmojis && candidates != null && candidates.allEmojis) { if (scaleEmojis && candidates != null && candidates.allEmojis) {
@ -149,10 +158,15 @@ public class EmojiTextView extends AppCompatTextView {
} }
private boolean unchanged(CharSequence text, CharSequence overflowText, BufferType bufferType) { private boolean unchanged(CharSequence text, CharSequence overflowText, BufferType bufferType) {
return Util.equals(previousText, text) && CharSequence finalPrevText = (previousText == null || previousText.length() == 0 ? "" : previousText);
Util.equals(previousOverflowText, overflowText) && CharSequence finalText = (text == null || text.length() == 0 ? "" : text);
Util.equals(previousBufferType, bufferType) && CharSequence finalPrevOverflowText = (previousOverflowText == null || previousOverflowText.length() == 0 ? "" : previousOverflowText);
useSystemEmoji == useSystemEmoji() && CharSequence finalOverflowText = (overflowText == null || overflowText.length() == 0 ? "" : overflowText);
return Util.equals(finalPrevText, finalText) &&
Util.equals(finalPrevOverflowText, finalOverflowText) &&
Util.equals(previousBufferType, bufferType) &&
useSystemEmoji == useSystemEmoji() &&
!sizeChangeInProgress; !sizeChangeInProgress;
} }

View File

@ -40,6 +40,8 @@ import network.loki.messenger.databinding.ViewVisibleMessageBinding
import nl.komponents.kovenant.ui.successUi import nl.komponents.kovenant.ui.successUi
import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.jobs.AttachmentDownloadJob
import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.mentions.Mention import org.session.libsession.messaging.mentions.Mention
import org.session.libsession.messaging.mentions.MentionsManager import org.session.libsession.messaging.mentions.MentionsManager
import org.session.libsession.messaging.messages.control.DataExtractionNotification import org.session.libsession.messaging.messages.control.DataExtractionNotification
@ -250,6 +252,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
onDeselect(message, position, it) onDeselect(message, position, it)
} }
}, },
onAttachmentNeedsDownload = { attachmentId, mmsId ->
// Start download (on IO thread)
lifecycleScope.launch(Dispatchers.IO) {
JobQueue.shared.add(AttachmentDownloadJob(attachmentId, mmsId))
}
},
glide = glide, glide = glide,
lifecycleCoroutineScope = lifecycleScope lifecycleCoroutineScope = lifecycleScope
) )
@ -307,11 +315,24 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
restoreDraftIfNeeded() restoreDraftIfNeeded()
setUpUiStateObserver() setUpUiStateObserver()
binding!!.scrollToBottomButton.setOnClickListener { binding!!.scrollToBottomButton.setOnClickListener {
val layoutManager = binding?.conversationRecyclerView?.layoutManager ?: return@setOnClickListener val layoutManager = (binding?.conversationRecyclerView?.layoutManager as? LinearLayoutManager) ?: return@setOnClickListener
if (layoutManager.isSmoothScrolling) { if (layoutManager.isSmoothScrolling) {
binding?.conversationRecyclerView?.scrollToPosition(0) binding?.conversationRecyclerView?.scrollToPosition(0)
} else { } else {
binding?.conversationRecyclerView?.smoothScrollToPosition(0) // It looks like 'smoothScrollToPosition' will actually load all intermediate items in
// order to do the scroll, this can be very slow if there are a lot of messages so
// instead we check the current position and if there are more than 10 items to scroll
// we jump instantly to the 10th item and scroll from there (this should happen quick
// enough to give a similar scroll effect without having to load everything)
val position = layoutManager.findFirstVisibleItemPosition()
if (position > 10) {
binding?.conversationRecyclerView?.scrollToPosition(10)
}
binding?.conversationRecyclerView?.post {
binding?.conversationRecyclerView?.smoothScrollToPosition(0)
}
} }
} }
unreadCount = mmsSmsDb.getUnreadCount(viewModel.threadId) unreadCount = mmsSmsDb.getUnreadCount(viewModel.threadId)
@ -343,7 +364,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
super.onResume() super.onResume()
ApplicationContext.getInstance(this).messageNotifier.setVisibleThread(viewModel.threadId) ApplicationContext.getInstance(this).messageNotifier.setVisibleThread(viewModel.threadId)
val recipient = viewModel.recipient ?: return val recipient = viewModel.recipient ?: return
threadDb.markAllAsRead(viewModel.threadId, recipient.isOpenGroupRecipient)
lifecycleScope.launch(Dispatchers.IO) {
threadDb.markAllAsRead(viewModel.threadId, recipient.isOpenGroupRecipient)
}
contentResolver.registerContentObserver( contentResolver.registerContentObserver(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
true, true,

View File

@ -39,10 +39,10 @@ class ConversationAdapter(
private val onItemSwipeToReply: (MessageRecord, Int) -> Unit, private val onItemSwipeToReply: (MessageRecord, Int) -> Unit,
private val onItemLongPress: (MessageRecord, Int, VisibleMessageView) -> Unit, private val onItemLongPress: (MessageRecord, Int, VisibleMessageView) -> Unit,
private val onDeselect: (MessageRecord, Int) -> Unit, private val onDeselect: (MessageRecord, Int) -> Unit,
private val onAttachmentNeedsDownload: (Long, Long) -> Unit,
private val glide: GlideRequests, private val glide: GlideRequests,
lifecycleCoroutineScope: LifecycleCoroutineScope lifecycleCoroutineScope: LifecycleCoroutineScope
) ) : CursorRecyclerViewAdapter<ViewHolder>(context, cursor) {
: CursorRecyclerViewAdapter<ViewHolder>(context, cursor) {
private val messageDB by lazy { DatabaseComponent.get(context).mmsSmsDatabase() } private val messageDB by lazy { DatabaseComponent.get(context).mmsSmsDatabase() }
private val contactDB by lazy { DatabaseComponent.get(context).sessionContactDatabase() } private val contactDB by lazy { DatabaseComponent.get(context).sessionContactDatabase() }
var selectedItems = mutableSetOf<MessageRecord>() var selectedItems = mutableSetOf<MessageRecord>()
@ -120,7 +120,18 @@ class ConversationAdapter(
} }
val contact = contactCache[senderIdHash] val contact = contactCache[senderIdHash]
visibleMessageView.bind(message, messageBefore, getMessageAfter(position, cursor), glide, searchQuery, contact, senderId, visibleMessageViewDelegate) visibleMessageView.bind(
message,
messageBefore,
getMessageAfter(position, cursor),
glide,
searchQuery,
contact,
senderId,
visibleMessageViewDelegate,
onAttachmentNeedsDownload
)
if (!message.isDeleted) { if (!message.isDeleted) {
visibleMessageView.onPress = { event -> onItemPress(message, viewHolder.adapterPosition, visibleMessageView, event) } visibleMessageView.onPress = { event -> onItemPress(message, viewHolder.adapterPosition, visibleMessageView, event) }
visibleMessageView.onSwipeToReply = { onItemSwipeToReply(message, viewHolder.adapterPosition) } visibleMessageView.onSwipeToReply = { onItemSwipeToReply(message, viewHolder.adapterPosition) }

View File

@ -6,6 +6,8 @@ import androidx.lifecycle.viewModelScope
import com.goterl.lazysodium.utils.KeyPair import com.goterl.lazysodium.utils.KeyPair
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@ -48,11 +50,19 @@ class ConversationViewModel(
} }
fun saveDraft(text: String) { fun saveDraft(text: String) {
repository.saveDraft(threadId, text) GlobalScope.launch(Dispatchers.IO) {
repository.saveDraft(threadId, text)
}
} }
fun getDraft(): String? { fun getDraft(): String? {
return repository.getDraft(threadId) val draft: String? = repository.getDraft(threadId)
viewModelScope.launch(Dispatchers.IO) {
repository.clearDrafts(threadId)
}
return draft
} }
fun inviteContacts(contacts: List<Recipient>) { fun inviteContacts(contacts: List<Recipient>) {

View File

@ -8,53 +8,39 @@ import android.view.LayoutInflater
import android.view.MotionEvent import android.view.MotionEvent
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.RelativeLayout
import android.widget.TextView import android.widget.TextView
import androidx.core.view.children import androidx.core.view.children
import androidx.core.view.isVisible import androidx.core.view.isVisible
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.AlbumThumbnailViewBinding import network.loki.messenger.databinding.AlbumThumbnailViewBinding
import org.session.libsession.messaging.jobs.AttachmentDownloadJob
import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.MediaPreviewActivity import org.thoughtcrime.securesms.MediaPreviewActivity
import org.thoughtcrime.securesms.components.CornerMask import org.thoughtcrime.securesms.components.CornerMask
import org.thoughtcrime.securesms.conversation.v2.utilities.KThumbnailView import org.thoughtcrime.securesms.conversation.v2.utilities.ThumbnailView
import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.mms.Slide import org.thoughtcrime.securesms.mms.Slide
import org.thoughtcrime.securesms.util.ActivityDispatcher import org.thoughtcrime.securesms.util.ActivityDispatcher
class AlbumThumbnailView : FrameLayout { class AlbumThumbnailView : RelativeLayout {
private lateinit var binding: AlbumThumbnailViewBinding
companion object { companion object {
const val MAX_ALBUM_DISPLAY_SIZE = 3 const val MAX_ALBUM_DISPLAY_SIZE = 3
} }
private val binding: AlbumThumbnailViewBinding by lazy { AlbumThumbnailViewBinding.bind(this) }
// region Lifecycle // region Lifecycle
constructor(context: Context) : super(context) { constructor(context: Context) : super(context)
initialize() constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
} constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
initialize()
}
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
initialize()
}
private val cornerMask by lazy { CornerMask(this) } private val cornerMask by lazy { CornerMask(this) }
private var slides: List<Slide> = listOf() private var slides: List<Slide> = listOf()
private var slideSize: Int = 0 private var slideSize: Int = 0
private fun initialize() {
binding = AlbumThumbnailViewBinding.inflate(LayoutInflater.from(context), this, true)
}
override fun dispatchDraw(canvas: Canvas?) { override fun dispatchDraw(canvas: Canvas?) {
super.dispatchDraw(canvas) super.dispatchDraw(canvas)
cornerMask.mask(canvas) cornerMask.mask(canvas)
@ -63,26 +49,25 @@ class AlbumThumbnailView : FrameLayout {
// region Interaction // region Interaction
fun calculateHitObject(event: MotionEvent, mms: MmsMessageRecord, threadRecipient: Recipient) { fun calculateHitObject(event: MotionEvent, mms: MmsMessageRecord, threadRecipient: Recipient, onAttachmentNeedsDownload: (Long, Long) -> Unit) {
val rawXInt = event.rawX.toInt() val rawXInt = event.rawX.toInt()
val rawYInt = event.rawY.toInt() val rawYInt = event.rawY.toInt()
val eventRect = Rect(rawXInt, rawYInt, rawXInt, rawYInt) val eventRect = Rect(rawXInt, rawYInt, rawXInt, rawYInt)
val testRect = Rect() val testRect = Rect()
// test each album child // test each album child
binding.albumCellContainer.findViewById<ViewGroup>(R.id.album_thumbnail_root)?.children?.forEachIndexed { index, child -> binding.albumCellContainer.findViewById<ViewGroup>(R.id.album_thumbnail_root)?.children?.forEachIndexed forEach@{ index, child ->
child.getGlobalVisibleRect(testRect) child.getGlobalVisibleRect(testRect)
if (testRect.contains(eventRect)) { if (testRect.contains(eventRect)) {
// hit intersects with this particular child // hit intersects with this particular child
val slide = slides.getOrNull(index) ?: return val slide = slides.getOrNull(index) ?: return@forEach
// only open to downloaded images // only open to downloaded images
if (slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED) { if (slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED) {
// restart download here // Restart download here (on IO thread)
(slide.asAttachment() as? DatabaseAttachment)?.let { attachment -> (slide.asAttachment() as? DatabaseAttachment)?.let { attachment ->
val attachmentId = attachment.attachmentId.rowId onAttachmentNeedsDownload(attachment.attachmentId.rowId, mms.getId())
JobQueue.shared.add(AttachmentDownloadJob(attachmentId, mms.getId()))
} }
} }
if (slide.isInProgress) return if (slide.isInProgress) return@forEach
ActivityDispatcher.get(context)?.dispatchIntent { context -> ActivityDispatcher.get(context)?.dispatchIntent { context ->
MediaPreviewActivity.getPreviewIntent(context, slide, mms, threadRecipient) MediaPreviewActivity.getPreviewIntent(context, slide, mms, threadRecipient)
@ -133,7 +118,7 @@ class AlbumThumbnailView : FrameLayout {
else -> R.layout.album_thumbnail_3 // three stacked with additional text else -> R.layout.album_thumbnail_3 // three stacked with additional text
} }
fun getThumbnailView(position: Int): KThumbnailView = when (position) { fun getThumbnailView(position: Int): ThumbnailView = when (position) {
0 -> binding.albumCellContainer.findViewById<ViewGroup>(R.id.albumCellContainer).findViewById(R.id.album_cell_1) 0 -> binding.albumCellContainer.findViewById<ViewGroup>(R.id.albumCellContainer).findViewById(R.id.album_cell_1)
1 -> binding.albumCellContainer.findViewById<ViewGroup>(R.id.albumCellContainer).findViewById(R.id.album_cell_2) 1 -> binding.albumCellContainer.findViewById<ViewGroup>(R.id.albumCellContainer).findViewById(R.id.album_cell_2)
2 -> binding.albumCellContainer.findViewById<ViewGroup>(R.id.albumCellContainer).findViewById(R.id.album_cell_3) 2 -> binding.albumCellContainer.findViewById<ViewGroup>(R.id.albumCellContainer).findViewById(R.id.album_cell_3)

View File

@ -23,7 +23,7 @@ class LinkPreviewDraftView : LinearLayout {
// Start out with the loader showing and the content view hidden // Start out with the loader showing and the content view hidden
binding = ViewLinkPreviewDraftBinding.inflate(LayoutInflater.from(context), this, true) binding = ViewLinkPreviewDraftBinding.inflate(LayoutInflater.from(context), this, true)
binding.linkPreviewDraftContainer.isVisible = false binding.linkPreviewDraftContainer.isVisible = false
binding.thumbnailImageView.clipToOutline = true binding.thumbnailImageView.root.clipToOutline = true
binding.linkPreviewDraftCancelButton.setOnClickListener { cancel() } binding.linkPreviewDraftCancelButton.setOnClickListener { cancel() }
} }
@ -31,10 +31,10 @@ class LinkPreviewDraftView : LinearLayout {
// Hide the loader and show the content view // Hide the loader and show the content view
binding.linkPreviewDraftContainer.isVisible = true binding.linkPreviewDraftContainer.isVisible = true
binding.linkPreviewDraftLoader.isVisible = false binding.linkPreviewDraftLoader.isVisible = false
binding.thumbnailImageView.radius = toPx(4, resources) binding.thumbnailImageView.root.radius = toPx(4, resources)
if (linkPreview.getThumbnail().isPresent) { if (linkPreview.getThumbnail().isPresent) {
// This internally fetches the thumbnail // This internally fetches the thumbnail
binding.thumbnailImageView.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), false, false) binding.thumbnailImageView.root.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), false, null)
} }
binding.linkPreviewDraftTitleTextView.text = linkPreview.title binding.linkPreviewDraftTitleTextView.text = linkPreview.title
} }

View File

@ -1,118 +0,0 @@
package org.thoughtcrime.securesms.conversation.v2.components;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.PorterDuff;
import androidx.annotation.Nullable;
import android.util.AttributeSet;
import android.view.View;
import android.widget.LinearLayout;
import network.loki.messenger.R;
public class TypingIndicatorView extends LinearLayout {
private boolean isActive;
private long startTime;
private static final long CYCLE_DURATION = 1500;
private static final long DOT_DURATION = 600;
private static final float MIN_ALPHA = 0.4f;
private static final float MIN_SCALE = 0.75f;
private View dot1;
private View dot2;
private View dot3;
public TypingIndicatorView(Context context) {
super(context);
initialize(null);
}
public TypingIndicatorView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
initialize(attrs);
}
private void initialize(@Nullable AttributeSet attrs) {
inflate(getContext(), R.layout.view_typing_indicator, this);
setWillNotDraw(false);
dot1 = findViewById(R.id.typing_dot1);
dot2 = findViewById(R.id.typing_dot2);
dot3 = findViewById(R.id.typing_dot3);
if (attrs != null) {
TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.TypingIndicatorView, 0, 0);
int tint = typedArray.getColor(R.styleable.TypingIndicatorView_typingIndicator_tint, Color.WHITE);
typedArray.recycle();
dot1.getBackground().setColorFilter(tint, PorterDuff.Mode.MULTIPLY);
dot2.getBackground().setColorFilter(tint, PorterDuff.Mode.MULTIPLY);
dot3.getBackground().setColorFilter(tint, PorterDuff.Mode.MULTIPLY);
}
}
@Override
protected void onDraw(Canvas canvas) {
if (!isActive) {
super.onDraw(canvas);
return;
}
long timeInCycle = (System.currentTimeMillis() - startTime) % CYCLE_DURATION;
render(dot1, timeInCycle, 0);
render(dot2, timeInCycle, 150);
render(dot3, timeInCycle, 300);
super.onDraw(canvas);
postInvalidate();
}
private void render(View dot, long timeInCycle, long start) {
long end = start + DOT_DURATION;
long peak = start + (DOT_DURATION / 2);
if (timeInCycle < start || timeInCycle > end) {
renderDefault(dot);
} else if (timeInCycle < peak) {
renderFadeIn(dot, timeInCycle, start);
} else {
renderFadeOut(dot, timeInCycle, peak);
}
}
private void renderDefault(View dot) {
dot.setAlpha(MIN_ALPHA);
dot.setScaleX(MIN_SCALE);
dot.setScaleY(MIN_SCALE);
}
private void renderFadeIn(View dot, long timeInCycle, long fadeInStart) {
float percent = (float) (timeInCycle - fadeInStart) / 300;
dot.setAlpha(MIN_ALPHA + (1 - MIN_ALPHA) * percent);
dot.setScaleX(MIN_SCALE + (1 - MIN_SCALE) * percent);
dot.setScaleY(MIN_SCALE + (1 - MIN_SCALE) * percent);
}
private void renderFadeOut(View dot, long timeInCycle, long fadeOutStart) {
float percent = (float) (timeInCycle - fadeOutStart) / 300;
dot.setAlpha(1 - (1 - MIN_ALPHA) * percent);
dot.setScaleX(1 - (1 - MIN_SCALE) * percent);
dot.setScaleY(1 - (1 - MIN_SCALE) * percent);
}
public void startAnimation() {
isActive = true;
startTime = System.currentTimeMillis();
postInvalidate();
}
public void stopAnimation() {
isActive = false;
}
}

View File

@ -0,0 +1,105 @@
package org.thoughtcrime.securesms.conversation.v2.components
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.PorterDuff
import android.util.AttributeSet
import android.view.View
import android.widget.LinearLayout
import network.loki.messenger.R
import network.loki.messenger.databinding.ViewTypingIndicatorBinding
class TypingIndicatorView : LinearLayout {
companion object {
private const val CYCLE_DURATION: Long = 1500
private const val DOT_DURATION: Long = 600
private const val MIN_ALPHA = 0.4f
private const val MIN_SCALE = 0.75f
}
private val binding: ViewTypingIndicatorBinding by lazy {
val binding = ViewTypingIndicatorBinding.bind(this)
if (tint != -1) {
binding.typingDot1.getBackground().setColorFilter(tint, PorterDuff.Mode.MULTIPLY)
binding.typingDot2.getBackground().setColorFilter(tint, PorterDuff.Mode.MULTIPLY)
binding.typingDot3.getBackground().setColorFilter(tint, PorterDuff.Mode.MULTIPLY)
}
return@lazy binding
}
private var isActive = false
private var startTime: Long = 0
private var tint: Int = -1
constructor(context: Context) : super(context) { initialize(null) }
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize(attrs) }
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize(attrs) }
private fun initialize(attrs: AttributeSet?) {
setWillNotDraw(false)
if (attrs != null) {
val typedArray = context.theme.obtainStyledAttributes(attrs, R.styleable.TypingIndicatorView, 0, 0)
this.tint = typedArray.getColor(R.styleable.TypingIndicatorView_typingIndicator_tint, Color.WHITE)
typedArray.recycle()
}
}
override fun onDraw(canvas: Canvas) {
if (!isActive) {
super.onDraw(canvas)
return
}
val timeInCycle = (System.currentTimeMillis() - startTime) % CYCLE_DURATION
render(binding.typingDot1, timeInCycle, 0)
render(binding.typingDot2, timeInCycle, 150)
render(binding.typingDot3, timeInCycle, 300)
super.onDraw(canvas)
postInvalidate()
}
private fun render(dot: View?, timeInCycle: Long, start: Long) {
val end = start + DOT_DURATION
val peak = start + DOT_DURATION / 2
if (timeInCycle < start || timeInCycle > end) {
renderDefault(dot)
} else if (timeInCycle < peak) {
renderFadeIn(dot, timeInCycle, start)
} else {
renderFadeOut(dot, timeInCycle, peak)
}
}
private fun renderDefault(dot: View?) {
dot!!.alpha = MIN_ALPHA
dot.scaleX = MIN_SCALE
dot.scaleY = MIN_SCALE
}
private fun renderFadeIn(dot: View?, timeInCycle: Long, fadeInStart: Long) {
val percent = (timeInCycle - fadeInStart).toFloat() / 300
dot!!.alpha = MIN_ALPHA + (1 - MIN_ALPHA) * percent
dot.scaleX = MIN_SCALE + (1 - MIN_SCALE) * percent
dot.scaleY = MIN_SCALE + (1 - MIN_SCALE) * percent
}
private fun renderFadeOut(dot: View?, timeInCycle: Long, fadeOutStart: Long) {
val percent = (timeInCycle - fadeOutStart).toFloat() / 300
dot!!.alpha = 1 - (1 - MIN_ALPHA) * percent
dot.scaleX = 1 - (1 - MIN_SCALE) * percent
dot.scaleY = 1 - (1 - MIN_SCALE) * percent
}
fun startAnimation() {
isActive = true
startTime = System.currentTimeMillis()
postInvalidate()
}
fun stopAnimation() {
isActive = false
}
}

View File

@ -19,7 +19,7 @@ class TypingIndicatorViewContainer : LinearLayout {
} }
fun setTypists(typists: List<Recipient>) { fun setTypists(typists: List<Recipient>) {
if (typists.isEmpty()) { binding.typingIndicator.stopAnimation(); return } if (typists.isEmpty()) { binding.typingIndicator.root.stopAnimation(); return }
binding.typingIndicator.startAnimation() binding.typingIndicator.root.startAnimation()
} }
} }

View File

@ -1,346 +0,0 @@
package org.thoughtcrime.securesms.conversation.v2.messages;
import android.content.Context;
import android.content.res.TypedArray;
import android.os.Handler;
import android.os.Looper;
import android.util.AttributeSet;
import android.view.HapticFeedbackConstants;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.LinearLayout;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.constraintlayout.widget.Group;
import androidx.core.content.ContextCompat;
import com.google.android.flexbox.FlexboxLayout;
import com.google.android.flexbox.JustifyContent;
import org.session.libsession.utilities.TextSecurePreferences;
import org.session.libsession.utilities.ThemeUtil;
import org.thoughtcrime.securesms.components.emoji.EmojiImageView;
import org.thoughtcrime.securesms.components.emoji.EmojiUtil;
import org.thoughtcrime.securesms.conversation.v2.ViewUtil;
import org.thoughtcrime.securesms.database.model.MessageId;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.util.NumberUtil;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import network.loki.messenger.R;
public class EmojiReactionsView extends LinearLayout implements View.OnTouchListener {
// Normally 6dp, but we have 1dp left+right margin on the pills themselves
private final int OUTER_MARGIN = ViewUtil.dpToPx(2);
private static final int DEFAULT_THRESHOLD = 5;
private List<ReactionRecord> records;
private long messageId;
private ViewGroup container;
private Group showLess;
private VisibleMessageViewDelegate delegate;
private Handler gestureHandler = new Handler(Looper.getMainLooper());
private Runnable pressCallback;
private Runnable longPressCallback;
private long onDownTimestamp = 0;
private static long longPressDurationThreshold = 250;
private static long maxDoubleTapInterval = 200;
private boolean extended = false;
public EmojiReactionsView(Context context) {
super(context);
init(null);
}
public EmojiReactionsView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init(attrs);
}
private void init(@Nullable AttributeSet attrs) {
inflate(getContext(), R.layout.view_emoji_reactions, this);
this.container = findViewById(R.id.layout_emoji_container);
this.showLess = findViewById(R.id.group_show_less);
records = new ArrayList<>();
if (attrs != null) {
TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.EmojiReactionsView, 0, 0);
typedArray.recycle();
}
}
public void clear() {
this.records.clear();
container.removeAllViews();
}
public void setReactions(long messageId, @NonNull List<ReactionRecord> records, boolean outgoing, VisibleMessageViewDelegate delegate) {
this.delegate = delegate;
if (records.equals(this.records)) {
return;
}
FlexboxLayout containerLayout = (FlexboxLayout) this.container;
containerLayout.setJustifyContent(outgoing ? JustifyContent.FLEX_END : JustifyContent.FLEX_START);
this.records.clear();
this.records.addAll(records);
if (this.messageId != messageId) {
extended = false;
}
this.messageId = messageId;
displayReactions(extended ? Integer.MAX_VALUE : DEFAULT_THRESHOLD);
}
@Override
public boolean onTouch(View v, MotionEvent event) {
if (v.getTag() == null) return false;
Reaction reaction = (Reaction) v.getTag();
int action = event.getAction();
if (action == MotionEvent.ACTION_DOWN) onDown(new MessageId(reaction.messageId, reaction.isMms));
else if (action == MotionEvent.ACTION_CANCEL) removeLongPressCallback();
else if (action == MotionEvent.ACTION_UP) onUp(reaction);
return true;
}
private void displayReactions(int threshold) {
String userPublicKey = TextSecurePreferences.getLocalNumber(getContext());
List<Reaction> reactions = buildSortedReactionsList(records, userPublicKey, threshold);
container.removeAllViews();
LinearLayout overflowContainer = new LinearLayout(getContext());
overflowContainer.setOrientation(LinearLayout.HORIZONTAL);
int innerPadding = ViewUtil.dpToPx(4);
overflowContainer.setPaddingRelative(innerPadding,innerPadding,innerPadding,innerPadding);
int pixelSize = ViewUtil.dpToPx(1);
for (Reaction reaction : reactions) {
if (container.getChildCount() + 1 >= DEFAULT_THRESHOLD && threshold != Integer.MAX_VALUE && reactions.size() > threshold) {
if (overflowContainer.getParent() == null) {
container.addView(overflowContainer);
MarginLayoutParams overflowParams = (MarginLayoutParams) overflowContainer.getLayoutParams();
overflowParams.height = ViewUtil.dpToPx(26);
overflowParams.setMargins(pixelSize, pixelSize, pixelSize, pixelSize);
overflowContainer.setLayoutParams(overflowParams);
overflowContainer.setBackground(ContextCompat.getDrawable(getContext(), R.drawable.reaction_pill_background));
}
View pill = buildPill(getContext(), this, reaction, true);
pill.setOnClickListener(v -> {
extended = true;
displayReactions(Integer.MAX_VALUE);
});
pill.findViewById(R.id.reactions_pill_count).setVisibility(View.GONE);
pill.findViewById(R.id.reactions_pill_spacer).setVisibility(View.GONE);
overflowContainer.addView(pill);
} else {
View pill = buildPill(getContext(), this, reaction, false);
pill.setTag(reaction);
pill.setOnTouchListener(this);
MarginLayoutParams params = (MarginLayoutParams) pill.getLayoutParams();
params.setMargins(pixelSize, pixelSize, pixelSize, pixelSize);
pill.setLayoutParams(params);
container.addView(pill);
}
}
int overflowChildren = overflowContainer.getChildCount();
int negativeMargin = ViewUtil.dpToPx(-8);
for (int i = 0; i < overflowChildren; i++) {
View child = overflowContainer.getChildAt(i);
MarginLayoutParams childParams = (MarginLayoutParams) child.getLayoutParams();
if ((i == 0 && overflowChildren > 1) || i + 1 < overflowChildren) {
// if first and there is more than one child, or we are not the last child then set negative right margin
childParams.setMargins(0,0, negativeMargin, 0);
child.setLayoutParams(childParams);
}
}
if (threshold == Integer.MAX_VALUE) {
showLess.setVisibility(VISIBLE);
for (int id : showLess.getReferencedIds()) {
findViewById(id).setOnClickListener(view -> {
extended = false;
displayReactions(DEFAULT_THRESHOLD);
});
}
} else {
showLess.setVisibility(GONE);
}
}
private void onReactionClicked(Reaction reaction) {
if (reaction.messageId != 0) {
MessageId messageId = new MessageId(reaction.messageId, reaction.isMms);
delegate.onReactionClicked(reaction.emoji, messageId, reaction.userWasSender);
}
}
private static @NonNull List<Reaction> buildSortedReactionsList(@NonNull List<ReactionRecord> records, String userPublicKey, int threshold) {
Map<String, Reaction> counters = new LinkedHashMap<>();
for (ReactionRecord record : records) {
String baseEmoji = EmojiUtil.getCanonicalRepresentation(record.getEmoji());
Reaction info = counters.get(baseEmoji);
if (info == null) {
info = new Reaction(record.getMessageId(), record.isMms(), record.getEmoji(), record.getCount(), record.getSortId(), record.getDateReceived(), userPublicKey.equals(record.getAuthor()));
} else {
info.update(record.getEmoji(), record.getCount(), record.getDateReceived(), userPublicKey.equals(record.getAuthor()));
}
counters.put(baseEmoji, info);
}
List<Reaction> reactions = new ArrayList<>(counters.values());
Collections.sort(reactions, Collections.reverseOrder());
if (reactions.size() >= threshold + 2 && threshold != Integer.MAX_VALUE) {
List<Reaction> shortened = new ArrayList<>(threshold + 2);
shortened.addAll(reactions.subList(0, threshold + 2));
return shortened;
} else {
return reactions;
}
}
private static View buildPill(@NonNull Context context, @NonNull ViewGroup parent, @NonNull Reaction reaction, boolean isCompact) {
View root = LayoutInflater.from(context).inflate(R.layout.reactions_pill, parent, false);
EmojiImageView emojiView = root.findViewById(R.id.reactions_pill_emoji);
TextView countView = root.findViewById(R.id.reactions_pill_count);
View spacer = root.findViewById(R.id.reactions_pill_spacer);
if (isCompact) {
root.setPaddingRelative(1,1,1,1);
ViewGroup.LayoutParams layoutParams = root.getLayoutParams();
layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT;
root.setLayoutParams(layoutParams);
}
if (reaction.emoji != null) {
emojiView.setImageEmoji(reaction.emoji);
if (reaction.count >= 1) {
countView.setText(NumberUtil.getFormattedNumber(reaction.count));
} else {
countView.setVisibility(GONE);
spacer.setVisibility(GONE);
}
} else {
emojiView.setVisibility(GONE);
spacer.setVisibility(GONE);
countView.setText(context.getString(R.string.ReactionsConversationView_plus, reaction.count));
}
if (reaction.userWasSender && !isCompact) {
root.setBackground(ContextCompat.getDrawable(context, R.drawable.reaction_pill_background_selected));
countView.setTextColor(ThemeUtil.getThemedColor(context, R.attr.reactionsPillSelectedTextColor));
} else {
if (!isCompact) {
root.setBackground(ContextCompat.getDrawable(context, R.drawable.reaction_pill_background));
}
}
return root;
}
private void onDown(MessageId messageId) {
removeLongPressCallback();
Runnable newLongPressCallback = () -> {
performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
if (delegate != null) {
delegate.onReactionLongClicked(messageId);
}
};
this.longPressCallback = newLongPressCallback;
gestureHandler.postDelayed(newLongPressCallback, longPressDurationThreshold);
onDownTimestamp = new Date().getTime();
}
private void removeLongPressCallback() {
if (longPressCallback != null) {
gestureHandler.removeCallbacks(longPressCallback);
}
}
private void onUp(Reaction reaction) {
if ((new Date().getTime() - onDownTimestamp) < longPressDurationThreshold) {
removeLongPressCallback();
if (pressCallback != null) {
gestureHandler.removeCallbacks(pressCallback);
this.pressCallback = null;
} else {
Runnable newPressCallback = () -> {
onReactionClicked(reaction);
pressCallback = null;
};
this.pressCallback = newPressCallback;
gestureHandler.postDelayed(newPressCallback, maxDoubleTapInterval);
}
}
}
private static class Reaction implements Comparable<Reaction> {
private final long messageId;
private final boolean isMms;
private String emoji;
private long count;
private long sortIndex;
private long lastSeen;
private boolean userWasSender;
Reaction(long messageId, boolean isMms, @Nullable String emoji, long count, long sortIndex, long lastSeen, boolean userWasSender) {
this.messageId = messageId;
this.isMms = isMms;
this.emoji = emoji;
this.count = count;
this.sortIndex = sortIndex;
this.lastSeen = lastSeen;
this.userWasSender = userWasSender;
}
void update(@NonNull String emoji, long count, long lastSeen, boolean userWasSender) {
if (!this.userWasSender) {
if (userWasSender || lastSeen > this.lastSeen) {
this.emoji = emoji;
}
}
this.count = this.count + count;
this.lastSeen = Math.max(this.lastSeen, lastSeen);
this.userWasSender = this.userWasSender || userWasSender;
}
@NonNull Reaction merge(@NonNull Reaction other) {
this.count = this.count + other.count;
this.lastSeen = Math.max(this.lastSeen, other.lastSeen);
this.userWasSender = this.userWasSender || other.userWasSender;
return this;
}
@Override
public int compareTo(Reaction rhs) {
Reaction lhs = this;
if (lhs.count == rhs.count ) {
return Long.compare(lhs.sortIndex, rhs.sortIndex);
} else {
return Long.compare(lhs.count, rhs.count);
}
}
}
}

View File

@ -0,0 +1,291 @@
package org.thoughtcrime.securesms.conversation.v2.messages
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.AttributeSet
import android.view.*
import android.view.View.OnTouchListener
import android.widget.LinearLayout
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
import com.google.android.flexbox.JustifyContent
import network.loki.messenger.R
import network.loki.messenger.databinding.ViewEmojiReactionsBinding
import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocalNumber
import org.session.libsession.utilities.ThemeUtil
import org.thoughtcrime.securesms.components.emoji.EmojiImageView
import org.thoughtcrime.securesms.components.emoji.EmojiUtil
import org.thoughtcrime.securesms.conversation.v2.ViewUtil
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.ReactionRecord
import org.thoughtcrime.securesms.util.NumberUtil.getFormattedNumber
import java.util.*
class EmojiReactionsView : ConstraintLayout, OnTouchListener {
companion object {
private const val DEFAULT_THRESHOLD = 5
private const val longPressDurationThreshold: Long = 250
private const val maxDoubleTapInterval: Long = 200
}
private val binding: ViewEmojiReactionsBinding by lazy { ViewEmojiReactionsBinding.bind(this) }
// Normally 6dp, but we have 1dp left+right margin on the pills themselves
private val OUTER_MARGIN = ViewUtil.dpToPx(2)
private var records: MutableList<ReactionRecord>? = null
private var messageId: Long = 0
private var delegate: VisibleMessageViewDelegate? = null
private val gestureHandler = Handler(Looper.getMainLooper())
private var pressCallback: Runnable? = null
private var longPressCallback: Runnable? = null
private var onDownTimestamp: Long = 0
private var extended = false
constructor(context: Context) : super(context) { init(null) }
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { init(attrs) }
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { init(attrs) }
private fun init(attrs: AttributeSet?) {
records = ArrayList()
if (attrs != null) {
val typedArray = context.theme.obtainStyledAttributes(attrs, R.styleable.EmojiReactionsView, 0, 0)
typedArray.recycle()
}
}
fun clear() {
records!!.clear()
binding.layoutEmojiContainer.removeAllViews()
}
fun setReactions(messageId: Long, records: List<ReactionRecord>, outgoing: Boolean, delegate: VisibleMessageViewDelegate?) {
this.delegate = delegate
if (records == this.records) {
return
}
binding.layoutEmojiContainer.justifyContent = if (outgoing) JustifyContent.FLEX_END else JustifyContent.FLEX_START
this.records!!.clear()
this.records!!.addAll(records)
if (this.messageId != messageId) {
extended = false
}
this.messageId = messageId
displayReactions(if (extended) Int.MAX_VALUE else DEFAULT_THRESHOLD)
}
override fun onTouch(v: View, event: MotionEvent): Boolean {
if (v.tag == null) return false
val reaction = v.tag as Reaction
val action = event.action
if (action == MotionEvent.ACTION_DOWN) onDown(MessageId(reaction.messageId, reaction.isMms)) else if (action == MotionEvent.ACTION_CANCEL) removeLongPressCallback() else if (action == MotionEvent.ACTION_UP) onUp(reaction)
return true
}
private fun displayReactions(threshold: Int) {
val userPublicKey = getLocalNumber(context)
val reactions = buildSortedReactionsList(records!!, userPublicKey, threshold)
binding.layoutEmojiContainer.removeAllViews()
val overflowContainer = LinearLayout(context)
overflowContainer.orientation = LinearLayout.HORIZONTAL
val innerPadding = ViewUtil.dpToPx(4)
overflowContainer.setPaddingRelative(innerPadding, innerPadding, innerPadding, innerPadding)
val pixelSize = ViewUtil.dpToPx(1)
for (reaction in reactions) {
if (binding.layoutEmojiContainer.childCount + 1 >= DEFAULT_THRESHOLD && threshold != Int.MAX_VALUE && reactions.size > threshold) {
if (overflowContainer.parent == null) {
binding.layoutEmojiContainer.addView(overflowContainer)
val overflowParams = overflowContainer.layoutParams as MarginLayoutParams
overflowParams.height = ViewUtil.dpToPx(26)
overflowParams.setMargins(pixelSize, pixelSize, pixelSize, pixelSize)
overflowContainer.layoutParams = overflowParams
overflowContainer.background = ContextCompat.getDrawable(context, R.drawable.reaction_pill_background)
}
val pill = buildPill(context, this, reaction, true)
pill.setOnClickListener { v: View? ->
extended = true
displayReactions(Int.MAX_VALUE)
}
pill.findViewById<View>(R.id.reactions_pill_count).visibility = GONE
pill.findViewById<View>(R.id.reactions_pill_spacer).visibility = GONE
overflowContainer.addView(pill)
} else {
val pill = buildPill(context, this, reaction, false)
pill.tag = reaction
pill.setOnTouchListener(this)
val params = pill.layoutParams as MarginLayoutParams
params.setMargins(pixelSize, pixelSize, pixelSize, pixelSize)
pill.layoutParams = params
binding.layoutEmojiContainer.addView(pill)
}
}
val overflowChildren = overflowContainer.childCount
val negativeMargin = ViewUtil.dpToPx(-8)
for (i in 0 until overflowChildren) {
val child = overflowContainer.getChildAt(i)
val childParams = child.layoutParams as MarginLayoutParams
if (i == 0 && overflowChildren > 1 || i + 1 < overflowChildren) {
// if first and there is more than one child, or we are not the last child then set negative right margin
childParams.setMargins(0, 0, negativeMargin, 0)
child.layoutParams = childParams
}
}
if (threshold == Int.MAX_VALUE) {
binding.groupShowLess.visibility = VISIBLE
for (id in binding.groupShowLess.referencedIds) {
findViewById<View>(id).setOnClickListener { view: View? ->
extended = false
displayReactions(DEFAULT_THRESHOLD)
}
}
} else {
binding.groupShowLess.visibility = GONE
}
}
private fun buildSortedReactionsList(records: List<ReactionRecord>, userPublicKey: String?, threshold: Int): List<Reaction> {
val counters: MutableMap<String, Reaction> = LinkedHashMap()
records.forEach {
val baseEmoji = EmojiUtil.getCanonicalRepresentation(it.emoji)
val info = counters[baseEmoji]
if (info == null) {
counters[baseEmoji] = Reaction(messageId, it.isMms, it.emoji, it.count, it.sortId, it.dateReceived, userPublicKey == it.author)
}
else {
info.update(it.emoji, it.count, it.dateReceived, userPublicKey == it.author)
}
}
val reactions: List<Reaction> = ArrayList(counters.values)
Collections.sort(reactions, Collections.reverseOrder())
return if (reactions.size >= threshold + 2 && threshold != Int.MAX_VALUE) {
val shortened: MutableList<Reaction> = ArrayList(threshold + 2)
shortened.addAll(reactions.subList(0, threshold + 2))
shortened
} else {
reactions
}
}
private fun buildPill(context: Context, parent: ViewGroup, reaction: Reaction, isCompact: Boolean): View {
val root = LayoutInflater.from(context).inflate(R.layout.reactions_pill, parent, false)
val emojiView = root.findViewById<EmojiImageView>(R.id.reactions_pill_emoji)
val countView = root.findViewById<TextView>(R.id.reactions_pill_count)
val spacer = root.findViewById<View>(R.id.reactions_pill_spacer)
if (isCompact) {
root.setPaddingRelative(1, 1, 1, 1)
val layoutParams = root.layoutParams
layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT
root.layoutParams = layoutParams
}
if (reaction.emoji != null) {
emojiView.setImageEmoji(reaction.emoji)
if (reaction.count >= 1) {
countView.text = getFormattedNumber(reaction.count)
} else {
countView.visibility = GONE
spacer.visibility = GONE
}
} else {
emojiView.visibility = GONE
spacer.visibility = GONE
countView.text = context.getString(R.string.ReactionsConversationView_plus, reaction.count)
}
if (reaction.userWasSender && !isCompact) {
root.background = ContextCompat.getDrawable(context, R.drawable.reaction_pill_background_selected)
countView.setTextColor(ThemeUtil.getThemedColor(context, R.attr.reactionsPillSelectedTextColor))
} else {
if (!isCompact) {
root.background = ContextCompat.getDrawable(context, R.drawable.reaction_pill_background)
}
}
return root
}
private fun onReactionClicked(reaction: Reaction) {
if (reaction.messageId != 0L) {
val messageId = MessageId(reaction.messageId, reaction.isMms)
delegate!!.onReactionClicked(reaction.emoji!!, messageId, reaction.userWasSender)
}
}
private fun onDown(messageId: MessageId) {
removeLongPressCallback()
val newLongPressCallback = Runnable {
performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
if (delegate != null) {
delegate!!.onReactionLongClicked(messageId)
}
}
longPressCallback = newLongPressCallback
gestureHandler.postDelayed(newLongPressCallback, longPressDurationThreshold)
onDownTimestamp = Date().time
}
private fun removeLongPressCallback() {
if (longPressCallback != null) {
gestureHandler.removeCallbacks(longPressCallback!!)
}
}
private fun onUp(reaction: Reaction) {
if (Date().time - onDownTimestamp < longPressDurationThreshold) {
removeLongPressCallback()
if (pressCallback != null) {
gestureHandler.removeCallbacks(pressCallback!!)
pressCallback = null
} else {
val newPressCallback = Runnable {
onReactionClicked(reaction)
pressCallback = null
}
pressCallback = newPressCallback
gestureHandler.postDelayed(newPressCallback, maxDoubleTapInterval)
}
}
}
internal class Reaction(
internal val messageId: Long,
internal val isMms: Boolean,
internal var emoji: String?,
internal var count: Long,
internal val sortIndex: Long,
internal var lastSeen: Long,
internal var userWasSender: Boolean
) : Comparable<Reaction?> {
fun update(emoji: String, count: Long, lastSeen: Long, userWasSender: Boolean) {
if (!this.userWasSender) {
if (userWasSender || lastSeen > this.lastSeen) {
this.emoji = emoji
}
}
this.count = this.count + count
this.lastSeen = Math.max(this.lastSeen, lastSeen)
this.userWasSender = this.userWasSender || userWasSender
}
fun merge(other: Reaction): Reaction {
count = count + other.count
lastSeen = Math.max(lastSeen, other.lastSeen)
userWasSender = userWasSender || other.userWasSender
return this
}
override fun compareTo(other: Reaction?): Int {
if (other == null) { return -1 }
if (this.count == other.count) {
return this.sortIndex.compareTo(other.sortIndex)
}
return this.count.compareTo(other.count)
}
}
}

View File

@ -4,11 +4,9 @@ import android.content.Context
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Rect import android.graphics.Rect
import android.util.AttributeSet import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.MotionEvent import android.view.MotionEvent
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.res.ResourcesCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ViewLinkPreviewBinding import network.loki.messenger.databinding.ViewLinkPreviewBinding
@ -19,21 +17,16 @@ import org.thoughtcrime.securesms.conversation.v2.utilities.MessageBubbleUtiliti
import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.mms.ImageSlide import org.thoughtcrime.securesms.mms.ImageSlide
import org.thoughtcrime.securesms.util.UiModeUtilities
class LinkPreviewView : LinearLayout { class LinkPreviewView : LinearLayout {
private lateinit var binding: ViewLinkPreviewBinding private val binding: ViewLinkPreviewBinding by lazy { ViewLinkPreviewBinding.bind(this) }
private val cornerMask by lazy { CornerMask(this) } private val cornerMask by lazy { CornerMask(this) }
private var url: String? = null private var url: String? = null
// region Lifecycle // region Lifecycle
constructor(context: Context) : super(context) { initialize() } constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() } constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
private fun initialize() {
binding = ViewLinkPreviewBinding.inflate(LayoutInflater.from(context), this, true)
}
// endregion // endregion
// region Updating // region Updating
@ -48,8 +41,8 @@ class LinkPreviewView : LinearLayout {
// Thumbnail // Thumbnail
if (linkPreview.getThumbnail().isPresent) { if (linkPreview.getThumbnail().isPresent) {
// This internally fetches the thumbnail // This internally fetches the thumbnail
binding.thumbnailImageView.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), isPreview = false, message) binding.thumbnailImageView.root.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), isPreview = false, message)
binding.thumbnailImageView.loadIndicator.isVisible = false binding.thumbnailImageView.root.loadIndicator.isVisible = false
} }
// Title // Title
binding.titleTextView.text = linkPreview.title binding.titleTextView.text = linkPreview.title
@ -80,7 +73,7 @@ class LinkPreviewView : LinearLayout {
val rawYInt = event.rawY.toInt() val rawYInt = event.rawY.toInt()
val hitRect = Rect(rawXInt, rawYInt, rawXInt, rawYInt) val hitRect = Rect(rawXInt, rawYInt, rawXInt, rawYInt)
val previewRect = Rect() val previewRect = Rect()
binding.mainLinkPreviewParent.getGlobalVisibleRect(previewRect) binding.mainLinkPreviewContainer.getGlobalVisibleRect(previewRect)
if (previewRect.contains(hitRect)) { if (previewRect.contains(hitRect)) {
openURL() openURL()
return return

View File

@ -93,7 +93,7 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet?
val backgroundColor = context.getAccentColor() val backgroundColor = context.getAccentColor()
binding.quoteViewAttachmentPreviewContainer.backgroundTintList = ColorStateList.valueOf(backgroundColor) binding.quoteViewAttachmentPreviewContainer.backgroundTintList = ColorStateList.valueOf(backgroundColor)
binding.quoteViewAttachmentPreviewImageView.isVisible = false binding.quoteViewAttachmentPreviewImageView.isVisible = false
binding.quoteViewAttachmentThumbnailImageView.isVisible = false binding.quoteViewAttachmentThumbnailImageView.root.isVisible = false
when { when {
attachments.audioSlide != null -> { attachments.audioSlide != null -> {
binding.quoteViewAttachmentPreviewImageView.setImageResource(R.drawable.ic_microphone) binding.quoteViewAttachmentPreviewImageView.setImageResource(R.drawable.ic_microphone)
@ -108,9 +108,9 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet?
attachments.thumbnailSlide != null -> { attachments.thumbnailSlide != null -> {
val slide = attachments.thumbnailSlide!! val slide = attachments.thumbnailSlide!!
// This internally fetches the thumbnail // This internally fetches the thumbnail
binding.quoteViewAttachmentThumbnailImageView.radius = toPx(4, resources) binding.quoteViewAttachmentThumbnailImageView.root.radius = toPx(4, resources)
binding.quoteViewAttachmentThumbnailImageView.setImageResource(glide, slide, false, false) binding.quoteViewAttachmentThumbnailImageView.root.setImageResource(glide, slide, false, null)
binding.quoteViewAttachmentThumbnailImageView.isVisible = true binding.quoteViewAttachmentThumbnailImageView.root.isVisible = true
binding.quoteViewBodyTextView.text = if (MediaUtil.isVideo(slide.asAttachment())) resources.getString(R.string.Slide_video) else resources.getString(R.string.Slide_image) binding.quoteViewBodyTextView.text = if (MediaUtil.isVideo(slide.asAttachment())) resources.getString(R.string.Slide_video) else resources.getString(R.string.Slide_image)
} }
} }

View File

@ -27,8 +27,6 @@ import network.loki.messenger.R
import network.loki.messenger.databinding.ViewVisibleMessageContentBinding import network.loki.messenger.databinding.ViewVisibleMessageContentBinding
import okhttp3.HttpUrl import okhttp3.HttpUrl
import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.jobs.AttachmentDownloadJob
import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
import org.session.libsession.utilities.getColorFromAttr import org.session.libsession.utilities.getColorFromAttr
@ -47,26 +45,30 @@ import org.thoughtcrime.securesms.util.getAccentColor
import java.util.* import java.util.*
import kotlin.math.roundToInt import kotlin.math.roundToInt
class VisibleMessageContentView : LinearLayout { class VisibleMessageContentView : ConstraintLayout {
private lateinit var binding: ViewVisibleMessageContentBinding private val binding: ViewVisibleMessageContentBinding by lazy { ViewVisibleMessageContentBinding.bind(this) }
var onContentClick: MutableList<((event: MotionEvent) -> Unit)> = mutableListOf() var onContentClick: MutableList<((event: MotionEvent) -> Unit)> = mutableListOf()
var onContentDoubleTap: (() -> Unit)? = null var onContentDoubleTap: (() -> Unit)? = null
var delegate: VisibleMessageViewDelegate? = null var delegate: VisibleMessageViewDelegate? = null
var indexInAdapter: Int = -1 var indexInAdapter: Int = -1
// region Lifecycle // region Lifecycle
constructor(context: Context) : super(context) { initialize() } constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() } constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
private fun initialize() {
binding = ViewVisibleMessageContentBinding.inflate(LayoutInflater.from(context), this, true)
}
// endregion // endregion
// region Updating // region Updating
fun bind(message: MessageRecord, isStartOfMessageCluster: Boolean, isEndOfMessageCluster: Boolean, fun bind(
glide: GlideRequests, thread: Recipient, searchQuery: String?, contactIsTrusted: Boolean) { message: MessageRecord,
isStartOfMessageCluster: Boolean,
isEndOfMessageCluster: Boolean,
glide: GlideRequests,
thread: Recipient,
searchQuery: String?,
contactIsTrusted: Boolean,
onAttachmentNeedsDownload: (Long, Long) -> Unit
) {
// Background // Background
val background = getBackground(message.isOutgoing) val background = getBackground(message.isOutgoing)
val color = if (message.isOutgoing) context.getAccentColor() val color = if (message.isOutgoing) context.getAccentColor()
@ -80,7 +82,7 @@ class VisibleMessageContentView : LinearLayout {
// reset visibilities / containers // reset visibilities / containers
onContentClick.clear() onContentClick.clear()
binding.albumThumbnailView.clearViews() binding.albumThumbnailView.root.clearViews()
onContentDoubleTap = null onContentDoubleTap = null
if (message.isDeleted) { if (message.isDeleted) {
@ -88,28 +90,23 @@ class VisibleMessageContentView : LinearLayout {
binding.deletedMessageView.root.bind(message, getTextColor(context, message)) binding.deletedMessageView.root.bind(message, getTextColor(context, message))
binding.bodyTextView.isVisible = false binding.bodyTextView.isVisible = false
binding.quoteView.root.isVisible = false binding.quoteView.root.isVisible = false
binding.linkPreviewView.isVisible = false binding.linkPreviewView.root.isVisible = false
binding.untrustedView.root.isVisible = false binding.untrustedView.root.isVisible = false
binding.voiceMessageView.root.isVisible = false binding.voiceMessageView.root.isVisible = false
binding.documentView.root.isVisible = false binding.documentView.root.isVisible = false
binding.albumThumbnailView.isVisible = false binding.albumThumbnailView.root.isVisible = false
binding.openGroupInvitationView.root.isVisible = false binding.openGroupInvitationView.root.isVisible = false
return return
} else { } else {
binding.deletedMessageView.root.isVisible = false binding.deletedMessageView.root.isVisible = false
} }
// clear the
binding.bodyTextView.text = null
binding.quoteView.root.isVisible = message is MmsMessageRecord && message.quote != null binding.quoteView.root.isVisible = message is MmsMessageRecord && message.quote != null
binding.linkPreviewView.root.isVisible = message is MmsMessageRecord && message.linkPreviews.isNotEmpty()
binding.linkPreviewView.isVisible = message is MmsMessageRecord && message.linkPreviews.isNotEmpty()
binding.untrustedView.root.isVisible = !contactIsTrusted && message is MmsMessageRecord && message.quote == null && message.linkPreviews.isEmpty() binding.untrustedView.root.isVisible = !contactIsTrusted && message is MmsMessageRecord && message.quote == null && message.linkPreviews.isEmpty()
binding.voiceMessageView.root.isVisible = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.audioSlide != null binding.voiceMessageView.root.isVisible = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.audioSlide != null
binding.documentView.root.isVisible = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.documentSlide != null binding.documentView.root.isVisible = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.documentSlide != null
binding.albumThumbnailView.isVisible = mediaThumbnailMessage binding.albumThumbnailView.root.isVisible = mediaThumbnailMessage
binding.openGroupInvitationView.root.isVisible = message.isOpenGroupInvitation binding.openGroupInvitationView.root.isVisible = message.isOpenGroupInvitation
var hideBody = false var hideBody = false
@ -141,8 +138,7 @@ class VisibleMessageContentView : LinearLayout {
val attachmentId = dbAttachment.attachmentId.rowId val attachmentId = dbAttachment.attachmentId.rowId
if (attach.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING if (attach.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING
&& MessagingModuleConfiguration.shared.storage.getAttachmentUploadJob(attachmentId) == null) { && MessagingModuleConfiguration.shared.storage.getAttachmentUploadJob(attachmentId) == null) {
// start download onAttachmentNeedsDownload(attachmentId, dbAttachment.mmsId)
JobQueue.shared.add(AttachmentDownloadJob(attachmentId, dbAttachment.mmsId))
} }
} }
message.linkPreviews.forEach { preview -> message.linkPreviews.forEach { preview ->
@ -150,15 +146,15 @@ class VisibleMessageContentView : LinearLayout {
val attachmentId = previewThumbnail.attachmentId.rowId val attachmentId = previewThumbnail.attachmentId.rowId
if (previewThumbnail.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING if (previewThumbnail.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING
&& MessagingModuleConfiguration.shared.storage.getAttachmentUploadJob(attachmentId) == null) { && MessagingModuleConfiguration.shared.storage.getAttachmentUploadJob(attachmentId) == null) {
JobQueue.shared.add(AttachmentDownloadJob(attachmentId, previewThumbnail.mmsId)) onAttachmentNeedsDownload(attachmentId, previewThumbnail.mmsId)
} }
} }
} }
when { when {
message is MmsMessageRecord && message.linkPreviews.isNotEmpty() -> { message is MmsMessageRecord && message.linkPreviews.isNotEmpty() -> {
binding.linkPreviewView.bind(message, glide, isStartOfMessageCluster, isEndOfMessageCluster) binding.linkPreviewView.root.bind(message, glide, isStartOfMessageCluster, isEndOfMessageCluster)
onContentClick.add { event -> binding.linkPreviewView.calculateHit(event) } onContentClick.add { event -> binding.linkPreviewView.root.calculateHit(event) }
// Body text view is inside the link preview for layout convenience // Body text view is inside the link preview for layout convenience
} }
message is MmsMessageRecord && message.slideDeck.audioSlide != null -> { message is MmsMessageRecord && message.slideDeck.audioSlide != null -> {
@ -195,21 +191,21 @@ class VisibleMessageContentView : LinearLayout {
if (contactIsTrusted || message.isOutgoing) { if (contactIsTrusted || message.isOutgoing) {
// isStart and isEnd of cluster needed for calculating the mask for full bubble image groups // isStart and isEnd of cluster needed for calculating the mask for full bubble image groups
// bind after add view because views are inflated and calculated during bind // bind after add view because views are inflated and calculated during bind
binding.albumThumbnailView.bind( binding.albumThumbnailView.root.bind(
glideRequests = glide, glideRequests = glide,
message = message, message = message,
isStart = isStartOfMessageCluster, isStart = isStartOfMessageCluster,
isEnd = isEndOfMessageCluster isEnd = isEndOfMessageCluster
) )
val layoutParams = binding.albumThumbnailView.layoutParams as ConstraintLayout.LayoutParams val layoutParams = binding.albumThumbnailView.root.layoutParams as ConstraintLayout.LayoutParams
layoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f layoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f
binding.albumThumbnailView.layoutParams = layoutParams binding.albumThumbnailView.root.layoutParams = layoutParams
onContentClick.add { event -> onContentClick.add { event ->
binding.albumThumbnailView.calculateHitObject(event, message, thread) binding.albumThumbnailView.root.calculateHitObject(event, message, thread, onAttachmentNeedsDownload)
} }
} else { } else {
hideBody = true hideBody = true
binding.albumThumbnailView.clearViews() binding.albumThumbnailView.root.clearViews()
binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.MEDIA, VisibleMessageContentView.getTextColor(context,message)) binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.MEDIA, VisibleMessageContentView.getTextColor(context,message))
onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) } onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) }
} }
@ -241,7 +237,7 @@ class VisibleMessageContentView : LinearLayout {
} }
private fun ViewVisibleMessageContentBinding.barrierViewsGone(): Boolean = private fun ViewVisibleMessageContentBinding.barrierViewsGone(): Boolean =
listOf<View>(albumThumbnailView, linkPreviewView, voiceMessageView.root, quoteView.root).none { it.isVisible } listOf<View>(albumThumbnailView.root, linkPreviewView.root, voiceMessageView.root, quoteView.root).none { it.isVisible }
private fun getBackground(isOutgoing: Boolean): Drawable { private fun getBackground(isOutgoing: Boolean): Drawable {
val backgroundID = if (isOutgoing) R.drawable.message_bubble_background_sent_alone else R.drawable.message_bubble_background_received_alone val backgroundID = if (isOutgoing) R.drawable.message_bubble_background_sent_alone else R.drawable.message_bubble_background_received_alone
@ -256,8 +252,8 @@ class VisibleMessageContentView : LinearLayout {
binding.openGroupInvitationView.root, binding.openGroupInvitationView.root,
binding.documentView.root, binding.documentView.root,
binding.quoteView.root, binding.quoteView.root,
binding.linkPreviewView, binding.linkPreviewView.root,
binding.albumThumbnailView, binding.albumThumbnailView.root,
binding.bodyTextView binding.bodyTextView
).forEach { view: View -> view.isVisible = false } ).forEach { view: View -> view.isVisible = false }
} }

View File

@ -85,7 +85,7 @@ class VisibleMessageView : LinearLayout {
var onPress: ((event: MotionEvent) -> Unit)? = null var onPress: ((event: MotionEvent) -> Unit)? = null
var onSwipeToReply: (() -> Unit)? = null var onSwipeToReply: (() -> Unit)? = null
var onLongPress: (() -> Unit)? = null var onLongPress: (() -> Unit)? = null
val messageContentView: VisibleMessageContentView by lazy { binding.messageContentView } val messageContentView: VisibleMessageContentView by lazy { binding.messageContentView.root }
companion object { companion object {
const val swipeToReplyThreshold = 64.0f // dp const val swipeToReplyThreshold = 64.0f // dp
@ -108,7 +108,7 @@ class VisibleMessageView : LinearLayout {
isHapticFeedbackEnabled = true isHapticFeedbackEnabled = true
setWillNotDraw(false) setWillNotDraw(false)
binding.messageInnerContainer.disableClipping() binding.messageInnerContainer.disableClipping()
binding.messageContentView.disableClipping() binding.messageContentView.root.disableClipping()
} }
// endregion // endregion
@ -122,6 +122,7 @@ class VisibleMessageView : LinearLayout {
contact: Contact?, contact: Contact?,
senderSessionID: String, senderSessionID: String,
delegate: VisibleMessageViewDelegate?, delegate: VisibleMessageViewDelegate?,
onAttachmentNeedsDownload: (Long, Long) -> Unit
) { ) {
val threadID = message.threadId val threadID = message.threadId
val thread = threadDb.getRecipientForThreadId(threadID) ?: return val thread = threadDb.getRecipientForThreadId(threadID) ?: return
@ -190,22 +191,23 @@ class VisibleMessageView : LinearLayout {
binding.dateBreakTextView.text = if (showDateBreak) DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), message.timestamp) else null binding.dateBreakTextView.text = if (showDateBreak) DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), message.timestamp) else null
binding.dateBreakTextView.isVisible = showDateBreak binding.dateBreakTextView.isVisible = showDateBreak
// Message status indicator // Message status indicator
val (iconID, iconColor, textId) = getMessageStatusImage(message)
if (textId != null) {
binding.messageStatusTextView.setText(textId)
if (iconColor != null) {
binding.messageStatusTextView.setTextColor(iconColor)
}
}
if (iconID != null) {
val drawable = ContextCompat.getDrawable(context, iconID)?.mutate()
if (iconColor != null) {
drawable?.setTint(iconColor)
}
binding.messageStatusImageView.setImageDrawable(drawable)
}
if (message.isOutgoing) { if (message.isOutgoing) {
val (iconID, iconColor, textId) = getMessageStatusImage(message)
if (textId != null) {
binding.messageStatusTextView.setText(textId)
if (iconColor != null) {
binding.messageStatusTextView.setTextColor(iconColor)
}
}
if (iconID != null) {
val drawable = ContextCompat.getDrawable(context, iconID)?.mutate()
if (iconColor != null) {
drawable?.setTint(iconColor)
}
binding.messageStatusImageView.setImageDrawable(drawable)
}
val lastMessageID = mmsSmsDb.getLastMessageID(message.threadId) val lastMessageID = mmsSmsDb.getLastMessageID(message.threadId)
binding.messageStatusTextView.isVisible = ( binding.messageStatusTextView.isVisible = (
textId != null && ( textId != null && (
@ -226,32 +228,37 @@ class VisibleMessageView : LinearLayout {
// Expiration timer // Expiration timer
updateExpirationTimer(message) updateExpirationTimer(message)
// Emoji Reactions // Emoji Reactions
val emojiLayoutParams = binding.emojiReactionsView.layoutParams as ConstraintLayout.LayoutParams val emojiLayoutParams = binding.emojiReactionsView.root.layoutParams as ConstraintLayout.LayoutParams
emojiLayoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f emojiLayoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f
binding.emojiReactionsView.layoutParams = emojiLayoutParams binding.emojiReactionsView.root.layoutParams = emojiLayoutParams
val capabilities = lokiThreadDb.getOpenGroupChat(threadID)?.server?.let { lokiApiDb.getServerCapabilities(it) }
if (message.reactions.isNotEmpty() && if (message.reactions.isNotEmpty()) {
(capabilities.isNullOrEmpty() || capabilities.contains(OpenGroupApi.Capability.REACTIONS.name.lowercase())) val capabilities = lokiThreadDb.getOpenGroupChat(threadID)?.server?.let { lokiApiDb.getServerCapabilities(it) }
) { if (capabilities.isNullOrEmpty() || capabilities.contains(OpenGroupApi.Capability.REACTIONS.name.lowercase())) {
binding.emojiReactionsView.setReactions(message.id, message.reactions, message.isOutgoing, delegate) binding.emojiReactionsView.root.setReactions(message.id, message.reactions, message.isOutgoing, delegate)
binding.emojiReactionsView.isVisible = true binding.emojiReactionsView.root.isVisible = true
} else { } else {
binding.emojiReactionsView.isVisible = false binding.emojiReactionsView.root.isVisible = false
}
}
else {
binding.emojiReactionsView.root.isVisible = false
} }
// Populate content view // Populate content view
binding.messageContentView.indexInAdapter = indexInAdapter binding.messageContentView.root.indexInAdapter = indexInAdapter
binding.messageContentView.bind( binding.messageContentView.root.bind(
message, message,
isStartOfMessageCluster, isStartOfMessageCluster,
isEndOfMessageCluster, isEndOfMessageCluster,
glide, glide,
thread, thread,
searchQuery, searchQuery,
message.isOutgoing || isGroupThread || (contact?.isTrusted ?: false) message.isOutgoing || isGroupThread || (contact?.isTrusted ?: false),
onAttachmentNeedsDownload
) )
binding.messageContentView.delegate = delegate binding.messageContentView.root.delegate = delegate
onDoubleTap = { binding.messageContentView.onContentDoubleTap?.invoke() } onDoubleTap = { binding.messageContentView.root.onContentDoubleTap?.invoke() }
} }
private fun isStartOfMessageCluster(current: MessageRecord, previous: MessageRecord?, isGroupThread: Boolean): Boolean { private fun isStartOfMessageCluster(current: MessageRecord, previous: MessageRecord?, isGroupThread: Boolean): Boolean {
@ -290,7 +297,7 @@ class VisibleMessageView : LinearLayout {
private fun updateExpirationTimer(message: MessageRecord) { private fun updateExpirationTimer(message: MessageRecord) {
val container = binding.messageInnerContainer val container = binding.messageInnerContainer
val content = binding.messageContentView val content = binding.messageContentView.root
val expiration = binding.expirationTimerView val expiration = binding.expirationTimerView
val spacing = binding.messageContentSpacing val spacing = binding.messageContentSpacing
container.removeAllViewsInLayout() container.removeAllViewsInLayout()
@ -341,7 +348,7 @@ class VisibleMessageView : LinearLayout {
override fun onDraw(canvas: Canvas) { override fun onDraw(canvas: Canvas) {
val spacing = context.resources.getDimensionPixelSize(R.dimen.small_spacing) val spacing = context.resources.getDimensionPixelSize(R.dimen.small_spacing)
val iconSize = toPx(24, context.resources) val iconSize = toPx(24, context.resources)
val left = binding.messageInnerContainer.left + binding.messageContentView.right + spacing val left = binding.messageInnerContainer.left + binding.messageContentView.root.right + spacing
val top = height - (binding.messageInnerContainer.height / 2) - binding.profilePictureView.root.marginBottom - (iconSize / 2) val top = height - (binding.messageInnerContainer.height / 2) - binding.profilePictureView.root.marginBottom - (iconSize / 2)
val right = left + iconSize val right = left + iconSize
val bottom = top + iconSize val bottom = top + iconSize
@ -363,7 +370,7 @@ class VisibleMessageView : LinearLayout {
fun recycle() { fun recycle() {
binding.profilePictureView.root.recycle() binding.profilePictureView.root.recycle()
binding.messageContentView.recycle() binding.messageContentView.root.recycle()
} }
// endregion // endregion
@ -459,7 +466,7 @@ class VisibleMessageView : LinearLayout {
} }
fun onContentClick(event: MotionEvent) { fun onContentClick(event: MotionEvent) {
binding.messageContentView.onContentClick.iterator().forEach { clickHandler -> clickHandler.invoke(event) } binding.messageContentView.root.onContentClick.iterator().forEach { clickHandler -> clickHandler.invoke(event) }
} }
private fun onPress(event: MotionEvent) { private fun onPress(event: MotionEvent) {
@ -479,7 +486,7 @@ class VisibleMessageView : LinearLayout {
} }
fun playVoiceMessage() { fun playVoiceMessage() {
binding.messageContentView.playVoiceMessage() binding.messageContentView.root.playVoiceMessage()
} }
// endregion // endregion
} }

View File

@ -1,425 +0,0 @@
package org.thoughtcrime.securesms.conversation.v2.utilities;
import static com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions.withCrossFade;
import android.content.Context;
import android.content.res.TypedArray;
import android.net.Uri;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.ImageView;
import androidx.annotation.NonNull;
import androidx.annotation.UiThread;
import com.bumptech.glide.RequestBuilder;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.load.resource.bitmap.BitmapTransformation;
import com.bumptech.glide.load.resource.bitmap.CenterCrop;
import com.bumptech.glide.load.resource.bitmap.FitCenter;
import com.bumptech.glide.load.resource.bitmap.RoundedCorners;
import com.bumptech.glide.request.RequestOptions;
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress;
import org.session.libsession.utilities.Util;
import org.session.libsession.utilities.ViewUtil;
import org.session.libsignal.utilities.ListenableFuture;
import org.session.libsignal.utilities.Log;
import org.session.libsignal.utilities.SettableFuture;
import org.session.libsignal.utilities.guava.Optional;
import org.thoughtcrime.securesms.components.GlideBitmapListeningTarget;
import org.thoughtcrime.securesms.components.GlideDrawableListeningTarget;
import org.thoughtcrime.securesms.components.TransferControlView;
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
import org.thoughtcrime.securesms.mms.GlideRequest;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideClickListener;
import org.thoughtcrime.securesms.mms.SlidesClickedListener;
import java.util.Collections;
import java.util.Locale;
import network.loki.messenger.R;
public class ThumbnailView extends FrameLayout {
private static final String TAG = ThumbnailView.class.getSimpleName();
private static final int WIDTH = 0;
private static final int HEIGHT = 1;
private static final int MIN_WIDTH = 0;
private static final int MAX_WIDTH = 1;
private static final int MIN_HEIGHT = 2;
private static final int MAX_HEIGHT = 3;
private ImageView image;
private View playOverlay;
private View loadIndicator;
private OnClickListener parentClickListener;
private final int[] dimens = new int[2];
private final int[] bounds = new int[4];
private final int[] measureDimens = new int[2];
private Optional<TransferControlView> transferControls = Optional.absent();
private SlideClickListener thumbnailClickListener = null;
private SlidesClickedListener downloadClickListener = null;
private Slide slide = null;
public int radius;
public ThumbnailView(Context context) {
this(context, null);
}
public ThumbnailView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public ThumbnailView(final Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
inflate(context, R.layout.thumbnail_view, this);
this.image = findViewById(R.id.thumbnail_image);
this.playOverlay = findViewById(R.id.play_overlay);
this.loadIndicator = findViewById(R.id.thumbnail_load_indicator);
super.setOnClickListener(new ThumbnailClickDispatcher());
if (attrs != null) {
TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.ThumbnailView, 0, 0);
bounds[MIN_WIDTH] = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_minWidth, 0);
bounds[MAX_WIDTH] = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_maxWidth, 0);
bounds[MIN_HEIGHT] = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_minHeight, 0);
bounds[MAX_HEIGHT] = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_maxHeight, 0);
radius = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_thumbnail_radius, 0);
typedArray.recycle();
} else {
radius = 0;
}
}
@Override
protected void onMeasure(int originalWidthMeasureSpec, int originalHeightMeasureSpec) {
fillTargetDimensions(measureDimens, dimens, bounds);
if (measureDimens[WIDTH] == 0 && measureDimens[HEIGHT] == 0) {
super.onMeasure(originalWidthMeasureSpec, originalHeightMeasureSpec);
return;
}
int finalWidth = measureDimens[WIDTH] + getPaddingLeft() + getPaddingRight();
int finalHeight = measureDimens[HEIGHT] + getPaddingTop() + getPaddingBottom();
super.onMeasure(MeasureSpec.makeMeasureSpec(finalWidth, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(finalHeight, MeasureSpec.EXACTLY));
}
@SuppressWarnings("SuspiciousNameCombination")
private void fillTargetDimensions(int[] targetDimens, int[] dimens, int[] bounds) {
int dimensFilledCount = getNonZeroCount(dimens);
int boundsFilledCount = getNonZeroCount(bounds);
if (dimensFilledCount == 0 || boundsFilledCount == 0) {
targetDimens[WIDTH] = 0;
targetDimens[HEIGHT] = 0;
return;
}
double naturalWidth = dimens[WIDTH];
double naturalHeight = dimens[HEIGHT];
int minWidth = bounds[MIN_WIDTH];
int maxWidth = bounds[MAX_WIDTH];
int minHeight = bounds[MIN_HEIGHT];
int maxHeight = bounds[MAX_HEIGHT];
if (dimensFilledCount > 0 && dimensFilledCount < dimens.length) {
throw new IllegalStateException(String.format(Locale.ENGLISH, "Width or height has been specified, but not both. Dimens: %f x %f",
naturalWidth, naturalHeight));
}
if (boundsFilledCount > 0 && boundsFilledCount < bounds.length) {
throw new IllegalStateException(String.format(Locale.ENGLISH, "One or more min/max dimensions have been specified, but not all. Bounds: [%d, %d, %d, %d]",
minWidth, maxWidth, minHeight, maxHeight));
}
double measuredWidth = naturalWidth;
double measuredHeight = naturalHeight;
boolean widthInBounds = measuredWidth >= minWidth && measuredWidth <= maxWidth;
boolean heightInBounds = measuredHeight >= minHeight && measuredHeight <= maxHeight;
if (!widthInBounds || !heightInBounds) {
double minWidthRatio = naturalWidth / minWidth;
double maxWidthRatio = naturalWidth / maxWidth;
double minHeightRatio = naturalHeight / minHeight;
double maxHeightRatio = naturalHeight / maxHeight;
if (maxWidthRatio > 1 || maxHeightRatio > 1) {
if (maxWidthRatio >= maxHeightRatio) {
measuredWidth /= maxWidthRatio;
measuredHeight /= maxWidthRatio;
} else {
measuredWidth /= maxHeightRatio;
measuredHeight /= maxHeightRatio;
}
measuredWidth = Math.max(measuredWidth, minWidth);
measuredHeight = Math.max(measuredHeight, minHeight);
} else if (minWidthRatio < 1 || minHeightRatio < 1) {
if (minWidthRatio <= minHeightRatio) {
measuredWidth /= minWidthRatio;
measuredHeight /= minWidthRatio;
} else {
measuredWidth /= minHeightRatio;
measuredHeight /= minHeightRatio;
}
measuredWidth = Math.min(measuredWidth, maxWidth);
measuredHeight = Math.min(measuredHeight, maxHeight);
}
}
targetDimens[WIDTH] = (int) measuredWidth;
targetDimens[HEIGHT] = (int) measuredHeight;
}
private int getNonZeroCount(int[] vals) {
int count = 0;
for (int val : vals) {
if (val > 0) {
count++;
}
}
return count;
}
@Override
public void setOnClickListener(OnClickListener l) {
parentClickListener = l;
}
@Override
public void setFocusable(boolean focusable) {
super.setFocusable(focusable);
if (transferControls.isPresent()) transferControls.get().setFocusable(focusable);
}
@Override
public void setClickable(boolean clickable) {
super.setClickable(clickable);
if (transferControls.isPresent()) transferControls.get().setClickable(clickable);
}
private TransferControlView getTransferControls() {
if (!transferControls.isPresent()) {
transferControls = Optional.of(ViewUtil.inflateStub(this, R.id.transfer_controls_stub));
}
return transferControls.get();
}
public void setBounds(int minWidth, int maxWidth, int minHeight, int maxHeight) {
bounds[MIN_WIDTH] = minWidth;
bounds[MAX_WIDTH] = maxWidth;
bounds[MIN_HEIGHT] = minHeight;
bounds[MAX_HEIGHT] = maxHeight;
forceLayout();
}
@UiThread
public ListenableFuture<Boolean> setImageResource(@NonNull GlideRequests glideRequests, @NonNull Slide slide,
boolean showControls, boolean isPreview)
{
return setImageResource(glideRequests, slide, showControls, isPreview, 0, 0);
}
@UiThread
public ListenableFuture<Boolean> setImageResource(@NonNull GlideRequests glideRequests, @NonNull Slide slide,
boolean showControls, boolean isPreview,
int naturalWidth, int naturalHeight)
{
if (showControls) {
getTransferControls().setSlide(slide);
getTransferControls().setDownloadClickListener(new DownloadClickDispatcher());
} else if (transferControls.isPresent()) {
getTransferControls().setVisibility(View.GONE);
}
if (slide.getThumbnailUri() != null && slide.hasPlayOverlay() &&
(slide.getTransferState() == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE || isPreview))
{
this.playOverlay.setVisibility(View.VISIBLE);
} else {
this.playOverlay.setVisibility(View.GONE);
}
if (Util.equals(slide, this.slide)) {
Log.i(TAG, "Not re-loading slide " + slide.asAttachment().getDataUri());
return new SettableFuture<>(false);
}
if (this.slide != null && this.slide.getFastPreflightId() != null &&
this.slide.getFastPreflightId().equals(slide.getFastPreflightId()))
{
Log.i(TAG, "Not re-loading slide for fast preflight: " + slide.getFastPreflightId());
this.slide = slide;
return new SettableFuture<>(false);
}
Log.i(TAG, "loading part with id " + slide.asAttachment().getDataUri()
+ ", progress " + slide.getTransferState() + ", fast preflight id: " +
slide.asAttachment().getFastPreflightId());
this.slide = slide;
dimens[WIDTH] = naturalWidth;
dimens[HEIGHT] = naturalHeight;
invalidate();
SettableFuture<Boolean> result = new SettableFuture<>();
if (slide.getThumbnailUri() != null) {
buildThumbnailGlideRequest(glideRequests, slide).into(new GlideDrawableListeningTarget(image, result));
} else if (slide.hasPlaceholder()) {
buildPlaceholderGlideRequest(glideRequests, slide).into(new GlideBitmapListeningTarget(image, result));
} else {
glideRequests.load(R.drawable.ic_image_white_24dp).centerInside().into(image);
result.set(false);
}
return result;
}
public ListenableFuture<Boolean> setImageResource(@NonNull GlideRequests glideRequests, @NonNull Uri uri) {
SettableFuture<Boolean> future = new SettableFuture<>();
if (transferControls.isPresent()) getTransferControls().setVisibility(View.GONE);
GlideRequest request = glideRequests.load(new DecryptableUri(uri))
.diskCacheStrategy(DiskCacheStrategy.NONE)
.transition(withCrossFade());
if (radius > 0) {
request = request.transforms(new CenterCrop(), new RoundedCorners(radius));
} else {
request = request.transforms(new CenterCrop());
}
request.into(new GlideDrawableListeningTarget(image, future));
return future;
}
public void setThumbnailClickListener(SlideClickListener listener) {
this.thumbnailClickListener = listener;
}
public void setDownloadClickListener(SlidesClickedListener listener) {
this.downloadClickListener = listener;
}
public void clear(GlideRequests glideRequests) {
glideRequests.clear(image);
if (transferControls.isPresent()) {
getTransferControls().clear();
}
slide = null;
}
public void showDownloadText(boolean showDownloadText) {
getTransferControls().setShowDownloadText(showDownloadText);
}
public void showProgressSpinner() {
getTransferControls().showProgressSpinner();
}
public void setLoadIndicatorVisibile(boolean visible) {
this.loadIndicator.setVisibility(visible ? VISIBLE : GONE);
}
protected void setRadius(int radius) {
this.radius = radius;
}
private GlideRequest buildThumbnailGlideRequest(@NonNull GlideRequests glideRequests, @NonNull Slide slide) {
GlideRequest request = applySizing(glideRequests.load(new DecryptableUri(slide.getThumbnailUri()))
.diskCacheStrategy(DiskCacheStrategy.NONE)
.transition(withCrossFade()), new CenterCrop());
if (slide.isInProgress()) return request;
else return request.apply(RequestOptions.errorOf(R.drawable.ic_missing_thumbnail_picture));
}
private RequestBuilder buildPlaceholderGlideRequest(@NonNull GlideRequests glideRequests, @NonNull Slide slide) {
return applySizing(glideRequests.asBitmap()
.load(slide.getPlaceholderRes(getContext().getTheme()))
.diskCacheStrategy(DiskCacheStrategy.NONE), new FitCenter());
}
private GlideRequest applySizing(@NonNull GlideRequest request, @NonNull BitmapTransformation fitting) {
int[] size = new int[2];
fillTargetDimensions(size, dimens, bounds);
if (size[WIDTH] == 0 && size[HEIGHT] == 0) {
size[WIDTH] = getDefaultWidth();
size[HEIGHT] = getDefaultHeight();
}
request = request.override(size[WIDTH], size[HEIGHT]);
if (radius > 0) {
return request.transforms(fitting, new RoundedCorners(radius));
} else {
return request.transforms(fitting);
}
}
private int getDefaultWidth() {
ViewGroup.LayoutParams params = getLayoutParams();
if (params != null) {
return Math.max(params.width, 0);
}
return 0;
}
private int getDefaultHeight() {
ViewGroup.LayoutParams params = getLayoutParams();
if (params != null) {
return Math.max(params.height, 0);
}
return 0;
}
private class ThumbnailClickDispatcher implements View.OnClickListener {
@Override
public void onClick(View view) {
if (thumbnailClickListener != null &&
slide != null &&
slide.asAttachment().getDataUri() != null &&
slide.getTransferState() == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE)
{
thumbnailClickListener.onClick(view, slide);
} else if (parentClickListener != null) {
parentClickListener.onClick(view);
}
}
}
private class DownloadClickDispatcher implements View.OnClickListener {
@Override
public void onClick(View view) {
if (downloadClickListener != null && slide != null) {
downloadClickListener.onClick(view, Collections.singletonList(slide));
} else {
Log.w(TAG, "Received a download button click, but unable to execute it. slide: " + String.valueOf(slide) + " downloadClickListener: " + String.valueOf(downloadClickListener));
}
}
}
}

View File

@ -2,14 +2,11 @@ package org.thoughtcrime.securesms.conversation.v2.utilities
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.net.Uri import android.net.Uri
import android.util.AttributeSet import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View import android.view.View
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.resource.bitmap.CenterCrop import com.bumptech.glide.load.resource.bitmap.CenterCrop
@ -29,31 +26,33 @@ import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri
import org.thoughtcrime.securesms.mms.GlideRequest import org.thoughtcrime.securesms.mms.GlideRequest
import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.mms.Slide import org.thoughtcrime.securesms.mms.Slide
import kotlin.Boolean
import kotlin.Int
import kotlin.getValue
import kotlin.lazy
import kotlin.let
open class KThumbnailView: FrameLayout { open class ThumbnailView: FrameLayout {
private lateinit var binding: ThumbnailViewBinding
companion object { companion object {
private const val WIDTH = 0 private const val WIDTH = 0
private const val HEIGHT = 1 private const val HEIGHT = 1
} }
private val binding: ThumbnailViewBinding by lazy { ThumbnailViewBinding.bind(this) }
// region Lifecycle // region Lifecycle
constructor(context: Context) : super(context) { initialize(null) } constructor(context: Context) : super(context) { initialize(null) }
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize(attrs) } constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize(attrs) }
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize(attrs) } constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize(attrs) }
private val image by lazy { binding.thumbnailImage }
private val playOverlay by lazy { binding.playOverlay }
val loadIndicator: View by lazy { binding.thumbnailLoadIndicator } val loadIndicator: View by lazy { binding.thumbnailLoadIndicator }
val downloadIndicator: View by lazy { binding.thumbnailDownloadIcon }
private val dimensDelegate = ThumbnailDimensDelegate() private val dimensDelegate = ThumbnailDimensDelegate()
private var slide: Slide? = null private var slide: Slide? = null
private var radius: Int = 0 var radius: Int = 0
private fun initialize(attrs: AttributeSet?) { private fun initialize(attrs: AttributeSet?) {
binding = ThumbnailViewBinding.inflate(LayoutInflater.from(context), this)
if (attrs != null) { if (attrs != null) {
val typedArray = context.theme.obtainStyledAttributes(attrs, R.styleable.ThumbnailView, 0, 0) val typedArray = context.theme.obtainStyledAttributes(attrs, R.styleable.ThumbnailView, 0, 0)
@ -66,8 +65,6 @@ open class KThumbnailView: FrameLayout {
typedArray.recycle() typedArray.recycle()
} }
val background = ContextCompat.getColor(context, R.color.transparent_black_6)
binding.root.background = ColorDrawable(background)
} }
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
@ -80,8 +77,8 @@ open class KThumbnailView: FrameLayout {
val finalHeight: Int = adjustedDimens[HEIGHT] + paddingTop + paddingBottom val finalHeight: Int = adjustedDimens[HEIGHT] + paddingTop + paddingBottom
super.onMeasure( super.onMeasure(
MeasureSpec.makeMeasureSpec(finalWidth, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(finalWidth, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(finalHeight, MeasureSpec.EXACTLY) MeasureSpec.makeMeasureSpec(finalHeight, MeasureSpec.EXACTLY)
) )
} }
@ -90,17 +87,17 @@ open class KThumbnailView: FrameLayout {
// endregion // endregion
// region Interaction // region Interaction
fun setImageResource(glide: GlideRequests, slide: Slide, isPreview: Boolean, mms: MmsMessageRecord): ListenableFuture<Boolean> { fun setImageResource(glide: GlideRequests, slide: Slide, isPreview: Boolean, mms: MmsMessageRecord?): ListenableFuture<Boolean> {
return setImageResource(glide, slide, isPreview, 0, 0, mms) return setImageResource(glide, slide, isPreview, 0, 0, mms)
} }
fun setImageResource(glide: GlideRequests, slide: Slide, fun setImageResource(glide: GlideRequests, slide: Slide,
isPreview: Boolean, naturalWidth: Int, isPreview: Boolean, naturalWidth: Int,
naturalHeight: Int, mms: MmsMessageRecord): ListenableFuture<Boolean> { naturalHeight: Int, mms: MmsMessageRecord?): ListenableFuture<Boolean> {
val currentSlide = this.slide val currentSlide = this.slide
playOverlay.isVisible = (slide.thumbnailUri != null && slide.hasPlayOverlay() && binding.playOverlay.isVisible = (slide.thumbnailUri != null && slide.hasPlayOverlay() &&
(slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE || isPreview)) (slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE || isPreview))
if (equals(currentSlide, slide)) { if (equals(currentSlide, slide)) {
@ -116,8 +113,8 @@ open class KThumbnailView: FrameLayout {
this.slide = slide this.slide = slide
loadIndicator.isVisible = slide.isInProgress binding.thumbnailLoadIndicator.isVisible = slide.isInProgress
downloadIndicator.isVisible = slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED binding.thumbnailDownloadIcon.isVisible = slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED
dimensDelegate.setDimens(naturalWidth, naturalHeight) dimensDelegate.setDimens(naturalWidth, naturalHeight)
invalidate() invalidate()
@ -126,13 +123,13 @@ open class KThumbnailView: FrameLayout {
when { when {
slide.thumbnailUri != null -> { slide.thumbnailUri != null -> {
buildThumbnailGlideRequest(glide, slide).into(GlideDrawableListeningTarget(image, result)) buildThumbnailGlideRequest(glide, slide).into(GlideDrawableListeningTarget(binding.thumbnailImage, result))
} }
slide.hasPlaceholder() -> { slide.hasPlaceholder() -> {
buildPlaceholderGlideRequest(glide, slide).into(GlideBitmapListeningTarget(image, result)) buildPlaceholderGlideRequest(glide, slide).into(GlideBitmapListeningTarget(binding.thumbnailImage, result))
} }
else -> { else -> {
glide.clear(image) glide.clear(binding.thumbnailImage)
result.set(false) result.set(false)
} }
} }
@ -176,7 +173,7 @@ open class KThumbnailView: FrameLayout {
} }
open fun clear(glideRequests: GlideRequests) { open fun clear(glideRequests: GlideRequests) {
glideRequests.clear(image) glideRequests.clear(binding.thumbnailImage)
slide = null slide = null
} }
@ -193,11 +190,8 @@ open class KThumbnailView: FrameLayout {
request.transforms(CenterCrop()) request.transforms(CenterCrop())
} }
request.into(GlideDrawableListeningTarget(image, future)) request.into(GlideDrawableListeningTarget(binding.thumbnailImage, future))
return future return future
} }
// endregion
} }

View File

@ -33,8 +33,9 @@ import androidx.annotation.VisibleForTesting;
import com.bumptech.glide.Glide; import com.bumptech.glide.Glide;
import net.sqlcipher.database.SQLiteDatabase; import net.zetetic.database.sqlcipher.SQLiteDatabase;
import org.apache.commons.lang3.StringUtils;
import org.json.JSONArray; import org.json.JSONArray;
import org.json.JSONException; import org.json.JSONException;
import org.session.libsession.messaging.sending_receiving.attachments.Attachment; import org.session.libsession.messaging.sending_receiving.attachments.Attachment;
@ -318,6 +319,28 @@ public class AttachmentDatabase extends Database {
notifyAttachmentListeners(); notifyAttachmentListeners();
} }
@SuppressWarnings("ResultOfMethodCallIgnored")
void deleteAttachmentsForMessages(long[] mmsIds) {
SQLiteDatabase database = databaseHelper.getWritableDatabase();
Cursor cursor = null;
String mmsIdString = StringUtils.join(mmsIds, ',');
try {
cursor = database.query(TABLE_NAME, new String[] {DATA, THUMBNAIL, CONTENT_TYPE}, MMS_ID + " IN (?)",
new String[] {mmsIdString}, null, null, null);
while (cursor != null && cursor.moveToNext()) {
deleteAttachmentOnDisk(cursor.getString(0), cursor.getString(1), cursor.getString(2));
}
} finally {
if (cursor != null)
cursor.close();
}
database.delete(TABLE_NAME, MMS_ID + " IN (?)", new String[] {mmsIdString});
notifyAttachmentListeners();
}
public void deleteAttachment(@NonNull AttachmentId id) { public void deleteAttachment(@NonNull AttachmentId id) {
SQLiteDatabase database = databaseHelper.getWritableDatabase(); SQLiteDatabase database = databaseHelper.getWritableDatabase();

View File

@ -23,7 +23,7 @@ import android.database.Cursor;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import net.sqlcipher.database.SQLiteDatabase; import net.zetetic.database.sqlcipher.SQLiteDatabase;
import org.session.libsession.utilities.WindowDebouncer; import org.session.libsession.utilities.WindowDebouncer;
import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.ApplicationContext;

View File

@ -19,7 +19,7 @@ package org.thoughtcrime.securesms.database;
import android.content.Context; import android.content.Context;
import net.sqlcipher.database.SQLiteDatabase; import net.zetetic.database.sqlcipher.SQLiteDatabase;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.dependencies.DatabaseComponent; import org.thoughtcrime.securesms.dependencies.DatabaseComponent;

View File

@ -1,9 +1,9 @@
package org.thoughtcrime.securesms.database package org.thoughtcrime.securesms.database
import android.content.ContentValues import android.content.ContentValues
import android.database.Cursor
import androidx.core.database.getStringOrNull import androidx.core.database.getStringOrNull
import net.sqlcipher.Cursor import net.zetetic.database.sqlcipher.SQLiteDatabase
import net.sqlcipher.database.SQLiteDatabase
import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Base64
fun <T> SQLiteDatabase.get(table: String, query: String?, arguments: Array<String>?, get: (Cursor) -> T): T? { fun <T> SQLiteDatabase.get(table: String, query: String?, arguments: Array<String>?, get: (Cursor) -> T): T? {

View File

@ -6,7 +6,7 @@ import android.database.Cursor;
import android.net.Uri; import android.net.Uri;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import net.sqlcipher.database.SQLiteDatabase; import net.zetetic.database.sqlcipher.SQLiteDatabase;
import network.loki.messenger.R; import network.loki.messenger.R;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;

View File

@ -1,6 +1,5 @@
package org.thoughtcrime.securesms.database; package org.thoughtcrime.securesms.database;
import android.annotation.SuppressLint; import android.annotation.SuppressLint;
import android.content.ContentValues; import android.content.ContentValues;
import android.content.Context; import android.content.Context;
@ -12,7 +11,7 @@ import androidx.annotation.Nullable;
import com.annimon.stream.Stream; import com.annimon.stream.Stream;
import net.sqlcipher.database.SQLiteDatabase; import net.zetetic.database.sqlcipher.SQLiteDatabase;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import org.session.libsession.utilities.Address; import org.session.libsession.utilities.Address;
@ -319,6 +318,19 @@ public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProt
notifyConversationListListeners(); notifyConversationListListeners();
} }
public boolean hasDownloadedProfilePicture(String groupId) {
try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, new String[]{AVATAR}, GROUP_ID + " = ?",
new String[] {groupId},
null, null, null))
{
if (cursor != null && cursor.moveToNext()) {
return !cursor.isNull(0);
}
return false;
}
}
public void updateMembers(String groupId, List<Address> members) { public void updateMembers(String groupId, List<Address> members) {
Collections.sort(members); Collections.sort(members);

View File

@ -1,14 +1,14 @@
package org.thoughtcrime.securesms.database; package org.thoughtcrime.securesms.database;
import android.content.ContentValues; import android.content.ContentValues;
import android.content.Context; import android.content.Context;
import android.database.Cursor; import android.database.Cursor;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import net.sqlcipher.database.SQLiteDatabase; import net.zetetic.database.sqlcipher.SQLiteDatabase;
import org.apache.commons.lang3.StringUtils;
import org.session.libsession.utilities.Address; import org.session.libsession.utilities.Address;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
@ -110,6 +110,11 @@ public class GroupReceiptDatabase extends Database {
db.delete(TABLE_NAME, MMS_ID + " = ?", new String[] {String.valueOf(mmsId)}); db.delete(TABLE_NAME, MMS_ID + " = ?", new String[] {String.valueOf(mmsId)});
} }
void deleteRowsForMessages(long[] mmsIds) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.delete(TABLE_NAME, MMS_ID + " IN (?)", new String[] {StringUtils.join(mmsIds, ',')});
}
void deleteAllRows() { void deleteAllRows() {
SQLiteDatabase db = databaseHelper.getWritableDatabase(); SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.delete(TABLE_NAME, null, null); db.delete(TABLE_NAME, null, null);

View File

@ -5,7 +5,7 @@ import android.content.Context;
import android.database.Cursor; import android.database.Cursor;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import net.sqlcipher.database.SQLiteDatabase; import net.zetetic.database.sqlcipher.SQLiteDatabase;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.jobmanager.persistence.ConstraintSpec; import org.thoughtcrime.securesms.jobmanager.persistence.ConstraintSpec;

View File

@ -300,6 +300,11 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
val lastHash = database.insertOrUpdate(lastMessageHashValueTable2, row, query, arrayOf( snode.toString(), publicKey, namespace.toString() )) val lastHash = database.insertOrUpdate(lastMessageHashValueTable2, row, query, arrayOf( snode.toString(), publicKey, namespace.toString() ))
} }
override fun clearAllLastMessageHashes() {
val database = databaseHelper.writableDatabase
database.delete(lastMessageHashValueTable2, null, null)
}
override fun getReceivedMessageHashValues(publicKey: String, namespace: Int): Set<String>? { override fun getReceivedMessageHashValues(publicKey: String, namespace: Int): Set<String>? {
val database = databaseHelper.readableDatabase val database = databaseHelper.readableDatabase
val query = "${Companion.publicKey} = ? AND ${Companion.receivedMessageHashNamespace} = ?" val query = "${Companion.publicKey} = ? AND ${Companion.receivedMessageHashNamespace} = ?"
@ -321,6 +326,11 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
database.insertOrUpdate(receivedMessageHashValuesTable, row, query, arrayOf( publicKey, namespace.toString() )) database.insertOrUpdate(receivedMessageHashValuesTable, row, query, arrayOf( publicKey, namespace.toString() ))
} }
override fun clearReceivedMessageHashValues() {
val database = databaseHelper.writableDatabase
database.delete(receivedMessageHashValuesTable, null, null)
}
override fun getAuthToken(server: String): String? { override fun getAuthToken(server: String): String? {
val database = databaseHelper.readableDatabase val database = databaseHelper.readableDatabase
return database.get(openGroupAuthTokenTable, "${Companion.server} = ?", wrap(server)) { cursor -> return database.get(openGroupAuthTokenTable, "${Companion.server} = ?", wrap(server)) { cursor ->
@ -339,7 +349,7 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
} }
override fun getLastMessageServerID(room: String, server: String): Long? { override fun getLastMessageServerID(room: String, server: String): Long? {
val database = databaseHelper.writableDatabase val database = databaseHelper.readableDatabase
val index = "$server.$room" val index = "$server.$room"
return database.get(lastMessageServerIDTable, "$lastMessageServerIDTableIndex = ?", wrap(index)) { cursor -> return database.get(lastMessageServerIDTable, "$lastMessageServerIDTableIndex = ?", wrap(index)) { cursor ->
cursor.getInt(lastMessageServerID) cursor.getInt(lastMessageServerID)
@ -510,7 +520,7 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
} }
fun getServerCapabilities(serverName: String): List<String> { fun getServerCapabilities(serverName: String): List<String> {
val database = databaseHelper.writableDatabase val database = databaseHelper.readableDatabase
return database.get(serverCapabilitiesTable, "$server = ?", wrap(serverName)) { cursor -> return database.get(serverCapabilitiesTable, "$server = ?", wrap(serverName)) { cursor ->
cursor.getString(capabilities) cursor.getString(capabilities)
}?.split(",") ?: emptyList() }?.split(",") ?: emptyList()
@ -523,7 +533,7 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
} }
fun getLastInboxMessageId(serverName: String): Long? { fun getLastInboxMessageId(serverName: String): Long? {
val database = databaseHelper.writableDatabase val database = databaseHelper.readableDatabase
return database.get(lastInboxMessageServerIdTable, "$server = ?", wrap(serverName)) { cursor -> return database.get(lastInboxMessageServerIdTable, "$server = ?", wrap(serverName)) { cursor ->
cursor.getInt(lastInboxMessageServerId) cursor.getInt(lastInboxMessageServerId)
}?.toLong() }?.toLong()
@ -540,7 +550,7 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
} }
fun getLastOutboxMessageId(serverName: String): Long? { fun getLastOutboxMessageId(serverName: String): Long? {
val database = databaseHelper.writableDatabase val database = databaseHelper.readableDatabase
return database.get(lastOutboxMessageServerIdTable, "$server = ?", wrap(serverName)) { cursor -> return database.get(lastOutboxMessageServerIdTable, "$server = ?", wrap(serverName)) { cursor ->
cursor.getInt(lastOutboxMessageServerId) cursor.getInt(lastOutboxMessageServerId)
}?.toLong() }?.toLong()

View File

@ -2,7 +2,7 @@ package org.thoughtcrime.securesms.database
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
import net.sqlcipher.database.SQLiteDatabase.CONFLICT_REPLACE import net.zetetic.database.sqlcipher.SQLiteDatabase.CONFLICT_REPLACE
import org.session.libsignal.database.LokiMessageDatabaseProtocol import org.session.libsignal.database.LokiMessageDatabaseProtocol
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
@ -77,6 +77,25 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
database.endTransaction() database.endTransaction()
} }
fun deleteMessages(messageIDs: List<Long>) {
val database = databaseHelper.writableDatabase
database.beginTransaction()
database.delete(
messageIDTable,
"${Companion.messageID} IN (${messageIDs.map { "?" }.joinToString(",")})",
messageIDs.map { "$it" }.toTypedArray()
)
database.delete(
messageThreadMappingTable,
"${Companion.messageID} IN (${messageIDs.map { "?" }.joinToString(",")})",
messageIDs.map { "$it" }.toTypedArray()
)
database.setTransactionSuccessful()
database.endTransaction()
}
/** /**
* @return pair of sms or mms table-specific ID and whether it is in SMS table * @return pair of sms or mms table-specific ID and whether it is in SMS table
*/ */
@ -96,6 +115,37 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
} }
} }
fun getMessageIDs(serverIDs: List<Long>, threadID: Long): Pair<List<Long>, List<Long>> {
val database = databaseHelper.readableDatabase
// Retrieve the message ids
val messageIdCursor = database
.rawQuery(
"""
SELECT ${messageThreadMappingTable}.${messageID}, ${messageIDTable}.${messageType}
FROM ${messageThreadMappingTable}
JOIN ${messageIDTable} ON ${messageIDTable}.message_id = ${messageThreadMappingTable}.${messageID}
WHERE (
${messageThreadMappingTable}.${Companion.threadID} = $threadID AND
${messageThreadMappingTable}.${Companion.serverID} IN (${serverIDs.joinToString(",")})
)
"""
)
val smsMessageIds: MutableList<Long> = mutableListOf()
val mmsMessageIds: MutableList<Long> = mutableListOf()
while (messageIdCursor.moveToNext()) {
if (messageIdCursor.getInt(1) == SMS_TYPE) {
smsMessageIds.add(messageIdCursor.getLong(0))
}
else {
mmsMessageIds.add(messageIdCursor.getLong(0))
}
}
return Pair(smsMessageIds, mmsMessageIds)
}
override fun setServerID(messageID: Long, serverID: Long, isSms: Boolean) { override fun setServerID(messageID: Long, serverID: Long, isSms: Boolean) {
val database = databaseHelper.writableDatabase val database = databaseHelper.writableDatabase
val contentValues = ContentValues(3) val contentValues = ContentValues(3)
@ -183,6 +233,15 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
database.delete(messageHashTable, "${Companion.messageID} = ?", arrayOf(messageID.toString())) database.delete(messageHashTable, "${Companion.messageID} = ?", arrayOf(messageID.toString()))
} }
fun deleteMessageServerHashes(messageIDs: List<Long>) {
val database = databaseHelper.writableDatabase
database.delete(
messageHashTable,
"${Companion.messageID} IN (${messageIDs.map { "?" }.joinToString(",")})",
messageIDs.map { "$it" }.toTypedArray()
)
}
fun migrateThreadId(legacyThreadId: Long, newThreadId: Long) { fun migrateThreadId(legacyThreadId: Long, newThreadId: Long) {
val database = databaseHelper.writableDatabase val database = databaseHelper.writableDatabase
val contentValues = ContentValues(1) val contentValues = ContentValues(1)

View File

@ -7,7 +7,7 @@ import android.database.Cursor;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import net.sqlcipher.database.SQLiteDatabase; import net.zetetic.database.sqlcipher.SQLiteDatabase;
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment; import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment;
import org.session.libsession.utilities.Address; import org.session.libsession.utilities.Address;

View File

@ -5,7 +5,7 @@ import android.content.Context;
import android.database.Cursor; import android.database.Cursor;
import android.text.TextUtils; import android.text.TextUtils;
import net.sqlcipher.database.SQLiteDatabase; import net.zetetic.database.sqlcipher.SQLiteDatabase;
import org.session.libsession.utilities.Address; import org.session.libsession.utilities.Address;
import org.session.libsession.utilities.Document; import org.session.libsession.utilities.Document;
@ -42,6 +42,7 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
public abstract void markAsDeleted(long messageId, boolean read); public abstract void markAsDeleted(long messageId, boolean read);
public abstract boolean deleteMessage(long messageId); public abstract boolean deleteMessage(long messageId);
public abstract boolean deleteMessages(long[] messageId, long threadId);
public abstract void updateThreadId(long fromId, long toId); public abstract void updateThreadId(long fromId, long toId);

View File

@ -995,6 +995,23 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
return threadDeleted return threadDeleted
} }
override fun deleteMessages(messageIds: LongArray, threadId: Long): Boolean {
val attachmentDatabase = get(context).attachmentDatabase()
val groupReceiptDatabase = get(context).groupReceiptDatabase()
queue(Runnable { attachmentDatabase.deleteAttachmentsForMessages(messageIds) })
groupReceiptDatabase.deleteRowsForMessages(messageIds)
val database = databaseHelper.writableDatabase
database!!.delete(TABLE_NAME, ID_IN, arrayOf(messageIds.joinToString(",")))
val threadDeleted = get(context).threadDatabase().update(threadId, false)
notifyConversationListeners(threadId)
notifyStickerListeners()
notifyStickerPackListeners()
return threadDeleted
}
override fun updateThreadId(fromId: Long, toId: Long) { override fun updateThreadId(fromId: Long, toId: Long) {
val contentValues = ContentValues(1) val contentValues = ContentValues(1)
contentValues.put(THREAD_ID, toId) contentValues.put(THREAD_ID, toId)

View File

@ -22,8 +22,8 @@ import android.database.Cursor;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import net.sqlcipher.database.SQLiteDatabase; import net.zetetic.database.sqlcipher.SQLiteDatabase;
import net.sqlcipher.database.SQLiteQueryBuilder; import net.zetetic.database.sqlcipher.SQLiteQueryBuilder;
import org.session.libsession.utilities.Address; import org.session.libsession.utilities.Address;
import org.session.libsession.utilities.Util; import org.session.libsession.utilities.Util;

View File

@ -6,7 +6,7 @@ import android.database.Cursor;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import org.session.libsignal.utilities.Log; import org.session.libsignal.utilities.Log;
import net.sqlcipher.database.SQLiteDatabase; import net.zetetic.database.sqlcipher.SQLiteDatabase;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.session.libsignal.utilities.Base64; import org.session.libsignal.utilities.Base64;

View File

@ -48,6 +48,14 @@ class ReactionDatabase(context: Context, helper: SQLCipherOpenHelper) : Database
) )
""".trimIndent() """.trimIndent()
@JvmField
val CREATE_INDEXS = arrayOf(
"CREATE INDEX IF NOT EXISTS reaction_message_id_index ON " + ReactionDatabase.TABLE_NAME + " (" + ReactionDatabase.MESSAGE_ID + ");",
"CREATE INDEX IF NOT EXISTS reaction_is_mms_index ON " + ReactionDatabase.TABLE_NAME + " (" + ReactionDatabase.IS_MMS + ");",
"CREATE INDEX IF NOT EXISTS reaction_message_id_is_mms_index ON " + ReactionDatabase.TABLE_NAME + " (" + ReactionDatabase.MESSAGE_ID + ", " + ReactionDatabase.IS_MMS + ");",
"CREATE INDEX IF NOT EXISTS reaction_sort_id_index ON " + ReactionDatabase.TABLE_NAME + " (" + ReactionDatabase.SORT_ID + ");",
)
@JvmField @JvmField
val CREATE_REACTION_TRIGGERS = arrayOf( val CREATE_REACTION_TRIGGERS = arrayOf(
""" """

View File

@ -11,7 +11,7 @@ import androidx.annotation.Nullable;
import com.annimon.stream.Stream; import com.annimon.stream.Stream;
import net.sqlcipher.database.SQLiteDatabase; import net.zetetic.database.sqlcipher.SQLiteDatabase;
import org.session.libsession.utilities.Address; import org.session.libsession.utilities.Address;
import org.session.libsession.utilities.MaterialColor; import org.session.libsession.utilities.MaterialColor;

View File

@ -1,13 +1,13 @@
package org.thoughtcrime.securesms.database; package org.thoughtcrime.securesms.database;
import android.content.Context; import android.content.Context;
import android.database.Cursor;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import com.annimon.stream.Stream; import com.annimon.stream.Stream;
import net.sqlcipher.Cursor; import net.zetetic.database.sqlcipher.SQLiteDatabase;
import net.sqlcipher.database.SQLiteDatabase;
import org.session.libsession.utilities.Util; import org.session.libsession.utilities.Util;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;

View File

@ -3,7 +3,7 @@ package org.thoughtcrime.securesms.database
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
import androidx.core.database.getStringOrNull import androidx.core.database.getStringOrNull
import net.sqlcipher.Cursor import android.database.Cursor
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Base64
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
@ -75,21 +75,6 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da
} }
fun contactFromCursor(cursor: Cursor): Contact { fun contactFromCursor(cursor: Cursor): Contact {
val sessionID = cursor.getString(sessionID)
val contact = Contact(sessionID)
contact.name = cursor.getStringOrNull(name)
contact.nickname = cursor.getStringOrNull(nickname)
contact.profilePictureURL = cursor.getStringOrNull(profilePictureURL)
contact.profilePictureFileName = cursor.getStringOrNull(profilePictureFileName)
cursor.getStringOrNull(profilePictureEncryptionKey)?.let {
contact.profilePictureEncryptionKey = Base64.decode(it)
}
contact.threadID = cursor.getLong(threadID)
contact.isTrusted = cursor.getInt(isTrusted) != 0
return contact
}
fun contactFromCursor(cursor: android.database.Cursor): Contact {
val sessionID = cursor.getString(cursor.getColumnIndexOrThrow(sessionID)) val sessionID = cursor.getString(cursor.getColumnIndexOrThrow(sessionID))
val contact = Contact(sessionID) val contact = Contact(sessionID)
contact.name = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(name)) contact.name = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(name))

View File

@ -2,7 +2,7 @@ package org.thoughtcrime.securesms.database
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
import net.sqlcipher.Cursor import android.database.Cursor
import org.session.libsession.messaging.jobs.AttachmentUploadJob import org.session.libsession.messaging.jobs.AttachmentUploadJob
import org.session.libsession.messaging.jobs.BackgroundGroupAddJob import org.session.libsession.messaging.jobs.BackgroundGroupAddJob
import org.session.libsession.messaging.jobs.GroupAvatarDownloadJob import org.session.libsession.messaging.jobs.GroupAvatarDownloadJob

View File

@ -28,9 +28,10 @@ import androidx.annotation.Nullable;
import com.annimon.stream.Stream; import com.annimon.stream.Stream;
import net.sqlcipher.database.SQLiteDatabase; import net.zetetic.database.sqlcipher.SQLiteDatabase;
import net.sqlcipher.database.SQLiteStatement; import net.zetetic.database.sqlcipher.SQLiteStatement;
import org.apache.commons.lang3.StringUtils;
import org.session.libsession.messaging.calls.CallMessageType; import org.session.libsession.messaging.calls.CallMessageType;
import org.session.libsession.messaging.messages.signal.IncomingGroupMessage; import org.session.libsession.messaging.messages.signal.IncomingGroupMessage;
import org.session.libsession.messaging.messages.signal.IncomingTextMessage; import org.session.libsession.messaging.messages.signal.IncomingTextMessage;
@ -52,6 +53,7 @@ import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
import java.io.IOException; import java.io.IOException;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.List; import java.util.List;
@ -596,6 +598,30 @@ public class SmsDatabase extends MessagingDatabase {
return threadDeleted; return threadDeleted;
} }
@Override
public boolean deleteMessages(long[] messageIds, long threadId) {
String[] argsArray = new String[messageIds.length];
String[] argValues = new String[messageIds.length];
Arrays.fill(argsArray, "?");
for (int i = 0; i < messageIds.length; i++) {
argValues[i] = (messageIds[i] + "");
}
String combinedMessageIdArgss = StringUtils.join(messageIds, ',');
String combinedMessageIds = StringUtils.join(messageIds, ',');
Log.i("MessageDatabase", "Deleting: " + combinedMessageIds);
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.delete(
TABLE_NAME,
ID + " IN (" + StringUtils.join(argsArray, ',') + ")",
argValues
);
boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false);
notifyConversationListeners(threadId);
return threadDeleted;
}
@Override @Override
public void updateThreadId(long fromId, long toId) { public void updateThreadId(long fromId, long toId) {
ContentValues contentValues = new ContentValues(1); ContentValues contentValues = new ContentValues(1);

View File

@ -16,6 +16,7 @@ import org.session.libsession.messaging.messages.visible.Reaction
import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.messages.visible.VisibleMessage
import org.session.libsession.messaging.open_groups.GroupMember import org.session.libsession.messaging.open_groups.GroupMember
import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.open_groups.OpenGroup
import org.session.libsession.messaging.open_groups.OpenGroupApi
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage
@ -320,6 +321,10 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
DatabaseComponent.get(context).groupDatabase().updateProfilePicture(groupID, newValue) DatabaseComponent.get(context).groupDatabase().updateProfilePicture(groupID, newValue)
} }
override fun hasDownloadedProfilePicture(groupID: String): Boolean {
return DatabaseComponent.get(context).groupDatabase().hasDownloadedProfilePicture(groupID)
}
override fun getReceivedMessageTimestamps(): Set<Long> { override fun getReceivedMessageTimestamps(): Set<Long> {
return SessionMetaProtocol.getTimestamps() return SessionMetaProtocol.getTimestamps()
} }
@ -552,8 +557,8 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
return DatabaseComponent.get(context).groupDatabase().allGroups return DatabaseComponent.get(context).groupDatabase().allGroups
} }
override fun addOpenGroup(urlAsString: String) { override fun addOpenGroup(urlAsString: String): OpenGroupApi.RoomInfo? {
OpenGroupManager.addOpenGroup(urlAsString, context) return OpenGroupManager.addOpenGroup(urlAsString, context)
} }
override fun onOpenGroupAdded(server: String) { override fun onOpenGroupAdded(server: String) {

View File

@ -32,7 +32,7 @@ import androidx.annotation.Nullable;
import com.annimon.stream.Stream; import com.annimon.stream.Stream;
import net.sqlcipher.database.SQLiteDatabase; import net.zetetic.database.sqlcipher.SQLiteDatabase;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import org.session.libsession.utilities.Address; import org.session.libsession.utilities.Address;

View File

@ -1,14 +1,18 @@
package org.thoughtcrime.securesms.database.helpers; package org.thoughtcrime.securesms.database.helpers;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.content.Context; import android.content.Context;
import android.database.Cursor; import android.database.Cursor;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.core.app.NotificationCompat;
import net.sqlcipher.database.SQLiteDatabase; import net.zetetic.database.sqlcipher.SQLiteConnection;
import net.sqlcipher.database.SQLiteDatabaseHook; import net.zetetic.database.sqlcipher.SQLiteDatabase;
import net.sqlcipher.database.SQLiteOpenHelper; import net.zetetic.database.sqlcipher.SQLiteDatabaseHook;
import net.zetetic.database.sqlcipher.SQLiteException;
import net.zetetic.database.sqlcipher.SQLiteOpenHelper;
import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsession.utilities.TextSecurePreferences;
import org.session.libsignal.utilities.Log; import org.session.libsignal.utilities.Log;
@ -35,6 +39,11 @@ import org.thoughtcrime.securesms.database.SessionContactDatabase;
import org.thoughtcrime.securesms.database.SessionJobDatabase; import org.thoughtcrime.securesms.database.SessionJobDatabase;
import org.thoughtcrime.securesms.database.SmsDatabase; import org.thoughtcrime.securesms.database.SmsDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.notifications.NotificationChannels;
import java.io.File;
import network.loki.messenger.R;
public class SQLCipherOpenHelper extends SQLiteOpenHelper { public class SQLCipherOpenHelper extends SQLiteOpenHelper {
@ -75,40 +84,157 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
private static final int lokiV36 = 57; private static final int lokiV36 = 57;
private static final int lokiV37 = 58; private static final int lokiV37 = 58;
private static final int lokiV38 = 59; private static final int lokiV38 = 59;
private static final int lokiV39 = 60;
// Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes // Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes
private static final int DATABASE_VERSION = lokiV38; private static final int DATABASE_VERSION = lokiV39;
private static final String DATABASE_NAME = "signal.db"; private static final int MIN_DATABASE_VERSION = lokiV7;
private static final String CIPHER3_DATABASE_NAME = "signal.db";
public static final String DATABASE_NAME = "signal_v4.db";
private final Context context; private final Context context;
private final DatabaseSecret databaseSecret; private final DatabaseSecret databaseSecret;
public SQLCipherOpenHelper(@NonNull Context context, @NonNull DatabaseSecret databaseSecret) { public SQLCipherOpenHelper(@NonNull Context context, @NonNull DatabaseSecret databaseSecret) {
super(context, DATABASE_NAME, null, DATABASE_VERSION, new SQLiteDatabaseHook() { super(context, DATABASE_NAME, databaseSecret.asString(), null, DATABASE_VERSION, MIN_DATABASE_VERSION, null, new SQLiteDatabaseHook() {
@Override @Override
public void preKey(SQLiteDatabase db) { public void preKey(SQLiteConnection connection) {
db.rawExecSQL("PRAGMA cipher_default_kdf_iter = 1;"); SQLCipherOpenHelper.applySQLCipherPragmas(connection, true);
db.rawExecSQL("PRAGMA cipher_default_page_size = 4096;");
} }
@Override @Override
public void postKey(SQLiteDatabase db) { public void postKey(SQLiteConnection connection) {
db.rawExecSQL("PRAGMA kdf_iter = '1';"); SQLCipherOpenHelper.applySQLCipherPragmas(connection, true);
db.rawExecSQL("PRAGMA cipher_page_size = 4096;");
// if not vacuumed in a while, perform that operation // if not vacuumed in a while, perform that operation
long currentTime = System.currentTimeMillis(); long currentTime = System.currentTimeMillis();
// 7 days // 7 days
if (currentTime - TextSecurePreferences.getLastVacuumTime(context) > 604_800_000) { if (currentTime - TextSecurePreferences.getLastVacuumTime(context) > 604_800_000) {
db.rawExecSQL("VACUUM;"); connection.execute("VACUUM;", null, null);
TextSecurePreferences.setLastVacuumNow(context); TextSecurePreferences.setLastVacuumNow(context);
} }
} }
}); }, true);
this.context = context.getApplicationContext(); this.context = context.getApplicationContext();
this.databaseSecret = databaseSecret; this.databaseSecret = databaseSecret;
} }
private static void applySQLCipherPragmas(SQLiteConnection connection, boolean useSQLCipher4) {
if (useSQLCipher4) {
connection.execute("PRAGMA kdf_iter = '256000';", null, null);
}
else {
connection.execute("PRAGMA cipher_compatibility = 3;", null, null);
connection.execute("PRAGMA kdf_iter = '1';", null, null);
}
connection.execute("PRAGMA cipher_page_size = 4096;", null, null);
}
private static SQLiteDatabase open(String path, DatabaseSecret databaseSecret, boolean useSQLCipher4) throws SQLiteException {
return SQLiteDatabase.openDatabase(path, databaseSecret.asString(), null, SQLiteDatabase.OPEN_READWRITE, new SQLiteDatabaseHook() {
@Override
public void preKey(SQLiteConnection connection) { SQLCipherOpenHelper.applySQLCipherPragmas(connection, useSQLCipher4); }
@Override
public void postKey(SQLiteConnection connection) { SQLCipherOpenHelper.applySQLCipherPragmas(connection, useSQLCipher4); }
});
}
public static void migrateSqlCipher3To4IfNeeded(@NonNull Context context, @NonNull DatabaseSecret databaseSecret) throws Exception {
String oldDbPath = context.getDatabasePath(CIPHER3_DATABASE_NAME).getPath();
File oldDbFile = new File(oldDbPath);
// If the old SQLCipher3 database file doesn't exist then no need to do anything
if (!oldDbFile.exists()) { return; }
try {
// Define the location for the new database
String newDbPath = context.getDatabasePath(DATABASE_NAME).getPath();
File newDbFile = new File(newDbPath);
// If the new database file already exists then check if it's valid first, if it's in an
// invalid state we should delete it and try to migrate again
if (newDbFile.exists()) {
// If the old database hasn't been modified since the new database was created, then we can
// assume the user hasn't downgraded for some reason and made changes to the old database and
// can remove the old database file (it won't be used anymore)
if (oldDbFile.lastModified() <= newDbFile.lastModified()) {
// TODO: Delete 'CIPHER3_DATABASE_NAME' once enough time has past
// //noinspection ResultOfMethodCallIgnored
// oldDbFile.delete();
return;
}
// If the old database does have newer changes then the new database could have stale/invalid
// data and we should re-migrate to avoid losing any data or issues
if (!newDbFile.delete()) {
throw new Exception("Failed to remove invalid new database");
}
}
if (!newDbFile.createNewFile()) {
throw new Exception("Failed to create new database");
}
// Open the old database and extract it's version
SQLiteDatabase oldDb = SQLCipherOpenHelper.open(oldDbPath, databaseSecret, false);
int oldDbVersion = oldDb.getVersion();
// Export the old database to the new one (will have the default 'kdf_iter' and 'page_size' settings)
oldDb.rawExecSQL(
String.format("ATTACH DATABASE '%s' AS sqlcipher4 KEY '%s'", newDbPath, databaseSecret.asString())
);
Cursor cursor = oldDb.rawQuery("SELECT sqlcipher_export('sqlcipher4')");
cursor.moveToLast();
cursor.close();
oldDb.rawExecSQL("DETACH DATABASE sqlcipher4");
oldDb.close();
// Open the newly migrated database (to ensure it works) and set it's version so we don't try
// to run any of our custom migrations
SQLiteDatabase newDb = SQLCipherOpenHelper.open(newDbPath, databaseSecret, true);
newDb.setVersion(oldDbVersion);
newDb.close();
// TODO: Delete 'CIPHER3_DATABASE_NAME' once enough time has past
// Remove the old database file since it will no longer be used
// //noinspection ResultOfMethodCallIgnored
// oldDbFile.delete();
}
catch (Exception e) {
Log.e(TAG, "Migration from SQLCipher3 to SQLCipher4 failed", e);
// Notify the user of the issue so they know they can downgrade until the issue is fixed
NotificationManager notificationManager = context.getSystemService(NotificationManager.class);
String channelId = context.getString(R.string.NotificationChannel_failures);
if (NotificationChannels.supported()) {
NotificationChannel channel = new NotificationChannel(channelId, channelId, NotificationManager.IMPORTANCE_HIGH);
channel.enableVibration(true);
notificationManager.createNotificationChannel(channel);
}
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_notification)
.setColor(context.getResources().getColor(R.color.textsecure_primary))
.setCategory(NotificationCompat.CATEGORY_ERROR)
.setContentTitle(context.getString(R.string.ErrorNotifier_migration))
.setContentText(context.getString(R.string.ErrorNotifier_migration_downgrade))
.setAutoCancel(true);
if (!NotificationChannels.supported()) {
builder.setPriority(NotificationCompat.PRIORITY_HIGH);
}
notificationManager.notify(5874, builder.build());
// Throw the error (app will crash but there is nothing else we can do unfortunately)
throw e;
}
}
@Override @Override
public void onCreate(SQLiteDatabase db) { public void onCreate(SQLiteDatabase db) {
db.execSQL(SmsDatabase.CREATE_TABLE); db.execSQL(SmsDatabase.CREATE_TABLE);
@ -188,6 +314,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
executeStatements(db, DraftDatabase.CREATE_INDEXS); executeStatements(db, DraftDatabase.CREATE_INDEXS);
executeStatements(db, GroupDatabase.CREATE_INDEXS); executeStatements(db, GroupDatabase.CREATE_INDEXS);
executeStatements(db, GroupReceiptDatabase.CREATE_INDEXES); executeStatements(db, GroupReceiptDatabase.CREATE_INDEXES);
executeStatements(db, ReactionDatabase.CREATE_INDEXS);
executeStatements(db, ReactionDatabase.CREATE_REACTION_TRIGGERS); executeStatements(db, ReactionDatabase.CREATE_REACTION_TRIGGERS);
} }
@ -195,9 +322,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
@Override @Override
public void onConfigure(SQLiteDatabase db) { public void onConfigure(SQLiteDatabase db) {
super.onConfigure(db); super.onConfigure(db);
// Loki - Enable write ahead logging mode and increase the cache size.
// This should be disabled if we ever run into serious race condition bugs.
db.enableWriteAheadLogging();
db.execSQL("PRAGMA cache_size = 10000"); db.execSQL("PRAGMA cache_size = 10000");
} }
@ -414,20 +539,16 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL(EmojiSearchDatabase.CREATE_EMOJI_SEARCH_TABLE_COMMAND); db.execSQL(EmojiSearchDatabase.CREATE_EMOJI_SEARCH_TABLE_COMMAND);
} }
if (oldVersion < lokiV39) {
executeStatements(db, ReactionDatabase.CREATE_INDEXS);
}
db.setTransactionSuccessful(); db.setTransactionSuccessful();
} finally { } finally {
db.endTransaction(); db.endTransaction();
} }
} }
public SQLiteDatabase getReadableDatabase() {
return getReadableDatabase(databaseSecret.asString());
}
public SQLiteDatabase getWritableDatabase() {
return getWritableDatabase(databaseSecret.asString());
}
public void markCurrent(SQLiteDatabase db) { public void markCurrent(SQLiteDatabase db) {
db.setVersion(DATABASE_VERSION); db.setVersion(DATABASE_VERSION);
} }

View File

@ -50,7 +50,6 @@ public class ThreadRecord extends DisplayRecord {
private final long expiresIn; private final long expiresIn;
private final long lastSeen; private final long lastSeen;
private final boolean pinned; private final boolean pinned;
private final int recipientHash;
public ThreadRecord(@NonNull String body, @Nullable Uri snippetUri, public ThreadRecord(@NonNull String body, @Nullable Uri snippetUri,
@NonNull Recipient recipient, long date, long count, int unreadCount, @NonNull Recipient recipient, long date, long count, int unreadCount,
@ -67,17 +66,12 @@ public class ThreadRecord extends DisplayRecord {
this.expiresIn = expiresIn; this.expiresIn = expiresIn;
this.lastSeen = lastSeen; this.lastSeen = lastSeen;
this.pinned = pinned; this.pinned = pinned;
this.recipientHash = recipient.hashCode();
} }
public @Nullable Uri getSnippetUri() { public @Nullable Uri getSnippetUri() {
return snippetUri; return snippetUri;
} }
public int getRecipientHash() {
return recipientHash;
}
@Override @Override
public SpannableString getDisplayBody(@NonNull Context context) { public SpannableString getDisplayBody(@NonNull Context context) {
if (isGroupUpdateMessage()) { if (isGroupUpdateMessage()) {

View File

@ -6,7 +6,7 @@ import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import net.sqlcipher.database.SQLiteDatabase import net.zetetic.database.sqlcipher.SQLiteDatabase
import org.session.libsession.database.MessageDataProvider import org.session.libsession.database.MessageDataProvider
import org.thoughtcrime.securesms.attachments.DatabaseAttachmentProvider import org.thoughtcrime.securesms.attachments.DatabaseAttachmentProvider
import org.thoughtcrime.securesms.crypto.AttachmentSecret import org.thoughtcrime.securesms.crypto.AttachmentSecret
@ -22,7 +22,7 @@ object DatabaseModule {
@JvmStatic @JvmStatic
fun init(context: Context) { fun init(context: Context) {
SQLiteDatabase.loadLibs(context) System.loadLibrary("sqlcipher")
} }
@Provides @Provides
@ -33,6 +33,7 @@ object DatabaseModule {
@Singleton @Singleton
fun provideOpenHelper(@ApplicationContext context: Context): SQLCipherOpenHelper { fun provideOpenHelper(@ApplicationContext context: Context): SQLCipherOpenHelper {
val dbSecret = DatabaseSecretProvider(context).orCreateDatabaseSecret val dbSecret = DatabaseSecretProvider(context).orCreateDatabaseSecret
SQLCipherOpenHelper.migrateSqlCipher3To4IfNeeded(context, dbSecret)
return SQLCipherOpenHelper(context, dbSecret) return SQLCipherOpenHelper(context, dbSecret)
} }

View File

@ -58,14 +58,14 @@ object OpenGroupManager {
} }
@WorkerThread @WorkerThread
fun add(server: String, room: String, publicKey: String, context: Context) { fun add(server: String, room: String, publicKey: String, context: Context): OpenGroupApi.RoomInfo? {
val openGroupID = "$server.$room" val openGroupID = "$server.$room"
var threadID = GroupManager.getOpenGroupThreadID(openGroupID, context) var threadID = GroupManager.getOpenGroupThreadID(openGroupID, context)
val storage = MessagingModuleConfiguration.shared.storage val storage = MessagingModuleConfiguration.shared.storage
val threadDB = DatabaseComponent.get(context).lokiThreadDatabase() val threadDB = DatabaseComponent.get(context).lokiThreadDatabase()
// Check it it's added already // Check it it's added already
val existingOpenGroup = threadDB.getOpenGroupChat(threadID) val existingOpenGroup = threadDB.getOpenGroupChat(threadID)
if (existingOpenGroup != null) { return } if (existingOpenGroup != null) { return null }
// Clear any existing data if needed // Clear any existing data if needed
storage.removeLastDeletionServerID(room, server) storage.removeLastDeletionServerID(room, server)
storage.removeLastMessageServerID(room, server) storage.removeLastMessageServerID(room, server)
@ -73,18 +73,17 @@ object OpenGroupManager {
storage.removeLastOutboxMessageId(server) storage.removeLastOutboxMessageId(server)
// Store the public key // Store the public key
storage.setOpenGroupPublicKey(server, publicKey) storage.setOpenGroupPublicKey(server, publicKey)
// Get capabilities // Get capabilities & room info
val capabilities = OpenGroupApi.getCapabilities(server).get() val (capabilities, info) = OpenGroupApi.getCapabilitiesAndRoomInfo(room, server).get()
storage.setServerCapabilities(server, capabilities.capabilities) storage.setServerCapabilities(server, capabilities.capabilities)
// Get room info
val info = OpenGroupApi.getRoomInfo(room, server).get()
storage.setUserCount(room, server, info.activeUsers) storage.setUserCount(room, server, info.activeUsers)
// Create the group locally if not available already // Create the group locally if not available already
if (threadID < 0) { if (threadID < 0) {
threadID = GroupManager.createOpenGroup(openGroupID, context, null, info.name).threadId threadID = GroupManager.createOpenGroup(openGroupID, context, null, info.name).threadId
} }
val openGroup = OpenGroup(server, room, info.name, info.infoUpdates, publicKey) val openGroup = OpenGroup(server = server, room = room, publicKey = publicKey, name = info.name, imageId = info.imageId, infoUpdates = info.infoUpdates)
threadDB.setOpenGroupChat(openGroup, threadID) threadDB.setOpenGroupChat(openGroup, threadID)
return info
} }
fun restartPollerForServer(server: String) { fun restartPollerForServer(server: String) {
@ -130,12 +129,13 @@ object OpenGroupManager {
} }
} }
fun addOpenGroup(urlAsString: String, context: Context) { fun addOpenGroup(urlAsString: String, context: Context): OpenGroupApi.RoomInfo? {
val url = HttpUrl.parse(urlAsString) ?: return val url = HttpUrl.parse(urlAsString) ?: return null
val server = OpenGroup.getServer(urlAsString) val server = OpenGroup.getServer(urlAsString)
val room = url.pathSegments().firstOrNull() ?: return val room = url.pathSegments().firstOrNull() ?: return null
val publicKey = url.queryParameter("public_key") ?: return val publicKey = url.queryParameter("public_key") ?: return null
add(server.toString().removeSuffix("/"), room, publicKey, context) // assume migrated from calling function
return add(server.toString().removeSuffix("/"), room, publicKey, context) // assume migrated from calling function
} }
fun updateOpenGroup(openGroup: OpenGroup, context: Context) { fun updateOpenGroup(openGroup: OpenGroup, context: Context) {

View File

@ -99,11 +99,11 @@ class ConversationView : LinearLayout {
binding.snippetTextView.typeface = if (unreadCount > 0 && !thread.isRead) Typeface.DEFAULT_BOLD else Typeface.DEFAULT binding.snippetTextView.typeface = if (unreadCount > 0 && !thread.isRead) Typeface.DEFAULT_BOLD else Typeface.DEFAULT
binding.snippetTextView.visibility = if (isTyping) View.GONE else View.VISIBLE binding.snippetTextView.visibility = if (isTyping) View.GONE else View.VISIBLE
if (isTyping) { if (isTyping) {
binding.typingIndicatorView.startAnimation() binding.typingIndicatorView.root.startAnimation()
} else { } else {
binding.typingIndicatorView.stopAnimation() binding.typingIndicatorView.root.stopAnimation()
} }
binding.typingIndicatorView.visibility = if (isTyping) View.VISIBLE else View.GONE binding.typingIndicatorView.root.visibility = if (isTyping) View.VISIBLE else View.GONE
binding.statusIndicatorImageView.visibility = View.VISIBLE binding.statusIndicatorImageView.visibility = View.VISIBLE
when { when {
!thread.isOutgoing -> binding.statusIndicatorImageView.visibility = View.GONE !thread.isOutgoing -> binding.statusIndicatorImageView.visibility = View.GONE

View File

@ -202,7 +202,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
OpenGroupManager.startPolling() OpenGroupManager.startPolling()
JobQueue.shared.resumePendingJobs() JobQueue.shared.resumePendingJobs()
} }
// Set up typing observer
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
updateProfileButton() updateProfileButton()
TextSecurePreferences.events.filter { it == TextSecurePreferences.PROFILE_NAME_PREF }.collect { TextSecurePreferences.events.filter { it == TextSecurePreferences.PROFILE_NAME_PREF }.collect {
@ -365,6 +365,10 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
setupMessageRequestsBanner() setupMessageRequestsBanner()
updateEmptyState() updateEmptyState()
} }
ApplicationContext.getInstance(this@HomeActivity).typingStatusRepository.typingThreads.observe(this) { threadIds ->
homeAdapter.typingThreadIDs = (threadIds ?: setOf())
}
} }
private fun updateEmptyState() { private fun updateEmptyState() {

View File

@ -63,6 +63,8 @@ class HomeAdapter(
lateinit var glide: GlideRequests lateinit var glide: GlideRequests
var typingThreadIDs = setOf<Long>() var typingThreadIDs = setOf<Long>()
set(value) { set(value) {
if (field == value) { return }
field = value field = value
// TODO: replace this with a diffed update or a partial change set with payloads // TODO: replace this with a diffed update or a partial change set with payloads
notifyDataSetChanged() notifyDataSetChanged()

View File

@ -22,22 +22,28 @@ class HomeDiffUtil(
val newItem = new[newItemPosition] val newItem = new[newItemPosition]
// return early to save getDisplayBody or expensive calls // return early to save getDisplayBody or expensive calls
val sameCount = oldItem.count == newItem.count var isSameItem = true
if (!sameCount) return false
val sameUnreads = oldItem.unreadCount == newItem.unreadCount
if (!sameUnreads) return false
val samePinned = oldItem.isPinned == newItem.isPinned
if (!samePinned) return false
val sameRecipientHash = oldItem.recipientHash == newItem.recipientHash
if (!sameRecipientHash) return false
val sameSnippet = oldItem.getDisplayBody(context) == newItem.getDisplayBody(context)
if (!sameSnippet) return false
val sameSendStatus = oldItem.isFailed == newItem.isFailed && oldItem.isDelivered == newItem.isDelivered
&& oldItem.isSent == newItem.isSent && oldItem.isPending == newItem.isPending
if (!sameSendStatus) return false
// all same if (isSameItem) { isSameItem = (oldItem.count == newItem.count) }
return true if (isSameItem) { isSameItem = (oldItem.unreadCount == newItem.unreadCount) }
if (isSameItem) { isSameItem = (oldItem.isPinned == newItem.isPinned) }
// Note: For some reason the 'hashCode' value can change after initialisation so we can't cache it
if (isSameItem) { isSameItem = (oldItem.recipient.hashCode() == newItem.recipient.hashCode()) }
// Note: Two instances of 'SpannableString' may not equate even though their content matches
if (isSameItem) { isSameItem = (oldItem.getDisplayBody(context).toString() == newItem.getDisplayBody(context).toString()) }
if (isSameItem) {
isSameItem = (
oldItem.isFailed == newItem.isFailed &&
oldItem.isDelivered == newItem.isDelivered &&
oldItem.isSent == newItem.isSent &&
oldItem.isPending == newItem.isPending
)
}
return isSameItem
} }
} }

View File

@ -44,7 +44,7 @@ class BackgroundPollWorker(val context: Context, params: WorkerParameters) : Wor
} }
override fun doWork(): Result { override fun doWork(): Result {
if (TextSecurePreferences.getLocalNumber(context) == null) { if (TextSecurePreferences.getLocalNumber(context) == null || !TextSecurePreferences.hasSeenWelcomeScreen(context)) {
Log.v(TAG, "User not registered yet.") Log.v(TAG, "User not registered yet.")
return Result.failure() return Result.failure()
} }

View File

@ -22,8 +22,10 @@ import kotlinx.coroutines.launch
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityLinkDeviceBinding import network.loki.messenger.databinding.ActivityLinkDeviceBinding
import network.loki.messenger.databinding.FragmentRecoveryPhraseBinding import network.loki.messenger.databinding.FragmentRecoveryPhraseBinding
import org.session.libsession.snode.SnodeModule
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.crypto.MnemonicCodec import org.session.libsignal.crypto.MnemonicCodec
import org.session.libsignal.database.LokiAPIDatabaseProtocol
import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.Hex
import org.session.libsignal.utilities.KeyHelper import org.session.libsignal.utilities.KeyHelper
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
@ -39,6 +41,8 @@ import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo
class LinkDeviceActivity : BaseActionBarActivity(), ScanQRCodeWrapperFragmentDelegate { class LinkDeviceActivity : BaseActionBarActivity(), ScanQRCodeWrapperFragmentDelegate {
private lateinit var binding: ActivityLinkDeviceBinding private lateinit var binding: ActivityLinkDeviceBinding
internal val database: LokiAPIDatabaseProtocol
get() = SnodeModule.shared.storage
private val adapter = LinkDeviceActivityAdapter(this) private val adapter = LinkDeviceActivityAdapter(this)
private var restoreJob: Job? = null private var restoreJob: Job? = null
@ -99,6 +103,11 @@ class LinkDeviceActivity : BaseActionBarActivity(), ScanQRCodeWrapperFragmentDel
if (restoreJob?.isActive == true) return if (restoreJob?.isActive == true) return
restoreJob = lifecycleScope.launch { restoreJob = lifecycleScope.launch {
// This is here to resolve a case where the app restarts before a user completes onboarding
// which can result in an invalid database state
database.clearAllLastMessageHashes()
database.clearReceivedMessageHashValues()
// RestoreActivity handles seed this way // RestoreActivity handles seed this way
val keyPairGenerationResult = KeyPairUtilities.generate(seed) val keyPairGenerationResult = KeyPairUtilities.generate(seed)
val x25519KeyPair = keyPairGenerationResult.x25519KeyPair val x25519KeyPair = keyPairGenerationResult.x25519KeyPair

View File

@ -13,8 +13,10 @@ import android.view.View
import android.widget.Toast import android.widget.Toast
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityRecoveryPhraseRestoreBinding import network.loki.messenger.databinding.ActivityRecoveryPhraseRestoreBinding
import org.session.libsession.snode.SnodeModule
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.crypto.MnemonicCodec import org.session.libsignal.crypto.MnemonicCodec
import org.session.libsignal.database.LokiAPIDatabaseProtocol
import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.Hex
import org.session.libsignal.utilities.KeyHelper import org.session.libsignal.utilities.KeyHelper
import org.session.libsignal.utilities.hexEncodedPublicKey import org.session.libsignal.utilities.hexEncodedPublicKey
@ -26,6 +28,8 @@ import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo
class RecoveryPhraseRestoreActivity : BaseActionBarActivity() { class RecoveryPhraseRestoreActivity : BaseActionBarActivity() {
private lateinit var binding: ActivityRecoveryPhraseRestoreBinding private lateinit var binding: ActivityRecoveryPhraseRestoreBinding
internal val database: LokiAPIDatabaseProtocol
get() = SnodeModule.shared.storage
// region Lifecycle // region Lifecycle
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -64,6 +68,11 @@ class RecoveryPhraseRestoreActivity : BaseActionBarActivity() {
private fun restore() { private fun restore() {
val mnemonic = binding.mnemonicEditText.text.toString() val mnemonic = binding.mnemonicEditText.text.toString()
try { try {
// This is here to resolve a case where the app restarts before a user completes onboarding
// which can result in an invalid database state
database.clearAllLastMessageHashes()
database.clearReceivedMessageHashValues()
val loadFileContents: (String) -> String = { fileName -> val loadFileContents: (String) -> String = { fileName ->
MnemonicUtilities.loadFileContents(this, fileName) MnemonicUtilities.loadFileContents(this, fileName)
} }

View File

@ -18,8 +18,10 @@ import android.widget.Toast
import com.goterl.lazysodium.utils.KeyPair import com.goterl.lazysodium.utils.KeyPair
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityRegisterBinding import network.loki.messenger.databinding.ActivityRegisterBinding
import org.session.libsession.snode.SnodeModule
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.crypto.ecc.ECKeyPair
import org.session.libsignal.database.LokiAPIDatabaseProtocol
import org.session.libsignal.utilities.KeyHelper import org.session.libsignal.utilities.KeyHelper
import org.session.libsignal.utilities.hexEncodedPublicKey import org.session.libsignal.utilities.hexEncodedPublicKey
import org.thoughtcrime.securesms.BaseActionBarActivity import org.thoughtcrime.securesms.BaseActionBarActivity
@ -29,6 +31,8 @@ import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo
class RegisterActivity : BaseActionBarActivity() { class RegisterActivity : BaseActionBarActivity() {
private lateinit var binding: ActivityRegisterBinding private lateinit var binding: ActivityRegisterBinding
internal val database: LokiAPIDatabaseProtocol
get() = SnodeModule.shared.storage
private var seed: ByteArray? = null private var seed: ByteArray? = null
private var ed25519KeyPair: KeyPair? = null private var ed25519KeyPair: KeyPair? = null
private var x25519KeyPair: ECKeyPair? = null private var x25519KeyPair: ECKeyPair? = null
@ -109,6 +113,11 @@ class RegisterActivity : BaseActionBarActivity() {
// region Interaction // region Interaction
private fun register() { private fun register() {
// This is here to resolve a case where the app restarts before a user completes onboarding
// which can result in an invalid database state
database.clearAllLastMessageHashes()
database.clearReceivedMessageHashValues()
KeyPairUtilities.store(this, seed!!, ed25519KeyPair!!, x25519KeyPair!!) KeyPairUtilities.store(this, seed!!, ed25519KeyPair!!, x25519KeyPair!!)
val userHexEncodedPublicKey = x25519KeyPair!!.hexEncodedPublicKey val userHexEncodedPublicKey = x25519KeyPair!!.hexEncodedPublicKey
val registrationID = KeyHelper.generateRegistrationId(false) val registrationID = KeyHelper.generateRegistrationId(false)

View File

@ -35,6 +35,7 @@ interface ConversationRepository {
fun maybeGetRecipientForThreadId(threadId: Long): Recipient? fun maybeGetRecipientForThreadId(threadId: Long): Recipient?
fun saveDraft(threadId: Long, text: String) fun saveDraft(threadId: Long, text: String)
fun getDraft(threadId: Long): String? fun getDraft(threadId: Long): String?
fun clearDrafts(threadId: Long)
fun inviteContacts(threadId: Long, contacts: List<Recipient>) fun inviteContacts(threadId: Long, contacts: List<Recipient>)
fun setBlocked(recipient: Recipient, blocked: Boolean) fun setBlocked(recipient: Recipient, blocked: Boolean)
fun deleteLocally(recipient: Recipient, message: MessageRecord) fun deleteLocally(recipient: Recipient, message: MessageRecord)
@ -98,10 +99,13 @@ class DefaultConversationRepository @Inject constructor(
override fun getDraft(threadId: Long): String? { override fun getDraft(threadId: Long): String? {
val drafts = draftDb.getDrafts(threadId) val drafts = draftDb.getDrafts(threadId)
draftDb.clearDrafts(threadId)
return drafts.find { it.type == DraftDatabase.Draft.TEXT }?.value return drafts.find { it.type == DraftDatabase.Draft.TEXT }?.value
} }
override fun clearDrafts(threadId: Long) {
draftDb.clearDrafts(threadId)
}
override fun inviteContacts(threadId: Long, contacts: List<Recipient>) { override fun inviteContacts(threadId: Long, contacts: List<Recipient>) {
val openGroup = lokiThreadDb.getOpenGroupChat(threadId) ?: return val openGroup = lokiThreadDb.getOpenGroupChat(threadId) ?: return
for (contact in contacts) { for (contact in contacts) {

View File

@ -0,0 +1,425 @@
package org.thoughtcrime.securesms.util
import android.content.Context
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.messages.signal.IncomingTextMessage
import org.session.libsession.messaging.messages.signal.OutgoingTextMessage
import org.session.libsession.messaging.open_groups.OpenGroup
import org.session.libsession.messaging.open_groups.OpenGroupApi
import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationAPI
import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.crypto.ecc.Curve
import org.session.libsignal.messages.SignalServiceGroup
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.guava.Optional
import org.session.libsignal.utilities.hexEncodedPublicKey
import org.thoughtcrime.securesms.crypto.KeyPairUtilities
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.groups.GroupManager
import java.security.SecureRandom
import java.util.*
import kotlin.random.asKotlinRandom
object MockDataGenerator {
private var printProgress = true
private var hasStartedGenerationThisRun = false
// FIXME: Update this to run in a transaction instead of individual db writes (should drastically speed it up)
fun generateMockData(context: Context) {
// Don't re-generate the mock data if it already exists
val mockDataExistsRecipient = Recipient.from(context, Address.fromSerialized("MockDatabaseThread"), false)
val storage = MessagingModuleConfiguration.shared.storage
val threadDb = DatabaseComponent.get(context).threadDatabase()
val lokiThreadDB = DatabaseComponent.get(context).lokiThreadDatabase()
val contactDb = DatabaseComponent.get(context).sessionContactDatabase()
val recipientDb = DatabaseComponent.get(context).recipientDatabase()
val smsDb = DatabaseComponent.get(context).smsDatabase()
if (hasStartedGenerationThisRun || threadDb.getThreadIdIfExistsFor(mockDataExistsRecipient) != -1L) {
hasStartedGenerationThisRun = true
return
}
/// The mock data generation is quite slow, there are 3 parts which take a decent amount of time (deleting the account afterwards will
/// also take a long time):
/// Generating the threads & content - ~3m per 100
val dmThreadCount: Int = 1000
val closedGroupThreadCount: Int = 50
val openGroupThreadCount: Int = 20
val messageRangePerThread: List<IntRange> = listOf(0..500)
val dmRandomSeed: String = "1111"
val cgRandomSeed: String = "2222"
val ogRandomSeed: String = "3333"
val chunkSize: Int = 1000 // Chunk up the thread writing to prevent memory issues
val stringContent: List<String> = "abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ 0123456789 ".map { it.toString() }
val wordContent: List<String> = listOf("alias", "consequatur", "aut", "perferendis", "sit", "voluptatem", "accusantium", "doloremque", "aperiam", "eaque", "ipsa", "quae", "ab", "illo", "inventore", "veritatis", "et", "quasi", "architecto", "beatae", "vitae", "dicta", "sunt", "explicabo", "aspernatur", "aut", "odit", "aut", "fugit", "sed", "quia", "consequuntur", "magni", "dolores", "eos", "qui", "ratione", "voluptatem", "sequi", "nesciunt", "neque", "dolorem", "ipsum", "quia", "dolor", "sit", "amet", "consectetur", "adipisci", "velit", "sed", "quia", "non", "numquam", "eius", "modi", "tempora", "incidunt", "ut", "labore", "et", "dolore", "magnam", "aliquam", "quaerat", "voluptatem", "ut", "enim", "ad", "minima", "veniam", "quis", "nostrum", "exercitationem", "ullam", "corporis", "nemo", "enim", "ipsam", "voluptatem", "quia", "voluptas", "sit", "suscipit", "laboriosam", "nisi", "ut", "aliquid", "ex", "ea", "commodi", "consequatur", "quis", "autem", "vel", "eum", "iure", "reprehenderit", "qui", "in", "ea", "voluptate", "velit", "esse", "quam", "nihil", "molestiae", "et", "iusto", "odio", "dignissimos", "ducimus", "qui", "blanditiis", "praesentium", "laudantium", "totam", "rem", "voluptatum", "deleniti", "atque", "corrupti", "quos", "dolores", "et", "quas", "molestias", "excepturi", "sint", "occaecati", "cupiditate", "non", "provident", "sed", "ut", "perspiciatis", "unde", "omnis", "iste", "natus", "error", "similique", "sunt", "in", "culpa", "qui", "officia", "deserunt", "mollitia", "animi", "id", "est", "laborum", "et", "dolorum", "fuga", "et", "harum", "quidem", "rerum", "facilis", "est", "et", "expedita", "distinctio", "nam", "libero", "tempore", "cum", "soluta", "nobis", "est", "eligendi", "optio", "cumque", "nihil", "impedit", "quo", "porro", "quisquam", "est", "qui", "minus", "id", "quod", "maxime", "placeat", "facere", "possimus", "omnis", "voluptas", "assumenda", "est", "omnis", "dolor", "repellendus", "temporibus", "autem", "quibusdam", "et", "aut", "consequatur", "vel", "illum", "qui", "dolorem", "eum", "fugiat", "quo", "voluptas", "nulla", "pariatur", "at", "vero", "eos", "et", "accusamus", "officiis", "debitis", "aut", "rerum", "necessitatibus", "saepe", "eveniet", "ut", "et", "voluptates", "repudiandae", "sint", "et", "molestiae", "non", "recusandae", "itaque", "earum", "rerum", "hic", "tenetur", "a", "sapiente", "delectus", "ut", "aut", "reiciendis", "voluptatibus", "maiores", "doloribus", "asperiores", "repellat")
val timestampNow: Long = System.currentTimeMillis()
val userSessionId: String = MessagingModuleConfiguration.shared.storage.getUserPublicKey()!!
val logProgress: ((String, String) -> Unit) = logProgress@{ title, event ->
if (!printProgress) { return@logProgress }
Log.i("[MockDataGenerator]", "${System.currentTimeMillis()} $title - $event")
}
hasStartedGenerationThisRun = true
// FIXME: Make sure this data doesn't go off device somehow?
logProgress("", "Start")
// First create the thread used to indicate that the mock data has been generated
threadDb.getOrCreateThreadIdFor(mockDataExistsRecipient)
// -- DM Thread
val dmThreadRandomGenerator: SecureRandom = SecureRandom(dmRandomSeed.toByteArray())
var dmThreadIndex: Int = 0
logProgress("DM Threads", "Start Generating $dmThreadCount threads")
while (dmThreadIndex < dmThreadCount) {
val remainingThreads: Int = (dmThreadCount - dmThreadIndex)
(0 until Math.min(chunkSize, remainingThreads)).forEach { index ->
val threadIndex: Int = (dmThreadIndex + index)
logProgress("DM Thread $threadIndex", "Start")
val dataBytes = (0 until 16).map { dmThreadRandomGenerator.nextInt(UByte.MAX_VALUE.toInt()).toByte() }
val randomSessionId: String = KeyPairUtilities.generate(dataBytes.toByteArray()).x25519KeyPair.hexEncodedPublicKey
val isMessageRequest: Boolean = dmThreadRandomGenerator.nextBoolean()
val contactNameLength: Int = (5 + dmThreadRandomGenerator.nextInt(15))
val numMessages: Int = (
messageRangePerThread[threadIndex % messageRangePerThread.count()].first +
dmThreadRandomGenerator.nextInt(messageRangePerThread[threadIndex % messageRangePerThread.count()].last())
)
// Generate the thread
val recipient = Recipient.from(context, Address.fromSerialized(randomSessionId), false)
val contact = Contact(randomSessionId)
val threadId = threadDb.getOrCreateThreadIdFor(recipient)
// Generate the contact
val contactIsApproved: Boolean = (!isMessageRequest || dmThreadRandomGenerator.nextBoolean())
contactDb.setContact(contact)
contactDb.setContactIsTrusted(contact, true, threadId)
recipientDb.setApproved(recipient, contactIsApproved)
recipientDb.setApprovedMe(recipient, (!isMessageRequest && (dmThreadRandomGenerator.nextInt(10) < 8))) // 80% approved the current user
contact.name = (0 until dmThreadRandomGenerator.nextInt(contactNameLength))
.map { stringContent.random(dmThreadRandomGenerator.asKotlinRandom()) }
.joinToString()
recipientDb.setProfileName(recipient, contact.name)
contactDb.setContact(contact)
// Generate the message history (Note: Unapproved message requests will only include incoming messages)
logProgress("DM Thread $threadIndex", "Generate $numMessages Messages")
(0 until numMessages).forEach { index ->
val isIncoming: Boolean = (
dmThreadRandomGenerator.nextBoolean() &&
(!isMessageRequest || contactIsApproved)
)
val messageWords: Int = (1 + dmThreadRandomGenerator.nextInt(19))
if (isIncoming) {
smsDb.insertMessageInbox(
IncomingTextMessage(
recipient.address,
1,
(timestampNow - (index * 5000)),
(0 until messageWords)
.map { wordContent.random(dmThreadRandomGenerator.asKotlinRandom()) }
.joinToString(),
Optional.absent(),
0,
false,
-1
),
(timestampNow - (index * 5000)),
false,
false
)
}
else {
smsDb.insertMessageOutbox(
threadId,
OutgoingTextMessage(
recipient,
(0 until messageWords)
.map { wordContent.random(dmThreadRandomGenerator.asKotlinRandom()) }
.joinToString(),
0,
-1,
(timestampNow - (index * 5000))
),
(timestampNow - (index * 5000)),
false
)
}
}
logProgress("DM Thread $threadIndex", "Done")
}
logProgress("DM Threads", "Done")
dmThreadIndex += chunkSize
}
logProgress("DM Threads", "Done")
// -- Closed Group
val cgThreadRandomGenerator: SecureRandom = SecureRandom(cgRandomSeed.toByteArray())
var cgThreadIndex: Int = 0
logProgress("Closed Group Threads", "Start Generating $closedGroupThreadCount threads")
while (cgThreadIndex < closedGroupThreadCount) {
val remainingThreads: Int = (closedGroupThreadCount - cgThreadIndex)
(0 until Math.min(chunkSize, remainingThreads)).forEach { index ->
val threadIndex: Int = (cgThreadIndex + index)
logProgress("Closed Group Thread $threadIndex", "Start")
val dataBytes = (0 until 16).map { cgThreadRandomGenerator.nextInt(UByte.MAX_VALUE.toInt()).toByte() }
val randomGroupPublicKey: String = KeyPairUtilities.generate(dataBytes.toByteArray()).x25519KeyPair.hexEncodedPublicKey
val groupNameLength: Int = (5 + cgThreadRandomGenerator.nextInt(15))
val groupName: String = (0 until groupNameLength)
.map { stringContent.random(cgThreadRandomGenerator.asKotlinRandom()) }
.joinToString()
val numGroupMembers: Int = cgThreadRandomGenerator.nextInt (10)
val numMessages: Int = (
messageRangePerThread[threadIndex % messageRangePerThread.count()].first +
cgThreadRandomGenerator.nextInt(messageRangePerThread[threadIndex % messageRangePerThread.count()].last())
)
// Generate the Contacts in the group
val members: MutableList<String> = mutableListOf(userSessionId)
logProgress("Closed Group Thread $threadIndex", "Generate $numGroupMembers Contacts")
(0 until numGroupMembers).forEach {
val contactBytes = (0 until 16).map { cgThreadRandomGenerator.nextInt(UByte.MAX_VALUE.toInt()).toByte() }
val randomSessionId: String = KeyPairUtilities.generate(contactBytes.toByteArray()).x25519KeyPair.hexEncodedPublicKey
val contactNameLength: Int = (5 + cgThreadRandomGenerator.nextInt(15))
val recipient = Recipient.from(context, Address.fromSerialized(randomSessionId), false)
val contact = Contact(randomSessionId)
contactDb.setContact(contact)
recipientDb.setApproved(recipient, true)
recipientDb.setApprovedMe(recipient, true)
contact.name = (0 until cgThreadRandomGenerator.nextInt(contactNameLength))
.map { stringContent.random(cgThreadRandomGenerator.asKotlinRandom()) }
.joinToString()
recipientDb.setProfileName(recipient, contact.name)
contactDb.setContact(contact)
members.add(randomSessionId)
}
val groupId = GroupUtil.doubleEncodeGroupID(randomGroupPublicKey)
val threadId = storage.getOrCreateThreadIdFor(Address.fromSerialized(groupId))
val adminUserId = members.random(cgThreadRandomGenerator.asKotlinRandom())
storage.createGroup(
groupId,
groupName,
members.map { Address.fromSerialized(it) },
null,
null,
listOf(Address.fromSerialized(adminUserId)),
timestampNow
)
storage.setProfileSharing(Address.fromSerialized(groupId), true)
storage.addClosedGroupPublicKey(randomGroupPublicKey)
// Add the group to the user's set of public keys to poll for and store the key pair
val encryptionKeyPair = Curve.generateKeyPair()
storage.addClosedGroupEncryptionKeyPair(encryptionKeyPair, randomGroupPublicKey)
storage.setExpirationTimer(groupId, 0)
// Add the group created message
if (userSessionId == adminUserId) {
storage.insertOutgoingInfoMessage(context, groupId, SignalServiceGroup.Type.CREATION, groupName, members, listOf(adminUserId), threadId, (timestampNow - (numMessages * 5000)))
}
else {
storage.insertIncomingInfoMessage(context, adminUserId, groupId, SignalServiceGroup.Type.CREATION, groupName, members, listOf(adminUserId), (timestampNow - (numMessages * 5000)))
}
// Generate the message history (Note: Unapproved message requests will only include incoming messages)
logProgress("Closed Group Thread $threadIndex", "Generate $numMessages Messages")
(0 until numGroupMembers).forEach {
val messageWords: Int = (1 + cgThreadRandomGenerator.nextInt(19))
val senderId: String = members.random(cgThreadRandomGenerator.asKotlinRandom())
if (senderId != userSessionId) {
smsDb.insertMessageInbox(
IncomingTextMessage(
Address.fromSerialized(senderId),
1,
(timestampNow - (index * 5000)),
(0 until messageWords)
.map { wordContent.random(cgThreadRandomGenerator.asKotlinRandom()) }
.joinToString(),
Optional.absent(),
0,
false,
-1
),
(timestampNow - (index * 5000)),
false,
false
)
}
else {
smsDb.insertMessageOutbox(
threadId,
OutgoingTextMessage(
threadDb.getRecipientForThreadId(threadId),
(0 until messageWords)
.map { wordContent.random(cgThreadRandomGenerator.asKotlinRandom()) }
.joinToString(),
0,
-1,
(timestampNow - (index * 5000))
),
(timestampNow - (index * 5000)),
false
)
}
}
logProgress("Closed Group Thread $threadIndex", "Done")
}
cgThreadIndex += chunkSize
}
logProgress("Closed Group Threads", "Done")
// --Open Group
val ogThreadRandomGenerator: SecureRandom = SecureRandom(cgRandomSeed.toByteArray())
var ogThreadIndex: Int = 0
logProgress("Open Group Threads", "Start Generating $openGroupThreadCount threads")
while (ogThreadIndex < openGroupThreadCount) {
val remainingThreads: Int = (openGroupThreadCount - ogThreadIndex)
(0 until Math.min(chunkSize, remainingThreads)).forEach { index ->
val threadIndex: Int = (ogThreadIndex + index)
logProgress("Open Group Thread $threadIndex", "Start")
val dataBytes = (0 until 32).map { ogThreadRandomGenerator.nextInt(UByte.MAX_VALUE.toInt()).toByte() }
val randomGroupPublicKey: String = KeyPairUtilities.generate(dataBytes.toByteArray()).x25519KeyPair.hexEncodedPublicKey
val serverNameLength: Int = (5 + ogThreadRandomGenerator.nextInt(15))
val roomNameLength: Int = (5 + ogThreadRandomGenerator.nextInt(15))
val roomDescriptionLength: Int = (10 + ogThreadRandomGenerator.nextInt(40))
val serverName: String = (0 until serverNameLength)
.map { stringContent.random(ogThreadRandomGenerator.asKotlinRandom()) }
.joinToString()
val roomName: String = (0 until roomNameLength)
.map { stringContent.random(ogThreadRandomGenerator.asKotlinRandom()) }
.joinToString()
val roomDescription: String = (0 until roomDescriptionLength)
.map { stringContent.random(ogThreadRandomGenerator.asKotlinRandom()) }
.joinToString()
val numGroupMembers: Int = ogThreadRandomGenerator.nextInt(250)
val numMessages: Int = (
messageRangePerThread[threadIndex % messageRangePerThread.count()].first +
ogThreadRandomGenerator.nextInt(messageRangePerThread[threadIndex % messageRangePerThread.count()].last())
)
// Generate the Contacts in the group
val members: MutableList<String> = mutableListOf(userSessionId)
logProgress("Open Group Thread $threadIndex", "Generate $numGroupMembers Contacts")
(0 until numGroupMembers).forEach {
val contactBytes = (0 until 16).map { ogThreadRandomGenerator.nextInt(UByte.MAX_VALUE.toInt()).toByte() }
val randomSessionId: String = KeyPairUtilities.generate(contactBytes.toByteArray()).x25519KeyPair.hexEncodedPublicKey
val contactNameLength: Int = (5 + ogThreadRandomGenerator.nextInt(15))
val recipient = Recipient.from(context, Address.fromSerialized(randomSessionId), false)
val contact = Contact(randomSessionId)
contactDb.setContact(contact)
recipientDb.setApproved(recipient, true)
recipientDb.setApprovedMe(recipient, true)
contact.name = (0 until ogThreadRandomGenerator.nextInt(contactNameLength))
.map { stringContent.random(cgThreadRandomGenerator.asKotlinRandom()) }
.joinToString()
recipientDb.setProfileName(recipient, contact.name)
contactDb.setContact(contact)
members.add(randomSessionId)
}
// Create the open group model and the thread
val openGroupId = "$serverName.$roomName"
val threadId = GroupManager.createOpenGroup(openGroupId, context, null, roomName).threadId
val hasBlinding: Boolean = ogThreadRandomGenerator.nextBoolean()
// Generate the capabilities and other data
storage.setOpenGroupPublicKey(serverName, randomGroupPublicKey)
storage.setServerCapabilities(
serverName,
(
listOf(OpenGroupApi.Capability.SOGS.name.lowercase()) +
if (hasBlinding) { listOf(OpenGroupApi.Capability.BLIND.name.lowercase()) } else { emptyList() }
)
)
storage.setUserCount(roomName, serverName, numGroupMembers)
lokiThreadDB.setOpenGroupChat(OpenGroup(server = serverName, room = roomName, publicKey = randomGroupPublicKey, name = roomName, imageId = null, infoUpdates = 0), threadId)
// Generate the message history (Note: Unapproved message requests will only include incoming messages)
logProgress("Open Group Thread $threadIndex", "Generate $numMessages Messages")
(0 until numMessages).forEach { index ->
val messageWords: Int = (1 + ogThreadRandomGenerator.nextInt(19))
val senderId: String = members.random(ogThreadRandomGenerator.asKotlinRandom())
if (senderId != userSessionId) {
smsDb.insertMessageInbox(
IncomingTextMessage(
Address.fromSerialized(senderId),
1,
(timestampNow - (index * 5000)),
(0 until messageWords)
.map { wordContent.random(ogThreadRandomGenerator.asKotlinRandom()) }
.joinToString(),
Optional.absent(),
0,
false,
-1
),
(timestampNow - (index * 5000)),
false,
false
)
} else {
smsDb.insertMessageOutbox(
threadId,
OutgoingTextMessage(
threadDb.getRecipientForThreadId(threadId),
(0 until messageWords)
.map { wordContent.random(ogThreadRandomGenerator.asKotlinRandom()) }
.joinToString(),
0,
-1,
(timestampNow - (index * 5000))
),
(timestampNow - (index * 5000)),
false
)
}
}
logProgress("Open Group Thread $threadIndex", "Done")
}
ogThreadIndex += chunkSize
}
logProgress("Open Group Threads", "Done")
logProgress("", "Complete")
}
}

View File

@ -1,7 +1,9 @@
package org.thoughtcrime.securesms.webrtc package org.thoughtcrime.securesms.webrtc
import android.content.Context import android.content.Context
import android.content.pm.PackageManager
import android.telephony.TelephonyManager import android.telephony.TelephonyManager
import androidx.core.content.ContextCompat
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@ -176,8 +178,22 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va
_callStateEvents.value = newState _callStateEvents.value = newState
} }
fun isBusy(context: Context, callId: UUID) = callId != this.callId && (currentConnectionState != CallState.Idle fun isBusy(context: Context, callId: UUID): Boolean {
|| context.getSystemService(TelephonyManager::class.java).callState != TelephonyManager.CALL_STATE_IDLE) // Make sure we have the permission before accessing the callState
if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED) {
return (
callId != this.callId && (
currentConnectionState != CallState.Idle ||
context.getSystemService(TelephonyManager::class.java).callState != TelephonyManager.CALL_STATE_IDLE
)
)
}
return (
callId != this.callId &&
currentConnectionState != CallState.Idle
)
}
fun isPreOffer() = currentConnectionState == CallState.RemotePreOffer fun isPreOffer() = currentConnectionState == CallState.RemotePreOffer

View File

@ -12,6 +12,7 @@ import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.calls.CallMessageType import org.session.libsession.messaging.calls.CallMessageType
import org.session.libsession.messaging.messages.control.CallMessage import org.session.libsession.messaging.messages.control.CallMessage
import org.session.libsession.messaging.utilities.WebRtcUtils import org.session.libsession.messaging.utilities.WebRtcUtils
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
@ -29,6 +30,10 @@ import org.webrtc.IceCandidate
class CallMessageProcessor(private val context: Context, private val textSecurePreferences: TextSecurePreferences, lifecycle: Lifecycle, private val storage: StorageProtocol) { class CallMessageProcessor(private val context: Context, private val textSecurePreferences: TextSecurePreferences, lifecycle: Lifecycle, private val storage: StorageProtocol) {
companion object {
private const val VERY_EXPIRED_TIME = 15 * 60 * 1000L
}
init { init {
lifecycle.coroutineScope.launch(IO) { lifecycle.coroutineScope.launch(IO) {
while (isActive) { while (isActive) {
@ -53,6 +58,13 @@ class CallMessageProcessor(private val context: Context, private val textSecureP
} }
continue continue
} }
val isVeryExpired = (nextMessage.sentTimestamp?:0) + VERY_EXPIRED_TIME < SnodeAPI.nowWithOffset
if (isVeryExpired) {
Log.e("Loki", "Dropping very expired call message")
continue
}
when (nextMessage.type) { when (nextMessage.type) {
OFFER -> incomingCall(nextMessage) OFFER -> incomingCall(nextMessage)
ANSWER -> incomingAnswer(nextMessage) ANSWER -> incomingAnswer(nextMessage)
@ -78,7 +90,7 @@ class CallMessageProcessor(private val context: Context, private val textSecureP
private fun incomingHangup(callMessage: CallMessage) { private fun incomingHangup(callMessage: CallMessage) {
val callId = callMessage.callId ?: return val callId = callMessage.callId ?: return
val hangupIntent = WebRtcCallService.remoteHangupIntent(context, callId) val hangupIntent = WebRtcCallService.remoteHangupIntent(context, callId)
ContextCompat.startForegroundService(context, hangupIntent) context.startService(hangupIntent)
} }
private fun incomingAnswer(callMessage: CallMessage) { private fun incomingAnswer(callMessage: CallMessage) {
@ -91,7 +103,7 @@ class CallMessageProcessor(private val context: Context, private val textSecureP
sdp = sdp, sdp = sdp,
callId = callId callId = callId
) )
ContextCompat.startForegroundService(context, answerIntent) context.startService(answerIntent)
} }
private fun handleIceCandidates(callMessage: CallMessage) { private fun handleIceCandidates(callMessage: CallMessage) {
@ -120,7 +132,7 @@ class CallMessageProcessor(private val context: Context, private val textSecureP
callId = callId, callId = callId,
callTime = callMessage.sentTimestamp!! callTime = callMessage.sentTimestamp!!
) )
ContextCompat.startForegroundService(context, incomingIntent) context.startService(incomingIntent)
} }
private fun incomingCall(callMessage: CallMessage) { private fun incomingCall(callMessage: CallMessage) {
@ -134,8 +146,7 @@ class CallMessageProcessor(private val context: Context, private val textSecureP
callId = callId, callId = callId,
callTime = callMessage.sentTimestamp!! callTime = callMessage.sentTimestamp!!
) )
ContextCompat.startForegroundService(context, incomingIntent) context.startService(incomingIntent)
} }
private fun CallMessage.iceCandidates(): List<IceCandidate> { private fun CallMessage.iceCandidates(): List<IceCandidate> {

View File

@ -6,7 +6,7 @@
android:layout_width="@dimen/media_bubble_default_dimens" android:layout_width="@dimen/media_bubble_default_dimens"
android:layout_height="@dimen/media_bubble_default_dimens"> android:layout_height="@dimen/media_bubble_default_dimens">
<org.thoughtcrime.securesms.conversation.v2.utilities.KThumbnailView <include layout="@layout/thumbnail_view"
android:id="@+id/album_cell_1" android:id="@+id/album_cell_1"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"

View File

@ -7,13 +7,13 @@
android:layout_width="@dimen/album_total_width" android:layout_width="@dimen/album_total_width"
android:layout_height="@dimen/album_2_total_height"> android:layout_height="@dimen/album_2_total_height">
<org.thoughtcrime.securesms.conversation.v2.utilities.KThumbnailView <include layout="@layout/thumbnail_view"
android:id="@+id/album_cell_1" android:id="@+id/album_cell_1"
android:layout_width="@dimen/album_2_cell_width" android:layout_width="@dimen/album_2_cell_width"
android:layout_height="@dimen/album_2_total_height" android:layout_height="@dimen/album_2_total_height"
app:thumbnail_radius="0dp"/> app:thumbnail_radius="0dp"/>
<org.thoughtcrime.securesms.conversation.v2.utilities.KThumbnailView <include layout="@layout/thumbnail_view"
android:id="@+id/album_cell_2" android:id="@+id/album_cell_2"
android:layout_width="@dimen/album_2_cell_width" android:layout_width="@dimen/album_2_cell_width"
android:layout_height="@dimen/album_2_total_height" android:layout_height="@dimen/album_2_total_height"

View File

@ -6,13 +6,13 @@
android:layout_width="@dimen/album_total_width" android:layout_width="@dimen/album_total_width"
android:layout_height="@dimen/album_3_total_height"> android:layout_height="@dimen/album_3_total_height">
<org.thoughtcrime.securesms.conversation.v2.utilities.KThumbnailView <include layout="@layout/thumbnail_view"
android:id="@+id/album_cell_1" android:id="@+id/album_cell_1"
android:layout_width="@dimen/album_3_cell_width_big" android:layout_width="@dimen/album_3_cell_width_big"
android:layout_height="@dimen/album_3_total_height" android:layout_height="@dimen/album_3_total_height"
app:thumbnail_radius="0dp"/> app:thumbnail_radius="0dp"/>
<org.thoughtcrime.securesms.conversation.v2.utilities.KThumbnailView <include layout="@layout/thumbnail_view"
android:id="@+id/album_cell_2" android:id="@+id/album_cell_2"
android:layout_width="@dimen/album_3_cell_size_small" android:layout_width="@dimen/album_3_cell_size_small"
android:layout_height="@dimen/album_3_cell_size_small" android:layout_height="@dimen/album_3_cell_size_small"
@ -25,7 +25,7 @@
android:layout_height="@dimen/album_5_cell_size_small" android:layout_height="@dimen/album_5_cell_size_small"
android:layout_gravity="end|bottom"> android:layout_gravity="end|bottom">
<org.thoughtcrime.securesms.conversation.v2.utilities.KThumbnailView <include layout="@layout/thumbnail_view"
android:id="@+id/album_cell_3" android:id="@+id/album_cell_3"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" <org.thoughtcrime.securesms.conversation.v2.components.AlbumThumbnailView
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
@ -20,4 +21,4 @@
android:layout_gravity="center" android:layout_gravity="center"
android:layout="@layout/transfer_controls_stub" /> android:layout="@layout/transfer_controls_stub" />
</RelativeLayout> </org.thoughtcrime.securesms.conversation.v2.components.AlbumThumbnailView>

View File

@ -1,104 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<merge
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/linkpreview_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="@dimen/small_spacing"
android:background="?linkpreview_background_color">
<org.thoughtcrime.securesms.components.OutlinedThumbnailView
android:id="@+id/linkpreview_thumbnail"
android:layout_width="72dp"
android:layout_height="0dp"
android:scaleType="centerCrop"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@+id/linkpreview_divider"
app:layout_constraintHeight_min="72dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/linkpreview_title"
tools:src="@drawable/ic_contact_picture"
tools:visibility="visible" />
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/linkpreview_title"
style="@style/Signal.Text.Body"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:fontFamily="sans-serif-medium"
android:maxLines="1"
android:textSize="@dimen/medium_font_size"
android:textColor="?linkpreview_primary_text_color"
app:layout_constraintEnd_toStartOf="@+id/linkpreview_close"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toEndOf="@+id/linkpreview_thumbnail"
app:layout_constraintTop_toTopOf="parent"
tools:text="Wall Crawler Strikes Again!" />
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/linkpreview_site"
style="@style/Signal.Text.Caption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="2dp"
android:maxLines="1"
android:textSize="@dimen/small_font_size"
android:textColor="?linkpreview_secondary_text_color"
android:alpha="0.6"
app:layout_constraintStart_toEndOf="@+id/linkpreview_thumbnail"
app:layout_constraintTop_toBottomOf="@+id/linkpreview_title"
tools:text="dailybugle.com" />
<View
android:id="@+id/linkpreview_divider"
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_marginStart="8dp"
android:layout_marginTop="6dp"
android:background="?linkpreview_divider_color"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@+id/linkpreview_thumbnail"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/linkpreview_thumbnail"
app:layout_constraintTop_toBottomOf="@+id/linkpreview_site"
app:layout_constraintVertical_bias="0.0"
tools:visibility="visible" />
<ImageView
android:id="@+id/linkpreview_close"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="6dp"
android:layout_marginTop="4dp"
android:src="@drawable/ic_close_white_18dp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:tint="@color/gray70"
tools:visibility="visible" />
<com.github.ybq.android.spinkit.SpinKitView
style="@style/SpinKitView.DoubleBounce"
android:id="@+id/linkpreview_progress_wheel"
android:layout_width="72dp"
android:layout_height="72dp"
android:layout_gravity="center"
android:padding="@dimen/small_spacing"
app:SpinKit_Color="?android:textColorPrimary"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@+id/linkpreview_divider"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</merge>

View File

@ -5,7 +5,7 @@
android:layout_height="match_parent" android:layout_height="match_parent"
android:padding="2dp"> android:padding="2dp">
<org.thoughtcrime.securesms.conversation.v2.utilities.ThumbnailView <include layout="@layout/thumbnail_view"
android:id="@+id/image" android:id="@+id/image"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"

View File

@ -8,7 +8,7 @@
android:layout_margin="2dp" android:layout_margin="2dp"
android:animateLayoutChanges="true"> android:animateLayoutChanges="true">
<org.thoughtcrime.securesms.conversation.v2.utilities.ThumbnailView <include layout="@layout/thumbnail_view"
android:id="@+id/rail_item_image" android:id="@+id/rail_item_image"
android:layout_width="56dp" android:layout_width="56dp"
android:layout_height="56dp" android:layout_height="56dp"

View File

@ -1,8 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<merge <org.thoughtcrime.securesms.conversation.v2.utilities.ThumbnailView
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"> xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/transparent_black_6">
<ImageView <ImageView
android:id="@+id/thumbnail_image" android:id="@+id/thumbnail_image"
@ -60,4 +63,4 @@
android:layout="@layout/transfer_controls_stub" android:layout="@layout/transfer_controls_stub"
android:visibility="gone" /> android:visibility="gone" />
</merge> </org.thoughtcrime.securesms.conversation.v2.utilities.ThumbnailView>

View File

@ -34,64 +34,69 @@
android:layout_gravity="center_vertical" android:layout_gravity="center_vertical"
android:orientation="vertical"> android:orientation="vertical">
<LinearLayout <androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content">
android:orientation="horizontal"
android:gravity="center_vertical">
<LinearLayout <TextView
android:layout_width="0dp" android:id="@+id/conversationViewDisplayNameTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" app:layout_constraintStart_toStartOf="parent"
android:orientation="horizontal" app:layout_constraintEnd_toStartOf="@id/unreadCountIndicator"
android:gravity="center_vertical"> app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constrainedWidth="true"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintHorizontal_bias="0"
android:drawablePadding="4dp"
android:maxLines="1"
android:ellipsize="end"
android:textAlignment="viewStart"
android:textSize="@dimen/medium_font_size"
android:textStyle="bold"
android:textColor="?android:textColorPrimary"
app:drawableTint="?conversation_pinned_icon_color"
tools:drawableRight="@drawable/ic_pin"
tools:text="I'm a very long display name. What are you going to do about it?" />
<RelativeLayout
android:id="@+id/unreadCountIndicator"
android:layout_width="wrap_content"
android:layout_height="20dp"
android:layout_marginStart="4dp"
app:layout_constraintStart_toEndOf="@id/conversationViewDisplayNameTextView"
app:layout_constraintEnd_toStartOf="@id/timestampTextView"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:minWidth="20dp"
android:maxWidth="40dp"
android:paddingLeft="4dp"
android:paddingRight="4dp"
android:background="@drawable/rounded_rectangle"
android:backgroundTint="?unreadIndicatorBackgroundColor">
<TextView <TextView
android:id="@+id/conversationViewDisplayNameTextView" android:id="@+id/unreadCountTextView"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:drawablePadding="4dp" android:layout_centerInParent="true"
android:maxLines="1" android:textColor="?unreadIndicatorTextColor"
android:ellipsize="end" android:textSize="@dimen/very_small_font_size"
android:textAlignment="viewStart"
android:textSize="@dimen/medium_font_size"
android:textStyle="bold" android:textStyle="bold"
android:textColor="?android:textColorPrimary" tools:text="8"
app:drawableTint="?conversation_pinned_icon_color" tools:textColor="?android:textColorPrimary" />
tools:drawableRight="@drawable/ic_pin"
tools:text="I'm a very long display name. What are you going to do about it?" />
<RelativeLayout </RelativeLayout>
android:id="@+id/unreadCountIndicator"
android:layout_width="wrap_content"
android:maxWidth="40dp"
android:paddingLeft="4dp"
android:paddingRight="4dp"
android:layout_height="20dp"
android:layout_marginStart="4dp"
android:background="@drawable/rounded_rectangle"
android:backgroundTint="?unreadIndicatorBackgroundColor">
<TextView
android:id="@+id/unreadCountTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:text="8"
android:textColor="?unreadIndicatorTextColor"
android:textSize="@dimen/very_small_font_size"
android:textStyle="bold" />
</RelativeLayout>
</LinearLayout>
<TextView <TextView
android:id="@+id/timestampTextView" android:id="@+id/timestampTextView"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="@dimen/medium_spacing" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:paddingStart="@dimen/medium_spacing"
android:maxLines="1" android:maxLines="1"
android:ellipsize="end" android:ellipsize="end"
android:textSize="@dimen/small_font_size" android:textSize="@dimen/small_font_size"
@ -99,7 +104,7 @@
android:alpha="0.4" android:alpha="0.4"
tools:text="9:41 AM" /> tools:text="9:41 AM" />
</LinearLayout> </androidx.constraintlayout.widget.ConstraintLayout>
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
@ -131,7 +136,7 @@
android:textSize="@dimen/medium_font_size" android:textSize="@dimen/medium_font_size"
tools:text="Sorry, gotta go fight crime again" /> tools:text="Sorry, gotta go fight crime again" />
<org.thoughtcrime.securesms.conversation.v2.components.TypingIndicatorView <include layout="@layout/view_typing_indicator"
android:id="@+id/typingIndicatorView" android:id="@+id/typingIndicatorView"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"

View File

@ -17,7 +17,7 @@
android:background="@drawable/message_bubble_background_received_alone" android:background="@drawable/message_bubble_background_received_alone"
android:backgroundTint="?message_received_background_color"> android:backgroundTint="?message_received_background_color">
<org.thoughtcrime.securesms.conversation.v2.components.TypingIndicatorView <include layout="@layout/view_typing_indicator"
android:id="@+id/typingIndicator" android:id="@+id/typingIndicator"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"

View File

@ -1,9 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" <org.thoughtcrime.securesms.conversation.v2.messages.EmojiReactionsView
android:layout_width="match_parent" xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_height="wrap_content"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="@dimen/small_spacing" android:padding="@dimen/small_spacing"
android:gravity="center"> android:gravity="center">
@ -65,4 +66,4 @@
android:visibility="gone" android:visibility="gone"
app:constraint_referenced_ids="image_view_show_less, text_view_show_less"/> app:constraint_referenced_ids="image_view_show_less, text_view_show_less"/>
</androidx.constraintlayout.widget.ConstraintLayout> </org.thoughtcrime.securesms.conversation.v2.messages.EmojiReactionsView>

View File

@ -1,54 +1,47 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout android:id="@+id/mainLinkPreviewContainer" <org.thoughtcrime.securesms.conversation.v2.messages.LinkPreviewView
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/mainLinkPreviewContainer"
android:background="@color/transparent_black_6"
android:layout_width="300dp" android:layout_width="300dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
xmlns:tools="http://schemas.android.com/tools" android:orientation="horizontal"
android:orientation="vertical" android:gravity="center">
xmlns:app="http://schemas.android.com/apk/res-auto">
<LinearLayout <RelativeLayout
android:background="@color/transparent_black_6" android:layout_width="96dp"
android:id="@+id/mainLinkPreviewParent" android:layout_height="96dp">
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center">
<RelativeLayout <ImageView
android:layout_width="96dp" android:layout_width="24dp"
android:layout_height="96dp"> android:layout_height="24dp"
android:layout_centerInParent="true"
android:src="@drawable/ic_link"
app:tint="?android:textColorPrimary" />
<ImageView <include layout="@layout/thumbnail_view"
android:layout_width="24dp" android:background="@color/transparent_black_6"
android:layout_height="24dp" android:id="@+id/thumbnailImageView"
android:layout_centerInParent="true"
android:src="@drawable/ic_link"
app:tint="?android:textColorPrimary" />
<org.thoughtcrime.securesms.conversation.v2.utilities.KThumbnailView
android:background="@color/transparent_black_6"
android:id="@+id/thumbnailImageView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop" />
</RelativeLayout>
<TextView
android:id="@+id/titleTextView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:paddingHorizontal="12dp" android:scaleType="centerCrop" />
android:gravity="center_vertical"
android:textSize="@dimen/small_font_size"
android:textStyle="bold"
tools:text="Some Text here"
android:minWidth="@dimen/media_bubble_min_width"
android:maxLines="3"
android:ellipsize="end"
android:textColor="?android:textColorPrimary"/>
</LinearLayout> </RelativeLayout>
</LinearLayout> <TextView
android:id="@+id/titleTextView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingHorizontal="12dp"
android:gravity="center_vertical"
android:textSize="@dimen/small_font_size"
android:textStyle="bold"
tools:text="Some Text here"
android:minWidth="@dimen/media_bubble_min_width"
android:maxLines="3"
android:ellipsize="end"
android:textColor="?android:textColorPrimary"/>
</org.thoughtcrime.securesms.conversation.v2.messages.LinkPreviewView>

View File

@ -26,7 +26,7 @@
android:src="@drawable/ic_link" android:src="@drawable/ic_link"
app:tint="?android:textColorPrimary" /> app:tint="?android:textColorPrimary" />
<org.thoughtcrime.securesms.conversation.v2.utilities.ThumbnailView <include layout="@layout/thumbnail_view"
android:id="@+id/thumbnailImageView" android:id="@+id/thumbnailImageView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"

View File

@ -44,7 +44,7 @@
android:scaleType="centerInside" android:scaleType="centerInside"
android:src="@drawable/ic_microphone" /> android:src="@drawable/ic_microphone" />
<org.thoughtcrime.securesms.conversation.v2.utilities.ThumbnailView <include layout="@layout/thumbnail_view"
android:id="@+id/quoteViewAttachmentThumbnailImageView" android:id="@+id/quoteViewAttachmentThumbnailImageView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"

View File

@ -44,7 +44,7 @@
android:scaleType="centerInside" android:scaleType="centerInside"
android:src="@drawable/ic_microphone" /> android:src="@drawable/ic_microphone" />
<org.thoughtcrime.securesms.conversation.v2.utilities.ThumbnailView <include layout="@layout/thumbnail_view"
android:id="@+id/quoteViewAttachmentThumbnailImageView" android:id="@+id/quoteViewAttachmentThumbnailImageView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"

View File

@ -1,11 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<merge <org.thoughtcrime.securesms.conversation.v2.components.TypingIndicatorView
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:orientation="horizontal" android:orientation="horizontal"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent">
tools:context="org.thoughtcrime.securesms.conversation.v2.components.TypingIndicatorView">
<View <View
android:id="@+id/typing_dot1" android:id="@+id/typing_dot1"
@ -37,4 +36,4 @@
android:alpha="0.5" android:alpha="0.5"
android:background="@drawable/circle_white" /> android:background="@drawable/circle_white" />
</merge> </org.thoughtcrime.securesms.conversation.v2.components.TypingIndicatorView>

View File

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView xmlns:android="http://schemas.android.com/apk/res/android" <org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/visibleMessageView" android:id="@+id/visibleMessageView"
@ -76,7 +77,7 @@
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/senderNameTextView"> app:layout_constraintTop_toBottomOf="@+id/senderNameTextView">
<org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageContentView <include layout="@layout/view_visible_message_content"
android:id="@+id/messageContentView" android:id="@+id/messageContentView"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" /> android:layout_height="wrap_content" />
@ -99,7 +100,7 @@
</LinearLayout> </LinearLayout>
<org.thoughtcrime.securesms.conversation.v2.messages.EmojiReactionsView <include layout="@layout/view_emoji_reactions"
android:id="@+id/emojiReactionsView" android:id="@+id/emojiReactionsView"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout <org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageContentView
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@ -75,7 +75,7 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content"/> android:layout_height="wrap_content"/>
<org.thoughtcrime.securesms.conversation.v2.messages.LinkPreviewView <include layout="@layout/view_link_preview"
app:layout_constraintTop_toBottomOf="@+id/quoteView" app:layout_constraintTop_toBottomOf="@+id/quoteView"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
android:visibility="gone" android:visibility="gone"
@ -112,8 +112,8 @@
android:layout_height="wrap_content"/> android:layout_height="wrap_content"/>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
<org.thoughtcrime.securesms.conversation.v2.components.AlbumThumbnailView <include layout="@layout/album_thumbnail_view"
android:visibility="visible" android:visibility="gone"
android:id="@+id/albumThumbnailView" android:id="@+id/albumThumbnailView"
android:layout_marginTop="4dp" android:layout_marginTop="4dp"
app:layout_constraintTop_toBottomOf="@+id/contentParent" app:layout_constraintTop_toBottomOf="@+id/contentParent"
@ -123,4 +123,4 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content"/> android:layout_height="wrap_content"/>
</androidx.constraintlayout.widget.ConstraintLayout> </org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageContentView>

View File

@ -864,6 +864,8 @@
<string name="fragment_enter_community_url_join_button_title">Join</string> <string name="fragment_enter_community_url_join_button_title">Join</string>
<string name="new_conversation_dialog_back_button_content_description">Navigate Back</string> <string name="new_conversation_dialog_back_button_content_description">Navigate Back</string>
<string name="new_conversation_dialog_close_button_content_description">Close Dialog</string> <string name="new_conversation_dialog_close_button_content_description">Close Dialog</string>
<string name="ErrorNotifier_migration">Database Upgrade Failed</string>
<string name="ErrorNotifier_migration_downgrade">Please contact support to report the error.</string>
<string name="delivery_status_sending">Sending</string> <string name="delivery_status_sending">Sending</string>
<string name="delivery_status_read">Read</string> <string name="delivery_status_read">Read</string>
<string name="delivery_status_sent">Sent</string> <string name="delivery_status_sent">Sent</string>

View File

@ -20,7 +20,9 @@ interface MessageDataProvider {
* @return pair of sms or mms table-specific ID and whether it is in SMS table * @return pair of sms or mms table-specific ID and whether it is in SMS table
*/ */
fun getMessageID(serverId: Long, threadId: Long): Pair<Long, Boolean>? fun getMessageID(serverId: Long, threadId: Long): Pair<Long, Boolean>?
fun getMessageIDs(serverIDs: List<Long>, threadID: Long): Pair<List<Long>, List<Long>>
fun deleteMessage(messageID: Long, isSms: Boolean) fun deleteMessage(messageID: Long, isSms: Boolean)
fun deleteMessages(messageIDs: List<Long>, threadId: Long, isSms: Boolean)
fun updateMessageAsDeleted(timestamp: Long, author: String) fun updateMessageAsDeleted(timestamp: Long, author: String)
fun getServerHashForMessage(messageID: Long): String? fun getServerHashForMessage(messageID: Long): String?
fun getDatabaseAttachment(attachmentId: Long): DatabaseAttachment? fun getDatabaseAttachment(attachmentId: Long): DatabaseAttachment?

View File

@ -16,6 +16,7 @@ import org.session.libsession.messaging.messages.visible.Reaction
import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.messages.visible.VisibleMessage
import org.session.libsession.messaging.open_groups.GroupMember import org.session.libsession.messaging.open_groups.GroupMember
import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.open_groups.OpenGroup
import org.session.libsession.messaging.open_groups.OpenGroupApi
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage
@ -66,7 +67,7 @@ interface StorageProtocol {
fun getAllOpenGroups(): Map<Long, OpenGroup> fun getAllOpenGroups(): Map<Long, OpenGroup>
fun updateOpenGroup(openGroup: OpenGroup) fun updateOpenGroup(openGroup: OpenGroup)
fun getOpenGroup(threadId: Long): OpenGroup? fun getOpenGroup(threadId: Long): OpenGroup?
fun addOpenGroup(urlAsString: String) fun addOpenGroup(urlAsString: String): OpenGroupApi.RoomInfo?
fun onOpenGroupAdded(server: String) fun onOpenGroupAdded(server: String)
fun hasBackgroundGroupAddJob(groupJoinUrl: String): Boolean fun hasBackgroundGroupAddJob(groupJoinUrl: String): Boolean
fun setOpenGroupServerMessageID(messageID: Long, serverID: Long, threadID: Long, isSms: Boolean) fun setOpenGroupServerMessageID(messageID: Long, serverID: Long, threadID: Long, isSms: Boolean)
@ -80,6 +81,7 @@ interface StorageProtocol {
// Open Group Metadata // Open Group Metadata
fun updateTitle(groupID: String, newValue: String) fun updateTitle(groupID: String, newValue: String)
fun updateProfilePicture(groupID: String, newValue: ByteArray) fun updateProfilePicture(groupID: String, newValue: ByteArray)
fun hasDownloadedProfilePicture(groupID: String): Boolean
fun setUserCount(room: String, server: String, newValue: Int) fun setUserCount(room: String, server: String, newValue: Int)
// Last Message Server ID // Last Message Server ID

View File

@ -77,7 +77,11 @@ object FileServerApi {
OnionRequestAPI.sendOnionRequest(requestBuilder.build(), server, serverPublicKey).map { OnionRequestAPI.sendOnionRequest(requestBuilder.build(), server, serverPublicKey).map {
it.body ?: throw Error.ParsingFailed it.body ?: throw Error.ParsingFailed
}.fail { e -> }.fail { e ->
Log.e("Loki", "File server request failed.", e) when (e) {
// No need for the stack trace for HTTP errors
is HTTP.HTTPRequestFailedException -> Log.e("Loki", "File server request failed due to error: ${e.message}")
else -> Log.e("Loki", "File server request failed", e)
}
} }
} else { } else {
Promise.ofFail(IllegalStateException("It's currently not allowed to send non onion routed requests.")) Promise.ofFail(IllegalStateException("It's currently not allowed to send non onion routed requests."))

View File

@ -41,15 +41,10 @@ class BackgroundGroupAddJob(val joinUrl: String): Job {
} }
// get image // get image
storage.setOpenGroupPublicKey(openGroup.server, openGroup.serverPublicKey) storage.setOpenGroupPublicKey(openGroup.server, openGroup.serverPublicKey)
val (capabilities, info) = OpenGroupApi.getCapabilitiesAndRoomInfo(openGroup.room, openGroup.server, false).get() val info = storage.addOpenGroup(openGroup.joinUrl())
storage.setServerCapabilities(openGroup.server, capabilities.capabilities) val imageId = info?.imageId
val imageId = info.imageId
storage.addOpenGroup(openGroup.joinUrl())
if (imageId != null) { if (imageId != null) {
val bytes = OpenGroupApi.downloadOpenGroupProfilePicture(openGroup.server, openGroup.room, imageId).get() JobQueue.shared.add(GroupAvatarDownloadJob(openGroup.room, openGroup.server))
val groupId = GroupUtil.getEncodedOpenGroupID("${openGroup.server}.${openGroup.room}".toByteArray())
storage.updateProfilePicture(groupId, bytes)
storage.updateTimestampUpdated(groupId, System.currentTimeMillis())
} }
Log.d(KEY, "onOpenGroupAdded(${openGroup.server})") Log.d(KEY, "onOpenGroupAdded(${openGroup.server})")
storage.onOpenGroupAdded(openGroup.server) storage.onOpenGroupAdded(openGroup.server)

View File

@ -94,12 +94,23 @@ class BatchMessageReceiveJob(
threadMap[threadID]!! += parsedParams threadMap[threadID]!! += parsedParams
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Couldn't receive message.", e) when (e) {
if (e is MessageReceiver.Error && !e.isRetryable) { is MessageReceiver.Error.DuplicateMessage, MessageReceiver.Error.SelfSend -> {
Log.e(TAG, "Message failed permanently",e) Log.i(TAG, "Couldn't receive message, failed with error: ${e.message}")
} else { }
Log.e(TAG, "Message failed",e) is MessageReceiver.Error -> {
failures += messageParameters if (!e.isRetryable) {
Log.e(TAG, "Couldn't receive message, failed permanently", e)
}
else {
Log.e(TAG, "Couldn't receive message, failed", e)
failures += messageParameters
}
}
else -> {
Log.e(TAG, "Couldn't receive message, failed", e)
failures += messageParameters
}
} }
} }
} }

View File

@ -14,10 +14,9 @@ class GroupAvatarDownloadJob(val room: String, val server: String) : Job {
override fun execute() { override fun execute() {
val storage = MessagingModuleConfiguration.shared.storage val storage = MessagingModuleConfiguration.shared.storage
val imageId = storage.getOpenGroup(room, server)?.imageId ?: return
try { try {
val info = OpenGroupApi.getRoomInfo(room, server).get() val bytes = OpenGroupApi.downloadOpenGroupProfilePicture(server, room, imageId).get()
val imageId = info.imageId ?: return
val bytes = OpenGroupApi.downloadOpenGroupProfilePicture(server, info.token, imageId).get()
val groupId = GroupUtil.getEncodedOpenGroupID("$server.$room".toByteArray()) val groupId = GroupUtil.getEncodedOpenGroupID("$server.$room".toByteArray())
storage.updateProfilePicture(groupId, bytes) storage.updateProfilePicture(groupId, bytes)
storage.updateTimestampUpdated(groupId, System.currentTimeMillis()) storage.updateTimestampUpdated(groupId, System.currentTimeMillis())

View File

@ -26,7 +26,7 @@ class JobQueue : JobDelegate {
private val jobTimestampMap = ConcurrentHashMap<Long, AtomicInteger>() private val jobTimestampMap = ConcurrentHashMap<Long, AtomicInteger>()
private val rxDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() private val rxDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
private val rxMediaDispatcher = Executors.newFixedThreadPool(4).asCoroutineDispatcher() private val rxMediaDispatcher = Executors.newFixedThreadPool(4).asCoroutineDispatcher()
private val openGroupDispatcher = Executors.newCachedThreadPool().asCoroutineDispatcher() private val openGroupDispatcher = Executors.newFixedThreadPool(8).asCoroutineDispatcher()
private val txDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() private val txDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
private val scope = CoroutineScope(Dispatchers.Default) + SupervisorJob() private val scope = CoroutineScope(Dispatchers.Default) + SupervisorJob()
private val queue = Channel<Job>(UNLIMITED) private val queue = Channel<Job>(UNLIMITED)

View File

@ -11,6 +11,7 @@ import org.session.libsession.messaging.messages.visible.VisibleMessage
import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.messaging.utilities.Data import org.session.libsession.messaging.utilities.Data
import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.snode.OnionRequestAPI
import org.session.libsignal.utilities.HTTP
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
class MessageSendJob(val message: Message, val destination: Destination) : Job { class MessageSendJob(val message: Message, val destination: Destination) : Job {
@ -67,14 +68,25 @@ class MessageSendJob(val message: Message, val destination: Destination) : Job {
val promise = MessageSender.send(this.message, this.destination).success { val promise = MessageSender.send(this.message, this.destination).success {
this.handleSuccess() this.handleSuccess()
}.fail { exception -> }.fail { exception ->
Log.e(TAG, "Couldn't send message due to error: $exception.") var logStacktrace = true
if (exception is MessageSender.Error) {
if (!exception.isRetryable) { this.handlePermanentFailure(exception) } when (exception) {
// No need for the stack trace for HTTP errors
is HTTP.HTTPRequestFailedException -> {
logStacktrace = false
if (exception.statusCode == 429) { this.handlePermanentFailure(exception) }
else { this.handleFailure(exception) }
}
is MessageSender.Error -> {
if (!exception.isRetryable) { this.handlePermanentFailure(exception) }
else { this.handleFailure(exception) }
}
else -> this.handleFailure(exception)
} }
if (exception is OnionRequestAPI.HTTPRequestFailedAtDestinationException && exception.statusCode == 429) {
this.handlePermanentFailure(exception) if (logStacktrace) { Log.e(TAG, "Couldn't send message due to error", exception) }
} else { Log.e(TAG, "Couldn't send message due to error: ${exception.message}") }
this.handleFailure(exception)
} }
try { try {
promise.get() promise.get()

View File

@ -23,14 +23,27 @@ class OpenGroupDeleteJob(private val messageServerIds: LongArray, private val th
val dataProvider = MessagingModuleConfiguration.shared.messageDataProvider val dataProvider = MessagingModuleConfiguration.shared.messageDataProvider
val numberToDelete = messageServerIds.size val numberToDelete = messageServerIds.size
Log.d(TAG, "Deleting $numberToDelete messages") Log.d(TAG, "Deleting $numberToDelete messages")
var numberDeleted = 0
messageServerIds.forEach { serverId -> // FIXME: This entire process should probably run in a transaction (with the attachment deletion happening only if it succeeded)
val (messageId, isSms) = dataProvider.getMessageID(serverId, threadId) ?: return@forEach try {
dataProvider.deleteMessage(messageId, isSms) val messageIds = dataProvider.getMessageIDs(messageServerIds.toList(), threadId)
numberDeleted++
// Delete the SMS messages
if (messageIds.first.isNotEmpty()) {
dataProvider.deleteMessages(messageIds.first, threadId, true)
}
// Delete the MMS messages
if (messageIds.second.isNotEmpty()) {
dataProvider.deleteMessages(messageIds.second, threadId, false)
}
Log.d(TAG, "Deleted ${messageIds.first.size + messageIds.second.size} messages successfully")
delegate?.handleJobSucceeded(this)
}
catch (e: Exception) {
delegate?.handleJobFailed(this, e)
} }
Log.d(TAG, "Deleted $numberDeleted messages successfully")
delegate?.handleJobSucceeded(this)
} }
override fun serialize(): Data = Data.Builder() override fun serialize(): Data = Data.Builder()

View File

@ -11,15 +11,17 @@ data class OpenGroup(
val id: String, val id: String,
val name: String, val name: String,
val publicKey: String, val publicKey: String,
val imageId: String?,
val infoUpdates: Int, val infoUpdates: Int,
) { ) {
constructor(server: String, room: String, name: String, infoUpdates: Int, publicKey: String) : this( constructor(server: String, room: String, publicKey: String, name: String, imageId: String?, infoUpdates: Int) : this(
server = server, server = server,
room = room, room = room,
id = "$server.$room", id = "$server.$room",
name = name, name = name,
publicKey = publicKey, publicKey = publicKey,
imageId = imageId,
infoUpdates = infoUpdates, infoUpdates = infoUpdates,
) )
@ -31,11 +33,12 @@ data class OpenGroup(
if (!json.has("room")) return null if (!json.has("room")) return null
val room = json.get("room").asText().toLowerCase(Locale.US) val room = json.get("room").asText().toLowerCase(Locale.US)
val server = json.get("server").asText().toLowerCase(Locale.US) val server = json.get("server").asText().toLowerCase(Locale.US)
val displayName = json.get("displayName").asText()
val publicKey = json.get("publicKey").asText() val publicKey = json.get("publicKey").asText()
val displayName = json.get("displayName").asText()
val imageId = json.get("imageId")?.asText()
val infoUpdates = json.get("infoUpdates")?.asText()?.toIntOrNull() ?: 0 val infoUpdates = json.get("infoUpdates")?.asText()?.toIntOrNull() ?: 0
val capabilities = json.get("capabilities")?.asText()?.split(",") ?: emptyList() val capabilities = json.get("capabilities")?.asText()?.split(",") ?: emptyList()
OpenGroup(server, room, displayName, infoUpdates, publicKey) OpenGroup(server = server, room = room, name = displayName, publicKey = publicKey, imageId = imageId, infoUpdates = infoUpdates)
} catch (e: Exception) { } catch (e: Exception) {
Log.w("Loki", "Couldn't parse open group from JSON: $jsonAsString.", e); Log.w("Loki", "Couldn't parse open group from JSON: $jsonAsString.", e);
null null
@ -53,11 +56,12 @@ data class OpenGroup(
} }
} }
fun toJson(): Map<String,String> = mapOf( fun toJson(): Map<String,String?> = mapOf(
"room" to room, "room" to room,
"server" to server, "server" to server,
"displayName" to name,
"publicKey" to publicKey, "publicKey" to publicKey,
"displayName" to name,
"imageId" to imageId,
"infoUpdates" to infoUpdates.toString(), "infoUpdates" to infoUpdates.toString(),
) )

View File

@ -91,7 +91,7 @@ object OpenGroupApi {
val created: Long = 0, val created: Long = 0,
val activeUsers: Int = 0, val activeUsers: Int = 0,
val activeUsersCutoff: Int = 0, val activeUsersCutoff: Int = 0,
val imageId: Long? = null, val imageId: String? = null,
val pinnedMessages: List<PinnedMessage> = emptyList(), val pinnedMessages: List<PinnedMessage> = emptyList(),
val admin: Boolean = false, val admin: Boolean = false,
val globalAdmin: Boolean = false, val globalAdmin: Boolean = false,
@ -148,7 +148,7 @@ object OpenGroupApi {
) )
enum class Capability { enum class Capability {
BLIND, REACTIONS SOGS, BLIND, REACTIONS
} }
@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class) @JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class)
@ -337,7 +337,7 @@ object OpenGroupApi {
.plus(request.verb.rawValue.toByteArray()) .plus(request.verb.rawValue.toByteArray())
.plus("/${request.endpoint.value}".toByteArray()) .plus("/${request.endpoint.value}".toByteArray())
.plus(bodyHash) .plus(bodyHash)
if (serverCapabilities.contains(Capability.BLIND.name.lowercase())) { if (serverCapabilities.isEmpty() || serverCapabilities.contains(Capability.BLIND.name.lowercase())) {
SodiumUtilities.blindedKeyPair(publicKey, ed25519KeyPair)?.let { keyPair -> SodiumUtilities.blindedKeyPair(publicKey, ed25519KeyPair)?.let { keyPair ->
pubKey = SessionId( pubKey = SessionId(
IdPrefix.BLINDED, IdPrefix.BLINDED,
@ -383,7 +383,11 @@ object OpenGroupApi {
} }
return if (request.useOnionRouting) { return if (request.useOnionRouting) {
OnionRequestAPI.sendOnionRequest(requestBuilder.build(), request.server, publicKey).fail { e -> OnionRequestAPI.sendOnionRequest(requestBuilder.build(), request.server, publicKey).fail { e ->
Log.e("SOGS", "Failed onion request", e) when (e) {
// No need for the stack trace for HTTP errors
is HTTP.HTTPRequestFailedException -> Log.e("SOGS", "Failed onion request: ${e.message}")
else -> Log.e("SOGS", "Failed onion request", e)
}
} }
} else { } else {
Promise.ofFail(IllegalStateException("It's currently not allowed to send non onion routed requests.")) Promise.ofFail(IllegalStateException("It's currently not allowed to send non onion routed requests."))
@ -395,13 +399,13 @@ object OpenGroupApi {
fun downloadOpenGroupProfilePicture( fun downloadOpenGroupProfilePicture(
server: String, server: String,
roomID: String, roomID: String,
imageId: Long imageId: String
): Promise<ByteArray, Exception> { ): Promise<ByteArray, Exception> {
val request = Request( val request = Request(
verb = GET, verb = GET,
room = roomID, room = roomID,
server = server, server = server,
endpoint = Endpoint.RoomFileIndividual(roomID, imageId.toString()) endpoint = Endpoint.RoomFileIndividual(roomID, imageId)
) )
return getResponseBody(request) return getResponseBody(request)
} }
@ -794,16 +798,14 @@ object OpenGroupApi {
private fun sequentialBatch( private fun sequentialBatch(
server: String, server: String,
requests: MutableList<BatchRequestInfo<*>>, requests: MutableList<BatchRequestInfo<*>>
authRequired: Boolean = true
): Promise<List<BatchResponse<*>>, Exception> { ): Promise<List<BatchResponse<*>>, Exception> {
val request = Request( val request = Request(
verb = POST, verb = POST,
room = null, room = null,
server = server, server = server,
endpoint = Endpoint.Sequence, endpoint = Endpoint.Sequence,
parameters = requests.map { it.request }, parameters = requests.map { it.request }
isAuthRequired = authRequired
) )
return getBatchResponseJson(request, requests) return getBatchResponseJson(request, requests)
} }
@ -912,8 +914,7 @@ object OpenGroupApi {
fun getCapabilitiesAndRoomInfo( fun getCapabilitiesAndRoomInfo(
room: String, room: String,
server: String, server: String
authRequired: Boolean = true
): Promise<Pair<Capabilities, RoomInfo>, Exception> { ): Promise<Pair<Capabilities, RoomInfo>, Exception> {
val requests = mutableListOf<BatchRequestInfo<*>>( val requests = mutableListOf<BatchRequestInfo<*>>(
BatchRequestInfo( BatchRequestInfo(
@ -933,7 +934,7 @@ object OpenGroupApi {
responseType = object : TypeReference<RoomInfo>(){} responseType = object : TypeReference<RoomInfo>(){}
) )
) )
return sequentialBatch(server, requests, authRequired).map { return sequentialBatch(server, requests).map {
val capabilities = it.firstOrNull()?.body as? Capabilities ?: throw Error.ParsingFailed val capabilities = it.firstOrNull()?.body as? Capabilities ?: throw Error.ParsingFailed
val roomInfo = it.lastOrNull()?.body as? RoomInfo ?: throw Error.ParsingFailed val roomInfo = it.lastOrNull()?.body as? RoomInfo ?: throw Error.ParsingFailed
capabilities to roomInfo capabilities to roomInfo

View File

@ -6,7 +6,15 @@ import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.jobs.BackgroundGroupAddJob import org.session.libsession.messaging.jobs.BackgroundGroupAddJob
import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.Message
import org.session.libsession.messaging.messages.control.* import org.session.libsession.messaging.messages.control.CallMessage
import org.session.libsession.messaging.messages.control.ClosedGroupControlMessage
import org.session.libsession.messaging.messages.control.ConfigurationMessage
import org.session.libsession.messaging.messages.control.DataExtractionNotification
import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate
import org.session.libsession.messaging.messages.control.MessageRequestResponse
import org.session.libsession.messaging.messages.control.ReadReceipt
import org.session.libsession.messaging.messages.control.TypingIndicator
import org.session.libsession.messaging.messages.control.UnsendRequest
import org.session.libsession.messaging.messages.visible.Attachment import org.session.libsession.messaging.messages.visible.Attachment
import org.session.libsession.messaging.messages.visible.Reaction import org.session.libsession.messaging.messages.visible.Reaction
import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.messages.visible.VisibleMessage
@ -21,18 +29,26 @@ import org.session.libsession.messaging.utilities.SessionId
import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.messaging.utilities.WebRtcUtils import org.session.libsession.messaging.utilities.WebRtcUtils
import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.* import org.session.libsession.utilities.Address
import org.session.libsession.utilities.GroupRecord
import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.ProfileKeyUtil
import org.session.libsession.utilities.SSKEnvironment
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.crypto.ecc.DjbECPrivateKey import org.session.libsignal.crypto.ecc.DjbECPrivateKey
import org.session.libsignal.crypto.ecc.DjbECPublicKey import org.session.libsignal.crypto.ecc.DjbECPublicKey
import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.crypto.ecc.ECKeyPair
import org.session.libsignal.messages.SignalServiceGroup import org.session.libsignal.messages.SignalServiceGroup
import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.protos.SignalServiceProtos
import org.session.libsignal.utilities.*
import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.guava.Optional import org.session.libsignal.utilities.guava.Optional
import org.session.libsignal.utilities.removingIdPrefixIfNeeded
import org.session.libsignal.utilities.toHexString
import java.security.MessageDigest import java.security.MessageDigest
import java.util.* import java.util.LinkedList
import kotlin.math.min import kotlin.math.min
internal fun MessageReceiver.isBlocked(publicKey: String): Boolean { internal fun MessageReceiver.isBlocked(publicKey: String): Boolean {
@ -407,7 +423,7 @@ private fun MessageReceiver.handleClosedGroupControlMessage(message: ClosedGroup
private fun MessageReceiver.handleNewClosedGroup(message: ClosedGroupControlMessage) { private fun MessageReceiver.handleNewClosedGroup(message: ClosedGroupControlMessage) {
val kind = message.kind!! as? ClosedGroupControlMessage.Kind.New ?: return val kind = message.kind!! as? ClosedGroupControlMessage.Kind.New ?: return
val recipient = Recipient.from(MessagingModuleConfiguration.shared.context, Address.fromSerialized(message.sender!!), false) val recipient = Recipient.from(MessagingModuleConfiguration.shared.context, Address.fromSerialized(message.sender!!), false)
if (!recipient.isApproved) return if (!recipient.isApproved && !recipient.isLocalNumber) return
val groupPublicKey = kind.publicKey.toByteArray().toHexString() val groupPublicKey = kind.publicKey.toByteArray().toHexString()
val members = kind.members.map { it.toByteArray().toHexString() } val members = kind.members.map { it.toByteArray().toHexString() }
val admins = kind.admins.map { it.toByteArray().toHexString() } val admins = kind.admins.map { it.toByteArray().toHexString() }

View File

@ -59,7 +59,7 @@ class OpenGroupPoller(private val server: String, private val executorService: S
fun poll(isPostCapabilitiesRetry: Boolean = false): Promise<Unit, Exception> { fun poll(isPostCapabilitiesRetry: Boolean = false): Promise<Unit, Exception> {
val storage = MessagingModuleConfiguration.shared.storage val storage = MessagingModuleConfiguration.shared.storage
val rooms = storage.getAllOpenGroups().values.filter { it.server == server }.map { it.room } val rooms = storage.getAllOpenGroups().values.filter { it.server == server }.map { it.room }
rooms.forEach { downloadGroupAvatarIfNeeded(it) }
return OpenGroupApi.poll(rooms, server).successBackground { responses -> return OpenGroupApi.poll(rooms, server).successBackground { responses ->
responses.filterNot { it.body == null }.forEach { response -> responses.filterNot { it.body == null }.forEach { response ->
when (response.endpoint) { when (response.endpoint) {
@ -117,15 +117,17 @@ class OpenGroupPoller(private val server: String, private val executorService: S
) { ) {
val storage = MessagingModuleConfiguration.shared.storage val storage = MessagingModuleConfiguration.shared.storage
val groupId = "$server.$roomToken" val groupId = "$server.$roomToken"
val dbGroupId = GroupUtil.getEncodedOpenGroupID(groupId.toByteArray())
val existingOpenGroup = storage.getOpenGroup(roomToken, server) val existingOpenGroup = storage.getOpenGroup(roomToken, server)
val publicKey = existingOpenGroup?.publicKey ?: return val publicKey = existingOpenGroup?.publicKey ?: return
val openGroup = OpenGroup( val openGroup = OpenGroup(
server = server, server = server,
room = pollInfo.token, room = pollInfo.token,
name = pollInfo.details?.name ?: "", name = if (pollInfo.details != null) { pollInfo.details.name } else { existingOpenGroup.name },
infoUpdates = pollInfo.details?.infoUpdates ?: 0, infoUpdates = if (pollInfo.details != null) { pollInfo.details.infoUpdates } else { existingOpenGroup.infoUpdates },
publicKey = publicKey, publicKey = publicKey,
imageId = if (pollInfo.details != null) { pollInfo.details.imageId } else { existingOpenGroup.imageId }
) )
// - Open Group changes // - Open Group changes
storage.updateOpenGroup(openGroup) storage.updateOpenGroup(openGroup)
@ -155,6 +157,22 @@ class OpenGroupPoller(private val server: String, private val executorService: S
GroupMember(groupId, it, GroupMemberRole.HIDDEN_ADMIN) GroupMember(groupId, it, GroupMemberRole.HIDDEN_ADMIN)
}) })
} }
if (
(
pollInfo.details != null &&
pollInfo.details.imageId != null && (
pollInfo.details.imageId != existingOpenGroup.imageId ||
!storage.hasDownloadedProfilePicture(dbGroupId)
)
) || (
pollInfo.details == null &&
existingOpenGroup.imageId != null &&
!storage.hasDownloadedProfilePicture(dbGroupId)
)
) {
JobQueue.shared.add(GroupAvatarDownloadJob(roomToken, server))
}
} }
private fun handleMessages( private fun handleMessages(
@ -284,16 +302,4 @@ class OpenGroupPoller(private val server: String, private val executorService: S
JobQueue.shared.add(deleteJob) JobQueue.shared.add(deleteJob)
} }
} }
private fun downloadGroupAvatarIfNeeded(room: String) {
val storage = MessagingModuleConfiguration.shared.storage
if (storage.getGroupAvatarDownloadJob(server, room) != null) return
val groupId = GroupUtil.getEncodedOpenGroupID("$server.$room".toByteArray())
storage.getGroup(groupId)?.let {
if (System.currentTimeMillis() > it.updatedTimestamp + TimeUnit.DAYS.toMillis(7)) {
JobQueue.shared.add(GroupAvatarDownloadJob(room, server))
}
}
}
} }

View File

@ -78,8 +78,8 @@ object OnionRequestAPI {
// endregion // endregion
class HTTPRequestFailedBlindingRequiredException(statusCode: Int, json: Map<*, *>, destination: String): HTTPRequestFailedAtDestinationException(statusCode, json, destination) class HTTPRequestFailedBlindingRequiredException(statusCode: Int, json: Map<*, *>, destination: String): HTTPRequestFailedAtDestinationException(statusCode, json, destination)
open class HTTPRequestFailedAtDestinationException(val statusCode: Int, val json: Map<*, *>, val destination: String) open class HTTPRequestFailedAtDestinationException(statusCode: Int, json: Map<*, *>, val destination: String)
: Exception("HTTP request failed at destination ($destination) with status code $statusCode.") : HTTP.HTTPRequestFailedException(statusCode, json, "HTTP request failed at destination ($destination) with status code $statusCode.")
class InsufficientSnodesException : Exception("Couldn't find enough snodes to build a path.") class InsufficientSnodesException : Exception("Couldn't find enough snodes to build a path.")
private data class OnionBuildingResult( private data class OnionBuildingResult(

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