Merge remote-tracking branch 'upstream/dev' into libsession-integration

# Conflicts:
#	app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt
#	app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt
#	libsession/src/main/java/org/session/libsession/messaging/messages/visible/VisibleMessage.kt
#	libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt
This commit is contained in:
0x330a 2023-05-12 16:29:26 +10:00
commit 42dbd11255
No known key found for this signature in database
GPG Key ID: 267811D6E6A2698C
23 changed files with 304 additions and 126 deletions

View File

@ -7,6 +7,10 @@ import android.os.Build
import android.os.Handler import android.os.Handler
import android.provider.MediaStore import android.provider.MediaStore
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.jobmanager.impl.JsonDataSerializer
private const val TAG = "ScreenshotObserver"
class ScreenshotObserver(private val context: Context, handler: Handler, private val screenshotTriggered: ()->Unit): ContentObserver(handler) { class ScreenshotObserver(private val context: Context, handler: Handler, private val screenshotTriggered: ()->Unit): ContentObserver(handler) {
@ -31,22 +35,26 @@ class ScreenshotObserver(private val context: Context, handler: Handler, private
val projection = arrayOf( val projection = arrayOf(
MediaStore.Images.Media.DATA MediaStore.Images.Media.DATA
) )
context.contentResolver.query( try {
uri, context.contentResolver.query(
projection, uri,
null, projection,
null, null,
null null,
)?.use { cursor -> null
val dataColumn = cursor.getColumnIndex(MediaStore.Images.Media.DATA) )?.use { cursor ->
while (cursor.moveToNext()) { val dataColumn = cursor.getColumnIndex(MediaStore.Images.Media.DATA)
val path = cursor.getString(dataColumn) while (cursor.moveToNext()) {
if (path.contains("screenshot", true)) { val path = cursor.getString(dataColumn)
if (cache.add(uri.hashCode())) { if (path.contains("screenshot", true)) {
screenshotTriggered() if (cache.add(uri.hashCode())) {
screenshotTriggered()
}
} }
} }
} }
} catch (e: SecurityException) {
Log.e(TAG, e)
} }
} }
@ -56,28 +64,32 @@ class ScreenshotObserver(private val context: Context, handler: Handler, private
MediaStore.Images.Media.DISPLAY_NAME, MediaStore.Images.Media.DISPLAY_NAME,
MediaStore.Images.Media.RELATIVE_PATH MediaStore.Images.Media.RELATIVE_PATH
) )
context.contentResolver.query(
uri, try {
projection, context.contentResolver.query(
null, uri,
null, projection,
null null,
)?.use { cursor -> null,
val relativePathColumn = null
cursor.getColumnIndex(MediaStore.Images.Media.RELATIVE_PATH) )?.use { cursor ->
val displayNameColumn = val relativePathColumn =
cursor.getColumnIndex(MediaStore.Images.Media.DISPLAY_NAME) cursor.getColumnIndex(MediaStore.Images.Media.RELATIVE_PATH)
while (cursor.moveToNext()) { val displayNameColumn =
val name = cursor.getString(displayNameColumn) cursor.getColumnIndex(MediaStore.Images.Media.DISPLAY_NAME)
val relativePath = cursor.getString(relativePathColumn) while (cursor.moveToNext()) {
if (name.contains("screenshot", true) or val name = cursor.getString(displayNameColumn)
relativePath.contains("screenshot", true)) { val relativePath = cursor.getString(relativePathColumn)
if (cache.add(uri.hashCode())) { if (name.contains("screenshot", true) or
screenshotTriggered() relativePath.contains("screenshot", true)) {
if (cache.add(uri.hashCode())) {
screenshotTriggered()
}
} }
} }
} }
} catch (e: IllegalStateException) {
Log.e(TAG, e)
} }
} }
} }

View File

