mirror of
https://github.com/oxen-io/session-android.git
synced 2025-12-10 23:32:30 +00:00
Merge branch 'dev' into sync-everything
This commit is contained in:
@@ -7,6 +7,10 @@ import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.provider.MediaStore
|
||||
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) {
|
||||
|
||||
@@ -31,22 +35,26 @@ class ScreenshotObserver(private val context: Context, handler: Handler, private
|
||||
val projection = arrayOf(
|
||||
MediaStore.Images.Media.DATA
|
||||
)
|
||||
context.contentResolver.query(
|
||||
uri,
|
||||
projection,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)?.use { cursor ->
|
||||
val dataColumn = cursor.getColumnIndex(MediaStore.Images.Media.DATA)
|
||||
while (cursor.moveToNext()) {
|
||||
val path = cursor.getString(dataColumn)
|
||||
if (path.contains("screenshot", true)) {
|
||||
if (cache.add(uri.hashCode())) {
|
||||
screenshotTriggered()
|
||||
try {
|
||||
context.contentResolver.query(
|
||||
uri,
|
||||
projection,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)?.use { cursor ->
|
||||
val dataColumn = cursor.getColumnIndex(MediaStore.Images.Media.DATA)
|
||||
while (cursor.moveToNext()) {
|
||||
val path = cursor.getString(dataColumn)
|
||||
if (path.contains("screenshot", true)) {
|
||||
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.RELATIVE_PATH
|
||||
)
|
||||
context.contentResolver.query(
|
||||
uri,
|
||||
projection,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)?.use { cursor ->
|
||||
val relativePathColumn =
|
||||
cursor.getColumnIndex(MediaStore.Images.Media.RELATIVE_PATH)
|
||||
val displayNameColumn =
|
||||
cursor.getColumnIndex(MediaStore.Images.Media.DISPLAY_NAME)
|
||||
while (cursor.moveToNext()) {
|
||||
val name = cursor.getString(displayNameColumn)
|
||||
val relativePath = cursor.getString(relativePathColumn)
|
||||
if (name.contains("screenshot", true) or
|
||||
relativePath.contains("screenshot", true)) {
|
||||
if (cache.add(uri.hashCode())) {
|
||||
screenshotTriggered()
|
||||
|
||||
try {
|
||||
context.contentResolver.query(
|
||||
uri,
|
||||
projection,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)?.use { cursor ->
|
||||
val relativePathColumn =
|
||||
cursor.getColumnIndex(MediaStore.Images.Media.RELATIVE_PATH)
|
||||
val displayNameColumn =
|
||||
cursor.getColumnIndex(MediaStore.Images.Media.DISPLAY_NAME)
|
||||
while (cursor.moveToNext()) {
|
||||
val name = cursor.getString(displayNameColumn)
|
||||
val relativePath = cursor.getString(relativePathColumn)
|
||||
if (name.contains("screenshot", true) or
|
||||
relativePath.contains("screenshot", true)) {
|
||||
if (cache.add(uri.hashCode())) {
|
||||
screenshotTriggered()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: IllegalStateException) {
|
||||
Log.e(TAG, e)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import android.widget.Toast
|
||||
import androidx.activity.viewModels
|
||||
import androidx.annotation.DimenRes
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.view.drawToBitmap
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.Observer
|
||||
@@ -212,10 +211,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
var searchViewItem: MenuItem? = null
|
||||
|
||||
private val isScrolledToBottom: Boolean
|
||||
get() {
|
||||
val position = layoutManager?.findFirstCompletelyVisibleItemPosition() ?: 0
|
||||
return position == 0
|
||||
}
|
||||
get() = binding?.conversationRecyclerView?.isScrolledToBottom ?: true
|
||||
|
||||
private val layoutManager: LinearLayoutManager?
|
||||
get() { return binding?.conversationRecyclerView?.layoutManager as LinearLayoutManager? }
|
||||
@@ -667,7 +663,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
}
|
||||
|
||||
private fun updateSendAfterApprovalText() {
|
||||
binding?.textSendAfterApproval?.isGone = viewModel.recipient?.hasApprovedMe() ?: true
|
||||
binding?.textSendAfterApproval?.isVisible = viewModel.showSendAfterApprovalText
|
||||
}
|
||||
|
||||
private fun showOrHideInputIfNeeded() {
|
||||
@@ -1109,12 +1105,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
}
|
||||
|
||||
})
|
||||
val contentBounds = Rect()
|
||||
visibleMessageView.messageContentView.getGlobalVisibleRect(contentBounds)
|
||||
val topLeft = intArrayOf(0, 0).also { visibleMessageView.messageContentView.getLocationInWindow(it) }
|
||||
val selectedConversationModel = SelectedConversationModel(
|
||||
messageContentBitmap,
|
||||
contentBounds.left.toFloat(),
|
||||
contentBounds.top.toFloat(),
|
||||
topLeft[0].toFloat(),
|
||||
topLeft[1].toFloat(),
|
||||
visibleMessageView.messageContentView.width,
|
||||
message.isOutgoing,
|
||||
visibleMessageView.messageContentView
|
||||
@@ -1755,6 +1750,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
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>) {
|
||||
messages.iterator().forEach { messageRecord ->
|
||||
ResendMessageUtilities.resend(this, messageRecord, viewModel.blindedPublicKey)
|
||||
@@ -1915,6 +1917,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
val selectedItems = setOf(message)
|
||||
when (action) {
|
||||
ConversationReactionOverlay.Action.REPLY -> reply(selectedItems)
|
||||
ConversationReactionOverlay.Action.RESYNC -> resyncMessage(selectedItems)
|
||||
ConversationReactionOverlay.Action.RESEND -> resendMessage(selectedItems)
|
||||
ConversationReactionOverlay.Action.DOWNLOAD -> saveAttachment(selectedItems)
|
||||
ConversationReactionOverlay.Action.COPY_MESSAGE -> copyMessages(selectedItems)
|
||||
|
||||
@@ -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),
|
||||
getContext().getResources().getString(R.string.AccessibilityId_select)));
|
||||
// Reply
|
||||
if (!message.isPending() && !message.isFailed()) {
|
||||
boolean canWrite = openGroup == null || openGroup.getCanWrite();
|
||||
if (canWrite && !message.isPending() && !message.isFailed()) {
|
||||
items.add(
|
||||
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))
|
||||
@@ -700,6 +701,10 @@ public final class ConversationReactionOverlay extends FrameLayout {
|
||||
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)));
|
||||
}
|
||||
// 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
|
||||
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),
|
||||
@@ -885,6 +890,7 @@ public final class ConversationReactionOverlay extends FrameLayout {
|
||||
public enum Action {
|
||||
REPLY,
|
||||
RESEND,
|
||||
RESYNC,
|
||||
DOWNLOAD,
|
||||
COPY_MESSAGE,
|
||||
COPY_SESSION_ID,
|
||||
|
||||
@@ -31,6 +31,9 @@ class ConversationViewModel(
|
||||
private val storage: Storage
|
||||
) : ViewModel() {
|
||||
|
||||
val showSendAfterApprovalText: Boolean
|
||||
get() = recipient?.run { isContactRecipient && !isLocalNumber && !hasApprovedMe() } ?: false
|
||||
|
||||
private val _uiState = MutableStateFlow(ConversationUiState())
|
||||
val uiState: StateFlow<ConversationUiState> = _uiState
|
||||
|
||||
|
||||
@@ -70,6 +70,8 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p
|
||||
menu.findItem(R.id.menu_message_details).isVisible = (selectedItems.size == 1 && firstMessage.isOutgoing)
|
||||
// Resend
|
||||
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
|
||||
menu.findItem(R.id.menu_context_save_attachment).isVisible = (selectedItems.size == 1
|
||||
&& 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_copy -> delegate?.copyMessages(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_message_details -> delegate?.showMessageDetail(selectedItems)
|
||||
R.id.menu_context_save_attachment -> delegate?.saveAttachment(selectedItems)
|
||||
@@ -113,6 +116,7 @@ interface ConversationActionModeCallbackDelegate {
|
||||
fun banAndDeleteAll(messages: Set<MessageRecord>)
|
||||
fun copyMessages(messages: Set<MessageRecord>)
|
||||
fun copySessionID(messages: Set<MessageRecord>)
|
||||
fun resyncMessage(messages: Set<MessageRecord>)
|
||||
fun resendMessage(messages: Set<MessageRecord>)
|
||||
fun showMessageDetail(messages: Set<MessageRecord>)
|
||||
fun saveAttachment(messages: Set<MessageRecord>)
|
||||
|
||||
@@ -292,39 +292,46 @@ class VisibleMessageView : LinearLayout {
|
||||
@StringRes val messageText: Int?,
|
||||
val contentDescription: String?)
|
||||
|
||||
private fun getMessageStatusImage(message: MessageRecord): MessageStatusInfo {
|
||||
return when {
|
||||
!message.isOutgoing -> MessageStatusInfo(null,
|
||||
null,
|
||||
null,
|
||||
null)
|
||||
message.isFailed ->
|
||||
MessageStatusInfo(
|
||||
R.drawable.ic_delivery_status_failed,
|
||||
resources.getColor(R.color.destructive, context.theme),
|
||||
R.string.delivery_status_failed,
|
||||
null
|
||||
)
|
||||
message.isPending ->
|
||||
MessageStatusInfo(
|
||||
R.drawable.ic_delivery_status_sending,
|
||||
context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_sending,
|
||||
context.getString(R.string.AccessibilityId_message_sent_status_pending)
|
||||
)
|
||||
message.isRead ->
|
||||
MessageStatusInfo(
|
||||
R.drawable.ic_delivery_status_read,
|
||||
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 getMessageStatusImage(message: MessageRecord): MessageStatusInfo = when {
|
||||
message.isFailed ->
|
||||
MessageStatusInfo(
|
||||
R.drawable.ic_delivery_status_failed,
|
||||
resources.getColor(R.color.destructive, context.theme),
|
||||
R.string.delivery_status_failed,
|
||||
null
|
||||
)
|
||||
message.isSyncFailed ->
|
||||
MessageStatusInfo(
|
||||
R.drawable.ic_delivery_status_failed,
|
||||
context.getColor(R.color.accent_orange),
|
||||
R.string.delivery_status_sync_failed,
|
||||
null
|
||||
)
|
||||
message.isPending ->
|
||||
MessageStatusInfo(
|
||||
R.drawable.ic_delivery_status_sending,
|
||||
context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_sending,
|
||||
context.getString(R.string.AccessibilityId_message_sent_status_pending)
|
||||
)
|
||||
message.isResyncing ->
|
||||
MessageStatusInfo(
|
||||
R.drawable.ic_delivery_status_sending,
|
||||
context.getColor(R.color.accent_orange), R.string.delivery_status_syncing,
|
||||
context.getString(R.string.AccessibilityId_message_sent_status_syncing)
|
||||
)
|
||||
message.isRead ->
|
||||
MessageStatusInfo(
|
||||
R.drawable.ic_delivery_status_read,
|
||||
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) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.conversation.v2.utilities
|
||||
|
||||
import android.content.Context
|
||||
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.OpenGroupInvitation
|
||||
import org.session.libsession.messaging.messages.visible.Quote
|
||||
@@ -15,7 +16,7 @@ import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||
|
||||
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 message = VisibleMessage()
|
||||
message.id = messageRecord.getId()
|
||||
@@ -55,8 +56,13 @@ object ResendMessageUtilities {
|
||||
val sentTimestamp = message.sentTimestamp
|
||||
val sender = MessagingModuleConfiguration.shared.storage.getUserPublicKey()
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -38,13 +38,12 @@ object TextUtilities {
|
||||
fun TextView.getIntersectedModalSpans(hitRect: Rect): List<ModalURLSpan> {
|
||||
val textLayout = layout ?: return emptyList()
|
||||
val lineRect = Rect()
|
||||
val bodyTextRect = Rect()
|
||||
getGlobalVisibleRect(bodyTextRect)
|
||||
val offset = intArrayOf(0, 0).also { getLocationOnScreen(it) }
|
||||
val textSpannable = text.toSpannable()
|
||||
return (0 until textLayout.lineCount).flatMap { line ->
|
||||
textLayout.getLineBounds(line, lineRect)
|
||||
lineRect.offset(bodyTextRect.left + totalPaddingLeft, bodyTextRect.top + totalPaddingTop)
|
||||
if ((Rect(lineRect)).contains(hitRect)) {
|
||||
lineRect.offset(offset[0] + totalPaddingLeft, offset[1] + totalPaddingTop)
|
||||
if (lineRect.contains(hitRect)) {
|
||||
// calculate the url span intersected with (if any)
|
||||
val off = textLayout.getOffsetForHorizontal(line, hitRect.left.toFloat()) // left and right will be the same
|
||||
textSpannable.getSpans<ModalURLSpan>(off, off).toList()
|
||||
|
||||
@@ -3,13 +3,11 @@ package org.thoughtcrime.securesms.crypto;
|
||||
|
||||
import static org.session.libsignal.crypto.CipherUtil.CIPHER_LOCK;
|
||||
|
||||
import android.os.Build;
|
||||
import android.security.keystore.KeyGenParameterSpec;
|
||||
import android.security.keystore.KeyProperties;
|
||||
import android.util.Base64;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.RequiresApi;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
@@ -51,6 +49,9 @@ public final class KeyStoreHelper {
|
||||
SecretKey secretKey = getOrCreateKeyStoreEntry();
|
||||
|
||||
try {
|
||||
// Cipher operations are not thread-safe so we synchronize over them through doFinal to
|
||||
// prevent crashes with quickly repeated encrypt/decrypt operations
|
||||
// https://github.com/mozilla-mobile/android-components/issues/5342
|
||||
synchronized (CIPHER_LOCK) {
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey);
|
||||
@@ -69,6 +70,9 @@ public final class KeyStoreHelper {
|
||||
SecretKey secretKey = getKeyStoreEntry();
|
||||
|
||||
try {
|
||||
// Cipher operations are not thread-safe so we synchronize over them through doFinal to
|
||||
// prevent crashes with quickly repeated encrypt/decrypt operations
|
||||
// https://github.com/mozilla-mobile/android-components/issues/5342
|
||||
synchronized (CIPHER_LOCK) {
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey, new GCMParameterSpec(128, sealedData.iv));
|
||||
|
||||
@@ -37,6 +37,13 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
|
||||
public abstract void markExpireStarted(long messageId, long startTime);
|
||||
|
||||
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 markAsDeleted(long messageId, boolean read, boolean hasMention);
|
||||
@@ -199,7 +206,6 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
|
||||
contentValues.put(THREAD_ID, newThreadId);
|
||||
db.update(getTableName(), contentValues, where, args);
|
||||
}
|
||||
|
||||
public static class SyncMessageId {
|
||||
|
||||
private final Address address;
|
||||
|
||||
@@ -276,6 +276,16 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
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) {
|
||||
markAs(messageId, MmsSmsColumns.Types.BASE_SENDING_TYPE)
|
||||
}
|
||||
|
||||
@@ -47,8 +47,13 @@ public interface MmsSmsColumns {
|
||||
protected static final long BASE_PENDING_INSECURE_SMS_FALLBACK = 26;
|
||||
public static final long BASE_DRAFT_TYPE = 27;
|
||||
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,
|
||||
BASE_SYNCING_TYPE, BASE_RESYNCING_TYPE,
|
||||
BASE_SYNC_FAILED_TYPE,
|
||||
BASE_SENDING_TYPE, BASE_SENT_FAILED_TYPE,
|
||||
BASE_PENDING_SECURE_SMS_FALLBACK,
|
||||
BASE_PENDING_INSECURE_SMS_FALLBACK,
|
||||
@@ -109,6 +114,18 @@ public interface MmsSmsColumns {
|
||||
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) {
|
||||
return (type & BASE_TYPE_MASK) == BASE_SENT_FAILED_TYPE;
|
||||
}
|
||||
|
||||
@@ -202,6 +202,21 @@ public class SmsDatabase extends MessagingDatabase {
|
||||
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
|
||||
public void markUnidentified(long id, boolean unidentified) {
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
|
||||
@@ -377,6 +377,22 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
val database = DatabaseComponent.get(context).mmsSmsDatabase()
|
||||
val messageRecord = database.getMessageFor(timestamp, author) ?: return
|
||||
@@ -402,7 +418,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
|
||||
}
|
||||
}
|
||||
|
||||
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 messageRecord = database.getMessageFor(timestamp, author) ?: return
|
||||
if (messageRecord.isMms) {
|
||||
@@ -425,6 +441,26 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
val db = DatabaseComponent.get(context).lokiMessageDatabase()
|
||||
db.clearErrorMessage(messageID)
|
||||
@@ -983,5 +1019,4 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
|
||||
val recipientDb = DatabaseComponent.get(context).recipientDatabase()
|
||||
return recipientDb.blockedContacts
|
||||
}
|
||||
|
||||
}
|
||||
@@ -80,6 +80,18 @@ public abstract class DisplayRecord {
|
||||
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() {
|
||||
return MmsSmsColumns.Types.isFailedMessageType(type)
|
||||
|| MmsSmsColumns.Types.isPendingSecureSmsFallbackType(type)
|
||||
|
||||
@@ -42,6 +42,7 @@ class ClearAllDataDialog : BaseDialog() {
|
||||
var selectedOption = device
|
||||
val optionAdapter = RadioOptionAdapter { selectedOption = it }
|
||||
binding.recyclerView.apply {
|
||||
itemAnimator = null
|
||||
adapter = optionAdapter
|
||||
addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL))
|
||||
setHasFixedSize(true)
|
||||
|
||||
@@ -1,42 +1,41 @@
|
||||
package org.thoughtcrime.securesms.preferences
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import network.loki.messenger.databinding.DialogListPreferenceBinding
|
||||
import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog
|
||||
|
||||
class ListPreferenceDialog(
|
||||
private val listPreference: ListPreference,
|
||||
private val dialogListener: () -> Unit
|
||||
) : BaseDialog() {
|
||||
private lateinit var binding: DialogListPreferenceBinding
|
||||
fun listPreferenceDialog(
|
||||
context: Context,
|
||||
listPreference: ListPreference,
|
||||
dialogListener: () -> Unit
|
||||
) : AlertDialog {
|
||||
|
||||
override fun setContentView(builder: AlertDialog.Builder) {
|
||||
binding = DialogListPreferenceBinding.inflate(LayoutInflater.from(requireContext()))
|
||||
binding.titleTextView.text = listPreference.dialogTitle
|
||||
binding.messageTextView.text = listPreference.dialogMessage
|
||||
binding.closeButton.setOnClickListener {
|
||||
dismiss()
|
||||
}
|
||||
val options = listPreference.entryValues.zip(listPreference.entries) { value, title ->
|
||||
RadioOption(value.toString(), title.toString())
|
||||
}
|
||||
val valueIndex = listPreference.findIndexOfValue(listPreference.value)
|
||||
val optionAdapter = RadioOptionAdapter(valueIndex) {
|
||||
listPreference.value = it.value
|
||||
dismiss()
|
||||
dialogListener.invoke()
|
||||
}
|
||||
binding.recyclerView.apply {
|
||||
adapter = optionAdapter
|
||||
addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL))
|
||||
setHasFixedSize(true)
|
||||
}
|
||||
optionAdapter.submitList(options)
|
||||
builder.setView(binding.root)
|
||||
builder.setCancelable(false)
|
||||
val builder = AlertDialog.Builder(context)
|
||||
|
||||
val binding = DialogListPreferenceBinding.inflate(LayoutInflater.from(context))
|
||||
binding.titleTextView.text = listPreference.dialogTitle
|
||||
binding.messageTextView.text = listPreference.dialogMessage
|
||||
|
||||
builder.setView(binding.root)
|
||||
|
||||
val dialog = builder.show()
|
||||
|
||||
val valueIndex = listPreference.findIndexOfValue(listPreference.value)
|
||||
RadioOptionAdapter(valueIndex) {
|
||||
listPreference.value = it.value
|
||||
dialog.dismiss()
|
||||
dialogListener()
|
||||
}
|
||||
.apply {
|
||||
listPreference.entryValues.zip(listPreference.entries) { value, title ->
|
||||
RadioOption(value.toString(), title.toString())
|
||||
}.let(this::submitList)
|
||||
}
|
||||
.let { binding.recyclerView.adapter = it }
|
||||
|
||||
}
|
||||
binding.closeButton.setOnClickListener { dialog.dismiss() }
|
||||
|
||||
return dialog
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package org.thoughtcrime.securesms.preferences;
|
||||
|
||||
import static android.app.Activity.RESULT_OK;
|
||||
|
||||
import static org.thoughtcrime.securesms.preferences.ListPreferenceDialogKt.listPreferenceDialog;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
@@ -77,10 +79,10 @@ public class NotificationsPreferenceFragment extends ListSummaryPreferenceFragme
|
||||
.setOnPreferenceClickListener(preference -> {
|
||||
ListPreference listPreference = (ListPreference) preference;
|
||||
listPreference.setDialogMessage(R.string.preferences_notifications__content_message);
|
||||
new ListPreferenceDialog(listPreference, () -> {
|
||||
initializeListSummary((ListPreference) findPreference(TextSecurePreferences.NOTIFICATION_PRIVACY_PREF));
|
||||
listPreferenceDialog(getContext(), listPreference, () -> {
|
||||
initializeListSummary(findPreference(TextSecurePreferences.NOTIFICATION_PRIVACY_PREF));
|
||||
return null;
|
||||
}).show(getChildFragmentManager(), "ListPreferenceDialog");
|
||||
});
|
||||
return true;
|
||||
});
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@ class RadioOptionAdapter(
|
||||
) : ListAdapter<RadioOption, RadioOptionAdapter.ViewHolder>(RadioOptionDiffer()) {
|
||||
|
||||
class RadioOptionDiffer: DiffUtil.ItemCallback<RadioOption>() {
|
||||
override fun areItemsTheSame(oldItem: RadioOption, newItem: RadioOption) = oldItem === newItem
|
||||
override fun areContentsTheSame(oldItem: RadioOption, newItem: RadioOption) = oldItem == newItem
|
||||
override fun areItemsTheSame(oldItem: RadioOption, newItem: RadioOption) = oldItem.title == newItem.title
|
||||
override fun areContentsTheSame(oldItem: RadioOption, newItem: RadioOption) = oldItem.value == newItem.value
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
@@ -31,7 +31,7 @@ class RadioOptionAdapter(
|
||||
holder.bind(option, isSelected) {
|
||||
onClickListener(it)
|
||||
selectedOptionPosition = position
|
||||
notifyDataSetChanged()
|
||||
notifyItemRangeChanged(0, itemCount)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,7 @@ package org.thoughtcrime.securesms.preferences
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.*
|
||||
import android.net.Uri
|
||||
import android.os.AsyncTask
|
||||
import android.os.Bundle
|
||||
@@ -19,6 +16,7 @@ import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.view.isVisible
|
||||
import network.loki.messenger.BuildConfig
|
||||
import network.loki.messenger.R
|
||||
@@ -28,13 +26,11 @@ import nl.komponents.kovenant.all
|
||||
import nl.komponents.kovenant.ui.alwaysUi
|
||||
import nl.komponents.kovenant.ui.successUi
|
||||
import org.session.libsession.avatars.AvatarHelper
|
||||
import org.session.libsession.utilities.Address
|
||||
import org.session.libsession.utilities.ProfileKeyUtil
|
||||
import org.session.libsession.utilities.ProfilePictureUtilities
|
||||
import org.session.libsession.utilities.*
|
||||
import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
|
||||
import org.thoughtcrime.securesms.avatar.AvatarSelection
|
||||
import org.thoughtcrime.securesms.components.ProfilePictureView
|
||||
import org.thoughtcrime.securesms.home.PathActivity
|
||||
import org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity
|
||||
import org.thoughtcrime.securesms.mms.GlideApp
|
||||
@@ -57,8 +53,6 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
|
||||
private var displayNameEditActionMode: ActionMode? = null
|
||||
set(value) { field = value; handleDisplayNameEditActionModeChanged() }
|
||||
private lateinit var glide: GlideRequests
|
||||
private var displayNameToBeUploaded: String? = null
|
||||
private var profilePictureToBeUploaded: ByteArray? = null
|
||||
private var tempFile: File? = null
|
||||
|
||||
private val hexEncodedPublicKey: String
|
||||
@@ -76,14 +70,10 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
|
||||
super.onCreate(savedInstanceState, isReady)
|
||||
binding = ActivitySettingsBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
val displayName = TextSecurePreferences.getProfileName(this) ?: hexEncodedPublicKey
|
||||
val displayName = getDisplayName()
|
||||
glide = GlideApp.with(this)
|
||||
with(binding) {
|
||||
profilePictureView.root.glide = glide
|
||||
profilePictureView.root.publicKey = hexEncodedPublicKey
|
||||
profilePictureView.root.displayName = displayName
|
||||
profilePictureView.root.isLarge = true
|
||||
profilePictureView.root.update()
|
||||
setupProfilePictureView(profilePictureView.root)
|
||||
profilePictureView.root.setOnClickListener { showEditProfilePictureUI() }
|
||||
ctnGroupNameSection.setOnClickListener { startActionMode(DisplayNameEditActionModeCallback()) }
|
||||
btnGroupNameDisplay.text = displayName
|
||||
@@ -105,6 +95,17 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDisplayName(): String =
|
||||
TextSecurePreferences.getProfileName(this) ?: truncateIdForDisplay(hexEncodedPublicKey)
|
||||
|
||||
private fun setupProfilePictureView(view: ProfilePictureView) {
|
||||
view.glide = glide
|
||||
view.publicKey = hexEncodedPublicKey
|
||||
view.displayName = getDisplayName()
|
||||
view.isLarge = true
|
||||
view.update()
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
val scrollBundle = SparseArray<Parcelable>()
|
||||
@@ -154,9 +155,9 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
|
||||
}
|
||||
AsyncTask.execute {
|
||||
try {
|
||||
profilePictureToBeUploaded = BitmapUtil.createScaledBytes(this@SettingsActivity, AvatarSelection.getResultUri(data), ProfileMediaConstraints()).bitmap
|
||||
val profilePictureToBeUploaded = BitmapUtil.createScaledBytes(this@SettingsActivity, AvatarSelection.getResultUri(data), ProfileMediaConstraints()).bitmap
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
updateProfile(true)
|
||||
updateProfile(true, profilePictureToBeUploaded)
|
||||
}
|
||||
} catch (e: BitmapDecodingException) {
|
||||
e.printStackTrace()
|
||||
@@ -190,23 +191,30 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateProfile(isUpdatingProfilePicture: Boolean) {
|
||||
private fun updateProfile(
|
||||
isUpdatingProfilePicture: Boolean,
|
||||
profilePicture: ByteArray? = null,
|
||||
displayName: String? = null
|
||||
) {
|
||||
binding.loader.isVisible = true
|
||||
val promises = mutableListOf<Promise<*, Exception>>()
|
||||
val displayName = displayNameToBeUploaded
|
||||
if (displayName != null) {
|
||||
TextSecurePreferences.setProfileName(this, displayName)
|
||||
}
|
||||
val profilePicture = profilePictureToBeUploaded
|
||||
val encodedProfileKey = ProfileKeyUtil.generateEncodedProfileKey(this)
|
||||
if (isUpdatingProfilePicture && profilePicture != null) {
|
||||
promises.add(ProfilePictureUtilities.upload(profilePicture, encodedProfileKey, this))
|
||||
if (isUpdatingProfilePicture) {
|
||||
if (profilePicture != null) {
|
||||
promises.add(ProfilePictureUtilities.upload(profilePicture, encodedProfileKey, this))
|
||||
} else {
|
||||
TextSecurePreferences.setLastProfilePictureUpload(this, System.currentTimeMillis())
|
||||
TextSecurePreferences.setProfilePictureURL(this, null)
|
||||
}
|
||||
}
|
||||
val compoundPromise = all(promises)
|
||||
compoundPromise.successUi { // Do this on the UI thread so that it happens before the alwaysUi clause below
|
||||
if (isUpdatingProfilePicture && profilePicture != null) {
|
||||
if (isUpdatingProfilePicture) {
|
||||
AvatarHelper.setAvatar(this, Address.fromSerialized(TextSecurePreferences.getLocalNumber(this)!!), profilePicture)
|
||||
TextSecurePreferences.setProfileAvatarId(this, SecureRandom().nextInt())
|
||||
TextSecurePreferences.setProfileAvatarId(this, profilePicture?.let { SecureRandom().nextInt() } ?: 0 )
|
||||
TextSecurePreferences.setLastProfilePictureUpload(this, Date().time)
|
||||
ProfileKeyUtil.setEncodedProfileKey(this, encodedProfileKey)
|
||||
}
|
||||
@@ -218,12 +226,10 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
|
||||
if (displayName != null) {
|
||||
binding.btnGroupNameDisplay.text = displayName
|
||||
}
|
||||
if (isUpdatingProfilePicture && profilePicture != null) {
|
||||
if (isUpdatingProfilePicture) {
|
||||
binding.profilePictureView.root.recycle() // Clear the cached image before updating
|
||||
binding.profilePictureView.root.update()
|
||||
}
|
||||
displayNameToBeUploaded = null
|
||||
profilePictureToBeUploaded = null
|
||||
binding.loader.isVisible = false
|
||||
}
|
||||
}
|
||||
@@ -244,8 +250,7 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
|
||||
Toast.makeText(this, R.string.activity_settings_display_name_too_long_error, Toast.LENGTH_SHORT).show()
|
||||
return false
|
||||
}
|
||||
displayNameToBeUploaded = displayName
|
||||
updateProfile(false)
|
||||
updateProfile(false, displayName = displayName)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -255,6 +260,28 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
|
||||
}
|
||||
|
||||
private fun showEditProfilePictureUI() {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.activity_settings_set_display_picture)
|
||||
.setView(R.layout.dialog_change_avatar)
|
||||
.setPositiveButton(R.string.activity_settings_upload) { _, _ ->
|
||||
startAvatarSelection()
|
||||
}
|
||||
.setNegativeButton(R.string.cancel) { _, _ -> }
|
||||
.apply {
|
||||
if (TextSecurePreferences.getProfileAvatarId(context) != 0) {
|
||||
setNeutralButton(R.string.activity_settings_remove) { _, _ -> removeAvatar() }
|
||||
}
|
||||
}
|
||||
.show().apply {
|
||||
findViewById<ProfilePictureView>(R.id.profile_picture_view)?.let(::setupProfilePictureView)
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeAvatar() {
|
||||
updateProfile(true)
|
||||
}
|
||||
|
||||
private fun startAvatarSelection() {
|
||||
// Ask for an optional camera permission.
|
||||
Permissions.with(this)
|
||||
.request(Manifest.permission.CAMERA)
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.animation.AnimatorListenerAdapter
|
||||
import android.animation.FloatEvaluator
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.PointF
|
||||
import android.graphics.Rect
|
||||
import android.view.View
|
||||
@@ -13,6 +14,7 @@ import androidx.annotation.DimenRes
|
||||
import network.loki.messenger.R
|
||||
import org.session.libsession.utilities.getColorFromAttr
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import androidx.core.graphics.applyCanvas
|
||||
|
||||
fun View.contains(point: PointF): Boolean {
|
||||
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
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user