@ -278,10 +278,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private val bufferedLastSeenChannel = Channel<Long>(capacity = 512, onBufferOverflow = BufferOverflow.DROP_OLDEST) private val bufferedLastSeenChannel = Channel<Long>(capacity = 512, onBufferOverflow = BufferOverflow.DROP_OLDEST)
private val isScrolledToBottom: Boolean private val isScrolledToBottom: Boolean
get() { get() = binding?.conversationRecyclerView?.isScrolledToBottom ?: true
val position = layoutManager?.findFirstCompletelyVisibleItemPosition() ?: 0
return position == 0
}
private val layoutManager: LinearLayoutManager? private val layoutManager: LinearLayoutManager?
get() { return binding?.conversationRecyclerView?.layoutManager as LinearLayoutManager? } get() { return binding?.conversationRecyclerView?.layoutManager as LinearLayoutManager? }
@ -1233,12 +1230,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
}) })
val contentBounds = Rect() val topLeft = intArrayOf(0, 0).also { visibleMessageView.messageContentView.getLocationInWindow(it) }
visibleMessageView.messageContentView.getGlobalVisibleRect(contentBounds)
val selectedConversationModel = SelectedConversationModel( val selectedConversationModel = SelectedConversationModel(
messageContentBitmap, messageContentBitmap,
contentBounds.left.toFloat(), topLeft[0].toFloat(),
contentBounds.top.toFloat(), topLeft[1].toFloat(),
visibleMessageView.messageContentView.width, visibleMessageView.messageContentView.width,
message.isOutgoing, message.isOutgoing,
visibleMessageView.messageContentView visibleMessageView.messageContentView
@ -1879,6 +1875,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
endActionMode() endActionMode()
} }
override fun resyncMessage(messages: Set<MessageRecord>) {
messages.iterator().forEach { messageRecord ->
ResendMessageUtilities.resend(this, messageRecord, viewModel.blindedPublicKey, isResync = true)
}
endActionMode()
}
override fun resendMessage(messages: Set<MessageRecord>) { override fun resendMessage(messages: Set<MessageRecord>) {
messages.iterator().forEach { messageRecord -> messages.iterator().forEach { messageRecord ->
ResendMessageUtilities.resend(this, messageRecord, viewModel.blindedPublicKey) ResendMessageUtilities.resend(this, messageRecord, viewModel.blindedPublicKey)
@ -2039,6 +2042,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
val selectedItems = setOf(message) val selectedItems = setOf(message)
when (action) { when (action) {
ConversationReactionOverlay.Action.REPLY -> reply(selectedItems) ConversationReactionOverlay.Action.REPLY -> reply(selectedItems)
ConversationReactionOverlay.Action.RESYNC -> resyncMessage(selectedItems)
ConversationReactionOverlay.Action.RESEND -> resendMessage(selectedItems) ConversationReactionOverlay.Action.RESEND -> resendMessage(selectedItems)
ConversationReactionOverlay.Action.DOWNLOAD -> saveAttachment(selectedItems) ConversationReactionOverlay.Action.DOWNLOAD -> saveAttachment(selectedItems)
ConversationReactionOverlay.Action.COPY_MESSAGE -> copyMessages(selectedItems) ConversationReactionOverlay.Action.COPY_MESSAGE -> copyMessages(selectedItems)

View File

@ -660,7 +660,8 @@ public final class ConversationReactionOverlay extends FrameLayout {
items.add(new ActionItem(R.attr.menu_select_icon, getContext().getResources().getString(R.string.conversation_context__menu_select), () -> handleActionItemClicked(Action.SELECT), items.add(new ActionItem(R.attr.menu_select_icon, getContext().getResources().getString(R.string.conversation_context__menu_select), () -> handleActionItemClicked(Action.SELECT),
getContext().getResources().getString(R.string.AccessibilityId_select))); getContext().getResources().getString(R.string.AccessibilityId_select)));
// Reply // Reply
if (!message.isPending() && !message.isFailed()) { boolean canWrite = openGroup == null || openGroup.getCanWrite();
if (canWrite && !message.isPending() && !message.isFailed()) {
items.add( items.add(
new ActionItem(R.attr.menu_reply_icon, getContext().getResources().getString(R.string.conversation_context__menu_reply), () -> handleActionItemClicked(Action.REPLY), new ActionItem(R.attr.menu_reply_icon, getContext().getResources().getString(R.string.conversation_context__menu_reply), () -> handleActionItemClicked(Action.REPLY),
getContext().getResources().getString(R.string.AccessibilityId_reply_message)) getContext().getResources().getString(R.string.AccessibilityId_reply_message))
@ -700,6 +701,10 @@ public final class ConversationReactionOverlay extends FrameLayout {
if (message.isFailed()) { if (message.isFailed()) {
items.add(new ActionItem(R.attr.menu_reply_icon, getContext().getResources().getString(R.string.conversation_context__menu_resend_message), () -> handleActionItemClicked(Action.RESEND))); items.add(new ActionItem(R.attr.menu_reply_icon, getContext().getResources().getString(R.string.conversation_context__menu_resend_message), () -> handleActionItemClicked(Action.RESEND)));
} }
// Resync
if (message.isSyncFailed()) {
items.add(new ActionItem(R.attr.menu_reply_icon, getContext().getResources().getString(R.string.conversation_context__menu_resync_message), () -> handleActionItemClicked(Action.RESYNC)));
}
// Save media // Save media
if (message.isMms() && ((MediaMmsMessageRecord)message).containsMediaSlide()) { if (message.isMms() && ((MediaMmsMessageRecord)message).containsMediaSlide()) {
items.add(new ActionItem(R.attr.menu_save_icon, getContext().getResources().getString(R.string.conversation_context_image__save_attachment), () -> handleActionItemClicked(Action.DOWNLOAD), items.add(new ActionItem(R.attr.menu_save_icon, getContext().getResources().getString(R.string.conversation_context_image__save_attachment), () -> handleActionItemClicked(Action.DOWNLOAD),
@ -885,6 +890,7 @@ public final class ConversationReactionOverlay extends FrameLayout {
public enum Action { public enum Action {
REPLY, REPLY,
RESEND, RESEND,
RESYNC,
DOWNLOAD, DOWNLOAD,
COPY_MESSAGE, COPY_MESSAGE,
COPY_SESSION_ID, COPY_SESSION_ID,

View File

@ -70,6 +70,8 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p
menu.findItem(R.id.menu_message_details).isVisible = (selectedItems.size == 1 && firstMessage.isOutgoing) menu.findItem(R.id.menu_message_details).isVisible = (selectedItems.size == 1 && firstMessage.isOutgoing)
// Resend // Resend
menu.findItem(R.id.menu_context_resend).isVisible = (selectedItems.size == 1 && firstMessage.isFailed) menu.findItem(R.id.menu_context_resend).isVisible = (selectedItems.size == 1 && firstMessage.isFailed)
// Resync
menu.findItem(R.id.menu_context_resync).isVisible = (selectedItems.size == 1 && firstMessage.isSyncFailed)
// Save media // Save media
menu.findItem(R.id.menu_context_save_attachment).isVisible = (selectedItems.size == 1 menu.findItem(R.id.menu_context_save_attachment).isVisible = (selectedItems.size == 1
&& firstMessage.isMms && (firstMessage as MediaMmsMessageRecord).containsMediaSlide()) && firstMessage.isMms && (firstMessage as MediaMmsMessageRecord).containsMediaSlide())
@ -90,6 +92,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p
R.id.menu_context_ban_and_delete_all -> delegate?.banAndDeleteAll(selectedItems) R.id.menu_context_ban_and_delete_all -> delegate?.banAndDeleteAll(selectedItems)
R.id.menu_context_copy -> delegate?.copyMessages(selectedItems) R.id.menu_context_copy -> delegate?.copyMessages(selectedItems)
R.id.menu_context_copy_public_key -> delegate?.copySessionID(selectedItems) R.id.menu_context_copy_public_key -> delegate?.copySessionID(selectedItems)
R.id.menu_context_resync -> delegate?.resyncMessage(selectedItems)
R.id.menu_context_resend -> delegate?.resendMessage(selectedItems) R.id.menu_context_resend -> delegate?.resendMessage(selectedItems)
R.id.menu_message_details -> delegate?.showMessageDetail(selectedItems) R.id.menu_message_details -> delegate?.showMessageDetail(selectedItems)
R.id.menu_context_save_attachment -> delegate?.saveAttachment(selectedItems) R.id.menu_context_save_attachment -> delegate?.saveAttachment(selectedItems)
@ -113,6 +116,7 @@ interface ConversationActionModeCallbackDelegate {
fun banAndDeleteAll(messages: Set<MessageRecord>) fun banAndDeleteAll(messages: Set<MessageRecord>)
fun copyMessages(messages: Set<MessageRecord>) fun copyMessages(messages: Set<MessageRecord>)
fun copySessionID(messages: Set<MessageRecord>) fun copySessionID(messages: Set<MessageRecord>)
fun resyncMessage(messages: Set<MessageRecord>)
fun resendMessage(messages: Set<MessageRecord>) fun resendMessage(messages: Set<MessageRecord>)
fun showMessageDetail(messages: Set<MessageRecord>) fun showMessageDetail(messages: Set<MessageRecord>)
fun saveAttachment(messages: Set<MessageRecord>) fun saveAttachment(messages: Set<MessageRecord>)

View File

@ -292,39 +292,46 @@ class VisibleMessageView : LinearLayout {
@StringRes val messageText: Int?, @StringRes val messageText: Int?,
val contentDescription: String?) val contentDescription: String?)
private fun getMessageStatusImage(message: MessageRecord): MessageStatusInfo { private fun getMessageStatusImage(message: MessageRecord): MessageStatusInfo = when {
return when { message.isFailed ->
!message.isOutgoing -> MessageStatusInfo(null, MessageStatusInfo(
null, R.drawable.ic_delivery_status_failed,
null, resources.getColor(R.color.destructive, context.theme),
null) R.string.delivery_status_failed,
message.isFailed -> null
MessageStatusInfo( )
R.drawable.ic_delivery_status_failed, message.isSyncFailed ->
resources.getColor(R.color.destructive, context.theme), MessageStatusInfo(
R.string.delivery_status_failed, R.drawable.ic_delivery_status_failed,
null context.getColor(R.color.accent_orange),
) R.string.delivery_status_sync_failed,
message.isPending -> null
MessageStatusInfo( )
R.drawable.ic_delivery_status_sending, message.isPending ->
context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_sending, MessageStatusInfo(
context.getString(R.string.AccessibilityId_message_sent_status_pending) R.drawable.ic_delivery_status_sending,
) context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_sending,
message.isRead -> context.getString(R.string.AccessibilityId_message_sent_status_pending)
MessageStatusInfo( )
R.drawable.ic_delivery_status_read, message.isResyncing ->
context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_read, MessageStatusInfo(
null R.drawable.ic_delivery_status_sending,
) context.getColor(R.color.accent_orange), R.string.delivery_status_syncing,
else -> context.getString(R.string.AccessibilityId_message_sent_status_syncing)
MessageStatusInfo( )
R.drawable.ic_delivery_status_sent, message.isRead ->
context.getColorFromAttr(R.attr.message_status_color), MessageStatusInfo(
R.string.delivery_status_sent, R.drawable.ic_delivery_status_read,
context.getString(R.string.AccessibilityId_message_sent_status_tick) context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_read,
) null
} )
else ->
MessageStatusInfo(
R.drawable.ic_delivery_status_sent,
context.getColorFromAttr(R.attr.message_status_color),
R.string.delivery_status_sent,
context.getString(R.string.AccessibilityId_message_sent_status_tick)
)
} }
private fun updateExpirationTimer(message: MessageRecord) { private fun updateExpirationTimer(message: MessageRecord) {

View File

@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.conversation.v2.utilities
import android.content.Context import android.content.Context
import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.messages.Destination
import org.session.libsession.messaging.messages.visible.LinkPreview import org.session.libsession.messaging.messages.visible.LinkPreview
import org.session.libsession.messaging.messages.visible.OpenGroupInvitation import org.session.libsession.messaging.messages.visible.OpenGroupInvitation
import org.session.libsession.messaging.messages.visible.Quote import org.session.libsession.messaging.messages.visible.Quote
@ -15,7 +16,7 @@ import org.thoughtcrime.securesms.database.model.MmsMessageRecord
object ResendMessageUtilities { object ResendMessageUtilities {
fun resend(context: Context, messageRecord: MessageRecord, userBlindedKey: String?) { fun resend(context: Context, messageRecord: MessageRecord, userBlindedKey: String?, isResync: Boolean = false) {
val recipient: Recipient = messageRecord.recipient val recipient: Recipient = messageRecord.recipient
val message = VisibleMessage() val message = VisibleMessage()
message.id = messageRecord.getId() message.id = messageRecord.getId()
@ -55,8 +56,13 @@ object ResendMessageUtilities {
val sentTimestamp = message.sentTimestamp val sentTimestamp = message.sentTimestamp
val sender = MessagingModuleConfiguration.shared.storage.getUserPublicKey() val sender = MessagingModuleConfiguration.shared.storage.getUserPublicKey()
if (sentTimestamp != null && sender != null) { if (sentTimestamp != null && sender != null) {
MessagingModuleConfiguration.shared.storage.markAsSending(sentTimestamp, sender) if (isResync) {
MessagingModuleConfiguration.shared.storage.markAsResyncing(sentTimestamp, sender)
MessageSender.send(message, Destination.from(recipient.address), isSyncMessage = true)
} else {
MessagingModuleConfiguration.shared.storage.markAsSending(sentTimestamp, sender)
MessageSender.send(message, recipient.address)
}
} }
MessageSender.send(message, recipient.address)
} }
} }

View File

@ -38,13 +38,12 @@ object TextUtilities {
fun TextView.getIntersectedModalSpans(hitRect: Rect): List<ModalURLSpan> { fun TextView.getIntersectedModalSpans(hitRect: Rect): List<ModalURLSpan> {
val textLayout = layout ?: return emptyList() val textLayout = layout ?: return emptyList()
val lineRect = Rect() val lineRect = Rect()
val bodyTextRect = Rect() val offset = intArrayOf(0, 0).also { getLocationOnScreen(it) }
getGlobalVisibleRect(bodyTextRect)
val textSpannable = text.toSpannable() val textSpannable = text.toSpannable()
return (0 until textLayout.lineCount).flatMap { line -> return (0 until textLayout.lineCount).flatMap { line ->
textLayout.getLineBounds(line, lineRect) textLayout.getLineBounds(line, lineRect)
lineRect.offset(bodyTextRect.left + totalPaddingLeft, bodyTextRect.top + totalPaddingTop) lineRect.offset(offset[0] + totalPaddingLeft, offset[1] + totalPaddingTop)
if ((Rect(lineRect)).contains(hitRect)) { if (lineRect.contains(hitRect)) {
// calculate the url span intersected with (if any) // calculate the url span intersected with (if any)
val off = textLayout.getOffsetForHorizontal(line, hitRect.left.toFloat()) // left and right will be the same val off = textLayout.getOffsetForHorizontal(line, hitRect.left.toFloat()) // left and right will be the same
textSpannable.getSpans<ModalURLSpan>(off, off).toList() textSpannable.getSpans<ModalURLSpan>(off, off).toList()

View File

@ -45,44 +45,52 @@ public final class KeyStoreHelper {
private static final String ANDROID_KEY_STORE = "AndroidKeyStore"; private static final String ANDROID_KEY_STORE = "AndroidKeyStore";
private static final String KEY_ALIAS = "SignalSecret"; private static final String KEY_ALIAS = "SignalSecret";
@RequiresApi(Build.VERSION_CODES.M) private static final Object lock = new Object();
public static SealedData seal(@NonNull byte[] input) { public static SealedData seal(@NonNull byte[] input) {
SecretKey secretKey = getOrCreateKeyStoreEntry(); SecretKey secretKey = getOrCreateKeyStoreEntry();
try { try {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); // Cipher operations are not thread-safe so we synchronize over them through doFinal to
cipher.init(Cipher.ENCRYPT_MODE, secretKey); // prevent crashes with quickly repeated encrypt/decrypt operations
// https://github.com/mozilla-mobile/android-components/issues/5342
synchronized (lock) {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, secretKey);
byte[] iv = cipher.getIV(); byte[] iv = cipher.getIV();
byte[] data = cipher.doFinal(input); byte[] data = cipher.doFinal(input);
return new SealedData(iv, data); return new SealedData(iv, data);
}
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) { } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) {
throw new AssertionError(e); throw new AssertionError(e);
} }
} }
@RequiresApi(Build.VERSION_CODES.M)
public static byte[] unseal(@NonNull SealedData sealedData) { public static byte[] unseal(@NonNull SealedData sealedData) {
SecretKey secretKey = getKeyStoreEntry(); SecretKey secretKey = getKeyStoreEntry();
try { try {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); // Cipher operations are not thread-safe so we synchronize over them through doFinal to
cipher.init(Cipher.DECRYPT_MODE, secretKey, new GCMParameterSpec(128, sealedData.iv)); // prevent crashes with quickly repeated encrypt/decrypt operations
// https://github.com/mozilla-mobile/android-components/issues/5342
synchronized (lock) {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, secretKey, new GCMParameterSpec(128, sealedData.iv));
return cipher.doFinal(sealedData.data); return cipher.doFinal(sealedData.data);
}
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) { } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) {
throw new AssertionError(e); throw new AssertionError(e);
} }
} }
@RequiresApi(Build.VERSION_CODES.M)
private static SecretKey getOrCreateKeyStoreEntry() { private static SecretKey getOrCreateKeyStoreEntry() {
if (hasKeyStoreEntry()) return getKeyStoreEntry(); if (hasKeyStoreEntry()) return getKeyStoreEntry();
else return createKeyStoreEntry(); else return createKeyStoreEntry();
} }
@RequiresApi(Build.VERSION_CODES.M)
private static SecretKey createKeyStoreEntry() { private static SecretKey createKeyStoreEntry() {
try { try {
KeyGenerator keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEY_STORE); KeyGenerator keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEY_STORE);
@ -99,7 +107,6 @@ public final class KeyStoreHelper {
} }
} }
@RequiresApi(Build.VERSION_CODES.M)
private static SecretKey getKeyStoreEntry() { private static SecretKey getKeyStoreEntry() {
KeyStore keyStore = getKeyStore(); KeyStore keyStore = getKeyStore();
@ -137,7 +144,6 @@ public final class KeyStoreHelper {
} }
} }
@RequiresApi(Build.VERSION_CODES.M)
private static boolean hasKeyStoreEntry() { private static boolean hasKeyStoreEntry() {
try { try {
KeyStore ks = KeyStore.getInstance(ANDROID_KEY_STORE); KeyStore ks = KeyStore.getInstance(ANDROID_KEY_STORE);

View File

@ -37,6 +37,13 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
public abstract void markExpireStarted(long messageId, long startTime); public abstract void markExpireStarted(long messageId, long startTime);
public abstract void markAsSent(long messageId, boolean secure); public abstract void markAsSent(long messageId, boolean secure);
public abstract void markAsSyncing(long id);
public abstract void markAsResyncing(long id);
public abstract void markAsSyncFailed(long id);
public abstract void markUnidentified(long messageId, boolean unidentified); public abstract void markUnidentified(long messageId, boolean unidentified);
public abstract void markAsDeleted(long messageId, boolean read, boolean hasMention); public abstract void markAsDeleted(long messageId, boolean read, boolean hasMention);
@ -199,7 +206,6 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
contentValues.put(THREAD_ID, newThreadId); contentValues.put(THREAD_ID, newThreadId);
db.update(getTableName(), contentValues, where, args); db.update(getTableName(), contentValues, where, args);
} }
public static class SyncMessageId { public static class SyncMessageId {
private final Address address; private final Address address;

View File

@ -252,6 +252,16 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
notifyConversationListeners(threadId) notifyConversationListeners(threadId)
} }
override fun markAsSyncing(messageId: Long) {
markAs(messageId, MmsSmsColumns.Types.BASE_SYNCING_TYPE)
}
override fun markAsResyncing(messageId: Long) {
markAs(messageId, MmsSmsColumns.Types.BASE_RESYNCING_TYPE)
}
override fun markAsSyncFailed(messageId: Long) {
markAs(messageId, MmsSmsColumns.Types.BASE_SYNC_FAILED_TYPE)
}
fun markAsSending(messageId: Long) { fun markAsSending(messageId: Long) {
markAs(messageId, MmsSmsColumns.Types.BASE_SENDING_TYPE) markAs(messageId, MmsSmsColumns.Types.BASE_SENDING_TYPE)
} }

View File

@ -47,8 +47,13 @@ public interface MmsSmsColumns {
protected static final long BASE_PENDING_INSECURE_SMS_FALLBACK = 26; protected static final long BASE_PENDING_INSECURE_SMS_FALLBACK = 26;
public static final long BASE_DRAFT_TYPE = 27; public static final long BASE_DRAFT_TYPE = 27;
protected static final long BASE_DELETED_TYPE = 28; protected static final long BASE_DELETED_TYPE = 28;
protected static final long BASE_SYNCING_TYPE = 29;
protected static final long BASE_RESYNCING_TYPE = 30;
protected static final long BASE_SYNC_FAILED_TYPE = 31;
protected static final long[] OUTGOING_MESSAGE_TYPES = {BASE_OUTBOX_TYPE, BASE_SENT_TYPE, protected static final long[] OUTGOING_MESSAGE_TYPES = {BASE_OUTBOX_TYPE, BASE_SENT_TYPE,
BASE_SYNCING_TYPE, BASE_RESYNCING_TYPE,
BASE_SYNC_FAILED_TYPE,
BASE_SENDING_TYPE, BASE_SENT_FAILED_TYPE, BASE_SENDING_TYPE, BASE_SENT_FAILED_TYPE,
BASE_PENDING_SECURE_SMS_FALLBACK, BASE_PENDING_SECURE_SMS_FALLBACK,
BASE_PENDING_INSECURE_SMS_FALLBACK, BASE_PENDING_INSECURE_SMS_FALLBACK,
@ -109,6 +114,18 @@ public interface MmsSmsColumns {
return (type & BASE_TYPE_MASK) == BASE_DRAFT_TYPE; return (type & BASE_TYPE_MASK) == BASE_DRAFT_TYPE;
} }
public static boolean isResyncingType(long type) {
return (type & BASE_TYPE_MASK) == BASE_RESYNCING_TYPE;
}
public static boolean isSyncingType(long type) {
return (type & BASE_TYPE_MASK) == BASE_SYNCING_TYPE;
}
public static boolean isSyncFailedMessageType(long type) {
return (type & BASE_TYPE_MASK) == BASE_SYNC_FAILED_TYPE;
}
public static boolean isFailedMessageType(long type) { public static boolean isFailedMessageType(long type) {
return (type & BASE_TYPE_MASK) == BASE_SENT_FAILED_TYPE; return (type & BASE_TYPE_MASK) == BASE_SENT_FAILED_TYPE;
} }

View File

@ -202,6 +202,21 @@ public class SmsDatabase extends MessagingDatabase {
updateTypeBitmask(id, Types.BASE_TYPE_MASK, Types.BASE_SENDING_TYPE); updateTypeBitmask(id, Types.BASE_TYPE_MASK, Types.BASE_SENDING_TYPE);
} }
@Override
public void markAsSyncing(long id) {
updateTypeBitmask(id, Types.BASE_TYPE_MASK, Types.BASE_SYNCING_TYPE);
}
@Override
public void markAsResyncing(long id) {
updateTypeBitmask(id, Types.BASE_TYPE_MASK, Types.BASE_RESYNCING_TYPE);
}
@Override
public void markAsSyncFailed(long id) {
updateTypeBitmask(id, Types.BASE_TYPE_MASK, Types.BASE_SYNC_FAILED_TYPE);
}
@Override @Override
public void markUnidentified(long id, boolean unidentified) { public void markUnidentified(long id, boolean unidentified) {
ContentValues contentValues = new ContentValues(1); ContentValues contentValues = new ContentValues(1);

View File

@ -714,6 +714,22 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
} }
} }
override fun markAsSyncing(timestamp: Long, author: String) {
DatabaseComponent.get(context).mmsSmsDatabase()
.getMessageFor(timestamp, author)
?.run { getMmsDatabaseElseSms(isMms).markAsSyncing(id) }
}
private fun getMmsDatabaseElseSms(isMms: Boolean) =
if (isMms) DatabaseComponent.get(context).mmsDatabase()
else DatabaseComponent.get(context).smsDatabase()
override fun markAsResyncing(timestamp: Long, author: String) {
DatabaseComponent.get(context).mmsSmsDatabase()
.getMessageFor(timestamp, author)
?.run { getMmsDatabaseElseSms(isMms).markAsResyncing(id) }
}
override fun markAsSending(timestamp: Long, author: String) { override fun markAsSending(timestamp: Long, author: String) {
val database = DatabaseComponent.get(context).mmsSmsDatabase() val database = DatabaseComponent.get(context).mmsSmsDatabase()
val messageRecord = database.getMessageFor(timestamp, author) ?: return val messageRecord = database.getMessageFor(timestamp, author) ?: return
@ -739,7 +755,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
} }
} }
override fun setErrorMessage(timestamp: Long, author: String, error: Exception) { override fun markAsSentFailed(timestamp: Long, author: String, error: Exception) {
val database = DatabaseComponent.get(context).mmsSmsDatabase() val database = DatabaseComponent.get(context).mmsSmsDatabase()
val messageRecord = database.getMessageFor(timestamp, author) ?: return val messageRecord = database.getMessageFor(timestamp, author) ?: return
if (messageRecord.isMms) { if (messageRecord.isMms) {
@ -762,6 +778,26 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
} }
} }
override fun markAsSyncFailed(timestamp: Long, author: String, error: Exception) {
val database = DatabaseComponent.get(context).mmsSmsDatabase()
val messageRecord = database.getMessageFor(timestamp, author) ?: return
database.getMessageFor(timestamp, author)
?.run { getMmsDatabaseElseSms(isMms).markAsSyncFailed(id) }
if (error.localizedMessage != null) {
val message: String
if (error is OnionRequestAPI.HTTPRequestFailedAtDestinationException && error.statusCode == 429) {
message = "429: Rate limited."
} else {
message = error.localizedMessage!!
}
DatabaseComponent.get(context).lokiMessageDatabase().setErrorMessage(messageRecord.getId(), message)
} else {
DatabaseComponent.get(context).lokiMessageDatabase().setErrorMessage(messageRecord.getId(), error.javaClass.simpleName)
}
}
override fun clearErrorMessage(messageID: Long) { override fun clearErrorMessage(messageID: Long) {
val db = DatabaseComponent.get(context).lokiMessageDatabase() val db = DatabaseComponent.get(context).lokiMessageDatabase()
db.clearErrorMessage(messageID) db.clearErrorMessage(messageID)
@ -1538,5 +1574,4 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
val recipientDb = DatabaseComponent.get(context).recipientDatabase() val recipientDb = DatabaseComponent.get(context).recipientDatabase()
return recipientDb.blockedContacts return recipientDb.blockedContacts
} }
} }

View File

@ -80,6 +80,18 @@ public abstract class DisplayRecord {
return !isFailed() && !isPending(); return !isFailed() && !isPending();
} }
public boolean isSyncing() {
return MmsSmsColumns.Types.isSyncingType(type);
}
public boolean isResyncing() {
return MmsSmsColumns.Types.isResyncingType(type);
}
public boolean isSyncFailed() {
return MmsSmsColumns.Types.isSyncFailedMessageType(type);
}
public boolean isFailed() { public boolean isFailed() {
return MmsSmsColumns.Types.isFailedMessageType(type) return MmsSmsColumns.Types.isFailedMessageType(type)
|| MmsSmsColumns.Types.isPendingSecureSmsFallbackType(type) || MmsSmsColumns.Types.isPendingSecureSmsFallbackType(type)

View File

@ -5,14 +5,16 @@ import android.animation.AnimatorListenerAdapter
import android.animation.FloatEvaluator import android.animation.FloatEvaluator
import android.animation.ValueAnimator import android.animation.ValueAnimator
import android.content.Context import android.content.Context
import android.graphics.Bitmap
import android.graphics.PointF import android.graphics.PointF
import android.graphics.Rect import android.graphics.Rect
import android.view.View import android.view.View
import android.view.inputmethod.InputMethodManager
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.annotation.DimenRes import androidx.annotation.DimenRes
import network.loki.messenger.R import network.loki.messenger.R
import org.session.libsession.utilities.getColorFromAttr import org.session.libsession.utilities.getColorFromAttr
import android.view.inputmethod.InputMethodManager
import androidx.core.graphics.applyCanvas
fun View.contains(point: PointF): Boolean { fun View.contains(point: PointF): Boolean {
return hitRect.contains(point.x.toInt(), point.y.toInt()) return hitRect.contains(point.x.toInt(), point.y.toInt())
@ -65,3 +67,9 @@ fun View.hideKeyboard() {
val imm = this.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager val imm = this.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(this.windowToken, 0) imm.hideSoftInputFromWindow(this.windowToken, 0)
} }
fun View.drawToBitmap(config: Bitmap.Config = Bitmap.Config.ARGB_8888): Bitmap =
Bitmap.createBitmap(width, height, config).applyCanvas {
translate(-scrollX.toFloat(), -scrollY.toFloat())
draw(this)
}

View File

@ -138,6 +138,7 @@
android:layout_height="50dp" android:layout_height="50dp"
android:layout_alignParentEnd="true" android:layout_alignParentEnd="true"
android:layout_above="@+id/messageRequestBar" android:layout_above="@+id/messageRequestBar"
android:layout_alignWithParentIfMissing="true"
android:layout_marginEnd="12dp" android:layout_marginEnd="12dp"
android:layout_marginBottom="32dp"> android:layout_marginBottom="32dp">

View File

@ -32,6 +32,11 @@
android:icon="?menu_copy_icon" android:icon="?menu_copy_icon"
app:showAsAction="always" /> app:showAsAction="always" />
<item
android:title="@string/conversation_context__menu_resync_message"
android:id="@+id/menu_context_resync"
app:showAsAction="never" />
<item <item
android:title="@string/conversation_context__menu_resend_message" android:title="@string/conversation_context__menu_resend_message"
android:id="@+id/menu_context_resend" android:id="@+id/menu_context_resend"

View File

@ -112,6 +112,7 @@
<!-- Conversation View--> <!-- Conversation View-->
<string name="AccessibilityId_message_sent_status_tick">Message sent status: Sent</string> <string name="AccessibilityId_message_sent_status_tick">Message sent status: Sent</string>
<string name="AccessibilityId_message_sent_status_pending">Message sent status pending</string> <string name="AccessibilityId_message_sent_status_pending">Message sent status pending</string>
<string name="AccessibilityId_message_sent_status_syncing">Message sent status syncing</string>
<string name="AccessibilityId_message_request_config_message">Message request has been accepted</string> <string name="AccessibilityId_message_request_config_message">Message request has been accepted</string>
<string name="AccessibilityId_message_body">Message Body</string> <string name="AccessibilityId_message_body">Message Body</string>
<string name="AccessibilityId_voice_message">Voice message</string> <string name="AccessibilityId_voice_message">Voice message</string>
@ -627,6 +628,7 @@
<string name="conversation_context__menu_delete_message">Delete message</string> <string name="conversation_context__menu_delete_message">Delete message</string>
<string name="conversation_context__menu_ban_user">Ban user</string> <string name="conversation_context__menu_ban_user">Ban user</string>
<string name="conversation_context__menu_ban_and_delete_all">Ban and delete all</string> <string name="conversation_context__menu_ban_and_delete_all">Ban and delete all</string>
<string name="conversation_context__menu_resync_message">Resync message</string>
<string name="conversation_context__menu_resend_message">Resend message</string> <string name="conversation_context__menu_resend_message">Resend message</string>
<string name="conversation_context__menu_reply">Reply</string> <string name="conversation_context__menu_reply">Reply</string>
<string name="conversation_context__menu_reply_to_message">Reply to message</string> <string name="conversation_context__menu_reply_to_message">Reply to message</string>
@ -1007,9 +1009,11 @@
<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">Database Upgrade Failed</string>
<string name="ErrorNotifier_migration_downgrade">Please contact support to report the error.</string> <string name="ErrorNotifier_migration_downgrade">Please contact support to report the error.</string>
<string name="delivery_status_syncing">Syncing</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>
<string name="delivery_status_sync_failed">Failed to sync</string>
<string name="delivery_status_failed">Failed to send</string> <string name="delivery_status_failed">Failed to send</string>
<string name="giphy_permission_title">Search GIFs?</string> <string name="giphy_permission_title">Search GIFs?</string>
<string name="giphy_permission_message">Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.</string> <string name="giphy_permission_message">Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.</string>

View File

@ -112,10 +112,13 @@ interface StorageProtocol {
fun getAttachmentsForMessage(messageID: Long): List<DatabaseAttachment> fun getAttachmentsForMessage(messageID: Long): List<DatabaseAttachment>
fun getMessageIdInDatabase(timestamp: Long, author: String): Long? // TODO: This is a weird name fun getMessageIdInDatabase(timestamp: Long, author: String): Long? // TODO: This is a weird name
fun updateSentTimestamp(messageID: Long, isMms: Boolean, openGroupSentTimestamp: Long, threadId: Long) fun updateSentTimestamp(messageID: Long, isMms: Boolean, openGroupSentTimestamp: Long, threadId: Long)
fun markAsResyncing(timestamp: Long, author: String)
fun markAsSyncing(timestamp: Long, author: String)
fun markAsSending(timestamp: Long, author: String) fun markAsSending(timestamp: Long, author: String)
fun markAsSent(timestamp: Long, author: String) fun markAsSent(timestamp: Long, author: String)
fun markUnidentified(timestamp: Long, author: String) fun markUnidentified(timestamp: Long, author: String)
fun setErrorMessage(timestamp: Long, author: String, error: Exception) fun markAsSyncFailed(timestamp: Long, author: String, error: Exception)
fun markAsSentFailed(timestamp: Long, author: String, error: Exception)
fun clearErrorMessage(messageID: Long) fun clearErrorMessage(messageID: Long)
fun setMessageServerHash(messageID: Long, serverHash: String) fun setMessageServerHash(messageID: Long, serverHash: String)

View File

@ -7,13 +7,13 @@ import org.session.libsignal.utilities.toHexString
sealed class Destination { sealed class Destination {
class Contact(var publicKey: String) : Destination() { data class Contact(var publicKey: String) : Destination() {
internal constructor(): this("") internal constructor(): this("")
} }
class ClosedGroup(var groupPublicKey: String) : Destination() { data class ClosedGroup(var groupPublicKey: String) : Destination() {
internal constructor(): this("") internal constructor(): this("")
} }
class LegacyOpenGroup(var roomToken: String, var server: String) : Destination() { data class LegacyOpenGroup(var roomToken: String, var server: String) : Destination() {
internal constructor(): this("", "") internal constructor(): this("", "")
} }

View File

@ -11,20 +11,23 @@ import org.session.libsignal.protos.SignalServiceProtos
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.session.libsession.messaging.sending_receiving.attachments.Attachment as SignalAttachment import org.session.libsession.messaging.sending_receiving.attachments.Attachment as SignalAttachment
class VisibleMessage : Message() { /**
/** In the case of a sync message, the public key of the person the message was targeted at. * @param syncTarget In the case of a sync message, the public key of the person the message was targeted at.
* *
* **Note:** `nil` if this isn't a sync message. * **Note:** `nil` if this isn't a sync message.
*/ */
var syncTarget: String? = null class VisibleMessage(
var text: String? = null var syncTarget: String? = null,
val attachmentIDs: MutableList<Long> = mutableListOf() var text: String? = null,
var quote: Quote? = null val attachmentIDs: MutableList<Long> = mutableListOf(),
var linkPreview: LinkPreview? = null var quote: Quote? = null,
var profile: Profile? = null var linkPreview: LinkPreview? = null,
var openGroupInvitation: OpenGroupInvitation? = null var profile: Profile? = null,
var reaction: Reaction? = null var openGroupInvitation: OpenGroupInvitation? = null,
var reaction: Reaction? = null,
var hasMention: Boolean = false var hasMention: Boolean = false
) : Message() {
override val isSelfSendValid: Boolean = true override val isSelfSendValid: Boolean = true
// region Validation // region Validation

View File

@ -14,6 +14,7 @@ import org.session.libsession.messaging.messages.control.ConfigurationMessage
import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate
import org.session.libsession.messaging.messages.control.MessageRequestResponse import org.session.libsession.messaging.messages.control.MessageRequestResponse
import org.session.libsession.messaging.messages.control.SharedConfigurationMessage import org.session.libsession.messaging.messages.control.SharedConfigurationMessage
import org.session.libsession.messaging.messages.control.SharedConfigurationMessage
import org.session.libsession.messaging.messages.control.UnsendRequest import org.session.libsession.messaging.messages.control.UnsendRequest
import org.session.libsession.messaging.messages.visible.LinkPreview import org.session.libsession.messaging.messages.visible.LinkPreview
import org.session.libsession.messaging.messages.visible.Quote import org.session.libsession.messaging.messages.visible.Quote
@ -62,12 +63,11 @@ object MessageSender {
} }
// Convenience // Convenience
fun send(message: Message, destination: Destination): Promise<Unit, Exception> { fun send(message: Message, destination: Destination, isSyncMessage: Boolean = false): Promise<Unit, Exception> {
return if (destination is Destination.LegacyOpenGroup || destination is Destination.OpenGroup || destination is Destination.OpenGroupInbox) { return if (destination is Destination.LegacyOpenGroup || destination is Destination.OpenGroup || destination is Destination.OpenGroupInbox) {
sendToOpenGroupDestination(destination, message) sendToOpenGroupDestination(destination, message)
} else { } else {
val userPublicKey = MessagingModuleConfiguration.shared.storage.getUserPublicKey() sendToSnodeDestination(destination, message, isSyncMessage)
sendToSnodeDestination(destination, message, destination is Destination.Contact && destination.publicKey == userPublicKey)
} }
} }
@ -160,6 +160,7 @@ object MessageSender {
) )
} }
// One-on-One Chats & Closed Groups
private fun sendToSnodeDestination(destination: Destination, message: Message, isSyncMessage: Boolean = false): Promise<Unit, Exception> { private fun sendToSnodeDestination(destination: Destination, message: Message, isSyncMessage: Boolean = false): Promise<Unit, Exception> {
val deferred = deferred<Unit, Exception>() val deferred = deferred<Unit, Exception>()
val promise = deferred.promise val promise = deferred.promise
@ -171,7 +172,7 @@ object MessageSender {
// Set the failure handler (need it here already for precondition failure handling) // Set the failure handler (need it here already for precondition failure handling)
fun handleFailure(error: Exception) { fun handleFailure(error: Exception) {
handleFailedMessageSend(message, error) handleFailedMessageSend(message, error, isSyncMessage)
if (destination is Destination.Contact && message is VisibleMessage && !isSelfSend()) { if (destination is Destination.Contact && message is VisibleMessage && !isSelfSend()) {
SnodeModule.shared.broadcaster.broadcast("messageFailed", message.sentTimestamp!!) SnodeModule.shared.broadcaster.broadcast("messageFailed", message.sentTimestamp!!)
} }
@ -393,16 +394,23 @@ object MessageSender {
// • the destination was a contact // • the destination was a contact
// • we didn't sync it already // • we didn't sync it already
if (destination is Destination.Contact && !isSyncMessage) { if (destination is Destination.Contact && !isSyncMessage) {
if (message is VisibleMessage) { message.syncTarget = destination.publicKey } if (message is VisibleMessage) message.syncTarget = destination.publicKey
if (message is ExpirationTimerUpdate) { message.syncTarget = destination.publicKey } if (message is ExpirationTimerUpdate) message.syncTarget = destination.publicKey
storage.markAsSyncing(message.sentTimestamp!!, userPublicKey)
sendToSnodeDestination(Destination.Contact(userPublicKey), message, true) sendToSnodeDestination(Destination.Contact(userPublicKey), message, true)
} }
} }
fun handleFailedMessageSend(message: Message, error: Exception) { fun handleFailedMessageSend(message: Message, error: Exception, isSyncMessage: Boolean = false) {
val storage = MessagingModuleConfiguration.shared.storage val storage = MessagingModuleConfiguration.shared.storage
val userPublicKey = storage.getUserPublicKey()!! val userPublicKey = storage.getUserPublicKey()!!
storage.setErrorMessage(message.sentTimestamp!!, message.sender?:userPublicKey, error)
val timestamp = message.sentTimestamp!!
val author = message.sender ?: userPublicKey
if (isSyncMessage) storage.markAsSyncFailed(timestamp, author, error)
else storage.markAsSentFailed(timestamp, author, error)
} }
// Convenience // Convenience

View File

@ -4,6 +4,7 @@ import android.content.Context
import android.util.TypedValue import android.util.TypedValue
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.recyclerview.widget.RecyclerView
@ColorInt @ColorInt
fun Context.getColorFromAttr( fun Context.getColorFromAttr(
@ -14,3 +15,9 @@ fun Context.getColorFromAttr(
theme.resolveAttribute(attrColor, typedValue, resolveRefs) theme.resolveAttribute(attrColor, typedValue, resolveRefs)
return typedValue.data return typedValue.data
} }
val RecyclerView.isScrolledToBottom: Boolean
get() {
val contentHeight = height - (paddingTop + paddingBottom)
return computeVerticalScrollRange() == computeVerticalScrollOffset() + contentHeight
}