From 6ddefb7a2e409bd26478b319f5831d6e9383640d Mon Sep 17 00:00:00 2001 From: Harris Date: Wed, 8 Jun 2022 17:12:34 +1000 Subject: [PATCH] Performance improvements and bug fixes (#869) * refactor: fail on testSnode instead of recursively using up snode list. add call timeout on http client * refactor: refactoring batch message receives and pollers * refactor: reduce thread utils pool count to a 2 thread fixed pool. Do a check against pubkey instead of room names for oxenHostedOpenGroup * refactor: caching lib with potential loader fixes and no-cache for giphy * refactor: remove store and instead use ConcurrentHashMap with a backing update coroutine * refactor: queue trim thread jobs instead of add every message processed * fix: wrapping auth token and initial sync for open groups in a threadutils queued runnable, getting initial sync times down * fix: fixing the user contacts cache in ConversationAdapter.kt * refactor: improve polling and initial sync, move group joins from config messages into a background job fetching image. * refactor: improving the job queuing for open groups, replacing placeholder avatar generation with a custom glide loader and archiving initial sync of open groups * feat: add OpenGroupDeleteJob.kt * feat: add open group delete job to process deletions after batch adding * feat: add vacuum and fix job queue re-adding jobs forever, only try to set message hash values in DB if they have changed * refactor: remove redundant inflation for profile image views throughout app * refactor(wip): reducing layout inflation and starting to refactor the open group deletion issues taking a long time * refactor(wip): refactoring group deletion to not iterate through and delete messages individually * refactor(wip): refactoring group deletion to not iterate through and delete messages individually * fix: group deletion optimisation * build: bump build number * build: bump build number and fix batch message receive retry logic * fix: clear out open group deletes * fix: update visible ConversationAdapter.kt binding for initial contact fetching and better traces for debugging background jobs * fix: add in check for / force sync latest encryption key pair from linked devices if we already have that closed group * Rename .java to .kt * refactor: change MmsDatabase to kotlin to make list operations easier * fix: nullable type * fix: compilation issues and constants in .kt instead of .java * fix: bug fix expiration timer on closed group recipient * feat: use the job queue properly across executors * feat: start on open group dispatcher-specific logic, probably a queue factory based on openGroupId if that is the same across new message and deletion jobs to ensure consistent entry and removal * refactor: removing redundant code and fixing jobqueue per opengroup * fix: allow attachments in note to self * fix: make the minWidth in quote view bind max of text / title and body, wrapped ? * fix: fixing up layouts and code view layouts * fix: remove TODO, remove timestamp binding * feat: fix view logic, avatars and padding, downloading attachments lazily (on bind), fixing potential crash, add WindowDebouncer.kt * fix: NPE on viewModel recipient from removed thread while tearing down the Recipient observer in ConversationActivityV2.kt * refactor: replace conversation notification debouncer handler with handlerthread, same as conversation list debouncer * refactor: UI for groups and poller improvements * fix: revert some changes in poller * feat: add header back in for message requests * refactor: remove Trace calls, add more conditions to the HomeDiffUtil for updating more efficiently * feat: try update the home adapter if we get a profile picture modified event * feat: bump build numbers * fix: try to start with list in homeViewModel if we don't have already, render quotes to be width of attachment slide view instead of fixed * fix: set channel to be conflated instead of no buffer * fix: set unreads based off last local user message vs incrementing unreads to be all amount * feat: add profile update flag, update build number * fix: link preview thumbnails download on bind * fix: centercrop placeholder in glide request * feat: recycle the contact selection list and profile image in unbind * fix: try to prevent user KP crash at weird times * fix: remove additional log, improve attachment download success rate, fix share logs dialog issue --- app/build.gradle | 5 +- .../securesms/ApplicationContext.java | 44 +- .../attachments/DatabaseAttachmentProvider.kt | 11 +- .../securesms/backup/FullBackupExporter.kt | 56 +- .../securesms/backup/FullBackupImporter.kt | 32 +- .../securesms/components/AvatarImageView.java | 20 +- .../components/ProfilePictureView.kt | 43 +- .../securesms/components/QuoteView.java | 2 +- .../contacts/ContactSelectionListAdapter.kt | 7 + .../securesms/contacts/UserView.kt | 6 +- .../conversation/v2/ConversationActivityV2.kt | 163 +- .../conversation/v2/ConversationAdapter.kt | 80 +- .../conversation/v2/ConversationViewModel.kt | 11 +- .../v2/components/AlbumThumbnailView.kt | 2 +- .../v2/components/MentionCandidateView.kt | 10 +- .../v2/dialogs/JoinOpenGroupDialog.kt | 2 + .../conversation/v2/input_bar/InputBar.kt | 7 +- .../mentions/MentionCandidateView.kt | 10 +- .../v2/messages/DeletedMessageView.kt | 12 +- .../conversation/v2/messages/DocumentView.kt | 13 +- .../v2/messages/OpenGroupInvitationView.kt | 13 +- .../conversation/v2/messages/QuoteView.kt | 99 +- .../v2/messages/UntrustedAttachmentView.kt | 12 +- .../v2/messages/VisibleMessageContentView.kt | 121 +- .../v2/messages/VisibleMessageView.kt | 141 +- .../v2/messages/VoiceMessageView.kt | 18 +- .../v2/utilities/KThumbnailView.kt | 2 +- .../v2/utilities/MentionUtilities.kt | 16 +- .../v2/utilities/ThumbnailView.java | 2 +- .../database/AttachmentDatabase.java | 53 + .../ConversationNotificationDebouncer.kt | 8 +- .../securesms/database/Database.java | 13 +- .../database/DatabaseContentProviders.java | 3 +- .../securesms/database/DatabaseUtilities.kt | 5 +- .../securesms/database/GroupDatabase.java | 2 + .../database/GroupReceiptDatabase.java | 17 +- .../securesms/database/LokiAPIDatabase.kt | 4 +- .../securesms/database/LokiMessageDatabase.kt | 5 +- .../securesms/database/MmsDatabase.java | 1367 -------------- .../securesms/database/MmsDatabase.kt | 1607 +++++++++++++++++ .../securesms/database/SessionJobDatabase.kt | 8 + .../securesms/database/SmsDatabase.java | 29 +- .../securesms/database/Storage.kt | 55 +- .../securesms/database/ThreadDatabase.java | 11 + .../database/helpers/SQLCipherOpenHelper.java | 12 +- .../database/loaders/PagingMediaLoader.java | 4 +- .../database/model/MmsAttachmentInfo.kt | 17 + .../database/model/ThreadRecord.java | 3 +- .../securesms/giph/ui/GiphyAdapter.java | 30 +- .../glide/PlaceholderAvatarFetcher.kt | 34 + .../glide/PlaceholderAvatarLoader.kt | 31 + .../securesms/groups/GroupManager.java | 3 +- .../groups/JoinPublicChatActivity.kt | 2 + .../securesms/groups/OpenGroupManager.kt | 42 +- .../securesms/home/ConversationView.kt | 17 +- .../securesms/home/HomeActivity.kt | 105 +- .../securesms/home/HomeAdapter.kt | 45 - .../securesms/home/HomeDiffUtil.kt | 42 + .../thoughtcrime/securesms/home/HomeLoader.kt | 7 +- .../securesms/home/HomeViewModel.kt | 59 + .../securesms/home/NewHomeAdapter.kt | 114 ++ .../securesms/home/UserDetailsBottomSheet.kt | 10 +- .../home/search/GlobalSearchAdapter.kt | 7 +- .../home/search/GlobalSearchAdapterUtils.kt | 16 +- .../jobs/RetrieveProfileAvatarJob.java | 3 +- .../messagerequests/MessageRequestView.kt | 7 +- .../securesms/mms/SignalGlideModule.java | 7 +- .../AndroidAutoReplyReceiver.java | 4 +- .../notifications/DefaultMessageNotifier.java | 4 +- .../notifications/RemoteReplyReceiver.java | 4 +- .../SingleRecipientNotificationBuilder.java | 2 +- .../securesms/onboarding/LandingActivity.kt | 7 +- .../securesms/preferences/SettingsActivity.kt | 16 +- .../securesms/preferences/ShareLogsDialog.kt | 24 +- .../repository/ConversationRepository.kt | 11 +- .../service/ExpiringMessageManager.java | 4 +- .../sskenvironment/ProfileManager.kt | 3 +- .../activity_conversation_v2_action_bar.xml | 2 +- app/src/main/res/layout/activity_home.xml | 2 +- app/src/main/res/layout/activity_settings.xml | 2 +- .../res/layout/fragment_call_bottom_sheet.xml | 2 +- .../fragment_user_details_bottom_sheet.xml | 2 +- app/src/main/res/layout/thumbnail_view.xml | 1 + app/src/main/res/layout/view_conversation.xml | 2 +- .../main/res/layout/view_deleted_message.xml | 4 +- app/src/main/res/layout/view_document.xml | 4 +- .../res/layout/view_global_search_result.xml | 2 +- app/src/main/res/layout/view_link_preview.xml | 2 +- .../res/layout/view_mention_candidate.xml | 2 +- .../res/layout/view_mention_candidate_v2.xml | 2 +- .../main/res/layout/view_message_request.xml | 2 +- .../res/layout/view_open_group_invitation.xml | 4 +- .../main/res/layout/view_profile_picture.xml | 8 +- app/src/main/res/layout/view_quote.xml | 90 +- app/src/main/res/layout/view_quote_draft.xml | 114 ++ .../res/layout/view_untrusted_attachment.xml | 4 +- app/src/main/res/layout/view_user.xml | 2 +- .../main/res/layout/view_visible_message.xml | 181 +- .../layout/view_visible_message_content.xml | 30 +- .../main/res/layout/view_voice_message.xml | 4 +- .../main/res/values-notnight-v21/colors.xml | 2 +- app/src/main/res/values/attrs.xml | 9 +- app/src/main/res/values/colors.xml | 2 +- .../v2/ConversationViewModelTest.kt | 2 +- .../avatars/GroupRecordContactPhoto.java | 6 +- .../avatars/PlaceholderAvatarPhoto.kt | 13 + .../database/MessageDataProvider.kt | 10 +- .../libsession/database/StorageProtocol.kt | 7 +- .../messaging/MessagingModuleConfiguration.kt | 16 +- .../messaging/jobs/AttachmentDownloadJob.kt | 23 +- .../messaging/jobs/BackgroundGroupAddJob.kt | 79 + .../messaging/jobs/BatchMessageReceiveJob.kt | 88 +- .../libsession/messaging/jobs/JobQueue.kt | 144 +- .../messaging/jobs/OpenGroupDeleteJob.kt | 51 + .../jobs/SessionJobManagerFactories.kt | 4 +- .../messaging/jobs/TrimThreadJob.kt | 12 +- .../messages/visible/ParsedMessage.kt | 11 + .../messaging/open_groups/OpenGroupAPIV2.kt | 25 +- .../messaging/open_groups/OpenGroupV2.kt | 12 +- .../sending_receiving/MessageReceiver.kt | 44 +- .../ReceivedMessageHandler.kt | 86 +- .../attachments/DatabaseAttachment.java | 4 +- .../pollers/ClosedGroupPollerV2.kt | 2 +- .../pollers/OpenGroupPollerV2.kt | 30 +- .../sending_receiving/pollers/Poller.kt | 31 +- .../libsession/snode/OnionRequestAPI.kt | 8 +- .../org/session/libsession/snode/SnodeAPI.kt | 18 +- .../utilities/TextSecurePreferences.kt | 22 + .../libsession/utilities/WindowDebouncer.kt | 31 + .../utilities/recipients/Recipient.java | 2 +- .../recipients/RecipientProvider.java | 6 +- .../org/session/libsignal/utilities/HTTP.kt | 12 +- .../libsignal/utilities/ThreadUtils.kt | 9 +- 133 files changed, 3775 insertions(+), 2357 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/model/MmsAttachmentInfo.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/glide/PlaceholderAvatarFetcher.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/glide/PlaceholderAvatarLoader.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/home/NewHomeAdapter.kt create mode 100644 app/src/main/res/layout/view_quote_draft.xml create mode 100644 libsession/src/main/java/org/session/libsession/avatars/PlaceholderAvatarPhoto.kt create mode 100644 libsession/src/main/java/org/session/libsession/messaging/jobs/BackgroundGroupAddJob.kt create mode 100644 libsession/src/main/java/org/session/libsession/messaging/jobs/OpenGroupDeleteJob.kt create mode 100644 libsession/src/main/java/org/session/libsession/messaging/messages/visible/ParsedMessage.kt create mode 100644 libsession/src/main/java/org/session/libsession/utilities/WindowDebouncer.kt diff --git a/app/build.gradle b/app/build.gradle index 47db31d2d4..c6504df15c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -110,6 +110,7 @@ dependencies { implementation "com.google.protobuf:protobuf-java:$protobufVersion" implementation "com.fasterxml.jackson.core:jackson-databind:$jacksonDatabindVersion" implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" + implementation 'app.cash.copper:copper-flow:1.0.0' implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" implementation "nl.komponents.kovenant:kovenant:$kovenantVersion" @@ -158,8 +159,8 @@ dependencies { testImplementation 'org.robolectric:shadows-multidex:4.4' } -def canonicalVersionCode = 279 -def canonicalVersionName = "1.13.1" +def canonicalVersionCode = 282 +def canonicalVersionName = "1.13.4" def postFixSize = 10 def abiPostFix = ['armeabi-v7a' : 1, diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index 6dd1cc9369..308e05b2d3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -24,7 +24,7 @@ import android.content.Intent; import android.os.AsyncTask; import android.os.Build; import android.os.Handler; -import android.os.Looper; +import android.os.HandlerThread; import androidx.annotation.NonNull; import androidx.lifecycle.DefaultLifecycleObserver; @@ -44,6 +44,7 @@ import org.session.libsession.utilities.ProfilePictureUtilities; import org.session.libsession.utilities.SSKEnvironment; import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsession.utilities.Util; +import org.session.libsession.utilities.WindowDebouncer; import org.session.libsession.utilities.dynamiclanguage.DynamicLanguageContextWrapper; import org.session.libsession.utilities.dynamiclanguage.LocaleParser; import org.session.libsignal.utilities.Log; @@ -93,6 +94,7 @@ import java.security.Security; import java.util.Date; import java.util.HashSet; import java.util.Set; +import java.util.Timer; import javax.inject.Inject; @@ -127,7 +129,9 @@ public class ApplicationContext extends Application implements DefaultLifecycleO public Poller poller = null; public Broadcaster broadcaster = null; private Job firebaseInstanceIdJob; - private Handler conversationListNotificationHandler; + private WindowDebouncer conversationListDebouncer; + private HandlerThread conversationListHandlerThread; + private Handler conversationListHandler; private PersistentLogger persistentLogger; @Inject LokiAPIDatabase lokiAPIDatabase; @@ -136,9 +140,18 @@ public class ApplicationContext extends Application implements DefaultLifecycleO @Inject JobDatabase jobDatabase; @Inject TextSecurePreferences textSecurePreferences; CallMessageProcessor callMessageProcessor; + MessagingModuleConfiguration messagingModuleConfiguration; private volatile boolean isAppVisible; + @Override + public Object getSystemService(String name) { + if (MessagingModuleConfiguration.MESSAGING_MODULE_SERVICE.equals(name)) { + return messagingModuleConfiguration; + } + return super.getSystemService(name); + } + public static ApplicationContext getInstance(Context context) { return (ApplicationContext) context.getApplicationContext(); } @@ -148,10 +161,21 @@ public class ApplicationContext extends Application implements DefaultLifecycleO } public Handler getConversationListNotificationHandler() { - if (this.conversationListNotificationHandler == null) { - conversationListNotificationHandler = new Handler(Looper.getMainLooper()); + if (this.conversationListHandlerThread == null) { + conversationListHandlerThread = new HandlerThread("ConversationListHandler"); + conversationListHandlerThread.start(); } - return this.conversationListNotificationHandler; + if (this.conversationListHandler == null) { + conversationListHandler = new Handler(conversationListHandlerThread.getLooper()); + } + return conversationListHandler; + } + + public WindowDebouncer getConversationListDebouncer() { + if (conversationListDebouncer == null) { + conversationListDebouncer = new WindowDebouncer(1000, new Timer()); + } + return conversationListDebouncer; } public PersistentLogger getPersistentLogger() { @@ -161,7 +185,12 @@ public class ApplicationContext extends Application implements DefaultLifecycleO @Override public void onCreate() { DatabaseModule.init(this); + MessagingModuleConfiguration.configure(this); super.onCreate(); + messagingModuleConfiguration = new MessagingModuleConfiguration(this, + storage, + messageDataProvider, + ()-> KeyPairUtilities.INSTANCE.getUserED25519KeyPair(this)); callMessageProcessor = new CallMessageProcessor(this, textSecurePreferences, ProcessLifecycleOwner.get().getLifecycle(), storage); Log.i(TAG, "onCreate()"); startKovenant(); @@ -174,11 +203,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO messageNotifier = new OptimizedMessageNotifier(new DefaultMessageNotifier()); broadcaster = new Broadcaster(this); LokiAPIDatabase apiDB = getDatabaseComponent().lokiAPIDatabase(); - MessagingModuleConfiguration.Companion.configure(this, - storage, - messageDataProvider, - ()-> KeyPairUtilities.INSTANCE.getUserED25519KeyPair(this) - ); SnodeModule.Companion.configure(apiDB, broadcaster); String userPublicKey = TextSecurePreferences.getLocalNumber(this); if (userPublicKey != null) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt index fccfd7694c..fa0fce7bd3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt @@ -5,7 +5,14 @@ import android.text.TextUtils import com.google.protobuf.ByteString import org.greenrobot.eventbus.EventBus import org.session.libsession.database.MessageDataProvider -import org.session.libsession.messaging.sending_receiving.attachments.* +import org.session.libsession.messaging.sending_receiving.attachments.Attachment +import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId +import org.session.libsession.messaging.sending_receiving.attachments.AttachmentState +import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment +import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachmentAudioExtras +import org.session.libsession.messaging.sending_receiving.attachments.PointerAttachment +import org.session.libsession.messaging.sending_receiving.attachments.SessionServiceAttachmentPointer +import org.session.libsession.messaging.sending_receiving.attachments.SessionServiceAttachmentStream import org.session.libsession.utilities.Address import org.session.libsession.utilities.UploadResult import org.session.libsession.utilities.Util @@ -126,7 +133,7 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper) val mmsDb = DatabaseComponent.get(context).mmsDatabase() return mmsDb.getMessage(mmsMessageId).use { cursor -> mmsDb.readerFor(cursor).next - }.isOutgoing + }?.isOutgoing ?: false } override fun isOutgoingMessage(timestamp: Long): Boolean { diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupExporter.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupExporter.kt index 3ea5d8e400..33b8b67258 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupExporter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupExporter.kt @@ -10,29 +10,51 @@ import com.annimon.stream.function.Predicate import com.google.protobuf.ByteString import net.sqlcipher.database.SQLiteDatabase import org.greenrobot.eventbus.EventBus - -import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId import org.session.libsession.avatars.AvatarHelper +import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId import org.session.libsession.utilities.Conversions - -import org.thoughtcrime.securesms.backup.BackupProtos.* -import org.thoughtcrime.securesms.crypto.AttachmentSecret -import org.thoughtcrime.securesms.crypto.ClassicDecryptingPartInputStream -import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream -import org.thoughtcrime.securesms.database.* -import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.database.LokiBackupFilesDatabase -import org.thoughtcrime.securesms.util.BackupUtil import org.session.libsession.utilities.Util import org.session.libsignal.crypto.kdf.HKDFv3 import org.session.libsignal.utilities.ByteUtil -import java.io.* -import java.lang.Exception +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.backup.BackupProtos.Attachment +import org.thoughtcrime.securesms.backup.BackupProtos.Avatar +import org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame +import org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion +import org.thoughtcrime.securesms.backup.BackupProtos.Header +import org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference +import org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement +import org.thoughtcrime.securesms.backup.BackupProtos.Sticker +import org.thoughtcrime.securesms.crypto.AttachmentSecret +import org.thoughtcrime.securesms.crypto.ClassicDecryptingPartInputStream +import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream +import org.thoughtcrime.securesms.database.AttachmentDatabase +import org.thoughtcrime.securesms.database.GroupReceiptDatabase +import org.thoughtcrime.securesms.database.JobDatabase +import org.thoughtcrime.securesms.database.LokiAPIDatabase +import org.thoughtcrime.securesms.database.LokiBackupFilesDatabase +import org.thoughtcrime.securesms.database.MmsDatabase +import org.thoughtcrime.securesms.database.MmsSmsColumns +import org.thoughtcrime.securesms.database.PushDatabase +import org.thoughtcrime.securesms.database.SearchDatabase +import org.thoughtcrime.securesms.database.SmsDatabase +import org.thoughtcrime.securesms.util.BackupUtil +import java.io.Closeable +import java.io.File +import java.io.FileInputStream +import java.io.Flushable +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream import java.security.InvalidAlgorithmParameterException import java.security.InvalidKeyException import java.security.NoSuchAlgorithmException -import java.util.* -import javax.crypto.* +import java.util.LinkedList +import javax.crypto.BadPaddingException +import javax.crypto.Cipher +import javax.crypto.IllegalBlockSizeException +import javax.crypto.Mac +import javax.crypto.NoSuchPaddingException import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.SecretKeySpec @@ -245,8 +267,8 @@ object FullBackupExporter { } private fun isForNonExpiringMessage(db: SQLiteDatabase, mmsId: Long): Boolean { - val columns = arrayOf(MmsDatabase.EXPIRES_IN) - val where = MmsDatabase.ID + " = ?" + val columns = arrayOf(MmsSmsColumns.EXPIRES_IN) + val where = MmsSmsColumns.ID + " = ?" val args = arrayOf(mmsId.toString()) db.query(MmsDatabase.TABLE_NAME, columns, where, args, null, null, null).use { mmsCursor -> if (mmsCursor != null && mmsCursor.moveToFirst()) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupImporter.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupImporter.kt index 201671c1b0..ba1df97d56 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupImporter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupImporter.kt @@ -15,19 +15,39 @@ import org.session.libsession.utilities.Util import org.session.libsignal.crypto.kdf.HKDFv3 import org.session.libsignal.utilities.ByteUtil import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.backup.BackupProtos.* +import org.thoughtcrime.securesms.backup.BackupProtos.Attachment +import org.thoughtcrime.securesms.backup.BackupProtos.Avatar +import org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame +import org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion +import org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference +import org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement import org.thoughtcrime.securesms.crypto.AttachmentSecret import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream -import org.thoughtcrime.securesms.database.* +import org.thoughtcrime.securesms.database.AttachmentDatabase +import org.thoughtcrime.securesms.database.GroupReceiptDatabase +import org.thoughtcrime.securesms.database.MmsDatabase +import org.thoughtcrime.securesms.database.MmsSmsColumns +import org.thoughtcrime.securesms.database.SearchDatabase +import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.util.BackupUtil -import java.io.* +import java.io.Closeable +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream import java.security.InvalidAlgorithmParameterException import java.security.InvalidKeyException import java.security.MessageDigest import java.security.NoSuchAlgorithmException -import java.util.* -import javax.crypto.* +import java.util.LinkedList +import java.util.Locale +import javax.crypto.BadPaddingException +import javax.crypto.Cipher +import javax.crypto.IllegalBlockSizeException +import javax.crypto.Mac +import javax.crypto.NoSuchPaddingException import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.SecretKeySpec @@ -172,7 +192,7 @@ object FullBackupImporter { } private fun trimEntriesForExpiredMessages(context: Context, db: SQLiteDatabase) { - val trimmedCondition = " NOT IN (SELECT ${MmsDatabase.ID} FROM ${MmsDatabase.TABLE_NAME})" + val trimmedCondition = " NOT IN (SELECT ${MmsSmsColumns.ID} FROM ${MmsDatabase.TABLE_NAME})" db.delete(GroupReceiptDatabase.TABLE_NAME, GroupReceiptDatabase.MMS_ID + trimmedCondition, null) val columns = arrayOf(AttachmentDatabase.ROW_ID, AttachmentDatabase.UNIQUE_ID) val where = AttachmentDatabase.MMS_ID + trimmedCondition diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java b/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java index 21797a9eef..573e8d2d2b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java @@ -8,28 +8,26 @@ import android.graphics.Outline; import android.graphics.Paint; import android.graphics.drawable.Drawable; import android.provider.ContactsContract; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.widget.AppCompatImageView; import android.util.AttributeSet; import android.view.View; import android.view.ViewOutlineProvider; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.AppCompatImageView; + import com.bumptech.glide.load.engine.DiskCacheStrategy; - - -import org.thoughtcrime.securesms.util.AvatarPlaceholderGenerator; -import org.thoughtcrime.securesms.mms.GlideApp; -import org.thoughtcrime.securesms.mms.GlideRequests; - import org.session.libsession.avatars.ContactColors; import org.session.libsession.avatars.ContactPhoto; import org.session.libsession.avatars.ResourceContactPhoto; import org.session.libsession.utilities.Address; +import org.session.libsession.utilities.ThemeUtil; import org.session.libsession.utilities.recipients.Recipient; import org.session.libsession.utilities.recipients.RecipientExporter; -import org.session.libsession.utilities.ThemeUtil; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.util.AvatarPlaceholderGenerator; import java.util.Objects; @@ -139,7 +137,7 @@ public class AvatarImageView extends AppCompatImageView { requestManager.load(photo.contactPhoto) .fallback(photoPlaceholderDrawable) .error(photoPlaceholderDrawable) - .diskCacheStrategy(DiskCacheStrategy.ALL) + .diskCacheStrategy(DiskCacheStrategy.NONE) .circleCrop() .into(this); } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt index e5419774f4..6265f2d1f5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt @@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.components import android.content.Context import android.util.AttributeSet -import android.view.LayoutInflater import android.view.View import android.widget.ImageView import android.widget.RelativeLayout @@ -10,16 +9,20 @@ import androidx.annotation.DimenRes import com.bumptech.glide.load.engine.DiskCacheStrategy import network.loki.messenger.R import network.loki.messenger.databinding.ViewProfilePictureBinding +import org.session.libsession.avatars.ContactColors +import org.session.libsession.avatars.PlaceholderAvatarPhoto import org.session.libsession.avatars.ProfileContactPhoto +import org.session.libsession.avatars.ResourceContactPhoto import org.session.libsession.messaging.contacts.Contact import org.session.libsession.utilities.Address import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.mms.GlideRequests -import org.thoughtcrime.securesms.util.AvatarPlaceholderGenerator -class ProfilePictureView : RelativeLayout { - private lateinit var binding: ViewProfilePictureBinding +class ProfilePictureView @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null +) : RelativeLayout(context, attrs) { + private val binding: ViewProfilePictureBinding by lazy { ViewProfilePictureBinding.bind(this) } lateinit var glide: GlideRequests var publicKey: String? = null var displayName: String? = null @@ -28,16 +31,9 @@ class ProfilePictureView : RelativeLayout { var isLarge = false private val profilePicturesCache = mutableMapOf() + private val unknownRecipientDrawable = ResourceContactPhoto(R.drawable.ic_profile_default) + .asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false) - // region Lifecycle - constructor(context: Context) : super(context) { initialize() } - constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() } - constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } - constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) { initialize() } - - private fun initialize() { - binding = ViewProfilePictureBinding.inflate(LayoutInflater.from(context), this, true) - } // endregion // region Updating @@ -105,21 +101,24 @@ class ProfilePictureView : RelativeLayout { if (profilePicturesCache.containsKey(publicKey) && profilePicturesCache[publicKey] == recipient.profileAvatar) return val signalProfilePicture = recipient.contactPhoto val avatar = (signalProfilePicture as? ProfileContactPhoto)?.avatarObject - val sizeInPX = resources.getDimensionPixelSize(sizeResId) + val placeholder = PlaceholderAvatarPhoto(publicKey, displayName ?: "${publicKey.take(4)}...${publicKey.takeLast(4)}") if (signalProfilePicture != null && avatar != "0" && avatar != "") { glide.clear(imageView) glide.load(signalProfilePicture) - .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) - .circleCrop() - .error(AvatarPlaceholderGenerator.generate(context,sizeInPX, publicKey, displayName)) - .into(imageView) - profilePicturesCache[publicKey] = recipient.profileAvatar + .placeholder(unknownRecipientDrawable) + .centerCrop() + .error(unknownRecipientDrawable) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .circleCrop() + .into(imageView) } else { glide.clear(imageView) - glide.load(AvatarPlaceholderGenerator.generate(context, sizeInPX, publicKey, displayName)) - .diskCacheStrategy(DiskCacheStrategy.ALL).circleCrop().into(imageView) - profilePicturesCache[publicKey] = recipient.profileAvatar + glide.load(placeholder) + .placeholder(unknownRecipientDrawable) + .centerCrop() + .diskCacheStrategy(DiskCacheStrategy.NONE).circleCrop().into(imageView) } + profilePicturesCache[publicKey] = recipient.profileAvatar } else { imageView.setImageDrawable(null) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java b/app/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java index a3b54d51e7..6ffb3dde97 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java @@ -264,7 +264,7 @@ public class QuoteView extends FrameLayout implements RecipientModifiedListener } glideRequests.load(new DecryptableUri(imageVideoSlides.get(0).getThumbnailUri())) .centerCrop() - .diskCacheStrategy(DiskCacheStrategy.RESOURCE) + .diskCacheStrategy(DiskCacheStrategy.NONE) .into(thumbnailView); } else if (!documentSlides.isEmpty()){ thumbnailView.setVisibility(GONE); diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.kt index d7a45c3172..2d758c9015 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.kt @@ -35,6 +35,13 @@ class ContactSelectionListAdapter(private val context: Context, private val mult return items.size } + override fun onViewRecycled(holder: RecyclerView.ViewHolder) { + super.onViewRecycled(holder) + if (holder is UserViewHolder) { + holder.view.unbind() + } + } + override fun getItemViewType(position: Int): Int { return when (items[position]) { is ContactSelectionListItem.Header -> ViewType.Divider diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt index 7597be4749..88d8787e88 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt @@ -54,8 +54,8 @@ class UserView : LinearLayout { val threadID = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(user) MentionManagerUtilities.populateUserPublicKeyCacheIfNeeded(threadID, context) // FIXME: This is a bad place to do this val address = user.address.serialize() - binding.profilePictureView.glide = glide - binding.profilePictureView.update(user) + binding.profilePictureView.root.glide = glide + binding.profilePictureView.root.update(user) binding.actionIndicatorImageView.setImageResource(R.drawable.ic_baseline_edit_24) binding.nameTextView.text = if (user.isGroupRecipient) user.name else getUserDisplayName(address) when (actionIndicator) { @@ -83,7 +83,7 @@ class UserView : LinearLayout { } fun unbind() { - + binding.profilePictureView.root.recycle() } // endregion } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index 67aae625bb..047e65e48f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -49,6 +49,7 @@ import kotlinx.coroutines.launch import network.loki.messenger.R import network.loki.messenger.databinding.ActivityConversationV2ActionBarBinding import network.loki.messenger.databinding.ActivityConversationV2Binding +import network.loki.messenger.databinding.ViewVisibleMessageBinding import nl.komponents.kovenant.ui.successUi import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.mentions.Mention @@ -241,7 +242,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe actionMode?.let { onDeselect(message, position, it) } - } + }, + lifecycleCoroutineScope = lifecycleScope ) adapter.visibleMessageContentViewDelegate = this adapter @@ -314,11 +316,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe scrollToFirstUnreadMessageIfNeeded() showOrHideInputIfNeeded() setUpMessageRequestsBar() - if (viewModel.recipient.isOpenGroupRecipient) { - val openGroup = lokiThreadDb.getOpenGroupChat(viewModel.threadId) - if (openGroup == null) { - Toast.makeText(this, "This thread has been deleted.", Toast.LENGTH_LONG).show() - return finish() + viewModel.recipient?.let { recipient -> + if (recipient.isOpenGroupRecipient) { + val openGroup = lokiThreadDb.getOpenGroupChat(viewModel.threadId) + if (openGroup == null) { + Toast.makeText(this, "This thread has been deleted.", Toast.LENGTH_LONG).show() + return finish() + } } } } @@ -326,7 +330,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe override fun onResume() { super.onResume() ApplicationContext.getInstance(this).messageNotifier.setVisibleThread(viewModel.threadId) - threadDb.markAllAsRead(viewModel.threadId, viewModel.recipient.isOpenGroupRecipient) + val recipient = viewModel.recipient ?: return + threadDb.markAllAsRead(viewModel.threadId, recipient.isOpenGroupRecipient) } override fun onPause() { @@ -391,17 +396,20 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe actionBar.title = "" actionBar.customView = actionBarBinding!!.root actionBar.setDisplayShowCustomEnabled(true) - actionBarBinding!!.conversationTitleView.text = viewModel.recipient.toShortString() - @DimenRes val sizeID: Int = if (viewModel.recipient.isClosedGroupRecipient) { + actionBarBinding!!.conversationTitleView.text = viewModel.recipient?.toShortString() + @DimenRes val sizeID: Int = if (viewModel.recipient?.isClosedGroupRecipient == true) { R.dimen.medium_profile_picture_size } else { R.dimen.small_profile_picture_size } val size = resources.getDimension(sizeID).roundToInt() - actionBarBinding!!.profilePictureView.layoutParams = LinearLayout.LayoutParams(size, size) - actionBarBinding!!.profilePictureView.glide = glide + actionBarBinding!!.profilePictureView.root.layoutParams = LinearLayout.LayoutParams(size, size) + actionBarBinding!!.profilePictureView.root.glide = glide MentionManagerUtilities.populateUserPublicKeyCacheIfNeeded(viewModel.threadId, this) - actionBarBinding!!.profilePictureView.update(viewModel.recipient) + val profilePictureView = actionBarBinding!!.profilePictureView.root + viewModel.recipient?.let { recipient -> + profilePictureView.update(recipient) + } } // called from onCreate @@ -437,7 +445,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe if (mediaURI != null && mediaType != null) { if (AttachmentManager.MediaType.IMAGE == mediaType || AttachmentManager.MediaType.GIF == mediaType || AttachmentManager.MediaType.VIDEO == mediaType) { val media = Media(mediaURI, MediaUtil.getMimeType(this, mediaURI)!!, 0, 0, 0, 0, Optional.absent(), Optional.absent()) - startActivityForResult(MediaSendActivity.buildEditorIntent(this, listOf( media ), viewModel.recipient, ""), PICK_FROM_LIBRARY) + startActivityForResult(MediaSendActivity.buildEditorIntent(this, listOf( media ), viewModel.recipient!!, ""), PICK_FROM_LIBRARY) return } else { prepMediaForSending(mediaURI, mediaType).addListener(object : ListenableFuture.Listener { @@ -491,11 +499,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } private fun setUpRecipientObserver() { - viewModel.recipient.addListener(this) + viewModel.recipient?.addListener(this) } private fun tearDownRecipientObserver() { - viewModel.recipient.removeListener(this) + viewModel.recipient?.removeListener(this) } private fun getLatestOpenGroupInfoIfNeeded() { @@ -505,12 +513,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // called from onCreate private fun setUpBlockedBanner() { - if (viewModel.recipient.isGroupRecipient) { return } - val sessionID = viewModel.recipient.address.toString() + val recipient = viewModel.recipient ?: return + if (recipient.isGroupRecipient) { return } + val sessionID = recipient.address.toString() val contact = sessionContactDb.getContactWithSessionID(sessionID) val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID binding?.blockedBannerTextView?.text = resources.getString(R.string.activity_conversation_blocked_banner_text, name) - binding?.blockedBanner?.isVisible = viewModel.recipient.isBlocked + binding?.blockedBanner?.isVisible = recipient.isBlocked binding?.blockedBanner?.setOnClickListener { viewModel.unblock() } } @@ -558,13 +567,16 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe override fun onPrepareOptionsMenu(menu: Menu): Boolean { if (!isMessageRequestThread()) { - ConversationMenuHelper.onPrepareOptionsMenu( - menu, - menuInflater, - viewModel.recipient, - viewModel.threadId, - this - ) { onOptionsItemSelected(it) } + val recipient = viewModel.recipient + if (recipient != null) { + ConversationMenuHelper.onPrepareOptionsMenu( + menu, + menuInflater, + recipient, + viewModel.threadId, + this + ) { onOptionsItemSelected(it) } + } } super.onPrepareOptionsMenu(menu) return true @@ -582,21 +594,25 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // region Animation & Updating override fun onModified(recipient: Recipient) { runOnUiThread { - if (viewModel.recipient.isContactRecipient) { - binding?.blockedBanner?.isVisible = viewModel.recipient.isBlocked + val recipient = viewModel.recipient + if (recipient != null && recipient.isContactRecipient) { + binding?.blockedBanner?.isVisible = recipient.isBlocked } setUpMessageRequestsBar() invalidateOptionsMenu() updateSubtitle() showOrHideInputIfNeeded() - actionBarBinding?.profilePictureView?.update(recipient) - actionBarBinding?.conversationTitleView?.text = recipient.toShortString() + if (recipient != null) { + actionBarBinding?.profilePictureView?.root?.update(recipient) + } + actionBarBinding?.conversationTitleView?.text = recipient?.toShortString() } } private fun showOrHideInputIfNeeded() { - if (viewModel.recipient.isClosedGroupRecipient) { - val group = groupDb.getGroup(viewModel.recipient.address.toGroupString()).orNull() + val recipient = viewModel.recipient + if (recipient != null && recipient.isClosedGroupRecipient) { + val group = groupDb.getGroup(recipient.address.toGroupString()).orNull() val isActive = (group?.isActive == true) binding?.inputBar?.showInput = isActive } else { @@ -632,17 +648,22 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } private fun isMessageRequestThread(): Boolean { - return !viewModel.recipient.isGroupRecipient && !viewModel.recipient.isApproved + val recipient = viewModel.recipient ?: return false + return !recipient.isGroupRecipient && !recipient.isApproved } private fun isOutgoingMessageRequestThread(): Boolean { - return !viewModel.recipient.isGroupRecipient && - !(viewModel.recipient.hasApprovedMe() || viewModel.hasReceived()) + val recipient = viewModel.recipient ?: return false + return !recipient.isGroupRecipient && + !recipient.isLocalNumber && + !(recipient.hasApprovedMe() || viewModel.hasReceived()) } private fun isIncomingMessageRequestThread(): Boolean { - return !viewModel.recipient.isGroupRecipient && - !viewModel.recipient.isApproved && + val recipient = viewModel.recipient ?: return false + return !recipient.isGroupRecipient && + !recipient.isApproved && + !recipient.isLocalNumber && !threadDb.getLastSeenAndHasSent(viewModel.threadId).second() && threadDb.getMessageCount(viewModel.threadId) > 0 } @@ -701,17 +722,18 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private fun showOrUpdateMentionCandidatesIfNeeded(query: String = "") { val additionalContentContainer = binding?.additionalContentContainer ?: return + val recipient = viewModel.recipient ?: return if (!isShowingMentionCandidatesView) { additionalContentContainer.removeAllViews() val view = MentionCandidatesView(this) view.glide = glide view.onCandidateSelected = { handleMentionSelected(it) } additionalContentContainer.addView(view) - val candidates = MentionsManager.getMentionCandidates(query, viewModel.threadId, viewModel.recipient.isOpenGroupRecipient) + val candidates = MentionsManager.getMentionCandidates(query, viewModel.threadId, recipient.isOpenGroupRecipient) this.mentionCandidatesView = view view.show(candidates, viewModel.threadId) } else { - val candidates = MentionsManager.getMentionCandidates(query, viewModel.threadId, viewModel.recipient.isOpenGroupRecipient) + val candidates = MentionsManager.getMentionCandidates(query, viewModel.threadId, recipient.isOpenGroupRecipient) this.mentionCandidatesView!!.setMentionCandidates(candidates) } isShowingMentionCandidatesView = true @@ -839,15 +861,16 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private fun updateSubtitle() { val actionBarBinding = actionBarBinding ?: return - actionBarBinding.muteIconImageView.isVisible = viewModel.recipient.isMuted + val recipient = viewModel.recipient ?: return + actionBarBinding.muteIconImageView.isVisible = recipient.isMuted actionBarBinding.conversationSubtitleView.isVisible = true - if (viewModel.recipient.isMuted) { - if (viewModel.recipient.mutedUntil != Long.MAX_VALUE) { - actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_muted_until_date, DateUtils.getFormattedDateTime(viewModel.recipient.mutedUntil, "EEE, MMM d, yyyy HH:mm", Locale.getDefault())) + if (recipient.isMuted) { + if (recipient.mutedUntil != Long.MAX_VALUE) { + actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_muted_until_date, DateUtils.getFormattedDateTime(recipient.mutedUntil, "EEE, MMM d, yyyy HH:mm", Locale.getDefault())) } else { actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_muted_forever) } - } else if (viewModel.recipient.isGroupRecipient) { + } else if (recipient.isGroupRecipient) { val openGroup = lokiThreadDb.getOpenGroupChat(viewModel.threadId) if (openGroup != null) { val userCount = lokiApiDb.getUserCount(openGroup.room, openGroup.server) ?: 0 @@ -866,7 +889,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe if (item.itemId == android.R.id.home) { return false } - return ConversationMenuHelper.onOptionItemSelected(this, item, viewModel.recipient) + return viewModel.recipient?.let { recipient -> + ConversationMenuHelper.onOptionItemSelected(this, item, recipient) + } ?: false } // `position` is the adapter position; not the visual position @@ -896,7 +921,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // `position` is the adapter position; not the visual position private fun handleSwipeToReply(message: MessageRecord, position: Int) { - binding?.inputBar?.draftQuote(viewModel.recipient, message, glide) + val recipient = viewModel.recipient ?: return + binding?.inputBar?.draftQuote(recipient, message, glide) } // `position` is the adapter position; not the visual position @@ -1002,12 +1028,14 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe override fun playVoiceMessageAtIndexIfPossible(indexInAdapter: Int) { if (indexInAdapter < 0 || indexInAdapter >= adapter.itemCount) { return } val viewHolder = binding?.conversationRecyclerView?.findViewHolderForAdapterPosition(indexInAdapter) as? ConversationAdapter.VisibleMessageViewHolder ?: return - viewHolder.view.playVoiceMessage() + val visibleMessageView = ViewVisibleMessageBinding.bind(viewHolder.view).visibleMessageView + visibleMessageView.playVoiceMessage() } override fun sendMessage() { - if (viewModel.recipient.isContactRecipient && viewModel.recipient.isBlocked) { - BlockedDialog(viewModel.recipient).show(supportFragmentManager, "Blocked Dialog") + val recipient = viewModel.recipient ?: return + if (recipient.isContactRecipient && recipient.isBlocked) { + BlockedDialog(recipient).show(supportFragmentManager, "Blocked Dialog") return } val binding = binding ?: return @@ -1019,24 +1047,26 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } override fun commitInputContent(contentUri: Uri) { + val recipient = viewModel.recipient ?: return val media = Media(contentUri, MediaUtil.getMimeType(this, contentUri)!!, 0, 0, 0, 0, Optional.absent(), Optional.absent()) - startActivityForResult(MediaSendActivity.buildEditorIntent(this, listOf( media ), viewModel.recipient, getMessageBody()), PICK_FROM_LIBRARY) + startActivityForResult(MediaSendActivity.buildEditorIntent(this, listOf( media ), recipient, getMessageBody()), PICK_FROM_LIBRARY) } private fun processMessageRequestApproval() { if (isIncomingMessageRequestThread()) { acceptMessageRequest() - } else if (!viewModel.recipient.isApproved) { + } else if (viewModel.recipient?.isApproved == false) { // edge case for new outgoing thread on new recipient without sending approval messages viewModel.setRecipientApproved() } } private fun sendTextOnlyMessage(hasPermissionToSendSeed: Boolean = false) { + val recipient = viewModel.recipient ?: return processMessageRequestApproval() val text = getMessageBody() val userPublicKey = textSecurePreferences.getLocalNumber() - val isNoteToSelf = (viewModel.recipient.isContactRecipient && viewModel.recipient.address.toString() == userPublicKey) + val isNoteToSelf = (recipient.isContactRecipient && recipient.address.toString() == userPublicKey) if (text.contains(seed) && !isNoteToSelf && !hasPermissionToSendSeed) { val dialog = SendSeedDialog { sendTextOnlyMessage(true) } return dialog.show(supportFragmentManager, "Send Seed Dialog") @@ -1045,7 +1075,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val message = VisibleMessage() message.sentTimestamp = System.currentTimeMillis() message.text = text - val outgoingTextMessage = OutgoingTextMessage.from(message, viewModel.recipient) + val outgoingTextMessage = OutgoingTextMessage.from(message, recipient) // Clear the input bar binding?.inputBar?.text = "" binding?.inputBar?.cancelQuoteDraft() @@ -1055,14 +1085,15 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe currentMentionStartIndex = -1 mentions.clear() // Put the message in the database - message.id = smsDb.insertMessageOutbox(viewModel.threadId, outgoingTextMessage, false, message.sentTimestamp!!) { } + message.id = smsDb.insertMessageOutbox(viewModel.threadId, outgoingTextMessage, false, message.sentTimestamp!!, null, true) // Send it - MessageSender.send(message, viewModel.recipient.address) + MessageSender.send(message, recipient.address) // Send a typing stopped message ApplicationContext.getInstance(this).typingStatusSender.onTypingStopped(viewModel.threadId) } private fun sendAttachments(attachments: List, body: String?, quotedMessage: MessageRecord? = null, linkPreview: LinkPreview? = null) { + val recipient = viewModel.recipient ?: return processMessageRequestApproval() // Create the message val message = VisibleMessage() @@ -1073,7 +1104,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val sender = if (it.isOutgoing) fromSerialized(textSecurePreferences.getLocalNumber()!!) else it.individualRecipient.address QuoteModel(it.dateSent, sender, it.body, false, quotedAttachments) } - val outgoingTextMessage = OutgoingMediaMessage.from(message, viewModel.recipient, attachments, quote, linkPreview) + val outgoingTextMessage = OutgoingMediaMessage.from(message, recipient, attachments, quote, linkPreview) // Clear the input bar binding?.inputBar?.text = "" binding?.inputBar?.cancelQuoteDraft() @@ -1087,9 +1118,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // Reset attachments button if needed if (isShowingAttachmentOptions) { toggleAttachmentOptions() } // Put the message in the database - message.id = mmsDb.insertMessageOutbox(outgoingTextMessage, viewModel.threadId, false) { } + message.id = mmsDb.insertMessageOutbox(outgoingTextMessage, viewModel.threadId, false, null, runThreadUpdate = true) // Send it - MessageSender.send(message, viewModel.recipient.address, attachments, quote, linkPreview) + MessageSender.send(message, recipient.address, attachments, quote, linkPreview) // Send a typing stopped message ApplicationContext.getInstance(this).typingStatusSender.onTypingStopped(viewModel.threadId) } @@ -1119,8 +1150,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } private fun pickFromLibrary() { + val recipient = viewModel.recipient ?: return binding?.inputBar?.text?.trim()?.let { text -> - AttachmentManager.selectGallery(this, PICK_FROM_LIBRARY, viewModel.recipient, text) + AttachmentManager.selectGallery(this, PICK_FROM_LIBRARY, recipient, text) } } @@ -1187,7 +1219,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe sendAttachments(slideDeck.asAttachments(), body) } INVITE_CONTACTS -> { - if (!viewModel.recipient.isOpenGroupRecipient) { return } + if (viewModel.recipient?.isOpenGroupRecipient != true) { return } val extras = intent?.extras ?: return if (!intent.hasExtra(selectedContactsKey)) { return } val selectedContacts = extras.getStringArray(selectedContactsKey)!! @@ -1268,13 +1300,14 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } override fun deleteMessages(messages: Set) { + val recipient = viewModel.recipient ?: return if (!IS_UNSEND_REQUESTS_ENABLED) { deleteMessagesWithoutUnsendRequest(messages) return } val allSentByCurrentUser = messages.all { it.isOutgoing } val allHasHash = messages.all { lokiMessageDb.getMessageServerHash(it.id) != null } - if (viewModel.recipient.isOpenGroupRecipient) { + if (recipient.isOpenGroupRecipient) { val messageCount = messages.size val builder = AlertDialog.Builder(this) builder.setTitle(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount)) @@ -1293,7 +1326,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe builder.show() } else if (allSentByCurrentUser && allHasHash) { val bottomSheet = DeleteOptionsBottomSheet() - bottomSheet.recipient = viewModel.recipient + bottomSheet.recipient = recipient bottomSheet.onDeleteForMeTapped = { for (message in messages) { viewModel.deleteLocally(message) @@ -1452,16 +1485,18 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } override fun reply(messages: Set) { - binding?.inputBar?.draftQuote(viewModel.recipient, messages.first(), glide) + val recipient = viewModel.recipient ?: return + binding?.inputBar?.draftQuote(recipient, messages.first(), glide) endActionMode() } private fun sendMediaSavedNotification() { - if (viewModel.recipient.isGroupRecipient) { return } + val recipient = viewModel.recipient ?: return + if (recipient.isGroupRecipient) { return } val timestamp = System.currentTimeMillis() val kind = DataExtractionNotification.Kind.MediaSaved(timestamp) val message = DataExtractionNotification(kind) - MessageSender.send(message, viewModel.recipient.address) + MessageSender.send(message, recipient.address) } private fun endActionMode() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt index c90739ab15..10ae36fb8a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt @@ -4,10 +4,25 @@ import android.app.AlertDialog import android.content.Context import android.content.Intent import android.database.Cursor +import android.util.SparseArray +import android.util.SparseBooleanArray +import android.view.LayoutInflater import android.view.MotionEvent +import android.view.View import android.view.ViewGroup +import androidx.annotation.WorkerThread +import androidx.core.util.getOrDefault +import androidx.core.util.set +import androidx.lifecycle.LifecycleCoroutineScope import androidx.recyclerview.widget.RecyclerView.ViewHolder +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import network.loki.messenger.R +import network.loki.messenger.databinding.ViewVisibleMessageBinding +import org.session.libsession.messaging.contacts.Contact import org.thoughtcrime.securesms.conversation.v2.messages.ControlMessageView import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageContentViewDelegate import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView @@ -19,13 +34,33 @@ import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity class ConversationAdapter(context: Context, cursor: Cursor, private val onItemPress: (MessageRecord, Int, VisibleMessageView, MotionEvent) -> Unit, private val onItemSwipeToReply: (MessageRecord, Int) -> Unit, private val onItemLongPress: (MessageRecord, Int) -> Unit, - private val glide: GlideRequests, private val onDeselect: (MessageRecord, Int) -> Unit) + private val glide: GlideRequests, private val onDeselect: (MessageRecord, Int) -> Unit, lifecycleCoroutineScope: LifecycleCoroutineScope) : CursorRecyclerViewAdapter(context, cursor) { - private val messageDB = DatabaseComponent.get(context).mmsSmsDatabase() + private val messageDB by lazy { DatabaseComponent.get(context).mmsSmsDatabase() } + private val contactDB by lazy { DatabaseComponent.get(context).sessionContactDatabase() } var selectedItems = mutableSetOf() private var searchQuery: String? = null var visibleMessageContentViewDelegate: VisibleMessageContentViewDelegate? = null + private val updateQueue = Channel(1024, onBufferOverflow = BufferOverflow.DROP_OLDEST) + private val contactCache = SparseArray(100) + private val contactLoadedCache = SparseBooleanArray(100) + init { + lifecycleCoroutineScope.launch(IO) { + while (isActive) { + val item = updateQueue.receive() + val contact = getSenderInfo(item) ?: continue + contactCache[item.hashCode()] = contact + contactLoadedCache[item.hashCode()] = true + } + } + } + + @WorkerThread + private fun getSenderInfo(sender: String): Contact? { + return contactDB.getContactWithSessionID(sender) + } + sealed class ViewType(val rawValue: Int) { object Visible : ViewType(0) object Control : ViewType(1) @@ -39,7 +74,7 @@ class ConversationAdapter(context: Context, cursor: Cursor, private val onItemPr } } - class VisibleMessageViewHolder(val view: VisibleMessageView) : ViewHolder(view) + class VisibleMessageViewHolder(val view: View) : ViewHolder(view) class ControlMessageViewHolder(val view: ControlMessageView) : ViewHolder(view) override fun getItemViewType(cursor: Cursor): Int { @@ -52,7 +87,7 @@ class ConversationAdapter(context: Context, cursor: Cursor, private val onItemPr @Suppress("NAME_SHADOWING") val viewType = ViewType.allValues[viewType] return when (viewType) { - ViewType.Visible -> VisibleMessageViewHolder(VisibleMessageView(context)) + ViewType.Visible -> VisibleMessageViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.view_visible_message, parent, false)) ViewType.Control -> ControlMessageViewHolder(ControlMessageView(context)) else -> throw IllegalStateException("Unexpected view type: $viewType.") } @@ -65,20 +100,31 @@ class ConversationAdapter(context: Context, cursor: Cursor, private val onItemPr when (viewHolder) { is VisibleMessageViewHolder -> { val view = viewHolder.view + val visibleMessageView = ViewVisibleMessageBinding.bind(viewHolder.view).visibleMessageView val isSelected = selectedItems.contains(message) - view.snIsSelected = isSelected - view.indexInAdapter = position - view.bind(message, messageBefore, getMessageAfter(position, cursor), glide, searchQuery) - if (!message.isDeleted) { - view.onPress = { event -> onItemPress(message, viewHolder.adapterPosition, view, event) } - view.onSwipeToReply = { onItemSwipeToReply(message, viewHolder.adapterPosition) } - view.onLongPress = { onItemLongPress(message, viewHolder.adapterPosition) } - } else { - view.onPress = null - view.onSwipeToReply = null - view.onLongPress = null + visibleMessageView.snIsSelected = isSelected + visibleMessageView.indexInAdapter = position + val senderId = message.individualRecipient.address.serialize() + val senderIdHash = senderId.hashCode() + updateQueue.trySend(senderId) + if (contactCache[senderIdHash] == null && !contactLoadedCache.getOrDefault(senderIdHash, false)) { + getSenderInfo(senderId)?.let { contact -> + contactCache[senderIdHash] = contact + } } - view.contentViewDelegate = visibleMessageContentViewDelegate + val contact = contactCache[senderIdHash] + + visibleMessageView.bind(message, messageBefore, getMessageAfter(position, cursor), glide, searchQuery, contact, senderId) + if (!message.isDeleted) { + visibleMessageView.onPress = { event -> onItemPress(message, viewHolder.adapterPosition, visibleMessageView, event) } + visibleMessageView.onSwipeToReply = { onItemSwipeToReply(message, viewHolder.adapterPosition) } + visibleMessageView.onLongPress = { onItemLongPress(message, viewHolder.adapterPosition) } + } else { + visibleMessageView.onPress = null + visibleMessageView.onSwipeToReply = null + visibleMessageView.onLongPress = null + } + visibleMessageView.contentViewDelegate = visibleMessageContentViewDelegate } is ControlMessageViewHolder -> { viewHolder.view.bind(message, messageBefore) @@ -105,7 +151,7 @@ class ConversationAdapter(context: Context, cursor: Cursor, private val onItemPr override fun onItemViewRecycled(viewHolder: ViewHolder?) { when (viewHolder) { - is VisibleMessageViewHolder -> viewHolder.view.recycle() + is VisibleMessageViewHolder -> viewHolder.view.findViewById(R.id.visibleMessageView).recycle() is ControlMessageViewHolder -> viewHolder.view.recycle() } super.onItemViewRecycled(viewHolder) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 89cfc6db58..e86fd935d3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.repository.ConversationRepository import java.util.UUID @@ -22,8 +23,8 @@ class ConversationViewModel( private val _uiState = MutableStateFlow(ConversationUiState()) val uiState: StateFlow = _uiState - val recipient: Recipient - get() = repository.getRecipientForThreadId(threadId) + val recipient: Recipient? + get() = repository.maybeGetRecipientForThreadId(threadId) init { _uiState.update { @@ -44,20 +45,24 @@ class ConversationViewModel( } fun unblock() { + val recipient = recipient ?: return Log.w("Loki", "Recipient was null for unblock action") if (recipient.isContactRecipient) { repository.unblock(recipient) } } fun deleteLocally(message: MessageRecord) { + val recipient = recipient ?: return Log.w("Loki", "Recipient was null for delete locally action") repository.deleteLocally(recipient, message) } fun setRecipientApproved() { + val recipient = recipient ?: return Log.w("Loki", "Recipient was null for set approved action") repository.setApproved(recipient, true) } fun deleteForEveryone(message: MessageRecord) = viewModelScope.launch { + val recipient = recipient ?: return@launch repository.deleteForEveryone(threadId, recipient, message) .onFailure { showMessage("Couldn't delete message due to error: $it") @@ -92,6 +97,7 @@ class ConversationViewModel( } fun acceptMessageRequest() = viewModelScope.launch { + val recipient = recipient ?: return@launch Log.w("Loki", "Recipient was null for accept message request action") repository.acceptMessageRequest(threadId, recipient) .onSuccess { _uiState.update { @@ -104,6 +110,7 @@ class ConversationViewModel( } fun declineMessageRequest() { + val recipient = recipient ?: return Log.w("Loki", "Recipient was null for decline message request action") repository.declineMessageRequest(threadId, recipient) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/AlbumThumbnailView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/AlbumThumbnailView.kt index 46387d9c95..3dc7c76985 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/AlbumThumbnailView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/AlbumThumbnailView.kt @@ -118,7 +118,7 @@ class AlbumThumbnailView : FrameLayout { this.slideSize = slides.size } // iterate binding - slides.take(5).forEachIndexed { position, slide -> + slides.take(MAX_ALBUM_DISPLAY_SIZE).forEachIndexed { position, slide -> val thumbnailView = getThumbnailView(position) thumbnailView.setImageResource(glideRequests, slide, isPreview = false, mms = message) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/MentionCandidateView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/MentionCandidateView.kt index 5486cf0e9d..a72ee0daf8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/MentionCandidateView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/MentionCandidateView.kt @@ -28,11 +28,11 @@ class MentionCandidateView : LinearLayout { private fun update() = with(binding) { mentionCandidateNameTextView.text = mentionCandidate.displayName - profilePictureView.publicKey = mentionCandidate.publicKey - profilePictureView.displayName = mentionCandidate.displayName - profilePictureView.additionalPublicKey = null - profilePictureView.glide = glide!! - profilePictureView.update() + profilePictureView.root.publicKey = mentionCandidate.publicKey + profilePictureView.root.displayName = mentionCandidate.displayName + profilePictureView.root.additionalPublicKey = null + profilePictureView.root.glide = glide!! + profilePictureView.root.update() if (openGroupServer != null && openGroupRoom != null) { val isUserModerator = OpenGroupAPIV2.isUserModerator(mentionCandidate.publicKey, openGroupRoom!!, openGroupServer!!) moderatorIconImageView.visibility = if (isUserModerator) View.VISIBLE else View.GONE diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/JoinOpenGroupDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/JoinOpenGroupDialog.kt index 5c4c7444dc..39007db699 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/JoinOpenGroupDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/JoinOpenGroupDialog.kt @@ -9,6 +9,7 @@ import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import network.loki.messenger.R import network.loki.messenger.databinding.DialogJoinOpenGroupBinding +import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.utilities.OpenGroupUrlParser import org.session.libsignal.utilities.ThreadUtils import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog @@ -37,6 +38,7 @@ class JoinOpenGroupDialog(private val name: String, private val url: String) : B val activity = requireContext() as AppCompatActivity ThreadUtils.queue { OpenGroupManager.add(openGroup.server, openGroup.room, openGroup.serverPublicKey, activity) + MessagingModuleConfiguration.shared.storage.onOpenGroupAdded(url) ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(activity) } dismiss() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt index 2db3561607..affabd3f5a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt @@ -122,9 +122,12 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li linkPreview = null linkPreviewDraftView = null binding.inputBarAdditionalContentContainer.removeAllViews() - val quoteView = QuoteView(context, QuoteView.Mode.Draft) + + // inflate quoteview with typed array here + val layout = LayoutInflater.from(context).inflate(R.layout.view_quote_draft, binding.inputBarAdditionalContentContainer, false) + val quoteView = layout.findViewById(R.id.mainQuoteViewContainer) quoteView.delegate = this - binding.inputBarAdditionalContentContainer.addView(quoteView) + binding.inputBarAdditionalContentContainer.addView(layout) val attachments = (message as? MmsMessageRecord)?.slideDeck val sender = if (message.isOutgoing) TextSecurePreferences.getLocalNumber(context)!! else message.individualRecipient.address.serialize() quoteView.bind(sender, message.body, attachments, diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt index 2266a1c562..83cd2a250f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt @@ -28,11 +28,11 @@ class MentionCandidateView : RelativeLayout { private fun update() = with(binding) { mentionCandidateNameTextView.text = candidate.displayName - profilePictureView.publicKey = candidate.publicKey - profilePictureView.displayName = candidate.displayName - profilePictureView.additionalPublicKey = null - profilePictureView.glide = glide!! - profilePictureView.update() + profilePictureView.root.publicKey = candidate.publicKey + profilePictureView.root.displayName = candidate.displayName + profilePictureView.root.additionalPublicKey = null + profilePictureView.root.glide = glide!! + profilePictureView.root.update() if (openGroupServer != null && openGroupRoom != null) { val isUserModerator = OpenGroupAPIV2.isUserModerator(candidate.publicKey, openGroupRoom!!, openGroupServer!!) moderatorIconImageView.visibility = if (isUserModerator) View.VISIBLE else View.GONE diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/DeletedMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/DeletedMessageView.kt index 99af8c2ca4..9c725ee048 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/DeletedMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/DeletedMessageView.kt @@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.conversation.v2.messages import android.content.Context import android.content.res.ColorStateList import android.util.AttributeSet -import android.view.LayoutInflater import android.widget.LinearLayout import androidx.annotation.ColorInt import network.loki.messenger.R @@ -11,15 +10,12 @@ import network.loki.messenger.databinding.ViewDeletedMessageBinding import org.thoughtcrime.securesms.database.model.MessageRecord class DeletedMessageView : LinearLayout { - private lateinit var binding: ViewDeletedMessageBinding + private val binding: ViewDeletedMessageBinding by lazy { ViewDeletedMessageBinding.bind(this) } // region Lifecycle - constructor(context: Context) : super(context) { initialize() } - constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() } - constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } + constructor(context: Context) : super(context) + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) - private fun initialize() { - binding = ViewDeletedMessageBinding.inflate(LayoutInflater.from(context), this, true) - } // endregion // region Updating diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/DocumentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/DocumentView.kt index e0a0630e81..f4f1a2cd97 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/DocumentView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/DocumentView.kt @@ -3,22 +3,17 @@ package org.thoughtcrime.securesms.conversation.v2.messages import android.content.Context import android.content.res.ColorStateList import android.util.AttributeSet -import android.view.LayoutInflater import android.widget.LinearLayout import androidx.annotation.ColorInt import network.loki.messenger.databinding.ViewDocumentBinding import org.thoughtcrime.securesms.database.model.MmsMessageRecord class DocumentView : LinearLayout { - private lateinit var binding: ViewDocumentBinding + private val binding: ViewDocumentBinding by lazy { ViewDocumentBinding.bind(this) } // region Lifecycle - constructor(context: Context) : super(context) { initialize() } - constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() } - constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } - - private fun initialize() { - binding = ViewDocumentBinding.inflate(LayoutInflater.from(context), this, true) - } + constructor(context: Context) : super(context) + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) // endregion // region Updating diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/OpenGroupInvitationView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/OpenGroupInvitationView.kt index 4cfc5b556e..3fbc1e6ac0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/OpenGroupInvitationView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/OpenGroupInvitationView.kt @@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.conversation.v2.messages import android.content.Context import android.util.AttributeSet -import android.view.LayoutInflater import android.widget.LinearLayout import androidx.annotation.ColorInt import androidx.appcompat.app.AppCompatActivity @@ -14,16 +13,12 @@ import org.thoughtcrime.securesms.conversation.v2.dialogs.JoinOpenGroupDialog import org.thoughtcrime.securesms.database.model.MessageRecord class OpenGroupInvitationView : LinearLayout { - private lateinit var binding: ViewOpenGroupInvitationBinding + private val binding: ViewOpenGroupInvitationBinding by lazy { ViewOpenGroupInvitationBinding.bind(this) } private var data: UpdateMessageData.Kind.OpenGroupInvitation? = null - constructor(context: Context): super(context) { initialize() } - constructor(context: Context, attrs: AttributeSet?): super(context, attrs) { initialize() } - constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int): super(context, attrs, defStyleAttr) { initialize() } - - private fun initialize() { - binding = ViewOpenGroupInvitationBinding.inflate(LayoutInflater.from(context), this, true) - } + constructor(context: Context): super(context) + constructor(context: Context, attrs: AttributeSet?): super(context, attrs) + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int): super(context, attrs, defStyleAttr) fun bind(message: MessageRecord, @ColorInt textColor: Int) { // FIXME: This is a really weird approach... diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt index 95d76dd94f..12f840ff76 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt @@ -2,12 +2,11 @@ package org.thoughtcrime.securesms.conversation.v2.messages import android.content.Context import android.content.res.ColorStateList -import android.text.StaticLayout import android.util.AttributeSet -import android.view.LayoutInflater -import android.widget.LinearLayout import androidx.annotation.ColorInt +import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.res.ResourcesCompat +import androidx.core.content.res.use import androidx.core.text.toSpannable import androidx.core.view.isVisible import dagger.hilt.android.AndroidEntryPoint @@ -16,17 +15,13 @@ import network.loki.messenger.databinding.ViewQuoteBinding import org.session.libsession.messaging.contacts.Contact import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities -import org.thoughtcrime.securesms.conversation.v2.utilities.TextUtilities import org.thoughtcrime.securesms.database.SessionContactDatabase -import org.thoughtcrime.securesms.database.model.Quote import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.SlideDeck import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.UiModeUtilities import org.thoughtcrime.securesms.util.toPx import javax.inject.Inject -import kotlin.math.max -import kotlin.math.min // There's quite some calculation going on here. It's a bit complex so don't make changes // if you don't need to. If you do then test: @@ -35,27 +30,29 @@ import kotlin.math.min // • Quoted voice messages and documents in both private chats and group chats // • All of the above in both dark mode and light mode @AndroidEntryPoint -class QuoteView : LinearLayout { +class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : ConstraintLayout(context, attrs) { @Inject lateinit var contactDb: SessionContactDatabase - private lateinit var binding: ViewQuoteBinding - private lateinit var mode: Mode + private val binding: ViewQuoteBinding by lazy { ViewQuoteBinding.bind(this) } private val vPadding by lazy { toPx(6, resources) } var delegate: QuoteViewDelegate? = null + private val mode: Mode enum class Mode { Regular, Draft } - // region Lifecycle - constructor(context: Context) : this(context, Mode.Regular) - constructor(context: Context, attrs: AttributeSet) : this(context, Mode.Regular, attrs) + init { + mode = attrs?.let { attrSet -> + context.obtainStyledAttributes(attrSet, R.styleable.QuoteView).use { typedArray -> + val modeIndex = typedArray.getInt(R.styleable.QuoteView_quote_mode, 0) + Mode.values()[modeIndex] + } + } ?: Mode.Regular + } - constructor(context: Context, mode: Mode, attrs: AttributeSet? = null) : super(context, attrs) { - this.mode = mode - binding = ViewQuoteBinding.inflate(LayoutInflater.from(context), this, true) - // Add padding here (not on binding.mainQuoteViewContainer) to get a bit of a top inset while avoiding - // the clipping issue described in getIntrinsicHeight(maxContentWidth:). - setPadding(0, toPx(6, resources), 0, 0) + // region Lifecycle + override fun onFinishInflate() { + super.onFinishInflate() when (mode) { Mode.Draft -> binding.quoteViewCancelButton.setOnClickListener { delegate?.cancelQuoteDraft() } Mode.Regular -> { @@ -66,44 +63,6 @@ class QuoteView : LinearLayout { } // endregion - // region General - fun getIntrinsicContentHeight(maxContentWidth: Int): Int { - // If we're showing an attachment thumbnail, just constrain to the height of that - if (binding.quoteViewAttachmentPreviewContainer.isVisible) { return toPx(40, resources) } - var result = 0 - val authorTextViewIntrinsicHeight: Int - if (binding.quoteViewAuthorTextView.isVisible) { - val author = binding.quoteViewAuthorTextView.text - authorTextViewIntrinsicHeight = TextUtilities.getIntrinsicHeight(author, binding.quoteViewAuthorTextView.paint, maxContentWidth) - result += authorTextViewIntrinsicHeight - } - val body = binding.quoteViewBodyTextView.text - val bodyTextViewIntrinsicHeight = TextUtilities.getIntrinsicHeight(body, binding.quoteViewBodyTextView.paint, maxContentWidth) - val staticLayout = TextUtilities.getIntrinsicLayout(body, binding.quoteViewBodyTextView.paint, maxContentWidth) - result += bodyTextViewIntrinsicHeight - if (!binding.quoteViewAuthorTextView.isVisible) { - // We want to at least be as high as the cancel button 36DP, and no higher than 3 lines of text. - // Height from intrinsic layout is the height of the text before truncation so we shorten - // proportionally to our max lines setting. - return max(toPx(32, resources) ,min((result / staticLayout.lineCount) * 3, result)) - } else { - // Because we're showing the author text view, we should have a height of at least 32 DP - // anyway, so there's no need to constrain to that. We constrain to a max height of 56 DP - // because that's approximately the height of the author text view + 2 lines of the body - // text view. - return min(result, toPx(56, resources)) - } - } - - fun getIntrinsicHeight(maxContentWidth: Int): Int { - // The way all this works is that we just calculate the total height the quote view should be - // and then center everything inside vertically. This effectively means we're applying padding. - // Applying padding the regular way results in a clipping issue though due to a bug in - // RelativeLayout. - return getIntrinsicContentHeight(maxContentWidth) + (2 * vPadding ) - } - // endregion - // region Updating fun bind(authorPublicKey: String, body: String?, attachments: SlideDeck?, thread: Recipient, isOutgoingMessage: Boolean, isOpenGroupInvitation: Boolean, threadID: Long, @@ -115,7 +74,7 @@ class QuoteView : LinearLayout { // Author if (thread.isGroupRecipient) { val author = contactDb.getContactWithSessionID(authorPublicKey) - val authorDisplayName = author?.displayName(Contact.contextForRecipient(thread)) ?: authorPublicKey + val authorDisplayName = author?.displayName(Contact.contextForRecipient(thread)) ?: "${authorPublicKey.take(4)}...${authorPublicKey.takeLast(4)}" binding.quoteViewAuthorTextView.text = authorDisplayName binding.quoteViewAuthorTextView.setTextColor(getTextColor(isOutgoingMessage)) } @@ -190,30 +149,6 @@ class QuoteView : LinearLayout { } } - fun calculateWidth(quote: Quote, bodyWidth: Int, maxContentWidth: Int, thread: Recipient): Int { - binding.quoteViewAuthorTextView.isVisible = thread.isGroupRecipient - var paddingWidth = resources.getDimensionPixelSize(R.dimen.medium_spacing) * 5 // initial horizontal padding - with (binding) { - if (quoteViewAttachmentPreviewContainer.isVisible) { - paddingWidth += toPx(40, resources) - } - if (quoteViewAccentLine.isVisible) { - paddingWidth += resources.getDimensionPixelSize(R.dimen.accent_line_thickness) - } - } - val quoteBodyWidth = StaticLayout.getDesiredWidth(binding.quoteViewBodyTextView.text, binding.quoteViewBodyTextView.paint).toInt() + paddingWidth - - val quoteAuthorWidth = if (thread.isGroupRecipient) { - val authorPublicKey = quote.author.serialize() - val author = contactDb.getContactWithSessionID(authorPublicKey) - val authorDisplayName = author?.displayName(Contact.contextForRecipient(thread)) ?: authorPublicKey - StaticLayout.getDesiredWidth(authorDisplayName, binding.quoteViewBodyTextView.paint).toInt() + paddingWidth - } else 0 - - val quoteWidth = max(quoteBodyWidth, quoteAuthorWidth) - val usedWidth = max(quoteWidth, bodyWidth) - return min(maxContentWidth, usedWidth) - } // endregion } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/UntrustedAttachmentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/UntrustedAttachmentView.kt index 7e22da0917..47034cf8ed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/UntrustedAttachmentView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/UntrustedAttachmentView.kt @@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.conversation.v2.messages import android.content.Context import android.util.AttributeSet -import android.view.LayoutInflater import android.widget.LinearLayout import androidx.annotation.ColorInt import androidx.core.content.ContextCompat @@ -14,7 +13,7 @@ import org.thoughtcrime.securesms.util.ActivityDispatcher import java.util.Locale class UntrustedAttachmentView: LinearLayout { - private lateinit var binding: ViewUntrustedAttachmentBinding + private val binding: ViewUntrustedAttachmentBinding by lazy { ViewUntrustedAttachmentBinding.bind(this) } enum class AttachmentType { AUDIO, DOCUMENT, @@ -22,13 +21,10 @@ class UntrustedAttachmentView: LinearLayout { } // region Lifecycle - constructor(context: Context) : super(context) { initialize() } - constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() } - constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } + constructor(context: Context) : super(context) + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) - private fun initialize() { - binding = ViewUntrustedAttachmentBinding.inflate(LayoutInflater.from(context), this, true) - } // endregion // region Updating diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt index 96dc9a2065..b4af5a6139 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt @@ -5,7 +5,6 @@ import android.graphics.Color import android.graphics.Rect import android.graphics.drawable.Drawable import android.text.Spannable -import android.text.StaticLayout import android.text.style.BackgroundColorSpan import android.text.style.ForegroundColorSpan import android.text.style.URLSpan @@ -28,6 +27,11 @@ import androidx.core.view.isVisible import network.loki.messenger.R import network.loki.messenger.databinding.ViewVisibleMessageContentBinding import okhttp3.HttpUrl +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.DatabaseAttachment import org.session.libsession.utilities.ThemeUtil import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 @@ -65,7 +69,7 @@ class VisibleMessageContentView : LinearLayout { // region Updating fun bind(message: MessageRecord, isStartOfMessageCluster: Boolean, isEndOfMessageCluster: Boolean, - glide: GlideRequests, maxWidth: Int, thread: Recipient, searchQuery: String?, contactIsTrusted: Boolean) { + glide: GlideRequests, thread: Recipient, searchQuery: String?, contactIsTrusted: Boolean) { // Background val background = getBackground(message.isOutgoing, isStartOfMessageCluster, isEndOfMessageCluster) val colorID = if (message.isOutgoing) R.attr.message_sent_background_color else R.attr.message_received_background_color @@ -83,14 +87,17 @@ class VisibleMessageContentView : LinearLayout { onContentDoubleTap = null if (message.isDeleted) { - binding.deletedMessageView.isVisible = true - binding.deletedMessageView.bind(message, VisibleMessageContentView.getTextColor(context,message)) + binding.deletedMessageView.root.isVisible = true + binding.deletedMessageView.root.bind(message, VisibleMessageContentView.getTextColor(context,message)) return } else { - binding.deletedMessageView.isVisible = false + binding.deletedMessageView.root.isVisible = false } + // clear the + binding.bodyTextView.text = null - binding.quoteView.isVisible = message is MmsMessageRecord && message.quote != null + + binding.quoteView.root.isVisible = message is MmsMessageRecord && message.quote != null binding.linkPreviewView.isVisible = message is MmsMessageRecord && message.linkPreviews.isNotEmpty() @@ -98,36 +105,55 @@ class VisibleMessageContentView : LinearLayout { linkPreviewLayout.width = if (mediaThumbnailMessage) 0 else ViewGroup.LayoutParams.WRAP_CONTENT binding.linkPreviewView.layoutParams = linkPreviewLayout - binding.untrustedView.isVisible = !contactIsTrusted && message is MmsMessageRecord && message.quote == null && message.linkPreviews.isEmpty() - binding.voiceMessageView.isVisible = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.audioSlide != null - binding.documentView.isVisible = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.documentSlide != null + 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.documentView.root.isVisible = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.documentSlide != null binding.albumThumbnailView.isVisible = mediaThumbnailMessage - binding.openGroupInvitationView.isVisible = message.isOpenGroupInvitation + binding.openGroupInvitationView.root.isVisible = message.isOpenGroupInvitation var hideBody = false if (message is MmsMessageRecord && message.quote != null) { - binding.quoteView.isVisible = true + binding.quoteView.root.isVisible = true val quote = message.quote!! - // The max content width is the max message bubble size - 2 times the horizontal padding - 2 - // times the horizontal margin. This unfortunately has to be calculated manually - // here to get the layout right. - val maxContentWidth = (maxWidth - 2 * resources.getDimension(R.dimen.medium_spacing) - 2 * toPx(16, resources)).roundToInt() val quoteText = if (quote.isOriginalMissing) { context.getString(R.string.QuoteView_original_missing) } else { quote.text } - binding.quoteView.bind(quote.author.toString(), quoteText, quote.attachment, thread, + binding.quoteView.root.bind(quote.author.toString(), quoteText, quote.attachment, thread, message.isOutgoing, message.isOpenGroupInvitation, message.threadId, quote.isOriginalMissing, glide) onContentClick.add { event -> val r = Rect() - binding.quoteView.getGlobalVisibleRect(r) + binding.quoteView.root.getGlobalVisibleRect(r) if (r.contains(event.rawX.roundToInt(), event.rawY.roundToInt())) { delegate?.scrollToMessageIfPossible(quote.id) } } + val layoutParams = binding.quoteView.root.layoutParams as MarginLayoutParams + val hasMedia = message.slideDeck.asAttachments().isNotEmpty() + binding.quoteView.root.minWidth = if (hasMedia) 0 else toPx(300,context.resources) + } + + if (message is MmsMessageRecord) { + message.slideDeck.asAttachments().forEach { attach -> + val dbAttachment = attach as? DatabaseAttachment ?: return@forEach + val attachmentId = dbAttachment.attachmentId.rowId + if (attach.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING + && MessagingModuleConfiguration.shared.storage.getAttachmentUploadJob(attachmentId) == null) { + // start download + JobQueue.shared.add(AttachmentDownloadJob(attachmentId, dbAttachment.mmsId)) + } + } + message.linkPreviews.forEach { preview -> + val previewThumbnail = preview.getThumbnail().orNull() as? DatabaseAttachment ?: return@forEach + val attachmentId = previewThumbnail.attachmentId.rowId + if (previewThumbnail.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING + && MessagingModuleConfiguration.shared.storage.getAttachmentUploadJob(attachmentId) == null) { + JobQueue.shared.add(AttachmentDownloadJob(attachmentId, previewThumbnail.mmsId)) + } + } } if (message is MmsMessageRecord && message.linkPreviews.isNotEmpty()) { @@ -138,26 +164,26 @@ class VisibleMessageContentView : LinearLayout { hideBody = true // Audio attachment if (contactIsTrusted || message.isOutgoing) { - binding.voiceMessageView.indexInAdapter = indexInAdapter - binding.voiceMessageView.delegate = context as? ConversationActivityV2 - binding.voiceMessageView.bind(message, isStartOfMessageCluster, isEndOfMessageCluster) + binding.voiceMessageView.root.indexInAdapter = indexInAdapter + binding.voiceMessageView.root.delegate = context as? ConversationActivityV2 + binding.voiceMessageView.root.bind(message, isStartOfMessageCluster, isEndOfMessageCluster) // We have to use onContentClick (rather than a click listener directly on the voice // message view) so as to not interfere with all the other gestures. - onContentClick.add { binding.voiceMessageView.togglePlayback() } - onContentDoubleTap = { binding.voiceMessageView.handleDoubleTap() } + onContentClick.add { binding.voiceMessageView.root.togglePlayback() } + onContentDoubleTap = { binding.voiceMessageView.root.handleDoubleTap() } } else { // TODO: move this out to its own area - binding.untrustedView.bind(UntrustedAttachmentView.AttachmentType.AUDIO, VisibleMessageContentView.getTextColor(context,message)) - onContentClick.add { binding.untrustedView.showTrustDialog(message.individualRecipient) } + binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.AUDIO, VisibleMessageContentView.getTextColor(context,message)) + onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) } } } else if (message is MmsMessageRecord && message.slideDeck.documentSlide != null) { hideBody = true // Document attachment if (contactIsTrusted || message.isOutgoing) { - binding.documentView.bind(message, VisibleMessageContentView.getTextColor(context, message)) + binding.documentView.root.bind(message, VisibleMessageContentView.getTextColor(context, message)) } else { - binding.untrustedView.bind(UntrustedAttachmentView.AttachmentType.DOCUMENT, VisibleMessageContentView.getTextColor(context,message)) - onContentClick.add { binding.untrustedView.showTrustDialog(message.individualRecipient) } + binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.DOCUMENT, VisibleMessageContentView.getTextColor(context,message)) + onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) } } } else if (message is MmsMessageRecord && message.slideDeck.asAttachments().isNotEmpty()) { /* @@ -178,34 +204,21 @@ class VisibleMessageContentView : LinearLayout { } else { hideBody = true binding.albumThumbnailView.clearViews() - binding.untrustedView.bind(UntrustedAttachmentView.AttachmentType.MEDIA, VisibleMessageContentView.getTextColor(context,message)) - onContentClick.add { binding.untrustedView.showTrustDialog(message.individualRecipient) } + binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.MEDIA, VisibleMessageContentView.getTextColor(context,message)) + onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) } } } else if (message.isOpenGroupInvitation) { hideBody = true - binding.openGroupInvitationView.bind(message, VisibleMessageContentView.getTextColor(context, message)) - onContentClick.add { binding.openGroupInvitationView.joinOpenGroup() } + binding.openGroupInvitationView.root.bind(message, VisibleMessageContentView.getTextColor(context, message)) + onContentClick.add { binding.openGroupInvitationView.root.joinOpenGroup() } } binding.bodyTextView.isVisible = message.body.isNotEmpty() && !hideBody // set it to use constraints if not only a text message, otherwise wrap content to whatever width it wants val params = binding.bodyTextView.layoutParams - params.width = if (onlyBodyMessage || binding.barrierViewsGone()) ViewGroup.LayoutParams.WRAP_CONTENT else 0 + params.width = if (onlyBodyMessage || binding.barrierViewsGone()) ViewGroup.LayoutParams.MATCH_PARENT else 0 binding.bodyTextView.layoutParams = params - binding.bodyTextView.maxWidth = maxWidth - - val bodyWidth = with (binding.bodyTextView) { - StaticLayout.getDesiredWidth(text, paint).roundToInt() - } - - val quote = (message as? MmsMessageRecord)?.quote - val quoteLayoutParams = binding.quoteView.layoutParams - quoteLayoutParams.width = - if (mediaThumbnailMessage || quote == null) 0 - else binding.quoteView.calculateWidth(quote, bodyWidth, maxWidth, thread) - - binding.quoteView.layoutParams = quoteLayoutParams if (message.body.isNotEmpty() && !hideBody) { val color = getTextColor(context, message) @@ -222,7 +235,7 @@ class VisibleMessageContentView : LinearLayout { } private fun ViewVisibleMessageContentBinding.barrierViewsGone(): Boolean = - listOf(albumThumbnailView, linkPreviewView, voiceMessageView, quoteView).none { it.isVisible } + listOf(albumThumbnailView, linkPreviewView, voiceMessageView.root, quoteView.root).none { it.isVisible } private fun getBackground(isOutgoing: Boolean, isStartOfMessageCluster: Boolean, isEndOfMessageCluster: Boolean): Drawable { val isSingleMessage = (isStartOfMessageCluster && isEndOfMessageCluster) @@ -245,20 +258,20 @@ class VisibleMessageContentView : LinearLayout { fun recycle() { arrayOf( - binding.deletedMessageView, - binding.untrustedView, - binding.voiceMessageView, - binding.openGroupInvitationView, - binding.documentView, - binding.quoteView, + binding.deletedMessageView.root, + binding.untrustedView.root, + binding.voiceMessageView.root, + binding.openGroupInvitationView.root, + binding.documentView.root, + binding.quoteView.root, binding.linkPreviewView, binding.albumThumbnailView, binding.bodyTextView - ).forEach { view -> view.isVisible = false } + ).forEach { view: View -> view.isVisible = false } } fun playVoiceMessage() { - binding.voiceMessageView.togglePlayback() + binding.voiceMessageView.root.togglePlayback() } // endregion diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt index 615c5ff094..a8527b09b1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt @@ -8,14 +8,12 @@ import android.graphics.drawable.ColorDrawable import android.os.Handler import android.os.Looper import android.util.AttributeSet -import android.view.Gravity 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 androidx.appcompat.app.AppCompatActivity +import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.ContextCompat import androidx.core.content.res.ResourcesCompat import androidx.core.os.bundleOf @@ -23,6 +21,7 @@ import androidx.core.view.isVisible import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R import network.loki.messenger.databinding.ViewVisibleMessageBinding +import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact.ContactContext import org.session.libsession.messaging.open_groups.OpenGroupAPIV2 import org.session.libsession.utilities.ViewUtil @@ -31,7 +30,6 @@ import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.database.LokiThreadDatabase import org.thoughtcrime.securesms.database.MmsDatabase import org.thoughtcrime.securesms.database.MmsSmsDatabase -import org.thoughtcrime.securesms.database.SessionContactDatabase import org.thoughtcrime.securesms.database.SmsDatabase import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.MessageRecord @@ -43,7 +41,6 @@ import org.thoughtcrime.securesms.util.getColorWithID import org.thoughtcrime.securesms.util.toDp import org.thoughtcrime.securesms.util.toPx import java.util.Date -import java.util.Locale import javax.inject.Inject import kotlin.math.abs import kotlin.math.min @@ -54,13 +51,12 @@ import kotlin.math.sqrt class VisibleMessageView : LinearLayout { @Inject lateinit var threadDb: ThreadDatabase - @Inject lateinit var contactDb: SessionContactDatabase @Inject lateinit var lokiThreadDb: LokiThreadDatabase @Inject lateinit var mmsSmsDb: MmsSmsDatabase @Inject lateinit var smsDb: SmsDatabase @Inject lateinit var mmsDb: MmsDatabase - private lateinit var binding: ViewVisibleMessageBinding + private val binding by lazy { ViewVisibleMessageBinding.bind(this) } private val screenWidth = Resources.getSystem().displayMetrics.widthPixels private val swipeToReplyIcon = ContextCompat.getDrawable(context, R.drawable.ic_baseline_reply_24)!!.mutate() private val swipeToReplyIconRect = Rect() @@ -75,7 +71,6 @@ class VisibleMessageView : LinearLayout { var snIsSelected = false set(value) { field = value - binding.messageTimestampTextView.isVisible = isSelected handleIsSelectedChanged() } var onPress: ((event: MotionEvent) -> Unit)? = null @@ -91,73 +86,84 @@ class VisibleMessageView : LinearLayout { } // region Lifecycle - constructor(context: Context) : super(context) { initialize() } - constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() } - constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } + constructor(context: Context) : super(context) + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) + + override fun onFinishInflate() { + super.onFinishInflate() + initialize() + } private fun initialize() { - binding = ViewVisibleMessageBinding.inflate(LayoutInflater.from(context), this, true) - layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) isHapticFeedbackEnabled = true setWillNotDraw(false) binding.expirationTimerViewContainer.disableClipping() - binding.messageContentContainer.disableClipping() + binding.messageContentView.disableClipping() } // endregion // region Updating - fun bind(message: MessageRecord, previous: MessageRecord?, next: MessageRecord?, glide: GlideRequests, searchQuery: String?) { - val sender = message.individualRecipient - val senderSessionID = sender.address.serialize() + fun bind(message: MessageRecord, previous: MessageRecord?, next: MessageRecord?, + glide: GlideRequests, searchQuery: String?, contact: Contact?, senderSessionID: String, + ) { val threadID = message.threadId val thread = threadDb.getRecipientForThreadId(threadID) ?: return - val contact = contactDb.getContactWithSessionID(senderSessionID) val isGroupThread = thread.isGroupRecipient val isStartOfMessageCluster = isStartOfMessageCluster(message, previous, isGroupThread) val isEndOfMessageCluster = isEndOfMessageCluster(message, next, isGroupThread) // Show profile picture and sender name if this is a group thread AND // the message is incoming + binding.moderatorIconImageView.isVisible = false + binding.profilePictureView.root.visibility = when { + thread.isGroupRecipient && !message.isOutgoing && isEndOfMessageCluster -> View.VISIBLE + thread.isGroupRecipient -> View.INVISIBLE + else -> View.GONE + } + + val bottomMargin = if (isEndOfMessageCluster) resources.getDimensionPixelSize(R.dimen.small_spacing) + else ViewUtil.dpToPx(context,2) + + if (binding.profilePictureView.root.visibility == View.GONE) { + val expirationParams = binding.expirationTimerViewContainer.layoutParams as MarginLayoutParams + expirationParams.bottomMargin = bottomMargin + binding.expirationTimerViewContainer.layoutParams = expirationParams + } else { + val avatarLayoutParams = binding.profilePictureView.root.layoutParams as MarginLayoutParams + avatarLayoutParams.bottomMargin = bottomMargin + binding.profilePictureView.root.layoutParams = avatarLayoutParams + } + if (isGroupThread && !message.isOutgoing) { - binding.profilePictureContainer.visibility = if (isEndOfMessageCluster) View.VISIBLE else View.INVISIBLE - binding.profilePictureView.publicKey = senderSessionID - binding.profilePictureView.glide = glide - binding.profilePictureView.update(message.individualRecipient) - binding.profilePictureView.setOnClickListener { - showUserDetails(senderSessionID, threadID) - } - if (thread.isOpenGroupRecipient) { - val openGroup = lokiThreadDb.getOpenGroupChat(threadID) ?: return - val isModerator = OpenGroupAPIV2.isUserModerator(senderSessionID, openGroup.room, openGroup.server) - binding.moderatorIconImageView.visibility = if (isModerator) View.VISIBLE else View.INVISIBLE - } else { - binding.moderatorIconImageView.visibility = View.INVISIBLE + if (isEndOfMessageCluster) { + binding.profilePictureView.root.publicKey = senderSessionID + binding.profilePictureView.root.glide = glide + binding.profilePictureView.root.update(message.individualRecipient) + binding.profilePictureView.root.setOnClickListener { + showUserDetails(senderSessionID, threadID) + } + if (thread.isOpenGroupRecipient) { + val openGroup = lokiThreadDb.getOpenGroupChat(threadID) ?: return + val isModerator = OpenGroupAPIV2.isUserModerator( + senderSessionID, + openGroup.room, + openGroup.server + ) + binding.moderatorIconImageView.isVisible = !message.isOutgoing && isModerator + } } binding.senderNameTextView.isVisible = isStartOfMessageCluster - val context = if (thread.isOpenGroupRecipient) ContactContext.OPEN_GROUP else ContactContext.REGULAR + val context = + if (thread.isOpenGroupRecipient) ContactContext.OPEN_GROUP else ContactContext.REGULAR binding.senderNameTextView.text = contact?.displayName(context) ?: senderSessionID } else { - binding.profilePictureContainer.visibility = View.GONE binding.senderNameTextView.visibility = View.GONE } // Date break binding.dateBreakTextView.showDateBreak(message, previous) // Timestamp - binding.messageTimestampTextView.text = DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), message.timestamp) - // Margins - val startPadding = if (isGroupThread) { - if (message.isOutgoing) resources.getDimensionPixelSize(R.dimen.very_large_spacing) else toPx(50,resources) - } else { - if (message.isOutgoing) resources.getDimensionPixelSize(R.dimen.very_large_spacing) - else resources.getDimensionPixelSize(R.dimen.medium_spacing) - } - val endPadding = if (message.isOutgoing) resources.getDimensionPixelSize(R.dimen.medium_spacing) - else resources.getDimensionPixelSize(R.dimen.very_large_spacing) - binding.messageContentContainer.setPaddingRelative(startPadding, 0, endPadding, 0) + // binding.messageTimestampTextView.text = DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), message.timestamp) // Set inter-message spacing - setMessageSpacing(isStartOfMessageCluster, isEndOfMessageCluster) - // Gravity - val gravity = if (message.isOutgoing) Gravity.END else Gravity.START - binding.mainContainer.gravity = gravity or Gravity.BOTTOM // Message status indicator val (iconID, iconColor) = getMessageStatusImage(message) if (iconID != null) { @@ -169,29 +175,29 @@ class VisibleMessageView : LinearLayout { } if (message.isOutgoing) { val lastMessageID = mmsSmsDb.getLastMessageID(message.threadId) - binding.messageStatusImageView.isVisible = !message.isSent || message.id == lastMessageID + binding.messageStatusImageView.isVisible = + !message.isSent || message.id == lastMessageID } else { binding.messageStatusImageView.isVisible = false } // Expiration timer updateExpirationTimer(message) // Calculate max message bubble width - var maxWidth = screenWidth - startPadding - endPadding - if (binding.profilePictureContainer.visibility != View.GONE) { maxWidth -= binding.profilePictureContainer.width } // Populate content view binding.messageContentView.indexInAdapter = indexInAdapter - binding.messageContentView.bind(message, isStartOfMessageCluster, isEndOfMessageCluster, glide, maxWidth, thread, searchQuery, message.isOutgoing || isGroupThread || (contact?.isTrusted ?: false)) + binding.messageContentView.bind( + message, + isStartOfMessageCluster, + isEndOfMessageCluster, + glide, + thread, + searchQuery, + message.isOutgoing || isGroupThread || (contact?.isTrusted ?: false) + ) binding.messageContentView.delegate = contentViewDelegate onDoubleTap = { binding.messageContentView.onContentDoubleTap?.invoke() } } - private fun setMessageSpacing(isStartOfMessageCluster: Boolean, isEndOfMessageCluster: Boolean) { - val topPadding = if (isStartOfMessageCluster) R.dimen.conversation_vertical_message_spacing_default else R.dimen.conversation_vertical_message_spacing_collapse - ViewUtil.setPaddingTop(this, resources.getDimension(topPadding).roundToInt()) - val bottomPadding = if (isEndOfMessageCluster) R.dimen.conversation_vertical_message_spacing_default else R.dimen.conversation_vertical_message_spacing_collapse - ViewUtil.setPaddingBottom(this, resources.getDimension(bottomPadding).roundToInt()) - } - private fun isStartOfMessageCluster(current: MessageRecord, previous: MessageRecord?, isGroupThread: Boolean): Boolean { return if (isGroupThread) { previous == null || previous.isUpdate || !DateUtils.isSameHour(current.timestamp, previous.timestamp) @@ -223,18 +229,17 @@ class VisibleMessageView : LinearLayout { } private fun updateExpirationTimer(message: MessageRecord) { - val expirationTimerViewLayoutParams = binding.expirationTimerView.layoutParams as MarginLayoutParams val container = binding.expirationTimerViewContainer val content = binding.messageContentView val expiration = binding.expirationTimerView + val spacing = binding.messageContentSpacing container.removeAllViewsInLayout() container.addView(if (message.isOutgoing) expiration else content) container.addView(if (message.isOutgoing) content else expiration) - val expirationTimerViewSize = toPx(12, resources) - val smallSpacing = resources.getDimension(R.dimen.small_spacing).roundToInt() - expirationTimerViewLayoutParams.marginStart = if (message.isOutgoing) -(smallSpacing + expirationTimerViewSize) else 0 - expirationTimerViewLayoutParams.marginEnd = if (message.isOutgoing) 0 else -(smallSpacing + expirationTimerViewSize) - binding.expirationTimerView.layoutParams = expirationTimerViewLayoutParams + container.addView(spacing, if (message.isOutgoing) 0 else 2) + val containerParams = container.layoutParams as ConstraintLayout.LayoutParams + containerParams.horizontalBias = if (message.isOutgoing) 1f else 0f + container.layoutParams = containerParams if (message.expiresIn > 0 && !message.isPending) { binding.expirationTimerView.setColorFilter(ResourcesCompat.getColor(resources, R.color.text, context.theme)) binding.expirationTimerView.isVisible = true @@ -279,9 +284,9 @@ class VisibleMessageView : LinearLayout { val threshold = swipeToReplyThreshold val iconSize = toPx(24, context.resources) val bottomVOffset = paddingBottom + binding.messageStatusImageView.height + (binding.messageContentView.height - iconSize) / 2 - swipeToReplyIconRect.left = binding.messageContentContainer.right - binding.messageContentContainer.paddingEnd + spacing + swipeToReplyIconRect.left = binding.messageContentView.right - binding.messageContentView.paddingEnd + spacing swipeToReplyIconRect.top = height - bottomVOffset - iconSize - swipeToReplyIconRect.right = binding.messageContentContainer.right - binding.messageContentContainer.paddingEnd + iconSize + spacing + swipeToReplyIconRect.right = binding.messageContentView.right - binding.messageContentView.paddingEnd + iconSize + spacing swipeToReplyIconRect.bottom = height - bottomVOffset swipeToReplyIcon.bounds = swipeToReplyIconRect swipeToReplyIcon.alpha = (255.0f * (min(abs(translationX), threshold) / threshold)).roundToInt() @@ -293,7 +298,7 @@ class VisibleMessageView : LinearLayout { } fun recycle() { - binding.profilePictureView.recycle() + binding.profilePictureView.root.recycle() binding.messageContentView.recycle() } // endregion diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VoiceMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VoiceMessageView.kt index 6fca9ce6b4..fa4778e832 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VoiceMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VoiceMessageView.kt @@ -3,9 +3,7 @@ package org.thoughtcrime.securesms.conversation.v2.messages import android.content.Context import android.graphics.Canvas import android.util.AttributeSet -import android.view.LayoutInflater import android.view.View -import android.widget.LinearLayout import android.widget.RelativeLayout import androidx.core.view.isVisible import dagger.hilt.android.AndroidEntryPoint @@ -21,12 +19,13 @@ import java.util.concurrent.TimeUnit import javax.inject.Inject import kotlin.math.roundToInt import kotlin.math.roundToLong + @AndroidEntryPoint -class VoiceMessageView : LinearLayout, AudioSlidePlayer.Listener { +class VoiceMessageView : RelativeLayout, AudioSlidePlayer.Listener { @Inject lateinit var attachmentDb: AttachmentDatabase - private lateinit var binding: ViewVoiceMessageBinding + private val binding: ViewVoiceMessageBinding by lazy { ViewVoiceMessageBinding.bind(this) } private val cornerMask by lazy { CornerMask(this) } private var isPlaying = false set(value) { @@ -40,16 +39,17 @@ class VoiceMessageView : LinearLayout, AudioSlidePlayer.Listener { var indexInAdapter = -1 // region Lifecycle - constructor(context: Context) : super(context) { initialize() } - constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() } - constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } + constructor(context: Context) : super(context) + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) - private fun initialize() { - binding = ViewVoiceMessageBinding.inflate(LayoutInflater.from(context), this, true) + override fun onFinishInflate() { + super.onFinishInflate() binding.voiceMessageViewDurationTextView.text = String.format("%01d:%02d", TimeUnit.MILLISECONDS.toMinutes(0), TimeUnit.MILLISECONDS.toSeconds(0)) } + // endregion // region Updating diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/KThumbnailView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/KThumbnailView.kt index d01d86b6f5..eccb74b127 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/KThumbnailView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/KThumbnailView.kt @@ -140,7 +140,7 @@ open class KThumbnailView: FrameLayout { val dimens = dimensDelegate.resourceSize() val request = glide.load(DecryptableUri(slide.thumbnailUri!!)) - .diskCacheStrategy(DiskCacheStrategy.RESOURCE) + .diskCacheStrategy(DiskCacheStrategy.NONE) .let { request -> if (dimens[WIDTH] == 0 || dimens[HEIGHT] == 0) { request.override(getDefaultWidth(), getDefaultHeight()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt index 7d36bf2056..9dafdcf878 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt @@ -20,14 +20,26 @@ object MentionUtilities { @JvmStatic fun highlightMentions(text: CharSequence, threadID: Long, context: Context): String { - return highlightMentions(text, false, threadID, context).toString() // isOutgoingMessage is irrelevant + val threadDB = DatabaseComponent.get(context).threadDatabase() + val isOpenGroup = threadDB.getRecipientForThreadId(threadID)?.isOpenGroupRecipient ?: false + return highlightMentions(text, false, isOpenGroup, context).toString() // isOutgoingMessage is irrelevant + } + + @JvmStatic + fun highlightMentions(text:CharSequence, isOpenGroup: Boolean, context: Context): String { + return highlightMentions(text, false, isOpenGroup, context).toString() } @JvmStatic fun highlightMentions(text: CharSequence, isOutgoingMessage: Boolean, threadID: Long, context: Context): SpannableString { - @Suppress("NAME_SHADOWING") var text = text val threadDB = DatabaseComponent.get(context).threadDatabase() val isOpenGroup = threadDB.getRecipientForThreadId(threadID)?.isOpenGroupRecipient ?: false + return highlightMentions(text, isOutgoingMessage, isOpenGroup, context) // isOutgoingMessage is irrelevant + } + + @JvmStatic + fun highlightMentions(text: CharSequence, isOutgoingMessage: Boolean, isOpenGroup: Boolean, context: Context): SpannableString { + @Suppress("NAME_SHADOWING") var text = text val pattern = Pattern.compile("@[0-9a-fA-F]*") var matcher = pattern.matcher(text) val mentions = mutableListOf, String>>() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.java index 6a6ead67dc..912253ecd8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.java @@ -349,7 +349,7 @@ public class ThumbnailView extends FrameLayout { private GlideRequest buildThumbnailGlideRequest(@NonNull GlideRequests glideRequests, @NonNull Slide slide) { GlideRequest request = applySizing(glideRequests.load(new DecryptableUri(slide.getThumbnailUri())) - .diskCacheStrategy(DiskCacheStrategy.RESOURCE) + .diskCacheStrategy(DiskCacheStrategy.NONE) .transition(withCrossFade()), new CenterCrop()); if (slide.isInProgress()) return request; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentDatabase.java index 09f7b11578..7e4093ade3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentDatabase.java @@ -52,6 +52,7 @@ import org.thoughtcrime.securesms.crypto.ClassicDecryptingPartInputStream; import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream; import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; +import org.thoughtcrime.securesms.database.model.MmsAttachmentInfo; import org.thoughtcrime.securesms.dependencies.DatabaseComponent; import org.thoughtcrime.securesms.mms.MediaStream; import org.thoughtcrime.securesms.mms.MmsException; @@ -67,6 +68,7 @@ import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.LinkedList; @@ -266,6 +268,33 @@ public class AttachmentDatabase extends Database { return attachments; } + void deleteAttachmentsForMessages(String[] messageIds) { + StringBuilder queryBuilder = new StringBuilder(); + for (int i = 0; i < messageIds.length; i++) { + queryBuilder.append(MMS_ID+" = ").append(messageIds[i]); + if (i+1 < messageIds.length) { + queryBuilder.append(" OR "); + } + } + String idsAsString = queryBuilder.toString(); + SQLiteDatabase database = databaseHelper.getReadableDatabase(); + Cursor cursor = null; + List attachmentInfos = new ArrayList<>(); + try { + cursor = database.query(TABLE_NAME, new String[] { DATA, THUMBNAIL, CONTENT_TYPE}, idsAsString, null, null, null, null); + while (cursor != null && cursor.moveToNext()) { + attachmentInfos.add(new MmsAttachmentInfo(cursor.getString(0), cursor.getString(1), cursor.getString(2))); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + deleteAttachmentsOnDisk(attachmentInfos); + database.delete(TABLE_NAME, idsAsString, null); + notifyAttachmentListeners(); + } + @SuppressWarnings("ResultOfMethodCallIgnored") void deleteAttachmentsForMessage(long mmsId) { SQLiteDatabase database = databaseHelper.getWritableDatabase(); @@ -327,6 +356,30 @@ public class AttachmentDatabase extends Database { notifyAttachmentListeners(); } + private void deleteAttachmentsOnDisk(List mmsAttachmentInfos) { + for (MmsAttachmentInfo info : mmsAttachmentInfos) { + if (info.getDataFile() != null && !TextUtils.isEmpty(info.getDataFile())) { + File data = new File(info.getDataFile()); + if (data.exists()) { + data.delete(); + } + } + if (info.getThumbnailFile() != null && !TextUtils.isEmpty(info.getThumbnailFile())) { + File thumbnail = new File(info.getThumbnailFile()); + if (thumbnail.exists()) { + thumbnail.delete(); + } + } + } + + boolean anyImageType = MmsAttachmentInfo.anyImages(mmsAttachmentInfos); + boolean anyThumbnail = MmsAttachmentInfo.anyThumbnailNonNull(mmsAttachmentInfos); + + if (anyImageType || anyThumbnail) { + Glide.get(context).clearDiskCache(); + } + } + @SuppressWarnings("ResultOfMethodCallIgnored") private void deleteAttachmentOnDisk(@Nullable String data, @Nullable String thumbnail, @Nullable String contentType) { if (!TextUtils.isEmpty(data)) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ConversationNotificationDebouncer.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ConversationNotificationDebouncer.kt index 89c61a2e63..2d96e722f2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ConversationNotificationDebouncer.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ConversationNotificationDebouncer.kt @@ -2,15 +2,13 @@ package org.thoughtcrime.securesms.database import android.annotation.SuppressLint import android.content.Context -import android.os.Handler -import android.os.Looper import org.session.libsession.utilities.Debouncer import org.thoughtcrime.securesms.ApplicationContext class ConversationNotificationDebouncer(private val context: Context) { private val threadIDs = mutableSetOf() - private val handler = Handler(Looper.getMainLooper()) - private val debouncer = Debouncer(handler, 250); + private val handler = (context.applicationContext as ApplicationContext).conversationListNotificationHandler + private val debouncer = Debouncer(handler, 1000) companion object { @SuppressLint("StaticFieldLeak") @@ -29,7 +27,7 @@ class ConversationNotificationDebouncer(private val context: Context) { } private fun publish() { - for (threadID in threadIDs) { + for (threadID in threadIDs.toList()) { context.contentResolver.notifyChange(DatabaseContentProviders.Conversation.getUriForThread(threadID), null) } threadIDs.clear() diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Database.java b/app/src/main/java/org/thoughtcrime/securesms/database/Database.java index 3f57b1573d..a6db149053 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Database.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Database.java @@ -23,7 +23,7 @@ import android.database.Cursor; import androidx.annotation.NonNull; -import org.session.libsession.utilities.Debouncer; +import org.session.libsession.utilities.WindowDebouncer; import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; @@ -32,16 +32,21 @@ import java.util.Set; public abstract class Database { protected static final String ID_WHERE = "_id = ?"; + protected static final String ID_IN = "_id IN (?)"; protected SQLCipherOpenHelper databaseHelper; protected final Context context; - private final Debouncer conversationListNotificationDebouncer; + private final WindowDebouncer conversationListNotificationDebouncer; + private final Runnable conversationListUpdater; @SuppressLint("WrongConstant") public Database(Context context, SQLCipherOpenHelper databaseHelper) { this.context = context; + this.conversationListUpdater = () -> { + context.getContentResolver().notifyChange(DatabaseContentProviders.ConversationList.CONTENT_URI, null); + }; this.databaseHelper = databaseHelper; - this.conversationListNotificationDebouncer = new Debouncer(ApplicationContext.getInstance(context).getConversationListNotificationHandler(), 250); + this.conversationListNotificationDebouncer = ApplicationContext.getInstance(context).getConversationListDebouncer(); } protected void notifyConversationListeners(Set threadIds) { @@ -54,7 +59,7 @@ public abstract class Database { } protected void notifyConversationListListeners() { - conversationListNotificationDebouncer.publish(()->context.getContentResolver().notifyChange(DatabaseContentProviders.ConversationList.CONTENT_URI, null)); + conversationListNotificationDebouncer.publish(conversationListUpdater); } protected void notifyStickerListeners() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseContentProviders.java b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseContentProviders.java index a8a718b3b0..0570d91503 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseContentProviders.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseContentProviders.java @@ -4,6 +4,7 @@ import android.content.ContentProvider; import android.content.ContentValues; import android.database.Cursor; import android.net.Uri; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -41,7 +42,7 @@ public class DatabaseContentProviders { @Override public boolean onCreate() { - return false; + return true; } @Nullable diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseUtilities.kt index b063eb1b63..e6c9b9614e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseUtilities.kt @@ -36,11 +36,12 @@ fun SQLiteDatabase.getAll(table: String, query: String?, arguments: Array) { +fun SQLiteDatabase.insertOrUpdate(table: String, values: ContentValues, query: String, arguments: Array): Int { val id = insertWithOnConflict(table, null, values, SQLiteDatabase.CONFLICT_IGNORE).toInt() if (id == -1) { - update(table, values, query, arguments) + return update(table, values, query, arguments) } + return id } fun Cursor.getInt(columnName: String): Int { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java index e2ec7abb52..c0c08828b2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java @@ -243,6 +243,7 @@ public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProt recipient.setParticipants(Stream.of(members).map(memberAddress -> Recipient.from(context, memberAddress, true)).toList()); }); + notifyConversationListeners(threadId); notifyConversationListListeners(); return threadId; } @@ -314,6 +315,7 @@ public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProt new String[] {groupID}); Recipient.applyCached(Address.fromSerialized(groupID), recipient -> recipient.setGroupAvatarId(avatarId == 0 ? null : avatarId)); + notifyConversationListListeners(); } public void updateMembers(String groupId, List
members) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupReceiptDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/GroupReceiptDatabase.java index 2664aea804..81f8b62aa5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupReceiptDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupReceiptDatabase.java @@ -4,13 +4,13 @@ package org.thoughtcrime.securesms.database; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; + import androidx.annotation.NonNull; import net.sqlcipher.database.SQLiteDatabase; -import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; - import org.session.libsession.utilities.Address; +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import java.util.LinkedList; import java.util.List; @@ -92,6 +92,19 @@ public class GroupReceiptDatabase extends Database { return results; } + void deleteRowsForMessages(String[] mmsIds) { + StringBuilder queryBuilder = new StringBuilder(); + for (int i = 0; i < mmsIds.length; i++) { + queryBuilder.append(MMS_ID+" = ").append(mmsIds[i]); + if (i+1 < mmsIds.length) { + queryBuilder.append(" OR "); + } + } + String idsAsString = queryBuilder.toString(); + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + db.delete(TABLE_NAME, idsAsString, null); + } + void deleteRowsForMessage(long mmsId) { SQLiteDatabase db = databaseHelper.getWritableDatabase(); db.delete(TABLE_NAME, MMS_ID + " = ?", new String[] {String.valueOf(mmsId)}); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt index 0f97a0e614..97cfc3e6e8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt @@ -265,7 +265,7 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( override fun getLastMessageHashValue(snode: Snode, publicKey: String, namespace: Int): String? { val database = databaseHelper.readableDatabase val query = "${Companion.snode} = ? AND ${Companion.publicKey} = ? AND $lastMessageHashNamespace = ?" - return database.get(lastMessageHashValueTable2, query, arrayOf( snode.toString(), publicKey, namespace.toString() )) { cursor -> + return database.get(lastMessageHashValueTable2, query, arrayOf(snode.toString(), publicKey, namespace.toString())) { cursor -> cursor.getString(cursor.getColumnIndexOrThrow(lastMessageHashValue)) } } @@ -279,7 +279,7 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( lastMessageHashNamespace to namespace.toString() )) val query = "${Companion.snode} = ? AND ${Companion.publicKey} = ? AND $lastMessageHashNamespace = ?" - 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 getReceivedMessageHashValues(publicKey: String, namespace: Int): Set? { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt index 07513ec328..3fcbad60c1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt @@ -3,8 +3,8 @@ package org.thoughtcrime.securesms.database import android.content.ContentValues import android.content.Context import net.sqlcipher.database.SQLiteDatabase.CONFLICT_REPLACE -import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.session.libsignal.database.LokiMessageDatabaseProtocol +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper), LokiMessageDatabaseProtocol { @@ -77,6 +77,9 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab database.endTransaction() } + /** + * @return pair of sms or mms table-specific ID and whether it is in SMS table + */ fun getMessageID(serverID: Long, threadID: Long): Pair? { val database = databaseHelper.readableDatabase val mappingResult = database.get(messageThreadMappingTable, "${Companion.serverID} = ? AND ${Companion.threadID} = ?", diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java deleted file mode 100644 index 9d200971c7..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java +++ /dev/null @@ -1,1367 +0,0 @@ -/** - * Copyright (C) 2011 Whisper Systems - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.thoughtcrime.securesms.database; - -import android.content.ContentValues; -import android.content.Context; -import android.database.Cursor; -import android.text.TextUtils; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.annimon.stream.Collectors; -import com.annimon.stream.Stream; -import com.google.android.mms.pdu_alt.NotificationInd; -import com.google.android.mms.pdu_alt.PduHeaders; - -import net.sqlcipher.database.SQLiteDatabase; - -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; -import org.session.libsession.messaging.messages.signal.IncomingMediaMessage; -import org.session.libsession.messaging.messages.signal.OutgoingExpirationUpdateMessage; -import org.session.libsession.messaging.messages.signal.OutgoingGroupMediaMessage; -import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage; -import org.session.libsession.messaging.messages.signal.OutgoingSecureMediaMessage; -import org.session.libsession.messaging.sending_receiving.attachments.Attachment; -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.link_preview.LinkPreview; -import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel; -import org.session.libsession.utilities.Address; -import org.session.libsession.utilities.Contact; -import org.session.libsession.utilities.GroupUtil; -import org.session.libsession.utilities.IdentityKeyMismatch; -import org.session.libsession.utilities.IdentityKeyMismatchList; -import org.session.libsession.utilities.NetworkFailure; -import org.session.libsession.utilities.NetworkFailureList; -import org.session.libsession.utilities.TextSecurePreferences; -import org.session.libsession.utilities.Util; -import org.session.libsession.utilities.recipients.Recipient; -import org.session.libsession.utilities.recipients.RecipientFormattingException; -import org.session.libsignal.utilities.JsonUtil; -import org.session.libsignal.utilities.Log; -import org.session.libsignal.utilities.ThreadUtils; -import org.session.libsignal.utilities.guava.Optional; -import org.thoughtcrime.securesms.attachments.MmsNotificationAttachment; -import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; -import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord; -import org.thoughtcrime.securesms.database.model.MessageRecord; -import org.thoughtcrime.securesms.database.model.MmsMessageRecord; -import org.thoughtcrime.securesms.database.model.NotificationMmsMessageRecord; -import org.thoughtcrime.securesms.database.model.Quote; -import org.thoughtcrime.securesms.dependencies.DatabaseComponent; -import org.thoughtcrime.securesms.mms.MmsException; -import org.thoughtcrime.securesms.mms.SlideDeck; - -import java.io.Closeable; -import java.io.IOException; -import java.security.SecureRandom; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Set; - -public class MmsDatabase extends MessagingDatabase { - - private static final String TAG = MmsDatabase.class.getSimpleName(); - - public static final String TABLE_NAME = "mms"; - static final String DATE_SENT = "date"; - static final String DATE_RECEIVED = "date_received"; - public static final String MESSAGE_BOX = "msg_box"; - static final String CONTENT_LOCATION = "ct_l"; - static final String EXPIRY = "exp"; - public static final String MESSAGE_TYPE = "m_type"; - static final String MESSAGE_SIZE = "m_size"; - static final String STATUS = "st"; - static final String TRANSACTION_ID = "tr_id"; - static final String PART_COUNT = "part_count"; - static final String NETWORK_FAILURE = "network_failures"; - - static final String QUOTE_ID = "quote_id"; - static final String QUOTE_AUTHOR = "quote_author"; - static final String QUOTE_BODY = "quote_body"; - static final String QUOTE_ATTACHMENT = "quote_attachment"; - static final String QUOTE_MISSING = "quote_missing"; - - static final String SHARED_CONTACTS = "shared_contacts"; - static final String LINK_PREVIEWS = "previews"; - - public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY, " + - THREAD_ID + " INTEGER, " + DATE_SENT + " INTEGER, " + DATE_RECEIVED + " INTEGER, " + MESSAGE_BOX + " INTEGER, " + - READ + " INTEGER DEFAULT 0, " + "m_id" + " TEXT, " + "sub" + " TEXT, " + - "sub_cs" + " INTEGER, " + BODY + " TEXT, " + PART_COUNT + " INTEGER, " + - "ct_t" + " TEXT, " + CONTENT_LOCATION + " TEXT, " + ADDRESS + " TEXT, " + - ADDRESS_DEVICE_ID + " INTEGER, " + - EXPIRY + " INTEGER, " + "m_cls" + " TEXT, " + MESSAGE_TYPE + " INTEGER, " + - "v" + " INTEGER, " + MESSAGE_SIZE + " INTEGER, " + "pri" + " INTEGER, " + - "rr" + " INTEGER, " + "rpt_a" + " INTEGER, " + "resp_st" + " INTEGER, " + - STATUS + " INTEGER, " + TRANSACTION_ID + " TEXT, " + "retr_st" + " INTEGER, " + - "retr_txt" + " TEXT, " + "retr_txt_cs" + " INTEGER, " + "read_status" + " INTEGER, " + - "ct_cls" + " INTEGER, " + "resp_txt" + " TEXT, " + "d_tm" + " INTEGER, " + - DELIVERY_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + MISMATCHED_IDENTITIES + " TEXT DEFAULT NULL, " + - NETWORK_FAILURE + " TEXT DEFAULT NULL," + "d_rpt" + " INTEGER, " + - SUBSCRIPTION_ID + " INTEGER DEFAULT -1, " + EXPIRES_IN + " INTEGER DEFAULT 0, " + - EXPIRE_STARTED + " INTEGER DEFAULT 0, " + NOTIFIED + " INTEGER DEFAULT 0, " + - READ_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + QUOTE_ID + " INTEGER DEFAULT 0, " + - QUOTE_AUTHOR + " TEXT, " + QUOTE_BODY + " TEXT, " + QUOTE_ATTACHMENT + " INTEGER DEFAULT -1, " + - QUOTE_MISSING + " INTEGER DEFAULT 0, " + SHARED_CONTACTS + " TEXT, " + UNIDENTIFIED + " INTEGER DEFAULT 0, " + - LINK_PREVIEWS + " TEXT);"; - - public static final String[] CREATE_INDEXS = { - "CREATE INDEX IF NOT EXISTS mms_thread_id_index ON " + TABLE_NAME + " (" + THREAD_ID + ");", - "CREATE INDEX IF NOT EXISTS mms_read_index ON " + TABLE_NAME + " (" + READ + ");", - "CREATE INDEX IF NOT EXISTS mms_read_and_notified_and_thread_id_index ON " + TABLE_NAME + "(" + READ + "," + NOTIFIED + "," + THREAD_ID + ");", - "CREATE INDEX IF NOT EXISTS mms_message_box_index ON " + TABLE_NAME + " (" + MESSAGE_BOX + ");", - "CREATE INDEX IF NOT EXISTS mms_date_sent_index ON " + TABLE_NAME + " (" + DATE_SENT + ");", - "CREATE INDEX IF NOT EXISTS mms_thread_date_index ON " + TABLE_NAME + " (" + THREAD_ID + ", " + DATE_RECEIVED + ");", - }; - - private static final String[] MMS_PROJECTION = new String[] { - MmsDatabase.TABLE_NAME + "." + ID + " AS " + ID, - THREAD_ID, DATE_SENT + " AS " + NORMALIZED_DATE_SENT, - DATE_RECEIVED + " AS " + NORMALIZED_DATE_RECEIVED, - MESSAGE_BOX, READ, - CONTENT_LOCATION, EXPIRY, MESSAGE_TYPE, - MESSAGE_SIZE, STATUS, TRANSACTION_ID, - BODY, PART_COUNT, ADDRESS, ADDRESS_DEVICE_ID, - DELIVERY_RECEIPT_COUNT, READ_RECEIPT_COUNT, MISMATCHED_IDENTITIES, NETWORK_FAILURE, SUBSCRIPTION_ID, - EXPIRES_IN, EXPIRE_STARTED, NOTIFIED, QUOTE_ID, QUOTE_AUTHOR, QUOTE_BODY, QUOTE_ATTACHMENT, QUOTE_MISSING, - SHARED_CONTACTS, LINK_PREVIEWS, UNIDENTIFIED, - "json_group_array(json_object(" + - "'" + AttachmentDatabase.ROW_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.ROW_ID + ", " + - "'" + AttachmentDatabase.UNIQUE_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.UNIQUE_ID + ", " + - "'" + AttachmentDatabase.MMS_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + ", " + - "'" + AttachmentDatabase.SIZE + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.SIZE + ", " + - "'" + AttachmentDatabase.FILE_NAME + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.FILE_NAME + ", " + - "'" + AttachmentDatabase.DATA + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.DATA + ", " + - "'" + AttachmentDatabase.THUMBNAIL + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.THUMBNAIL + ", " + - "'" + AttachmentDatabase.CONTENT_TYPE + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CONTENT_TYPE + ", " + - "'" + AttachmentDatabase.CONTENT_LOCATION + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CONTENT_LOCATION + ", " + - "'" + AttachmentDatabase.FAST_PREFLIGHT_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.FAST_PREFLIGHT_ID + "," + - "'" + AttachmentDatabase.VOICE_NOTE + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.VOICE_NOTE + "," + - "'" + AttachmentDatabase.WIDTH + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.WIDTH + "," + - "'" + AttachmentDatabase.HEIGHT + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.HEIGHT + "," + - "'" + AttachmentDatabase.QUOTE + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.QUOTE + ", " + - "'" + AttachmentDatabase.CONTENT_DISPOSITION + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CONTENT_DISPOSITION + ", " + - "'" + AttachmentDatabase.NAME + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.NAME + ", " + - "'" + AttachmentDatabase.TRANSFER_STATE + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.TRANSFER_STATE + ", " + - "'" + AttachmentDatabase.CAPTION + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CAPTION + ", " + - "'" + AttachmentDatabase.STICKER_PACK_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_PACK_ID+ ", " + - "'" + AttachmentDatabase.STICKER_PACK_KEY + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_PACK_KEY + ", " + - "'" + AttachmentDatabase.STICKER_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_ID + - ")) AS " + AttachmentDatabase.ATTACHMENT_JSON_ALIAS, - }; - - private static final String RAW_ID_WHERE = TABLE_NAME + "._id = ?"; - - private final EarlyReceiptCache earlyDeliveryReceiptCache = new EarlyReceiptCache(); - private final EarlyReceiptCache earlyReadReceiptCache = new EarlyReceiptCache(); - - public static String getCreateMessageRequestResponseCommand() { - return "ALTER TABLE "+ TABLE_NAME + " " + - "ADD COLUMN " + MESSAGE_REQUEST_RESPONSE + " INTEGER DEFAULT 0;"; - } - - public MmsDatabase(Context context, SQLCipherOpenHelper databaseHelper) { - super(context, databaseHelper); - } - - @Override - protected String getTableName() { - return TABLE_NAME; - } - - public int getMessageCountForThread(long threadId) { - SQLiteDatabase db = databaseHelper.getReadableDatabase(); - Cursor cursor = null; - - try { - cursor = db.query(TABLE_NAME, new String[] {"COUNT(*)"}, THREAD_ID + " = ?", new String[] {threadId+""}, null, null, null); - - if (cursor != null && cursor.moveToFirst()) - return cursor.getInt(0); - } finally { - if (cursor != null) - cursor.close(); - } - - return 0; - } - - public void addFailures(long messageId, List failure) { - try { - addToDocument(messageId, NETWORK_FAILURE, failure, NetworkFailureList.class); - } catch (IOException e) { - Log.w(TAG, e); - } - } - - public void removeFailure(long messageId, NetworkFailure failure) { - try { - removeFromDocument(messageId, NETWORK_FAILURE, failure, NetworkFailureList.class); - } catch (IOException e) { - Log.w(TAG, e); - } - } - - public boolean isOutgoingMessage(long timestamp) { - SQLiteDatabase database = databaseHelper.getWritableDatabase(); - Cursor cursor = null; - boolean isOutgoing = false; - - try { - cursor = database.query(TABLE_NAME, new String[] { ID, THREAD_ID, MESSAGE_BOX, ADDRESS }, DATE_SENT + " = ?", new String[] { String.valueOf(timestamp) }, null, null, null, null); - - while (cursor.moveToNext()) { - if (Types.isOutgoingMessageType(cursor.getLong(cursor.getColumnIndexOrThrow(MESSAGE_BOX)))) { - isOutgoing = true; - } - } - } finally { - if (cursor != null) - cursor.close(); - } - return isOutgoing; - } - - public void incrementReceiptCount(SyncMessageId messageId, long timestamp, boolean deliveryReceipt, boolean readReceipt) { - SQLiteDatabase database = databaseHelper.getWritableDatabase(); - Cursor cursor = null; - boolean found = false; - - try { - cursor = database.query(TABLE_NAME, new String[] {ID, THREAD_ID, MESSAGE_BOX, ADDRESS}, DATE_SENT + " = ?", new String[] {String.valueOf(messageId.getTimetamp())}, null, null, null, null); - - while (cursor.moveToNext()) { - if (Types.isOutgoingMessageType(cursor.getLong(cursor.getColumnIndexOrThrow(MESSAGE_BOX)))) { - Address theirAddress = Address.fromSerialized(cursor.getString(cursor.getColumnIndexOrThrow(ADDRESS))); - Address ourAddress = messageId.getAddress(); - String columnName = deliveryReceipt ? DELIVERY_RECEIPT_COUNT : READ_RECEIPT_COUNT; - - if (ourAddress.equals(theirAddress) || theirAddress.isGroup()) { - long id = cursor.getLong(cursor.getColumnIndexOrThrow(ID)); - long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID)); - int status = deliveryReceipt ? GroupReceiptDatabase.STATUS_DELIVERED : GroupReceiptDatabase.STATUS_READ; - - found = true; - - database.execSQL("UPDATE " + TABLE_NAME + " SET " + - columnName + " = " + columnName + " + 1 WHERE " + ID + " = ?", - new String[] {String.valueOf(id)}); - - DatabaseComponent.get(context).groupReceiptDatabase().update(ourAddress, id, status, timestamp); - DatabaseComponent.get(context).threadDatabase().update(threadId, false); - notifyConversationListeners(threadId); - } - } - } - - if (!found) { - if (deliveryReceipt) earlyDeliveryReceiptCache.increment(messageId.getTimetamp(), messageId.getAddress()); - if (readReceipt) earlyReadReceiptCache.increment(messageId.getTimetamp(), messageId.getAddress()); - } - } finally { - if (cursor != null) - cursor.close(); - } - } - - public void updateSentTimestamp(long messageId, long newTimestamp, long threadId) { - SQLiteDatabase db = databaseHelper.getWritableDatabase(); - db.execSQL("UPDATE " + TABLE_NAME + " SET " + DATE_SENT + " = ? " + - "WHERE " + ID + " = ?", - new String[] {newTimestamp + "", messageId + ""}); - notifyConversationListeners(threadId); - notifyConversationListListeners(); - } - - public long getThreadIdForMessage(long id) { - String sql = "SELECT " + THREAD_ID + " FROM " + TABLE_NAME + " WHERE " + ID + " = ?"; - String[] sqlArgs = new String[] {id+""}; - SQLiteDatabase db = databaseHelper.getReadableDatabase(); - - Cursor cursor = null; - - try { - cursor = db.rawQuery(sql, sqlArgs); - if (cursor != null && cursor.moveToFirst()) - return cursor.getLong(0); - else - return -1; - } finally { - if (cursor != null) - cursor.close(); - } - } - - private long getThreadIdFor(IncomingMediaMessage retrieved) throws RecipientFormattingException, MmsException { - if (retrieved.getGroupId() != null) { - Recipient groupRecipients = Recipient.from(context, retrieved.getGroupId(), true); - return DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(groupRecipients); - } else { - Recipient sender = Recipient.from(context, retrieved.getFrom(), true); - return DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(sender); - } - } - - private long getThreadIdFor(@NonNull NotificationInd notification) { - String fromString = notification.getFrom() != null && notification.getFrom().getTextString() != null - ? Util.toIsoString(notification.getFrom().getTextString()) - : ""; - Recipient recipient = Recipient.from(context, Address.fromExternal(context, fromString), false); - return DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient); - } - - private Cursor rawQuery(@NonNull String where, @Nullable String[] arguments) { - SQLiteDatabase database = databaseHelper.getReadableDatabase(); - return database.rawQuery("SELECT " + Util.join(MMS_PROJECTION, ",") + - " FROM " + MmsDatabase.TABLE_NAME + " LEFT OUTER JOIN " + AttachmentDatabase.TABLE_NAME + - " ON (" + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " = " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + ")" + - " WHERE " + where + " GROUP BY " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID, arguments); - } - - public Cursor getMessage(long messageId) { - Cursor cursor = rawQuery(RAW_ID_WHERE, new String[] {messageId + ""}); - setNotifyConverationListeners(cursor, getThreadIdForMessage(messageId)); - return cursor; - } - - public Reader getExpireStartedMessages() { - String where = EXPIRE_STARTED + " > 0"; - return readerFor(rawQuery(where, null)); - } - - private void updateMailboxBitmask(long id, long maskOff, long maskOn, Optional threadId) { - SQLiteDatabase db = databaseHelper.getWritableDatabase(); - db.execSQL("UPDATE " + TABLE_NAME + - " SET " + MESSAGE_BOX + " = (" + MESSAGE_BOX + " & " + (Types.TOTAL_MASK - maskOff) + " | " + maskOn + " )" + - " WHERE " + ID + " = ?", new String[] {id + ""}); - - if (threadId.isPresent()) { - DatabaseComponent.get(context).threadDatabase().update(threadId.get(), false); - } - } - - public void markAsPendingInsecureSmsFallback(long messageId) { - long threadId = getThreadIdForMessage(messageId); - updateMailboxBitmask(messageId, Types.BASE_TYPE_MASK, Types.BASE_PENDING_INSECURE_SMS_FALLBACK, Optional.of(threadId)); - notifyConversationListeners(threadId); - } - - public void markAsSending(long messageId) { - long threadId = getThreadIdForMessage(messageId); - updateMailboxBitmask(messageId, Types.BASE_TYPE_MASK, Types.BASE_SENDING_TYPE, Optional.of(threadId)); - notifyConversationListeners(threadId); - } - - public void markAsSentFailed(long messageId) { - long threadId = getThreadIdForMessage(messageId); - updateMailboxBitmask(messageId, Types.BASE_TYPE_MASK, Types.BASE_SENT_FAILED_TYPE, Optional.of(threadId)); - notifyConversationListeners(threadId); - } - - @Override - public void markAsSent(long messageId, boolean secure) { - long threadId = getThreadIdForMessage(messageId); - updateMailboxBitmask(messageId, Types.BASE_TYPE_MASK, Types.BASE_SENT_TYPE | (secure ? Types.PUSH_MESSAGE_BIT | Types.SECURE_MESSAGE_BIT : 0), Optional.of(threadId)); - notifyConversationListeners(threadId); - } - - @Override - public void markUnidentified(long messageId, boolean unidentified) { - ContentValues contentValues = new ContentValues(); - contentValues.put(UNIDENTIFIED, unidentified ? 1 : 0); - - SQLiteDatabase db = databaseHelper.getWritableDatabase(); - db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(messageId)}); - } - - @Override - public void markAsDeleted(long messageId, boolean read) { - SQLiteDatabase database = databaseHelper.getWritableDatabase(); - ContentValues contentValues = new ContentValues(); - contentValues.put(READ, 1); - contentValues.put(BODY, ""); - database.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(messageId)}); - - AttachmentDatabase attachmentDatabase = DatabaseComponent.get(context).attachmentDatabase(); - ThreadUtils.queue(() -> attachmentDatabase.deleteAttachmentsForMessage(messageId)); - - long threadId = getThreadIdForMessage(messageId); - if (!read) { DatabaseComponent.get(context).threadDatabase().decrementUnread(threadId, 1); } - updateMailboxBitmask(messageId, Types.BASE_TYPE_MASK, Types.BASE_DELETED_TYPE, Optional.of(threadId)); - notifyConversationListeners(threadId); - } - - @Override - public void markExpireStarted(long messageId) { - markExpireStarted(messageId, System.currentTimeMillis()); - } - - @Override - public void markExpireStarted(long messageId, long startedTimestamp) { - ContentValues contentValues = new ContentValues(); - contentValues.put(EXPIRE_STARTED, startedTimestamp); - - SQLiteDatabase db = databaseHelper.getWritableDatabase(); - db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(messageId)}); - - long threadId = getThreadIdForMessage(messageId); - notifyConversationListeners(threadId); - } - - public void markAsNotified(long id) { - SQLiteDatabase database = databaseHelper.getWritableDatabase(); - ContentValues contentValues = new ContentValues(); - - contentValues.put(NOTIFIED, 1); - - database.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(id)}); - } - - - public List setMessagesRead(long threadId) { - return setMessagesRead(THREAD_ID + " = ? AND " + READ + " = 0", new String[] {String.valueOf(threadId)}); - } - - public List setAllMessagesRead() { - return setMessagesRead(READ + " = 0", null); - } - - private List setMessagesRead(String where, String[] arguments) { - SQLiteDatabase database = databaseHelper.getWritableDatabase(); - List result = new LinkedList<>(); - Cursor cursor = null; - - database.beginTransaction(); - - try { - cursor = database.query(TABLE_NAME, new String[] {ID, ADDRESS, DATE_SENT, MESSAGE_BOX, EXPIRES_IN, EXPIRE_STARTED}, where, arguments, null, null, null); - - while(cursor != null && cursor.moveToNext()) { - if (Types.isSecureType(cursor.getLong(3))) { - SyncMessageId syncMessageId = new SyncMessageId(Address.fromSerialized(cursor.getString(1)), cursor.getLong(2)); - ExpirationInfo expirationInfo = new ExpirationInfo(cursor.getLong(0), cursor.getLong(4), cursor.getLong(5), true); - - result.add(new MarkedMessageInfo(syncMessageId, expirationInfo)); - } - } - - ContentValues contentValues = new ContentValues(); - contentValues.put(READ, 1); - - database.update(TABLE_NAME, contentValues, where, arguments); - database.setTransactionSuccessful(); - } finally { - if (cursor != null) cursor.close(); - database.endTransaction(); - } - - return result; - } - - public OutgoingMediaMessage getOutgoingMessage(long messageId) - throws MmsException, NoSuchMessageException - { - AttachmentDatabase attachmentDatabase = DatabaseComponent.get(context).attachmentDatabase(); - Cursor cursor = null; - - try { - cursor = rawQuery(RAW_ID_WHERE, new String[] {String.valueOf(messageId)}); - - if (cursor != null && cursor.moveToNext()) { - List associatedAttachments = attachmentDatabase.getAttachmentsForMessage(messageId); - - long outboxType = cursor.getLong(cursor.getColumnIndexOrThrow(MESSAGE_BOX)); - String body = cursor.getString(cursor.getColumnIndexOrThrow(BODY)); - long timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(NORMALIZED_DATE_SENT)); - int subscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(SUBSCRIPTION_ID)); - long expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRES_IN)); - String address = cursor.getString(cursor.getColumnIndexOrThrow(ADDRESS)); - long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID)); - int distributionType = DatabaseComponent.get(context).threadDatabase().getDistributionType(threadId); - String mismatchDocument = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.MISMATCHED_IDENTITIES)); - String networkDocument = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.NETWORK_FAILURE)); - - long quoteId = cursor.getLong(cursor.getColumnIndexOrThrow(QUOTE_ID)); - String quoteAuthor = cursor.getString(cursor.getColumnIndexOrThrow(QUOTE_AUTHOR)); - String quoteText = cursor.getString(cursor.getColumnIndexOrThrow(QUOTE_BODY)); - boolean quoteMissing = cursor.getInt(cursor.getColumnIndexOrThrow(QUOTE_MISSING)) == 1; - List quoteAttachments = Stream.of(associatedAttachments).filter(Attachment::isQuote).map(a -> (Attachment)a).toList(); - List contacts = getSharedContacts(cursor, associatedAttachments); - Set contactAttachments = new HashSet<>(Stream.of(contacts).map(Contact::getAvatarAttachment).filter(a -> a != null).toList()); - List previews = getLinkPreviews(cursor, associatedAttachments); - Set previewAttachments = Stream.of(previews).filter(lp -> lp.getThumbnail().isPresent()).map(lp -> lp.getThumbnail().get()).collect(Collectors.toSet()); - List attachments = Stream.of(associatedAttachments).filterNot(Attachment::isQuote) - .filterNot(contactAttachments::contains) - .filterNot(previewAttachments::contains) - .map(a -> (Attachment)a).toList(); - - Recipient recipient = Recipient.from(context, Address.fromSerialized(address), false); - List networkFailures = new LinkedList<>(); - List mismatches = new LinkedList<>(); - QuoteModel quote = null; - - if (quoteId > 0 && (!TextUtils.isEmpty(quoteText) || !quoteAttachments.isEmpty())) { - quote = new QuoteModel(quoteId, Address.fromSerialized(quoteAuthor), quoteText, quoteMissing, quoteAttachments); - } - - if (!TextUtils.isEmpty(mismatchDocument)) { - try { - mismatches = JsonUtil.fromJson(mismatchDocument, IdentityKeyMismatchList.class).getList(); - } catch (IOException e) { - Log.w(TAG, e); - } - } - - if (!TextUtils.isEmpty(networkDocument)) { - try { - networkFailures = JsonUtil.fromJson(networkDocument, NetworkFailureList.class).getList(); - } catch (IOException e) { - Log.w(TAG, e); - } - } - - OutgoingMediaMessage message = new OutgoingMediaMessage(recipient, body, attachments, timestamp, subscriptionId, expiresIn, distributionType, quote, contacts, previews, networkFailures, mismatches); - - if (Types.isSecureType(outboxType)) { - return new OutgoingSecureMediaMessage(message); - } - - return message; - } - - throw new NoSuchMessageException("No record found for id: " + messageId); - } finally { - if (cursor != null) - cursor.close(); - } - } - - private List getSharedContacts(@NonNull Cursor cursor, @NonNull List attachments) { - String serializedContacts = cursor.getString(cursor.getColumnIndexOrThrow(SHARED_CONTACTS)); - - if (TextUtils.isEmpty(serializedContacts)) { - return Collections.emptyList(); - } - - Map attachmentIdMap = new HashMap<>(); - for (DatabaseAttachment attachment : attachments) { - attachmentIdMap.put(attachment.getAttachmentId(), attachment); - } - - try { - List contacts = new LinkedList<>(); - JSONArray jsonContacts = new JSONArray(serializedContacts); - - for (int i = 0; i < jsonContacts.length(); i++) { - Contact contact = Contact.deserialize(jsonContacts.getJSONObject(i).toString()); - - if (contact.getAvatar() != null && contact.getAvatar().getAttachmentId() != null) { - DatabaseAttachment attachment = attachmentIdMap.get(contact.getAvatar().getAttachmentId()); - Contact.Avatar updatedAvatar = new Contact.Avatar(contact.getAvatar().getAttachmentId(), - attachment, - contact.getAvatar().isProfile()); - contacts.add(new Contact(contact, updatedAvatar)); - } else { - contacts.add(contact); - } - } - - return contacts; - } catch (JSONException | IOException e) { - Log.w(TAG, "Failed to parse shared contacts.", e); - } - - return Collections.emptyList(); - } - - private List getLinkPreviews(@NonNull Cursor cursor, @NonNull List attachments) { - String serializedPreviews = cursor.getString(cursor.getColumnIndexOrThrow(LINK_PREVIEWS)); - - if (TextUtils.isEmpty(serializedPreviews)) { - return Collections.emptyList(); - } - - Map attachmentIdMap = new HashMap<>(); - for (DatabaseAttachment attachment : attachments) { - attachmentIdMap.put(attachment.getAttachmentId(), attachment); - } - - try { - List previews = new LinkedList<>(); - JSONArray jsonPreviews = new JSONArray(serializedPreviews); - - for (int i = 0; i < jsonPreviews.length(); i++) { - LinkPreview preview = LinkPreview.deserialize(jsonPreviews.getJSONObject(i).toString()); - - if (preview.getAttachmentId() != null) { - DatabaseAttachment attachment = attachmentIdMap.get(preview.getAttachmentId()); - if (attachment != null) { - previews.add(new LinkPreview(preview.getUrl(), preview.getTitle(), attachment)); - } - } else { - previews.add(preview); - } - } - - return previews; - } catch (JSONException | IOException e) { - Log.w(TAG, "Failed to parse shared contacts.", e); - } - - return Collections.emptyList(); - } - - private Optional insertMessageInbox(IncomingMediaMessage retrieved, - String contentLocation, - long threadId, long mailbox, - long serverTimestamp) - throws MmsException - { - if (threadId == -1 || retrieved.isGroupMessage()) { - try { - threadId = getThreadIdFor(retrieved); - } catch (RecipientFormattingException e) { - Log.w("MmsDatabase", e); - if (threadId == -1) - throw new MmsException(e); - } - } - - ContentValues contentValues = new ContentValues(); - - contentValues.put(DATE_SENT, retrieved.getSentTimeMillis()); - contentValues.put(ADDRESS, retrieved.getFrom().serialize()); - - contentValues.put(MESSAGE_BOX, mailbox); - contentValues.put(MESSAGE_TYPE, PduHeaders.MESSAGE_TYPE_RETRIEVE_CONF); - contentValues.put(THREAD_ID, threadId); - contentValues.put(CONTENT_LOCATION, contentLocation); - contentValues.put(STATUS, Status.DOWNLOAD_INITIALIZED); - // In open groups messages should be sorted by their server timestamp - long receivedTimestamp = serverTimestamp; - if (serverTimestamp == 0) { receivedTimestamp = retrieved.getSentTimeMillis(); } - contentValues.put(DATE_RECEIVED, receivedTimestamp); // Loki - This is important due to how we handle GIFs - contentValues.put(PART_COUNT, retrieved.getAttachments().size()); - contentValues.put(SUBSCRIPTION_ID, retrieved.getSubscriptionId()); - contentValues.put(EXPIRES_IN, retrieved.getExpiresIn()); - contentValues.put(READ, retrieved.isExpirationUpdate() ? 1 : 0); - contentValues.put(UNIDENTIFIED, retrieved.isUnidentified()); - contentValues.put(MESSAGE_REQUEST_RESPONSE, retrieved.isMessageRequestResponse()); - - if (!contentValues.containsKey(DATE_SENT)) { - contentValues.put(DATE_SENT, contentValues.getAsLong(DATE_RECEIVED)); - } - - List quoteAttachments = new LinkedList<>(); - - if (retrieved.getQuote() != null) { - contentValues.put(QUOTE_ID, retrieved.getQuote().getId()); - contentValues.put(QUOTE_BODY, retrieved.getQuote().getText()); - contentValues.put(QUOTE_AUTHOR, retrieved.getQuote().getAuthor().serialize()); - contentValues.put(QUOTE_MISSING, retrieved.getQuote().getMissing() ? 1 : 0); - - quoteAttachments = retrieved.getQuote().getAttachments(); - } - - if ((retrieved.isPushMessage() && isDuplicate(retrieved, threadId)) || - retrieved.isMessageRequestResponse() && isDuplicateMessageRequestResponse(retrieved, threadId)) { - Log.w(TAG, "Ignoring duplicate media message (" + retrieved.getSentTimeMillis() + ")"); - return Optional.absent(); - } - - long messageId = insertMediaMessage(retrieved.getBody(), retrieved.getAttachments(), quoteAttachments, retrieved.getSharedContacts(), retrieved.getLinkPreviews(), contentValues, null); - - if (!Types.isExpirationTimerUpdate(mailbox)) { - DatabaseComponent.get(context).threadDatabase().incrementUnread(threadId, 1); - DatabaseComponent.get(context).threadDatabase().update(threadId, true); - } - - notifyConversationListeners(threadId); - - return Optional.of(new InsertResult(messageId, threadId)); - } - - public Optional insertSecureDecryptedMessageOutbox(OutgoingMediaMessage retrieved, long threadId, long serverTimestamp) - throws MmsException - { - if (threadId == -1) { - if(retrieved.isGroup()) { - String decodedGroupId; - if (retrieved instanceof OutgoingExpirationUpdateMessage) { - decodedGroupId = ((OutgoingExpirationUpdateMessage)retrieved).getGroupId(); - } else { - decodedGroupId = ((OutgoingGroupMediaMessage)retrieved).getGroupId(); - } - String groupId; - try { - groupId = GroupUtil.doubleEncodeGroupID(decodedGroupId); - } catch (IOException e) { - Log.e(TAG, "Couldn't encrypt group ID"); - throw new MmsException(e); - } - Recipient group = Recipient.from(context, Address.fromSerialized(groupId), false); - threadId = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(group); - } else { - threadId = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(retrieved.getRecipient()); - } - } - long messageId = insertMessageOutbox(retrieved, threadId, false, null, serverTimestamp); - if (messageId == -1) { - return Optional.absent(); - } - markAsSent(messageId, true); - return Optional.fromNullable(new InsertResult(messageId, threadId)); - } - - public Optional insertSecureDecryptedMessageInbox(IncomingMediaMessage retrieved, long threadId, long serverTimestamp) - throws MmsException - { - long type = Types.BASE_INBOX_TYPE | Types.SECURE_MESSAGE_BIT; - - if (retrieved.isPushMessage()) { - type |= Types.PUSH_MESSAGE_BIT; - } - - if (retrieved.isExpirationUpdate()) { - type |= Types.EXPIRATION_TIMER_UPDATE_BIT; - } - - if (retrieved.isScreenshotDataExtraction()) { - type |= Types.SCREENSHOT_EXTRACTION_BIT; - } - - if (retrieved.isMediaSavedDataExtraction()) { - type |= Types.MEDIA_SAVED_EXTRACTION_BIT; - } - - if (retrieved.isMessageRequestResponse()) { - type |= Types.MESSAGE_REQUEST_RESPONSE_BIT; - } - - return insertMessageInbox(retrieved, "", threadId, type, serverTimestamp); - } - - public Optional insertSecureDecryptedMessageInbox(IncomingMediaMessage retrieved, long threadId) - throws MmsException - { - return insertSecureDecryptedMessageInbox(retrieved, threadId, 0); - } - - public long insertMessageOutbox(@NonNull OutgoingMediaMessage message, - long threadId, boolean forceSms, - @Nullable SmsDatabase.InsertListener insertListener) - throws MmsException { - return insertMessageOutbox(message, threadId, forceSms, insertListener, 0); - } - - public long insertMessageOutbox(@NonNull OutgoingMediaMessage message, - long threadId, boolean forceSms, - @Nullable SmsDatabase.InsertListener insertListener, - long serverTimestamp) - throws MmsException - { - long type = Types.BASE_SENDING_TYPE; - - if (message.isSecure()) type |= (Types.SECURE_MESSAGE_BIT | Types.PUSH_MESSAGE_BIT); - if (forceSms) type |= Types.MESSAGE_FORCE_SMS_BIT; - - if (message.isGroup() && message instanceof OutgoingGroupMediaMessage) { - if (((OutgoingGroupMediaMessage)message).isUpdateMessage()) type |= Types.GROUP_UPDATE_MESSAGE_BIT; - } - - if (message.isExpirationUpdate()) { - type |= Types.EXPIRATION_TIMER_UPDATE_BIT; - } - - Map earlyDeliveryReceipts = earlyDeliveryReceiptCache.remove(message.getSentTimeMillis()); - Map earlyReadReceipts = earlyReadReceiptCache.remove(message.getSentTimeMillis()); - - ContentValues contentValues = new ContentValues(); - contentValues.put(DATE_SENT, message.getSentTimeMillis()); - contentValues.put(MESSAGE_TYPE, PduHeaders.MESSAGE_TYPE_SEND_REQ); - - contentValues.put(MESSAGE_BOX, type); - contentValues.put(THREAD_ID, threadId); - contentValues.put(READ, 1); - // In open groups messages should be sorted by their server timestamp - long receivedTimestamp = serverTimestamp; - if (serverTimestamp == 0) { receivedTimestamp = System.currentTimeMillis(); } - contentValues.put(DATE_RECEIVED, receivedTimestamp); - contentValues.put(SUBSCRIPTION_ID, message.getSubscriptionId()); - contentValues.put(EXPIRES_IN, message.getExpiresIn()); - contentValues.put(ADDRESS, message.getRecipient().getAddress().serialize()); - contentValues.put(DELIVERY_RECEIPT_COUNT, Stream.of(earlyDeliveryReceipts.values()).mapToLong(Long::longValue).sum()); - contentValues.put(READ_RECEIPT_COUNT, Stream.of(earlyReadReceipts.values()).mapToLong(Long::longValue).sum()); - - List quoteAttachments = new LinkedList<>(); - - if (message.getOutgoingQuote() != null) { - contentValues.put(QUOTE_ID, message.getOutgoingQuote().getId()); - contentValues.put(QUOTE_AUTHOR, message.getOutgoingQuote().getAuthor().serialize()); - contentValues.put(QUOTE_BODY, message.getOutgoingQuote().getText()); - contentValues.put(QUOTE_MISSING, message.getOutgoingQuote().getMissing() ? 1 : 0); - - quoteAttachments.addAll(message.getOutgoingQuote().getAttachments()); - } - - if (isDuplicate(message, threadId)) { - Log.w(TAG, "Ignoring duplicate media message (" + message.getSentTimeMillis() + ")"); - return -1; - } - - long messageId = insertMediaMessage(message.getBody(), message.getAttachments(), quoteAttachments, message.getSharedContacts(), message.getLinkPreviews(), contentValues, insertListener); - - if (message.getRecipient().getAddress().isGroup()) { - List members = DatabaseComponent.get(context).groupDatabase().getGroupMembers(message.getRecipient().getAddress().toGroupString(), false); - GroupReceiptDatabase receiptDatabase = DatabaseComponent.get(context).groupReceiptDatabase(); - - receiptDatabase.insert(Stream.of(members).map(Recipient::getAddress).toList(), - messageId, GroupReceiptDatabase.STATUS_UNDELIVERED, message.getSentTimeMillis()); - - for (Address address : earlyDeliveryReceipts.keySet()) receiptDatabase.update(address, messageId, GroupReceiptDatabase.STATUS_DELIVERED, -1); - for (Address address : earlyReadReceipts.keySet()) receiptDatabase.update(address, messageId, GroupReceiptDatabase.STATUS_READ, -1); - } - - DatabaseComponent.get(context).threadDatabase().setLastSeen(threadId); - DatabaseComponent.get(context).threadDatabase().setHasSent(threadId, true); - - return messageId; - } - - private long insertMediaMessage(@Nullable String body, - @NonNull List attachments, - @NonNull List quoteAttachments, - @NonNull List sharedContacts, - @NonNull List linkPreviews, - @NonNull ContentValues contentValues, - @Nullable SmsDatabase.InsertListener insertListener) - throws MmsException - { - SQLiteDatabase db = databaseHelper.getWritableDatabase(); - AttachmentDatabase partsDatabase = DatabaseComponent.get(context).attachmentDatabase(); - - List allAttachments = new LinkedList<>(); - List contactAttachments = Stream.of(sharedContacts).map(Contact::getAvatarAttachment).filter(a -> a != null).toList(); - List previewAttachments = Stream.of(linkPreviews).filter(lp -> lp.getThumbnail().isPresent()).map(lp -> lp.getThumbnail().get()).toList(); - - allAttachments.addAll(attachments); - allAttachments.addAll(contactAttachments); - allAttachments.addAll(previewAttachments); - - contentValues.put(BODY, body); - contentValues.put(PART_COUNT, allAttachments.size()); - - db.beginTransaction(); - try { - long messageId = db.insert(TABLE_NAME, null, contentValues); - - Map insertedAttachments = partsDatabase.insertAttachmentsForMessage(messageId, allAttachments, quoteAttachments); - String serializedContacts = getSerializedSharedContacts(insertedAttachments, sharedContacts); - String serializedPreviews = getSerializedLinkPreviews(insertedAttachments, linkPreviews); - - if (!TextUtils.isEmpty(serializedContacts)) { - ContentValues contactValues = new ContentValues(); - contactValues.put(SHARED_CONTACTS, serializedContacts); - - SQLiteDatabase database = databaseHelper.getReadableDatabase(); - int rows = database.update(TABLE_NAME, contactValues, ID + " = ?", new String[]{ String.valueOf(messageId) }); - - if (rows <= 0) { - Log.w(TAG, "Failed to update message with shared contact data."); - } - } - - if (!TextUtils.isEmpty(serializedPreviews)) { - ContentValues contactValues = new ContentValues(); - contactValues.put(LINK_PREVIEWS, serializedPreviews); - - SQLiteDatabase database = databaseHelper.getReadableDatabase(); - int rows = database.update(TABLE_NAME, contactValues, ID + " = ?", new String[]{ String.valueOf(messageId) }); - - if (rows <= 0) { - Log.w(TAG, "Failed to update message with link preview data."); - } - } - - db.setTransactionSuccessful(); - return messageId; - } finally { - db.endTransaction(); - - if (insertListener != null) { - insertListener.onComplete(); - } - - notifyConversationListeners(contentValues.getAsLong(THREAD_ID)); - DatabaseComponent.get(context).threadDatabase().update(contentValues.getAsLong(THREAD_ID), true); - } - } - - public void deleteQuotedFromMessages(MessageRecord toDeleteRecord) { - if (toDeleteRecord == null) { return; } - String query = THREAD_ID + " = ?"; - Cursor threadMmsCursor = rawQuery(query, new String[]{String.valueOf(toDeleteRecord.getThreadId())}); - Reader reader = readerFor(threadMmsCursor); - MmsMessageRecord messageRecord; - - while ((messageRecord = (MmsMessageRecord) reader.getNext()) != null) { - if (messageRecord.getQuote() != null && toDeleteRecord.getDateSent() == messageRecord.getQuote().getId()) { - setQuoteMissing(messageRecord.getId()); - } - } - reader.close(); - } - - @Override - public boolean deleteMessage(long messageId) { - long threadId = getThreadIdForMessage(messageId); - AttachmentDatabase attachmentDatabase = DatabaseComponent.get(context).attachmentDatabase(); - ThreadUtils.queue(() -> attachmentDatabase.deleteAttachmentsForMessage(messageId)); - - GroupReceiptDatabase groupReceiptDatabase = DatabaseComponent.get(context).groupReceiptDatabase(); - groupReceiptDatabase.deleteRowsForMessage(messageId); - - MessageRecord toDelete; - try (Cursor messageCursor = getMessage(messageId)) { - toDelete = readerFor(messageCursor).getNext(); - } - - deleteQuotedFromMessages(toDelete); - SQLiteDatabase database = databaseHelper.getWritableDatabase(); - database.delete(TABLE_NAME, ID_WHERE, new String[] {messageId+""}); - boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false); - notifyConversationListeners(threadId); - notifyStickerListeners(); - notifyStickerPackListeners(); - return threadDeleted; - } - - public void deleteThread(long threadId) { - Set singleThreadSet = new HashSet<>(); - singleThreadSet.add(threadId); - deleteThreads(singleThreadSet); - } - - private @Nullable String getSerializedSharedContacts(@NonNull Map insertedAttachmentIds, @NonNull List contacts) { - if (contacts.isEmpty()) return null; - - JSONArray sharedContactJson = new JSONArray(); - - for (Contact contact : contacts) { - try { - AttachmentId attachmentId = null; - - if (contact.getAvatarAttachment() != null) { - attachmentId = insertedAttachmentIds.get(contact.getAvatarAttachment()); - } - - Contact.Avatar updatedAvatar = new Contact.Avatar(attachmentId, contact.getAvatarAttachment(), contact.getAvatar() != null && contact.getAvatar().isProfile()); - Contact updatedContact = new Contact(contact, updatedAvatar); - - sharedContactJson.put(new JSONObject(updatedContact.serialize())); - } catch (JSONException | IOException e) { - Log.w(TAG, "Failed to serialize shared contact. Skipping it.", e); - } - } - return sharedContactJson.toString(); - } - - private @Nullable String getSerializedLinkPreviews(@NonNull Map insertedAttachmentIds, @NonNull List previews) { - if (previews.isEmpty()) return null; - - JSONArray linkPreviewJson = new JSONArray(); - - for (LinkPreview preview : previews) { - try { - AttachmentId attachmentId = null; - - if (preview.getThumbnail().isPresent()) { - attachmentId = insertedAttachmentIds.get(preview.getThumbnail().get()); - } - - LinkPreview updatedPreview = new LinkPreview(preview.getUrl(), preview.getTitle(), attachmentId); - linkPreviewJson.put(new JSONObject(updatedPreview.serialize())); - } catch (JSONException | IOException e) { - Log.w(TAG, "Failed to serialize shared contact. Skipping it.", e); - } - } - return linkPreviewJson.toString(); - } - - private boolean isDuplicateMessageRequestResponse(IncomingMediaMessage message, long threadId) { - SQLiteDatabase database = databaseHelper.getReadableDatabase(); - Cursor cursor = database.query(TABLE_NAME, null, MESSAGE_REQUEST_RESPONSE + " = 1 AND " + ADDRESS + " = ? AND " + THREAD_ID + " = ?", - new String[]{message.getFrom().serialize(), String.valueOf(threadId)}, - null, null, null, "1"); - - try { - return cursor != null && cursor.moveToFirst(); - } finally { - if (cursor != null) cursor.close(); - } - } - - private boolean isDuplicate(IncomingMediaMessage message, long threadId) { - SQLiteDatabase database = databaseHelper.getReadableDatabase(); - Cursor cursor = database.query(TABLE_NAME, null, DATE_SENT + " = ? AND " + ADDRESS + " = ? AND " + THREAD_ID + " = ?", - new String[]{String.valueOf(message.getSentTimeMillis()), message.getFrom().serialize(), String.valueOf(threadId)}, - null, null, null, "1"); - - try { - return cursor != null && cursor.moveToFirst(); - } finally { - if (cursor != null) cursor.close(); - } - } - - private boolean isDuplicate(OutgoingMediaMessage message, long threadId) { - SQLiteDatabase database = databaseHelper.getReadableDatabase(); - Cursor cursor = database.query(TABLE_NAME, null, DATE_SENT + " = ? AND " + ADDRESS + " = ? AND " + THREAD_ID + " = ?", - new String[]{String.valueOf(message.getSentTimeMillis()), message.getRecipient().getAddress().serialize(), String.valueOf(threadId)}, - null, null, null, "1"); - - try { - return cursor != null && cursor.moveToFirst(); - } finally { - if (cursor != null) cursor.close(); - } - } - - public boolean isSent(long messageId) { - SQLiteDatabase database = databaseHelper.getReadableDatabase(); - try (Cursor cursor = database.query(TABLE_NAME, new String[] { MESSAGE_BOX }, ID + " = ?", new String[] { String.valueOf(messageId)}, null, null, null)) { - if (cursor != null && cursor.moveToNext()) { - long type = cursor.getLong(cursor.getColumnIndexOrThrow(MESSAGE_BOX)); - return Types.isSentType(type); - } - } - return false; - } - - /*package*/ void deleteThreads(Set threadIds) { - SQLiteDatabase db = databaseHelper.getWritableDatabase(); - String where = ""; - Cursor cursor = null; - - for (long threadId : threadIds) { - where += THREAD_ID + " = '" + threadId + "' OR "; - } - - where = where.substring(0, where.length() - 4); - - try { - cursor = db.query(TABLE_NAME, new String[] {ID}, where, null, null, null, null); - - while (cursor != null && cursor.moveToNext()) { - deleteMessage(cursor.getLong(0)); - } - - } finally { - if (cursor != null) - cursor.close(); - } - } - - /*package*/void deleteMessagesInThreadBeforeDate(long threadId, long date) { - Cursor cursor = null; - - try { - SQLiteDatabase db = databaseHelper.getReadableDatabase(); - String where = THREAD_ID + " = ? AND (CASE (" + MESSAGE_BOX + " & " + Types.BASE_TYPE_MASK + ") "; - - for (long outgoingType : Types.OUTGOING_MESSAGE_TYPES) { - where += " WHEN " + outgoingType + " THEN " + DATE_SENT + " < " + date; - } - - where += (" ELSE " + DATE_RECEIVED + " < " + date + " END)"); - - cursor = db.query(TABLE_NAME, new String[] {ID}, where, new String[] {threadId+""}, null, null, null); - - while (cursor != null && cursor.moveToNext()) { - Log.i("MmsDatabase", "Trimming: " + cursor.getLong(0)); - deleteMessage(cursor.getLong(0)); - } - - } finally { - if (cursor != null) - cursor.close(); - } - } - - - public void deleteAllThreads() { - DatabaseComponent.get(context).attachmentDatabase().deleteAllAttachments(); - DatabaseComponent.get(context).groupReceiptDatabase().deleteAllRows(); - - SQLiteDatabase database = databaseHelper.getWritableDatabase(); - database.delete(TABLE_NAME, null, null); - } - - public void beginTransaction() { - databaseHelper.getWritableDatabase().beginTransaction(); - } - - public void setTransactionSuccessful() { - databaseHelper.getWritableDatabase().setTransactionSuccessful(); - } - - public void endTransaction() { - databaseHelper.getWritableDatabase().endTransaction(); - } - - public Reader readerFor(Cursor cursor) { - return new Reader(cursor); - } - - public OutgoingMessageReader readerFor(OutgoingMediaMessage message, long threadId) { - return new OutgoingMessageReader(message, threadId); - } - - public int setQuoteMissing(long messageId) { - ContentValues contentValues = new ContentValues(); - contentValues.put(QUOTE_MISSING, 1); - SQLiteDatabase database = databaseHelper.getWritableDatabase(); - int rows = database.update(TABLE_NAME, contentValues, ID + " = ?", new String[]{ String.valueOf(messageId) }); - return rows; - } - - public static class Status { - public static final int DOWNLOAD_INITIALIZED = 1; - public static final int DOWNLOAD_NO_CONNECTIVITY = 2; - public static final int DOWNLOAD_CONNECTING = 3; - } - - public class OutgoingMessageReader { - - private final OutgoingMediaMessage message; - private final long id; - private final long threadId; - - public OutgoingMessageReader(OutgoingMediaMessage message, long threadId) { - this.message = message; - this.id = new SecureRandom().nextLong(); - this.threadId = threadId; - } - - public MessageRecord getCurrent() { - SlideDeck slideDeck = new SlideDeck(context, message.getAttachments()); - - return new MediaMmsMessageRecord(id, message.getRecipient(), message.getRecipient(), - 1, System.currentTimeMillis(), System.currentTimeMillis(), - 0, threadId, message.getBody(), - slideDeck, slideDeck.getSlides().size(), - message.isSecure() ? MmsSmsColumns.Types.getOutgoingEncryptedMessageType() : MmsSmsColumns.Types.getOutgoingSmsMessageType(), - new LinkedList(), - new LinkedList(), - message.getSubscriptionId(), - message.getExpiresIn(), - System.currentTimeMillis(), 0, - message.getOutgoingQuote() != null ? - new Quote(message.getOutgoingQuote().getId(), - message.getOutgoingQuote().getAuthor(), - message.getOutgoingQuote().getText(), - message.getOutgoingQuote().getMissing(), - new SlideDeck(context, message.getOutgoingQuote().getAttachments())) : - null, - message.getSharedContacts(), message.getLinkPreviews(), false); - } - } - - public class Reader implements Closeable { - - private final Cursor cursor; - - public Reader(Cursor cursor) { - this.cursor = cursor; - } - - public MessageRecord getNext() { - if (cursor == null || !cursor.moveToNext()) - return null; - - return getCurrent(); - } - - public MessageRecord getCurrent() { - long mmsType = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.MESSAGE_TYPE)); - - if (mmsType == PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND) { - return getNotificationMmsMessageRecord(cursor); - } else { - return getMediaMmsMessageRecord(cursor); - } - } - - private NotificationMmsMessageRecord getNotificationMmsMessageRecord(Cursor cursor) { - long id = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.ID)); - long dateSent = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.NORMALIZED_DATE_SENT)); - long dateReceived = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.NORMALIZED_DATE_RECEIVED)); - long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.THREAD_ID)); - long mailbox = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.MESSAGE_BOX)); - String address = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.ADDRESS)); - int addressDeviceId = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.ADDRESS_DEVICE_ID)); - Recipient recipient = getRecipientFor(address); - - String contentLocation = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.CONTENT_LOCATION)); - String transactionId = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.TRANSACTION_ID)); - long messageSize = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.MESSAGE_SIZE)); - long expiry = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.EXPIRY)); - int status = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.STATUS)); - int deliveryReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.DELIVERY_RECEIPT_COUNT)); - int readReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.READ_RECEIPT_COUNT)); - int subscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.SUBSCRIPTION_ID)); - - if (!TextSecurePreferences.isReadReceiptsEnabled(context)) { - readReceiptCount = 0; - } - - byte[]contentLocationBytes = null; - byte[]transactionIdBytes = null; - - if (!TextUtils.isEmpty(contentLocation)) - contentLocationBytes = Util.toIsoBytes(contentLocation); - - if (!TextUtils.isEmpty(transactionId)) - transactionIdBytes = Util.toIsoBytes(transactionId); - - SlideDeck slideDeck = new SlideDeck(context, new MmsNotificationAttachment(status, messageSize)); - - - return new NotificationMmsMessageRecord(id, recipient, recipient, - dateSent, dateReceived, deliveryReceiptCount, threadId, - contentLocationBytes, messageSize, expiry, status, - transactionIdBytes, mailbox, slideDeck, - readReceiptCount); - } - - private MediaMmsMessageRecord getMediaMmsMessageRecord(Cursor cursor) { - long id = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.ID)); - long dateSent = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.NORMALIZED_DATE_SENT)); - long dateReceived = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.NORMALIZED_DATE_RECEIVED)); - long box = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.MESSAGE_BOX)); - long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.THREAD_ID)); - String address = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.ADDRESS)); - int addressDeviceId = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.ADDRESS_DEVICE_ID)); - int deliveryReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.DELIVERY_RECEIPT_COUNT)); - int readReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.READ_RECEIPT_COUNT)); - String body = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.BODY)); - int partCount = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.PART_COUNT)); - String mismatchDocument = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.MISMATCHED_IDENTITIES)); - String networkDocument = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.NETWORK_FAILURE)); - int subscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.SUBSCRIPTION_ID)); - long expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.EXPIRES_IN)); - long expireStarted = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.EXPIRE_STARTED)); - boolean unidentified = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.UNIDENTIFIED)) == 1; - - if (!TextSecurePreferences.isReadReceiptsEnabled(context)) { - readReceiptCount = 0; - } - - Recipient recipient = getRecipientFor(address); - List mismatches = getMismatchedIdentities(mismatchDocument); - List networkFailures = getFailures(networkDocument); - List attachments = DatabaseComponent.get(context).attachmentDatabase().getAttachment(cursor); - List contacts = getSharedContacts(cursor, attachments); - Set contactAttachments = Stream.of(contacts).map(Contact::getAvatarAttachment).filter(a -> a != null).collect(Collectors.toSet()); - List previews = getLinkPreviews(cursor, attachments); - Set previewAttachments = Stream.of(previews).filter(lp -> lp.getThumbnail().isPresent()).map(lp -> lp.getThumbnail().get()).collect(Collectors.toSet()); - SlideDeck slideDeck = getSlideDeck(Stream.of(attachments).filterNot(contactAttachments::contains).filterNot(previewAttachments::contains).toList()); - Quote quote = getQuote(cursor); - - return new MediaMmsMessageRecord(id, recipient, recipient, - addressDeviceId, dateSent, dateReceived, deliveryReceiptCount, - threadId, body, slideDeck, partCount, box, mismatches, - networkFailures, subscriptionId, expiresIn, expireStarted, - readReceiptCount, quote, contacts, previews, unidentified); - } - - private Recipient getRecipientFor(String serialized) { - Address address; - - if (TextUtils.isEmpty(serialized) || "insert-address-token".equals(serialized)) { - address = Address.Companion.getUNKNOWN(); - } else { - address = Address.fromSerialized(serialized); - - } - return Recipient.from(context, address, true); - } - - private List getMismatchedIdentities(String document) { - if (!TextUtils.isEmpty(document)) { - try { - return JsonUtil.fromJson(document, IdentityKeyMismatchList.class).getList(); - } catch (IOException e) { - Log.w(TAG, e); - } - } - - return new LinkedList<>(); - } - - private List getFailures(String document) { - if (!TextUtils.isEmpty(document)) { - try { - return JsonUtil.fromJson(document, NetworkFailureList.class).getList(); - } catch (IOException ioe) { - Log.w(TAG, ioe); - } - } - - return new LinkedList<>(); - } - - private SlideDeck getSlideDeck(@NonNull List attachments) { - List messageAttachments = Stream.of(attachments) - .filterNot(Attachment::isQuote) - .toList(); - return new SlideDeck(context, messageAttachments); - } - - private @Nullable Quote getQuote(@NonNull Cursor cursor) { - long quoteId = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.QUOTE_ID)); - String quoteAuthor = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.QUOTE_AUTHOR)); - String quoteText = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.QUOTE_BODY)); - boolean quoteMissing = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.QUOTE_MISSING)) == 1; - List attachments = DatabaseComponent.get(context).attachmentDatabase().getAttachment(cursor); - List quoteAttachments = Stream.of(attachments).filter(Attachment::isQuote).toList(); - SlideDeck quoteDeck = new SlideDeck(context, quoteAttachments); - - if (quoteId > 0 && !TextUtils.isEmpty(quoteAuthor)) { - return new Quote(quoteId, Address.fromExternal(context, quoteAuthor), quoteText, quoteMissing, quoteDeck); - } else { - return null; - } - } - - @Override - public void close() { - if (cursor != null) { - cursor.close(); - } - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt new file mode 100644 index 0000000000..fdb9d1c161 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt @@ -0,0 +1,1607 @@ +/** + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see //www.gnu.org/licenses/>. + */ +package org.thoughtcrime.securesms.database + +import android.content.ContentValues +import android.content.Context +import android.database.Cursor +import android.text.TextUtils +import com.annimon.stream.Stream +import com.google.android.mms.pdu_alt.NotificationInd +import com.google.android.mms.pdu_alt.PduHeaders +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import org.session.libsession.messaging.messages.signal.IncomingMediaMessage +import org.session.libsession.messaging.messages.signal.OutgoingExpirationUpdateMessage +import org.session.libsession.messaging.messages.signal.OutgoingGroupMediaMessage +import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage +import org.session.libsession.messaging.messages.signal.OutgoingSecureMediaMessage +import org.session.libsession.messaging.sending_receiving.attachments.Attachment +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.link_preview.LinkPreview +import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.Address.Companion.UNKNOWN +import org.session.libsession.utilities.Address.Companion.fromExternal +import org.session.libsession.utilities.Address.Companion.fromSerialized +import org.session.libsession.utilities.Contact +import org.session.libsession.utilities.GroupUtil.doubleEncodeGroupID +import org.session.libsession.utilities.IdentityKeyMismatch +import org.session.libsession.utilities.IdentityKeyMismatchList +import org.session.libsession.utilities.NetworkFailure +import org.session.libsession.utilities.NetworkFailureList +import org.session.libsession.utilities.TextSecurePreferences.Companion.isReadReceiptsEnabled +import org.session.libsession.utilities.Util.toIsoBytes +import org.session.libsession.utilities.Util.toIsoString +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsession.utilities.recipients.RecipientFormattingException +import org.session.libsignal.utilities.JsonUtil +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.ThreadUtils.queue +import org.session.libsignal.utilities.guava.Optional +import org.thoughtcrime.securesms.attachments.MmsNotificationAttachment +import org.thoughtcrime.securesms.database.MmsDatabase +import org.thoughtcrime.securesms.database.NoSuchMessageException +import org.thoughtcrime.securesms.database.SmsDatabase.InsertListener +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper +import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.database.model.NotificationMmsMessageRecord +import org.thoughtcrime.securesms.database.model.Quote +import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get +import org.thoughtcrime.securesms.mms.MmsException +import org.thoughtcrime.securesms.mms.SlideDeck +import java.io.Closeable +import java.io.IOException +import java.security.SecureRandom +import java.util.LinkedList + +class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : MessagingDatabase(context, databaseHelper) { + private val earlyDeliveryReceiptCache = EarlyReceiptCache() + private val earlyReadReceiptCache = EarlyReceiptCache() + override fun getTableName() = TABLE_NAME + + fun getMessageCountForThread(threadId: Long): Int { + val db = databaseHelper.readableDatabase + db.query( + TABLE_NAME, + arrayOf("COUNT(*)"), + "$THREAD_ID = ?", + arrayOf(threadId.toString()), + null, + null, + null + ).use { cursor -> + if (cursor.moveToFirst()) return cursor.getInt(0) + } + return 0 + } + + fun addFailures(messageId: Long, failure: List) { + try { + addToDocument(messageId, NETWORK_FAILURE, failure, NetworkFailureList::class.java) + } catch (e: IOException) { + Log.w(TAG, e) + } + } + + fun removeFailure(messageId: Long, failure: NetworkFailure?) { + try { + removeFromDocument(messageId, NETWORK_FAILURE, failure, NetworkFailureList::class.java) + } catch (e: IOException) { + Log.w(TAG, e) + } + } + + fun isOutgoingMessage(timestamp: Long): Boolean { + val database = databaseHelper.writableDatabase + var cursor: Cursor? = null + var isOutgoing = false + try { + cursor = database.query( + TABLE_NAME, + arrayOf(ID, THREAD_ID, MESSAGE_BOX, ADDRESS), + DATE_SENT + " = ?", + arrayOf(timestamp.toString()), + null, + null, + null, + null + ) + while (cursor.moveToNext()) { + if (MmsSmsColumns.Types.isOutgoingMessageType( + cursor.getLong( + cursor.getColumnIndexOrThrow( + MESSAGE_BOX + ) + ) + ) + ) { + isOutgoing = true + } + } + } finally { + cursor?.close() + } + return isOutgoing + } + + fun incrementReceiptCount( + messageId: SyncMessageId, + timestamp: Long, + deliveryReceipt: Boolean, + readReceipt: Boolean + ) { + val database = databaseHelper.writableDatabase + var cursor: Cursor? = null + var found = false + try { + cursor = database.query( + TABLE_NAME, + arrayOf(ID, THREAD_ID, MESSAGE_BOX, ADDRESS), + "$DATE_SENT = ?", + arrayOf(messageId.timetamp.toString()), + null, + null, + null, + null + ) + while (cursor.moveToNext()) { + if (MmsSmsColumns.Types.isOutgoingMessageType( + cursor.getLong( + cursor.getColumnIndexOrThrow( + MESSAGE_BOX + ) + ) + ) + ) { + val theirAddress = fromSerialized( + cursor.getString( + cursor.getColumnIndexOrThrow( + ADDRESS + ) + ) + ) + val ourAddress = messageId.address + val columnName = + if (deliveryReceipt) DELIVERY_RECEIPT_COUNT else READ_RECEIPT_COUNT + if (ourAddress.equals(theirAddress) || theirAddress.isGroup) { + val id = cursor.getLong(cursor.getColumnIndexOrThrow(ID)) + val threadId = cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID)) + val status = + if (deliveryReceipt) GroupReceiptDatabase.STATUS_DELIVERED else GroupReceiptDatabase.STATUS_READ + found = true + database.execSQL( + "UPDATE " + TABLE_NAME + " SET " + + columnName + " = " + columnName + " + 1 WHERE " + ID + " = ?", + arrayOf(id.toString()) + ) + get(context).groupReceiptDatabase() + .update(ourAddress, id, status, timestamp) + get(context).threadDatabase().update(threadId, false) + notifyConversationListeners(threadId) + } + } + } + if (!found) { + if (deliveryReceipt) earlyDeliveryReceiptCache.increment( + messageId.timetamp, + messageId.address + ) + if (readReceipt) earlyReadReceiptCache.increment( + messageId.timetamp, + messageId.address + ) + } + } finally { + cursor?.close() + } + } + + fun updateSentTimestamp(messageId: Long, newTimestamp: Long, threadId: Long) { + val db = databaseHelper.writableDatabase + db.execSQL( + "UPDATE $TABLE_NAME SET $DATE_SENT = ? WHERE $ID = ?", + arrayOf(newTimestamp.toString(), messageId.toString()) + ) + notifyConversationListeners(threadId) + notifyConversationListListeners() + } + + fun getThreadIdForMessage(id: Long): Long { + val sql = "SELECT $THREAD_ID FROM $TABLE_NAME WHERE $ID = ?" + val sqlArgs = arrayOf(id.toString()) + val db = databaseHelper.readableDatabase + var cursor: Cursor? = null + return try { + cursor = db.rawQuery(sql, sqlArgs) + if (cursor != null && cursor.moveToFirst()) cursor.getLong(0) else -1 + } finally { + cursor?.close() + } + } + + @Throws(RecipientFormattingException::class, MmsException::class) + private fun getThreadIdFor(retrieved: IncomingMediaMessage): Long { + return if (retrieved.groupId != null) { + val groupRecipients = Recipient.from( + context, + retrieved.groupId, + true + ) + get(context).threadDatabase().getOrCreateThreadIdFor(groupRecipients) + } else { + val sender = Recipient.from( + context, + retrieved.from, + true + ) + get(context).threadDatabase().getOrCreateThreadIdFor(sender) + } + } + + private fun getThreadIdFor(notification: NotificationInd): Long { + val fromString = + if (notification.from != null && notification.from.textString != null) toIsoString( + notification.from.textString + ) else "" + val recipient = Recipient.from(context, fromExternal(context, fromString), false) + return get(context).threadDatabase().getOrCreateThreadIdFor(recipient) + } + + private fun rawQuery(where: String, arguments: Array?): Cursor { + val database = databaseHelper.readableDatabase + return database.rawQuery( + "SELECT " + MMS_PROJECTION.joinToString(",")+ + " FROM " + TABLE_NAME + " LEFT OUTER JOIN " + AttachmentDatabase.TABLE_NAME + + " ON (" + TABLE_NAME + "." + ID + " = " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + ")" + + " WHERE " + where + " GROUP BY " + TABLE_NAME + "." + ID, arguments + ) + } + + fun getMessages(idsAsString: String): Cursor { + return rawQuery(idsAsString, null) + } + + fun getMessage(messageId: Long): Cursor { + val cursor = rawQuery(RAW_ID_WHERE, arrayOf(messageId.toString())) + setNotifyConverationListeners(cursor, getThreadIdForMessage(messageId)) + return cursor + } + + val expireStartedMessages: Reader + get() { + val where = "$EXPIRE_STARTED > 0" + return readerFor(rawQuery(where, null))!! + } + + private fun updateMailboxBitmask( + id: Long, + maskOff: Long, + maskOn: Long, + threadId: Optional + ) { + val db = databaseHelper.writableDatabase + db.execSQL( + "UPDATE " + TABLE_NAME + + " SET " + MESSAGE_BOX + " = (" + MESSAGE_BOX + " & " + (MmsSmsColumns.Types.TOTAL_MASK - maskOff) + " | " + maskOn + " )" + + " WHERE " + ID + " = ?", arrayOf(id.toString() + "") + ) + if (threadId.isPresent) { + get(context).threadDatabase().update(threadId.get(), false) + } + } + + fun markAsPendingInsecureSmsFallback(messageId: Long) { + val threadId = getThreadIdForMessage(messageId) + updateMailboxBitmask( + messageId, + MmsSmsColumns.Types.BASE_TYPE_MASK, + MmsSmsColumns.Types.BASE_PENDING_INSECURE_SMS_FALLBACK, + Optional.of(threadId) + ) + notifyConversationListeners(threadId) + } + + fun markAsSending(messageId: Long) { + val threadId = getThreadIdForMessage(messageId) + updateMailboxBitmask( + messageId, + MmsSmsColumns.Types.BASE_TYPE_MASK, + MmsSmsColumns.Types.BASE_SENDING_TYPE, + Optional.of(threadId) + ) + notifyConversationListeners(threadId) + } + + fun markAsSentFailed(messageId: Long) { + val threadId = getThreadIdForMessage(messageId) + updateMailboxBitmask( + messageId, + MmsSmsColumns.Types.BASE_TYPE_MASK, + MmsSmsColumns.Types.BASE_SENT_FAILED_TYPE, + Optional.of(threadId) + ) + notifyConversationListeners(threadId) + } + + override fun markAsSent(messageId: Long, secure: Boolean) { + val threadId = getThreadIdForMessage(messageId) + updateMailboxBitmask( + messageId, + MmsSmsColumns.Types.BASE_TYPE_MASK, + MmsSmsColumns.Types.BASE_SENT_TYPE or if (secure) MmsSmsColumns.Types.PUSH_MESSAGE_BIT or MmsSmsColumns.Types.SECURE_MESSAGE_BIT else 0, + Optional.of(threadId) + ) + notifyConversationListeners(threadId) + } + + override fun markUnidentified(messageId: Long, unidentified: Boolean) { + val contentValues = ContentValues() + contentValues.put(UNIDENTIFIED, if (unidentified) 1 else 0) + val db = databaseHelper.writableDatabase + db.update(TABLE_NAME, contentValues, ID_WHERE, arrayOf(messageId.toString())) + } + + override fun markAsDeleted(messageId: Long, read: Boolean) { + val database = databaseHelper.writableDatabase + val contentValues = ContentValues() + contentValues.put(READ, 1) + contentValues.put(BODY, "") + database.update(TABLE_NAME, contentValues, ID_WHERE, arrayOf(messageId.toString())) + val attachmentDatabase = get(context).attachmentDatabase() + queue(Runnable { attachmentDatabase.deleteAttachmentsForMessage(messageId) }) + val threadId = getThreadIdForMessage(messageId) + if (!read) { + get(context).threadDatabase().decrementUnread(threadId, 1) + } + updateMailboxBitmask( + messageId, + MmsSmsColumns.Types.BASE_TYPE_MASK, + MmsSmsColumns.Types.BASE_DELETED_TYPE, + Optional.of(threadId) + ) + notifyConversationListeners(threadId) + } + + override fun markExpireStarted(messageId: Long) { + markExpireStarted(messageId, System.currentTimeMillis()) + } + + override fun markExpireStarted(messageId: Long, startedTimestamp: Long) { + val contentValues = ContentValues() + contentValues.put(EXPIRE_STARTED, startedTimestamp) + val db = databaseHelper.writableDatabase + db.update(TABLE_NAME, contentValues, ID_WHERE, arrayOf(messageId.toString())) + val threadId = getThreadIdForMessage(messageId) + notifyConversationListeners(threadId) + } + + fun markAsNotified(id: Long) { + val database = databaseHelper.writableDatabase + val contentValues = ContentValues() + contentValues.put(NOTIFIED, 1) + database.update(TABLE_NAME, contentValues, ID_WHERE, arrayOf(id.toString())) + } + + fun setMessagesRead(threadId: Long): List { + return setMessagesRead( + THREAD_ID + " = ? AND " + READ + " = 0", + arrayOf(threadId.toString()) + ) + } + + fun setAllMessagesRead(): List { + return setMessagesRead(READ + " = 0", null) + } + + private fun setMessagesRead(where: String, arguments: Array?): List { + val database = databaseHelper.writableDatabase + val result: MutableList = LinkedList() + var cursor: Cursor? = null + database.beginTransaction() + try { + cursor = database.query( + TABLE_NAME, + arrayOf(ID, ADDRESS, DATE_SENT, MESSAGE_BOX, EXPIRES_IN, EXPIRE_STARTED), + where, + arguments, + null, + null, + null + ) + while (cursor != null && cursor.moveToNext()) { + if (MmsSmsColumns.Types.isSecureType(cursor.getLong(3))) { + val syncMessageId = + SyncMessageId(fromSerialized(cursor.getString(1)), cursor.getLong(2)) + val expirationInfo = ExpirationInfo( + cursor.getLong(0), + cursor.getLong(4), + cursor.getLong(5), + true + ) + result.add(MarkedMessageInfo(syncMessageId, expirationInfo)) + } + } + val contentValues = ContentValues() + contentValues.put(READ, 1) + database.update(TABLE_NAME, contentValues, where, arguments) + database.setTransactionSuccessful() + } finally { + cursor?.close() + database.endTransaction() + } + return result + } + + @Throws(MmsException::class, NoSuchMessageException::class) + fun getOutgoingMessage(messageId: Long): OutgoingMediaMessage { + val attachmentDatabase = get(context).attachmentDatabase() + var cursor: Cursor? = null + try { + cursor = rawQuery(RAW_ID_WHERE, arrayOf(messageId.toString())) + if (cursor.moveToNext()) { + val associatedAttachments = attachmentDatabase.getAttachmentsForMessage(messageId) + val outboxType = cursor.getLong(cursor.getColumnIndexOrThrow(MESSAGE_BOX)) + val body = cursor.getString(cursor.getColumnIndexOrThrow(BODY)) + val timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(NORMALIZED_DATE_SENT)) + val subscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(SUBSCRIPTION_ID)) + val expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRES_IN)) + val address = cursor.getString(cursor.getColumnIndexOrThrow(ADDRESS)) + val threadId = cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID)) + val distributionType = get(context).threadDatabase().getDistributionType(threadId) + val mismatchDocument = cursor.getString( + cursor.getColumnIndexOrThrow( + MISMATCHED_IDENTITIES + ) + ) + val networkDocument = cursor.getString( + cursor.getColumnIndexOrThrow( + NETWORK_FAILURE + ) + ) + val quoteId = cursor.getLong(cursor.getColumnIndexOrThrow(QUOTE_ID)) + val quoteAuthor = cursor.getString(cursor.getColumnIndexOrThrow(QUOTE_AUTHOR)) + val quoteText = cursor.getString(cursor.getColumnIndexOrThrow(QUOTE_BODY)) + val quoteMissing = cursor.getInt(cursor.getColumnIndexOrThrow(QUOTE_MISSING)) == 1 + val quoteAttachments = associatedAttachments + .filter { obj: DatabaseAttachment -> obj.isQuote } + val contacts = getSharedContacts(cursor, associatedAttachments) + val contactAttachments: Set = + contacts.mapNotNull { obj: Contact -> obj.avatarAttachment }.toSet() + val previews = getLinkPreviews(cursor, associatedAttachments) + val previewAttachments = + previews.filter { lp: LinkPreview -> lp.getThumbnail().isPresent } + .map { lp: LinkPreview -> lp.getThumbnail().get() } + val attachments = associatedAttachments + .asSequence() + .filterNot { obj: DatabaseAttachment -> obj.isQuote || contactAttachments.contains(obj) || previewAttachments.contains(obj) } + .toList() + val recipient = Recipient.from(context, fromSerialized(address), false) + var networkFailures: List? = LinkedList() + var mismatches: List? = LinkedList() + var quote: QuoteModel? = null + if (quoteId > 0 && (!TextUtils.isEmpty(quoteText) || quoteAttachments.isNotEmpty())) { + quote = QuoteModel( + quoteId, + fromSerialized(quoteAuthor), + quoteText, + quoteMissing, + quoteAttachments + ) + } + if (!TextUtils.isEmpty(mismatchDocument)) { + try { + mismatches = JsonUtil.fromJson( + mismatchDocument, + IdentityKeyMismatchList::class.java + ).list + } catch (e: IOException) { + Log.w(TAG, e) + } + } + if (!TextUtils.isEmpty(networkDocument)) { + try { + networkFailures = + JsonUtil.fromJson(networkDocument, NetworkFailureList::class.java).list + } catch (e: IOException) { + Log.w(TAG, e) + } + } + val message = OutgoingMediaMessage( + recipient, + body, + attachments, + timestamp, + subscriptionId, + expiresIn, + distributionType, + quote, + contacts, + previews, + networkFailures!!, + mismatches!! + ) + return if (MmsSmsColumns.Types.isSecureType(outboxType)) { + OutgoingSecureMediaMessage(message) + } else message + } + throw NoSuchMessageException("No record found for id: $messageId") + } finally { + cursor?.close() + } + } + + private fun getSharedContacts( + cursor: Cursor, + attachments: List + ): List { + val serializedContacts = cursor.getString(cursor.getColumnIndexOrThrow(SHARED_CONTACTS)) + if (TextUtils.isEmpty(serializedContacts)) { + return emptyList() + } + val attachmentIdMap: MutableMap = HashMap() + for (attachment in attachments) { + attachmentIdMap[attachment.attachmentId] = attachment + } + try { + val contacts: MutableList = LinkedList() + val jsonContacts = JSONArray(serializedContacts) + for (i in 0 until jsonContacts.length()) { + val contact = Contact.deserialize(jsonContacts.getJSONObject(i).toString()) + if (contact.avatar != null && contact.avatar!!.attachmentId != null) { + val attachment = attachmentIdMap[contact.avatar!!.attachmentId] + val updatedAvatar = Contact.Avatar( + contact.avatar!!.attachmentId, + attachment, + contact.avatar!!.isProfile + ) + contacts.add(Contact(contact, updatedAvatar)) + } else { + contacts.add(contact) + } + } + return contacts + } catch (e: JSONException) { + Log.w(TAG, "Failed to parse shared contacts.", e) + } catch (e: IOException) { + Log.w(TAG, "Failed to parse shared contacts.", e) + } + return emptyList() + } + + private fun getLinkPreviews( + cursor: Cursor, + attachments: List + ): List { + val serializedPreviews = cursor.getString(cursor.getColumnIndexOrThrow(LINK_PREVIEWS)) + if (TextUtils.isEmpty(serializedPreviews)) { + return emptyList() + } + val attachmentIdMap: MutableMap = HashMap() + for (attachment in attachments) { + attachmentIdMap[attachment.attachmentId] = attachment + } + try { + val previews: MutableList = LinkedList() + val jsonPreviews = JSONArray(serializedPreviews) + for (i in 0 until jsonPreviews.length()) { + val preview = LinkPreview.deserialize(jsonPreviews.getJSONObject(i).toString()) + if (preview.attachmentId != null) { + val attachment = attachmentIdMap[preview.attachmentId] + if (attachment != null) { + previews.add(LinkPreview(preview.url, preview.title, attachment)) + } + } else { + previews.add(preview) + } + } + return previews + } catch (e: JSONException) { + Log.w(TAG, "Failed to parse shared contacts.", e) + } catch (e: IOException) { + Log.w(TAG, "Failed to parse shared contacts.", e) + } + return emptyList() + } + + @Throws(MmsException::class) + private fun insertMessageInbox( + retrieved: IncomingMediaMessage, + contentLocation: String, + threadId: Long, mailbox: Long, + serverTimestamp: Long, + runIncrement: Boolean, + runThreadUpdate: Boolean + ): Optional { + var threadId = threadId + if (threadId == -1L || retrieved.isGroupMessage) { + try { + threadId = getThreadIdFor(retrieved) + } catch (e: RecipientFormattingException) { + Log.w("MmsDatabase", e) + if (threadId == -1L) throw MmsException(e) + } + } + val contentValues = ContentValues() + contentValues.put(DATE_SENT, retrieved.sentTimeMillis) + contentValues.put(ADDRESS, retrieved.from.serialize()) + contentValues.put(MESSAGE_BOX, mailbox) + contentValues.put(MESSAGE_TYPE, PduHeaders.MESSAGE_TYPE_RETRIEVE_CONF) + contentValues.put(THREAD_ID, threadId) + contentValues.put(CONTENT_LOCATION, contentLocation) + contentValues.put(STATUS, Status.DOWNLOAD_INITIALIZED) + // In open groups messages should be sorted by their server timestamp + var receivedTimestamp = serverTimestamp + if (serverTimestamp == 0L) { + receivedTimestamp = retrieved.sentTimeMillis + } + contentValues.put( + DATE_RECEIVED, + receivedTimestamp + ) // Loki - This is important due to how we handle GIFs + contentValues.put(PART_COUNT, retrieved.attachments.size) + contentValues.put(SUBSCRIPTION_ID, retrieved.subscriptionId) + contentValues.put(EXPIRES_IN, retrieved.expiresIn) + contentValues.put(READ, if (retrieved.isExpirationUpdate) 1 else 0) + contentValues.put(UNIDENTIFIED, retrieved.isUnidentified) + contentValues.put(MESSAGE_REQUEST_RESPONSE, retrieved.isMessageRequestResponse) + if (!contentValues.containsKey(DATE_SENT)) { + contentValues.put(DATE_SENT, contentValues.getAsLong(DATE_RECEIVED)) + } + var quoteAttachments: List? = LinkedList() + if (retrieved.quote != null) { + contentValues.put(QUOTE_ID, retrieved.quote.id) + contentValues.put(QUOTE_BODY, retrieved.quote.text) + contentValues.put(QUOTE_AUTHOR, retrieved.quote.author.serialize()) + contentValues.put(QUOTE_MISSING, if (retrieved.quote.missing) 1 else 0) + quoteAttachments = retrieved.quote.attachments + } + if (retrieved.isPushMessage && isDuplicate(retrieved, threadId) || + retrieved.isMessageRequestResponse && isDuplicateMessageRequestResponse( + retrieved, + threadId + ) + ) { + Log.w(TAG, "Ignoring duplicate media message (" + retrieved.sentTimeMillis + ")") + return Optional.absent() + } + val messageId = insertMediaMessage( + retrieved.body, + retrieved.attachments, + quoteAttachments!!, + retrieved.sharedContacts, + retrieved.linkPreviews, + contentValues, + null, + ) + if (!MmsSmsColumns.Types.isExpirationTimerUpdate(mailbox)) { + if (runIncrement) { + get(context).threadDatabase().incrementUnread(threadId, 1) + } + if (runThreadUpdate) { + get(context).threadDatabase().update(threadId, true) + } + } + notifyConversationListeners(threadId) + return Optional.of(InsertResult(messageId, threadId)) + } + + @Throws(MmsException::class) + fun insertSecureDecryptedMessageOutbox( + retrieved: OutgoingMediaMessage, + threadId: Long, + serverTimestamp: Long, + runThreadUpdate: Boolean + ): Optional { + var threadId = threadId + if (threadId == -1L) { + if (retrieved.isGroup) { + val decodedGroupId: String = if (retrieved is OutgoingExpirationUpdateMessage) { + retrieved.groupId + } else { + (retrieved as OutgoingGroupMediaMessage).groupId + } + val groupId: String + groupId = try { + doubleEncodeGroupID(decodedGroupId) + } catch (e: IOException) { + Log.e(TAG, "Couldn't encrypt group ID") + throw MmsException(e) + } + val group = Recipient.from(context, fromSerialized(groupId), false) + threadId = get(context).threadDatabase().getOrCreateThreadIdFor(group) + } else { + threadId = get(context).threadDatabase().getOrCreateThreadIdFor(retrieved.recipient) + } + } + val messageId = insertMessageOutbox(retrieved, threadId, false, null, serverTimestamp, runThreadUpdate) + if (messageId == -1L) { + return Optional.absent() + } + markAsSent(messageId, true) + return Optional.fromNullable(InsertResult(messageId, threadId)) + } + + @JvmOverloads + @Throws(MmsException::class) + fun insertSecureDecryptedMessageInbox( + retrieved: IncomingMediaMessage, + threadId: Long, + serverTimestamp: Long = 0, + runIncrement: Boolean, + runThreadUpdate: Boolean + ): Optional { + var type = MmsSmsColumns.Types.BASE_INBOX_TYPE or MmsSmsColumns.Types.SECURE_MESSAGE_BIT + if (retrieved.isPushMessage) { + type = type or MmsSmsColumns.Types.PUSH_MESSAGE_BIT + } + if (retrieved.isExpirationUpdate) { + type = type or MmsSmsColumns.Types.EXPIRATION_TIMER_UPDATE_BIT + } + if (retrieved.isScreenshotDataExtraction) { + type = type or MmsSmsColumns.Types.SCREENSHOT_EXTRACTION_BIT + } + if (retrieved.isMediaSavedDataExtraction) { + type = type or MmsSmsColumns.Types.MEDIA_SAVED_EXTRACTION_BIT + } + if (retrieved.isMessageRequestResponse) { + type = type or MmsSmsColumns.Types.MESSAGE_REQUEST_RESPONSE_BIT + } + return insertMessageInbox(retrieved, "", threadId, type, serverTimestamp, runIncrement, runThreadUpdate) + } + + @JvmOverloads + @Throws(MmsException::class) + fun insertMessageOutbox( + message: OutgoingMediaMessage, + threadId: Long, forceSms: Boolean, + insertListener: InsertListener?, + serverTimestamp: Long = 0, + runThreadUpdate: Boolean + ): Long { + var type = MmsSmsColumns.Types.BASE_SENDING_TYPE + if (message.isSecure) type = + type or (MmsSmsColumns.Types.SECURE_MESSAGE_BIT or MmsSmsColumns.Types.PUSH_MESSAGE_BIT) + if (forceSms) type = type or MmsSmsColumns.Types.MESSAGE_FORCE_SMS_BIT + if (message.isGroup && message is OutgoingGroupMediaMessage) { + if (message.isUpdateMessage) type = type or MmsSmsColumns.Types.GROUP_UPDATE_MESSAGE_BIT + } + if (message.isExpirationUpdate) { + type = type or MmsSmsColumns.Types.EXPIRATION_TIMER_UPDATE_BIT + } + val earlyDeliveryReceipts = earlyDeliveryReceiptCache.remove(message.sentTimeMillis) + val earlyReadReceipts = earlyReadReceiptCache.remove(message.sentTimeMillis) + val contentValues = ContentValues() + contentValues.put(DATE_SENT, message.sentTimeMillis) + contentValues.put(MESSAGE_TYPE, PduHeaders.MESSAGE_TYPE_SEND_REQ) + contentValues.put(MESSAGE_BOX, type) + contentValues.put(THREAD_ID, threadId) + contentValues.put(READ, 1) + // In open groups messages should be sorted by their server timestamp + var receivedTimestamp = serverTimestamp + if (serverTimestamp == 0L) { + receivedTimestamp = System.currentTimeMillis() + } + contentValues.put(DATE_RECEIVED, receivedTimestamp) + contentValues.put(SUBSCRIPTION_ID, message.subscriptionId) + contentValues.put(EXPIRES_IN, message.expiresIn) + contentValues.put(ADDRESS, message.recipient.address.serialize()) + contentValues.put( + DELIVERY_RECEIPT_COUNT, + Stream.of(earlyDeliveryReceipts.values).mapToLong { obj: Long -> obj } + .sum()) + contentValues.put( + READ_RECEIPT_COUNT, + Stream.of(earlyReadReceipts.values).mapToLong { obj: Long -> obj } + .sum()) + val quoteAttachments: MutableList = LinkedList() + if (message.outgoingQuote != null) { + contentValues.put(QUOTE_ID, message.outgoingQuote!!.id) + contentValues.put(QUOTE_AUTHOR, message.outgoingQuote!!.author.serialize()) + contentValues.put(QUOTE_BODY, message.outgoingQuote!!.text) + contentValues.put(QUOTE_MISSING, if (message.outgoingQuote!!.missing) 1 else 0) + quoteAttachments.addAll(message.outgoingQuote!!.attachments!!) + } + if (isDuplicate(message, threadId)) { + Log.w(TAG, "Ignoring duplicate media message (" + message.sentTimeMillis + ")") + return -1 + } + val messageId = insertMediaMessage( + message.body, + message.attachments, + quoteAttachments, + message.sharedContacts, + message.linkPreviews, + contentValues, + insertListener, + ) + if (message.recipient.address.isGroup) { + val members = get(context).groupDatabase() + .getGroupMembers(message.recipient.address.toGroupString(), false) + val receiptDatabase = get(context).groupReceiptDatabase() + receiptDatabase.insert(Stream.of(members).map { obj: Recipient -> obj.address } + .toList(), + messageId, GroupReceiptDatabase.STATUS_UNDELIVERED, message.sentTimeMillis + ) + for (address in earlyDeliveryReceipts.keys) receiptDatabase.update( + address, + messageId, + GroupReceiptDatabase.STATUS_DELIVERED, + -1 + ) + for (address in earlyReadReceipts.keys) receiptDatabase.update( + address, + messageId, + GroupReceiptDatabase.STATUS_READ, + -1 + ) + } + with (get(context).threadDatabase()) { + setLastSeen(threadId) + setHasSent(threadId, true) + if (runThreadUpdate) { + update(threadId, true) + } + } + return messageId + } + + @Throws(MmsException::class) + private fun insertMediaMessage( + body: String?, + attachments: List, + quoteAttachments: List, + sharedContacts: List, + linkPreviews: List, + contentValues: ContentValues, + insertListener: InsertListener?, + ): Long { + val db = databaseHelper.writableDatabase + val partsDatabase = get(context).attachmentDatabase() + val allAttachments: MutableList = LinkedList() + val contactAttachments = + Stream.of(sharedContacts).map { obj: Contact -> obj.avatarAttachment } + .filter { a: Attachment? -> a != null } + .toList() + val previewAttachments = + Stream.of(linkPreviews).filter { lp: LinkPreview -> lp.getThumbnail().isPresent } + .map { lp: LinkPreview -> lp.getThumbnail().get() } + .toList() + allAttachments.addAll(attachments) + allAttachments.addAll(contactAttachments) + allAttachments.addAll(previewAttachments) + contentValues.put(BODY, body) + contentValues.put(PART_COUNT, allAttachments.size) + db.beginTransaction() + return try { + val messageId = db.insert(TABLE_NAME, null, contentValues) + val insertedAttachments = partsDatabase.insertAttachmentsForMessage( + messageId, + allAttachments, + quoteAttachments + ) + val serializedContacts = + getSerializedSharedContacts(insertedAttachments, sharedContacts) + val serializedPreviews = getSerializedLinkPreviews(insertedAttachments, linkPreviews) + if (!TextUtils.isEmpty(serializedContacts)) { + val contactValues = ContentValues() + contactValues.put(SHARED_CONTACTS, serializedContacts) + val database = databaseHelper.readableDatabase + val rows = database.update( + TABLE_NAME, + contactValues, + "$ID = ?", + arrayOf(messageId.toString()) + ) + if (rows <= 0) { + Log.w(TAG, "Failed to update message with shared contact data.") + } + } + if (!TextUtils.isEmpty(serializedPreviews)) { + val contactValues = ContentValues() + contactValues.put(LINK_PREVIEWS, serializedPreviews) + val database = databaseHelper.readableDatabase + val rows = database.update( + TABLE_NAME, + contactValues, + "$ID = ?", + arrayOf(messageId.toString()) + ) + if (rows <= 0) { + Log.w(TAG, "Failed to update message with link preview data.") + } + } + db.setTransactionSuccessful() + messageId + } finally { + db.endTransaction() + insertListener?.onComplete() + notifyConversationListeners(contentValues.getAsLong(THREAD_ID)) + } + } + + private fun deleteQuotedFromMessages(toDeleteRecords: List) { + if (toDeleteRecords.isEmpty()) return + val queryBuilder = StringBuilder() + for (i in toDeleteRecords.indices) { + queryBuilder.append("$QUOTE_ID = ").append(toDeleteRecords[i].getId()) + if (i + 1 < toDeleteRecords.size) { + queryBuilder.append(" OR ") + } + } + val query = queryBuilder.toString() + val db = databaseHelper.writableDatabase + val values = ContentValues(3) + values.put(QUOTE_MISSING, 1) + values.put(QUOTE_BODY, "") + values.put(QUOTE_AUTHOR, "") + db!!.update(TABLE_NAME, values, query, null) + } + + fun deleteQuotedFromMessages(toDeleteRecord: MessageRecord?) { + if (toDeleteRecord == null) { + return + } + val query = "$THREAD_ID = ?" + rawQuery(query, arrayOf(toDeleteRecord.threadId.toString())).use { threadMmsCursor -> + val reader = readerFor(threadMmsCursor) + var messageRecord: MmsMessageRecord? = reader.next as MmsMessageRecord? + while (messageRecord != null) { + if (messageRecord.quote != null && toDeleteRecord.dateSent == messageRecord.quote?.id) { + setQuoteMissing(messageRecord.id) + } + messageRecord = reader.next as MmsMessageRecord? + } + reader.close() + } + } + + /** + * Delete all the messages in single queries where possible + * @param messageIds a String array representation of regularly Long types representing message IDs + */ + private fun deleteMessages(messageIds: Array) { + if (messageIds.isEmpty()) { + return + } + // don't need thread IDs + val queryBuilder = StringBuilder() + for (i in messageIds.indices) { + queryBuilder.append("$TABLE_NAME.$ID").append(" = ").append( + messageIds[i] + ) + if (i + 1 < messageIds.size) { + queryBuilder.append(" OR ") + } + } + val idsAsString = queryBuilder.toString() + val attachmentDatabase = get(context).attachmentDatabase() + queue(Runnable { attachmentDatabase.deleteAttachmentsForMessages(messageIds) }) + val groupReceiptDatabase = get(context).groupReceiptDatabase() + groupReceiptDatabase.deleteRowsForMessages(messageIds) + val toDeleteList: MutableList = ArrayList() + getMessages(idsAsString).use { messageCursor -> + while (messageCursor.moveToNext()) { + toDeleteList.add(readerFor(messageCursor).current) + } + } + deleteQuotedFromMessages(toDeleteList) + val database = databaseHelper.writableDatabase + database.delete(TABLE_NAME, idsAsString, null) + notifyConversationListListeners() + notifyStickerListeners() + notifyStickerPackListeners() + } + + override fun deleteMessage(messageId: Long): Boolean { + val threadId = getThreadIdForMessage(messageId) + val attachmentDatabase = get(context).attachmentDatabase() + queue(Runnable { attachmentDatabase.deleteAttachmentsForMessage(messageId) }) + val groupReceiptDatabase = get(context).groupReceiptDatabase() + groupReceiptDatabase.deleteRowsForMessage(messageId) + var toDelete: MessageRecord? + getMessage(messageId).use { messageCursor -> + toDelete = readerFor(messageCursor).next + } + deleteQuotedFromMessages(toDelete) + val database = databaseHelper.writableDatabase + database!!.delete(TABLE_NAME, ID_WHERE, arrayOf(messageId.toString())) + val threadDeleted = get(context).threadDatabase().update(threadId, false) + notifyConversationListeners(threadId) + notifyStickerListeners() + notifyStickerPackListeners() + return threadDeleted + } + + fun deleteThread(threadId: Long) { + deleteThreads(setOf(threadId)) + } + + private fun getSerializedSharedContacts( + insertedAttachmentIds: Map, + contacts: List + ): String? { + if (contacts.isEmpty()) return null + val sharedContactJson = JSONArray() + for (contact in contacts) { + try { + var attachmentId: AttachmentId? = null + if (contact!!.avatarAttachment != null) { + attachmentId = insertedAttachmentIds[contact.avatarAttachment] + } + val updatedAvatar = Contact.Avatar( + attachmentId, + contact.avatarAttachment, + contact.avatar != null && contact.avatar!! + .isProfile + ) + val updatedContact = Contact( + contact, updatedAvatar + ) + sharedContactJson.put(JSONObject(updatedContact.serialize())) + } catch (e: JSONException) { + Log.w(TAG, "Failed to serialize shared contact. Skipping it.", e) + } catch (e: IOException) { + Log.w(TAG, "Failed to serialize shared contact. Skipping it.", e) + } + } + return sharedContactJson.toString() + } + + private fun getSerializedLinkPreviews( + insertedAttachmentIds: Map, + previews: List + ): String? { + if (previews.isEmpty()) return null + val linkPreviewJson = JSONArray() + for (preview in previews) { + try { + var attachmentId: AttachmentId? = null + if (preview!!.getThumbnail().isPresent) { + attachmentId = insertedAttachmentIds[preview.getThumbnail().get()] + } + val updatedPreview = LinkPreview( + preview.url, preview.title, attachmentId + ) + linkPreviewJson.put(JSONObject(updatedPreview.serialize())) + } catch (e: JSONException) { + Log.w(TAG, "Failed to serialize shared contact. Skipping it.", e) + } catch (e: IOException) { + Log.w(TAG, "Failed to serialize shared contact. Skipping it.", e) + } + } + return linkPreviewJson.toString() + } + + private fun isDuplicateMessageRequestResponse( + message: IncomingMediaMessage?, + threadId: Long + ): Boolean { + val database = databaseHelper.readableDatabase + val cursor: Cursor? = database!!.query( + TABLE_NAME, + null, + MESSAGE_REQUEST_RESPONSE + " = 1 AND " + ADDRESS + " = ? AND " + THREAD_ID + " = ?", + arrayOf( + message!!.from.serialize(), threadId.toString() + ), + null, + null, + null, + "1" + ) + return try { + cursor != null && cursor.moveToFirst() + } finally { + cursor?.close() + } + } + + private fun isDuplicate(message: IncomingMediaMessage?, threadId: Long): Boolean { + val database = databaseHelper.readableDatabase + val cursor: Cursor? = database!!.query( + TABLE_NAME, + null, + DATE_SENT + " = ? AND " + ADDRESS + " = ? AND " + THREAD_ID + " = ?", + arrayOf( + message!!.sentTimeMillis.toString(), message.from.serialize(), threadId.toString() + ), + null, + null, + null, + "1" + ) + return try { + cursor != null && cursor.moveToFirst() + } finally { + cursor?.close() + } + } + + private fun isDuplicate(message: OutgoingMediaMessage?, threadId: Long): Boolean { + val database = databaseHelper.readableDatabase + val cursor: Cursor? = database!!.query( + TABLE_NAME, + null, + DATE_SENT + " = ? AND " + ADDRESS + " = ? AND " + THREAD_ID + " = ?", + arrayOf( + message!!.sentTimeMillis.toString(), + message.recipient.address.serialize(), + threadId.toString() + ), + null, + null, + null, + "1" + ) + return try { + cursor != null && cursor.moveToFirst() + } finally { + cursor?.close() + } + } + + fun isSent(messageId: Long): Boolean { + val database = databaseHelper.readableDatabase + database!!.query( + TABLE_NAME, + arrayOf(MESSAGE_BOX), + "$ID = ?", + arrayOf(messageId.toString()), + null, + null, + null + ).use { cursor -> + if (cursor != null && cursor.moveToNext()) { + val type = cursor.getLong(cursor.getColumnIndexOrThrow(MESSAGE_BOX)) + return MmsSmsColumns.Types.isSentType(type) + } + } + return false + } + + /*package*/ + private fun deleteThreads(threadIds: Set) { + val db = databaseHelper.writableDatabase + val where = StringBuilder() + var cursor: Cursor? = null + for (threadId in threadIds) { + where.append(THREAD_ID).append(" = '").append(threadId).append("' OR ") + } + val whereString = where.substring(0, where.length - 4) + try { + cursor = + db!!.query(TABLE_NAME, arrayOf(ID), whereString, null, null, null, null) + val toDeleteStringMessageIds = mutableListOf() + while (cursor.moveToNext()) { + toDeleteStringMessageIds += cursor.getLong(0).toString() + } + // TODO: this can probably be optimized out, + // currently attachmentDB uses MmsID not threadID which makes it difficult to delete + // and clean up on threadID alone + toDeleteStringMessageIds.toList().chunked(50).forEach { sublist -> + deleteMessages(sublist.toTypedArray()) + } + } finally { + cursor?.close() + } + val threadDb = get(context).threadDatabase() + for (threadId in threadIds) { + val threadDeleted = threadDb.update(threadId, false) + notifyConversationListeners(threadId) + } + notifyStickerListeners() + notifyStickerPackListeners() + } + + /*package*/ + fun deleteMessagesInThreadBeforeDate(threadId: Long, date: Long) { + var cursor: Cursor? = null + try { + val db = databaseHelper.readableDatabase + var where = + THREAD_ID + " = ? AND (CASE (" + MESSAGE_BOX + " & " + MmsSmsColumns.Types.BASE_TYPE_MASK + ") " + for (outgoingType in MmsSmsColumns.Types.OUTGOING_MESSAGE_TYPES) { + where += " WHEN $outgoingType THEN $DATE_SENT < $date" + } + where += " ELSE $DATE_RECEIVED < $date END)" + cursor = db!!.query( + TABLE_NAME, + arrayOf(ID), + where, + arrayOf(threadId.toString() + ""), + null, + null, + null + ) + while (cursor != null && cursor.moveToNext()) { + Log.i("MmsDatabase", "Trimming: " + cursor.getLong(0)) + deleteMessage(cursor.getLong(0)) + } + } finally { + cursor?.close() + } + } + + fun readerFor(cursor: Cursor?): Reader { + return Reader(cursor) + } + + fun readerFor(message: OutgoingMediaMessage?, threadId: Long): OutgoingMessageReader { + return OutgoingMessageReader(message, threadId) + } + + fun setQuoteMissing(messageId: Long): Int { + val contentValues = ContentValues() + contentValues.put(QUOTE_MISSING, 1) + val database = databaseHelper.writableDatabase + return database!!.update( + TABLE_NAME, + contentValues, + "$ID = ?", + arrayOf(messageId.toString()) + ) + } + + object Status { + const val DOWNLOAD_INITIALIZED = 1 + const val DOWNLOAD_NO_CONNECTIVITY = 2 + const val DOWNLOAD_CONNECTING = 3 + } + + inner class OutgoingMessageReader(private val message: OutgoingMediaMessage?, + private val threadId: Long) { + private val id = SecureRandom().nextLong() + val current: MessageRecord + get() { + val slideDeck = SlideDeck(context, message!!.attachments) + return MediaMmsMessageRecord( + id, message.recipient, message.recipient, + 1, System.currentTimeMillis(), System.currentTimeMillis(), + 0, threadId, message.body, + slideDeck, slideDeck.slides.size, + if (message.isSecure) MmsSmsColumns.Types.getOutgoingEncryptedMessageType() else MmsSmsColumns.Types.getOutgoingSmsMessageType(), + LinkedList(), + LinkedList(), + message.subscriptionId, + message.expiresIn, + System.currentTimeMillis(), 0, + if (message.outgoingQuote != null) Quote( + message.outgoingQuote!!.id, + message.outgoingQuote!!.author, + message.outgoingQuote!!.text, + message.outgoingQuote!!.missing, + SlideDeck(context, message.outgoingQuote!!.attachments!!) + ) else null, + message.sharedContacts, message.linkPreviews, false + ) + } + + } + + inner class Reader(private val cursor: Cursor?) : Closeable { + val next: MessageRecord? + get() = if (cursor == null || !cursor.moveToNext()) null else current + val current: MessageRecord + get() { + val mmsType = cursor!!.getLong(cursor.getColumnIndexOrThrow(MESSAGE_TYPE)) + return if (mmsType == PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND.toLong()) { + getNotificationMmsMessageRecord(cursor) + } else { + getMediaMmsMessageRecord(cursor) + } + } + + private fun getNotificationMmsMessageRecord(cursor: Cursor): NotificationMmsMessageRecord { + val id = cursor.getLong(cursor.getColumnIndexOrThrow(ID)) + val dateSent = cursor.getLong(cursor.getColumnIndexOrThrow(NORMALIZED_DATE_SENT)) + val dateReceived = cursor.getLong( + cursor.getColumnIndexOrThrow( + NORMALIZED_DATE_RECEIVED + ) + ) + val threadId = cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID)) + val mailbox = cursor.getLong(cursor.getColumnIndexOrThrow(MESSAGE_BOX)) + val address = cursor.getString(cursor.getColumnIndexOrThrow(ADDRESS)) + val addressDeviceId = cursor.getInt(cursor.getColumnIndexOrThrow(ADDRESS_DEVICE_ID)) + val recipient = getRecipientFor(address) + val contentLocation = cursor.getString(cursor.getColumnIndexOrThrow(CONTENT_LOCATION)) + val transactionId = cursor.getString(cursor.getColumnIndexOrThrow(TRANSACTION_ID)) + val messageSize = cursor.getLong(cursor.getColumnIndexOrThrow(MESSAGE_SIZE)) + val expiry = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRY)) + val status = cursor.getInt(cursor.getColumnIndexOrThrow(STATUS)) + val deliveryReceiptCount = cursor.getInt( + cursor.getColumnIndexOrThrow( + DELIVERY_RECEIPT_COUNT + ) + ) + var readReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(READ_RECEIPT_COUNT)) + val subscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(SUBSCRIPTION_ID)) + if (!isReadReceiptsEnabled(context)) { + readReceiptCount = 0 + } + var contentLocationBytes: ByteArray? = null + var transactionIdBytes: ByteArray? = null + if (!TextUtils.isEmpty(contentLocation)) contentLocationBytes = toIsoBytes( + contentLocation!! + ) + if (!TextUtils.isEmpty(transactionId)) transactionIdBytes = toIsoBytes( + transactionId!! + ) + val slideDeck = SlideDeck(context, MmsNotificationAttachment(status, messageSize)) + return NotificationMmsMessageRecord( + id, recipient, recipient, + dateSent, dateReceived, deliveryReceiptCount, threadId, + contentLocationBytes, messageSize, expiry, status, + transactionIdBytes, mailbox, slideDeck, + readReceiptCount + ) + } + + private fun getMediaMmsMessageRecord(cursor: Cursor): MediaMmsMessageRecord { + val id = cursor.getLong(cursor.getColumnIndexOrThrow(ID)) + val dateSent = cursor.getLong(cursor.getColumnIndexOrThrow(NORMALIZED_DATE_SENT)) + val dateReceived = cursor.getLong( + cursor.getColumnIndexOrThrow( + NORMALIZED_DATE_RECEIVED + ) + ) + val box = cursor.getLong(cursor.getColumnIndexOrThrow(MESSAGE_BOX)) + val threadId = cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID)) + val address = cursor.getString(cursor.getColumnIndexOrThrow(ADDRESS)) + val addressDeviceId = cursor.getInt(cursor.getColumnIndexOrThrow(ADDRESS_DEVICE_ID)) + val deliveryReceiptCount = cursor.getInt( + cursor.getColumnIndexOrThrow( + DELIVERY_RECEIPT_COUNT + ) + ) + var readReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(READ_RECEIPT_COUNT)) + val body = cursor.getString(cursor.getColumnIndexOrThrow(BODY)) + val partCount = cursor.getInt(cursor.getColumnIndexOrThrow(PART_COUNT)) + val mismatchDocument = cursor.getString( + cursor.getColumnIndexOrThrow( + MISMATCHED_IDENTITIES + ) + ) + val networkDocument = cursor.getString(cursor.getColumnIndexOrThrow(NETWORK_FAILURE)) + val subscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(SUBSCRIPTION_ID)) + val expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRES_IN)) + val expireStarted = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRE_STARTED)) + val unidentified = cursor.getInt(cursor.getColumnIndexOrThrow(UNIDENTIFIED)) == 1 + if (!isReadReceiptsEnabled(context)) { + readReceiptCount = 0 + } + val recipient = getRecipientFor(address) + val mismatches = getMismatchedIdentities(mismatchDocument) + val networkFailures = getFailures(networkDocument) + val attachments = get(context).attachmentDatabase().getAttachment( + cursor + ) + val contacts: List = getSharedContacts( + cursor, attachments + ) + val contactAttachments = + contacts.map { obj: Contact? -> obj!!.avatarAttachment } + .filter { a: Attachment? -> a != null } + .toSet() + val previews: List = getLinkPreviews( + cursor, attachments + ) + val previewAttachments = + previews.filter { lp: LinkPreview? -> lp!!.getThumbnail().isPresent } + .map { lp: LinkPreview? -> lp!!.getThumbnail().get() } + .toSet() + val slideDeck = getSlideDeck( + Stream.of(attachments) + .filterNot { o: DatabaseAttachment? -> contactAttachments.contains(o) } + .filterNot { o: DatabaseAttachment? -> previewAttachments.contains(o) } + .toList() + ) + val quote = getQuote(cursor) + return MediaMmsMessageRecord( + id, recipient, recipient, + addressDeviceId, dateSent, dateReceived, deliveryReceiptCount, + threadId, body, slideDeck!!, partCount, box, mismatches, + networkFailures, subscriptionId, expiresIn, expireStarted, + readReceiptCount, quote, contacts, previews, unidentified + ) + } + + private fun getRecipientFor(serialized: String?): Recipient { + val address: Address = if (TextUtils.isEmpty(serialized) || "insert-address-token" == serialized) { + UNKNOWN + } else { + fromSerialized(serialized!!) + } + return Recipient.from(context, address, true) + } + + private fun getMismatchedIdentities(document: String?): List? { + if (!TextUtils.isEmpty(document)) { + try { + return JsonUtil.fromJson(document, IdentityKeyMismatchList::class.java).list + } catch (e: IOException) { + Log.w(TAG, e) + } + } + return LinkedList() + } + + private fun getFailures(document: String?): List? { + if (!TextUtils.isEmpty(document)) { + try { + return JsonUtil.fromJson(document, NetworkFailureList::class.java).list + } catch (ioe: IOException) { + Log.w(TAG, ioe) + } + } + return LinkedList() + } + + private fun getSlideDeck(attachments: List): SlideDeck? { + val messageAttachments: List? = Stream.of(attachments) + .filterNot { obj: DatabaseAttachment? -> obj!!.isQuote } + .toList() + return SlideDeck(context, messageAttachments!!) + } + + private fun getQuote(cursor: Cursor): Quote? { + val quoteId = cursor.getLong(cursor.getColumnIndexOrThrow(QUOTE_ID)) + val quoteAuthor = cursor.getString(cursor.getColumnIndexOrThrow(QUOTE_AUTHOR)) + val quoteText = cursor.getString(cursor.getColumnIndexOrThrow(QUOTE_BODY)) + val quoteMissing = cursor.getInt(cursor.getColumnIndexOrThrow(QUOTE_MISSING)) == 1 + val attachments = get(context).attachmentDatabase().getAttachment(cursor) + val quoteAttachments: List? = + Stream.of(attachments).filter { obj: DatabaseAttachment? -> obj!!.isQuote } + .toList() + val quoteDeck = SlideDeck(context, quoteAttachments!!) + return if (quoteId > 0 && !TextUtils.isEmpty(quoteAuthor)) { + Quote( + quoteId, + fromExternal(context, quoteAuthor), + quoteText, + quoteMissing, + quoteDeck + ) + } else { + null + } + } + + override fun close() { + cursor?.close() + } + } + + companion object { + private val TAG = MmsDatabase::class.java.simpleName + const val TABLE_NAME: String = "mms" + const val DATE_SENT: String = "date" + const val DATE_RECEIVED: String = "date_received" + const val MESSAGE_BOX: String = "msg_box" + const val CONTENT_LOCATION: String = "ct_l" + const val EXPIRY: String = "exp" + const val MESSAGE_TYPE: String = "m_type" + const val MESSAGE_SIZE: String = "m_size" + const val STATUS: String = "st" + const val TRANSACTION_ID: String = "tr_id" + const val PART_COUNT: String = "part_count" + const val NETWORK_FAILURE: String = "network_failures" + const val QUOTE_ID: String = "quote_id" + const val QUOTE_AUTHOR: String = "quote_author" + const val QUOTE_BODY: String = "quote_body" + const val QUOTE_ATTACHMENT: String = "quote_attachment" + const val QUOTE_MISSING: String = "quote_missing" + const val SHARED_CONTACTS: String = "shared_contacts" + const val LINK_PREVIEWS: String = "previews" + const val CREATE_TABLE: String = + "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY, " + + THREAD_ID + " INTEGER, " + DATE_SENT + " INTEGER, " + DATE_RECEIVED + " INTEGER, " + MESSAGE_BOX + " INTEGER, " + + READ + " INTEGER DEFAULT 0, " + "m_id" + " TEXT, " + "sub" + " TEXT, " + + "sub_cs" + " INTEGER, " + BODY + " TEXT, " + PART_COUNT + " INTEGER, " + + "ct_t" + " TEXT, " + CONTENT_LOCATION + " TEXT, " + ADDRESS + " TEXT, " + + ADDRESS_DEVICE_ID + " INTEGER, " + + EXPIRY + " INTEGER, " + "m_cls" + " TEXT, " + MESSAGE_TYPE + " INTEGER, " + + "v" + " INTEGER, " + MESSAGE_SIZE + " INTEGER, " + "pri" + " INTEGER, " + + "rr" + " INTEGER, " + "rpt_a" + " INTEGER, " + "resp_st" + " INTEGER, " + + STATUS + " INTEGER, " + TRANSACTION_ID + " TEXT, " + "retr_st" + " INTEGER, " + + "retr_txt" + " TEXT, " + "retr_txt_cs" + " INTEGER, " + "read_status" + " INTEGER, " + + "ct_cls" + " INTEGER, " + "resp_txt" + " TEXT, " + "d_tm" + " INTEGER, " + + DELIVERY_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + MISMATCHED_IDENTITIES + " TEXT DEFAULT NULL, " + + NETWORK_FAILURE + " TEXT DEFAULT NULL," + "d_rpt" + " INTEGER, " + + SUBSCRIPTION_ID + " INTEGER DEFAULT -1, " + EXPIRES_IN + " INTEGER DEFAULT 0, " + + EXPIRE_STARTED + " INTEGER DEFAULT 0, " + NOTIFIED + " INTEGER DEFAULT 0, " + + READ_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + QUOTE_ID + " INTEGER DEFAULT 0, " + + QUOTE_AUTHOR + " TEXT, " + QUOTE_BODY + " TEXT, " + QUOTE_ATTACHMENT + " INTEGER DEFAULT -1, " + + QUOTE_MISSING + " INTEGER DEFAULT 0, " + SHARED_CONTACTS + " TEXT, " + UNIDENTIFIED + " INTEGER DEFAULT 0, " + + LINK_PREVIEWS + " TEXT);" + + @JvmField + val CREATE_INDEXS: Array = arrayOf( + "CREATE INDEX IF NOT EXISTS mms_thread_id_index ON $TABLE_NAME ($THREAD_ID);", + "CREATE INDEX IF NOT EXISTS mms_read_index ON $TABLE_NAME ($READ);", + "CREATE INDEX IF NOT EXISTS mms_read_and_notified_and_thread_id_index ON $TABLE_NAME($READ,$NOTIFIED,$THREAD_ID);", + "CREATE INDEX IF NOT EXISTS mms_message_box_index ON $TABLE_NAME ($MESSAGE_BOX);", + "CREATE INDEX IF NOT EXISTS mms_date_sent_index ON $TABLE_NAME ($DATE_SENT);", + "CREATE INDEX IF NOT EXISTS mms_thread_date_index ON $TABLE_NAME ($THREAD_ID, $DATE_RECEIVED);" + ) + private val MMS_PROJECTION: Array = arrayOf( + "$TABLE_NAME.$ID AS $ID", + THREAD_ID, + "$DATE_SENT AS $NORMALIZED_DATE_SENT", + "$DATE_RECEIVED AS $NORMALIZED_DATE_RECEIVED", + MESSAGE_BOX, + READ, + CONTENT_LOCATION, + EXPIRY, + MESSAGE_TYPE, + MESSAGE_SIZE, + STATUS, + TRANSACTION_ID, + BODY, + PART_COUNT, + ADDRESS, + ADDRESS_DEVICE_ID, + DELIVERY_RECEIPT_COUNT, + READ_RECEIPT_COUNT, + MISMATCHED_IDENTITIES, + NETWORK_FAILURE, + SUBSCRIPTION_ID, + EXPIRES_IN, + EXPIRE_STARTED, + NOTIFIED, + QUOTE_ID, + QUOTE_AUTHOR, + QUOTE_BODY, + QUOTE_ATTACHMENT, + QUOTE_MISSING, + SHARED_CONTACTS, + LINK_PREVIEWS, + UNIDENTIFIED, + "json_group_array(json_object(" + + "'" + AttachmentDatabase.ROW_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.ROW_ID + ", " + + "'" + AttachmentDatabase.UNIQUE_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.UNIQUE_ID + ", " + + "'" + AttachmentDatabase.MMS_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + ", " + + "'" + AttachmentDatabase.SIZE + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.SIZE + ", " + + "'" + AttachmentDatabase.FILE_NAME + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.FILE_NAME + ", " + + "'" + AttachmentDatabase.DATA + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.DATA + ", " + + "'" + AttachmentDatabase.THUMBNAIL + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.THUMBNAIL + ", " + + "'" + AttachmentDatabase.CONTENT_TYPE + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CONTENT_TYPE + ", " + + "'" + AttachmentDatabase.CONTENT_LOCATION + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CONTENT_LOCATION + ", " + + "'" + AttachmentDatabase.FAST_PREFLIGHT_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.FAST_PREFLIGHT_ID + "," + + "'" + AttachmentDatabase.VOICE_NOTE + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.VOICE_NOTE + "," + + "'" + AttachmentDatabase.WIDTH + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.WIDTH + "," + + "'" + AttachmentDatabase.HEIGHT + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.HEIGHT + "," + + "'" + AttachmentDatabase.QUOTE + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.QUOTE + ", " + + "'" + AttachmentDatabase.CONTENT_DISPOSITION + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CONTENT_DISPOSITION + ", " + + "'" + AttachmentDatabase.NAME + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.NAME + ", " + + "'" + AttachmentDatabase.TRANSFER_STATE + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.TRANSFER_STATE + ", " + + "'" + AttachmentDatabase.CAPTION + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CAPTION + ", " + + "'" + AttachmentDatabase.STICKER_PACK_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_PACK_ID + ", " + + "'" + AttachmentDatabase.STICKER_PACK_KEY + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_PACK_KEY + ", " + + "'" + AttachmentDatabase.STICKER_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_ID + + ")) AS " + AttachmentDatabase.ATTACHMENT_JSON_ALIAS + ) + private const val RAW_ID_WHERE: String = "$TABLE_NAME._id = ?" + private const val RAW_ID_IN: String = "$TABLE_NAME._id IN (?)" + const val createMessageRequestResponseCommand: String = "ALTER TABLE $TABLE_NAME ADD COLUMN $MESSAGE_REQUEST_RESPONSE INTEGER DEFAULT 0;" + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SessionJobDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/SessionJobDatabase.kt index 95d7e5e3d4..595168fdf7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SessionJobDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SessionJobDatabase.kt @@ -4,6 +4,7 @@ import android.content.ContentValues import android.content.Context import net.sqlcipher.Cursor import org.session.libsession.messaging.jobs.AttachmentUploadJob +import org.session.libsession.messaging.jobs.BackgroundGroupAddJob import org.session.libsession.messaging.jobs.GroupAvatarDownloadJob import org.session.libsession.messaging.jobs.Job import org.session.libsession.messaging.jobs.MessageReceiveJob @@ -135,6 +136,13 @@ class SessionJobDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa job.failureCount = cursor.getInt(failureCount) return job } + + fun hasBackgroundGroupAddJob(groupJoinUrl: String): Boolean { + val database = databaseHelper.readableDatabase + return database.getAll(sessionJobTable, "$jobType = ?", arrayOf(BackgroundGroupAddJob.KEY)) { cursor -> + jobFromCursor(cursor) as? BackgroundGroupAddJob + }.filterNotNull().any { it.joinUrl == groupJoinUrl } + } } object SessionJobHelper { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java index a34de25a98..3e8f37cc36 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -362,7 +362,7 @@ public class SmsDatabase extends MessagingDatabase { return new Pair<>(messageId, threadId); } - protected Optional insertMessageInbox(IncomingTextMessage message, long type, long serverTimestamp) { + protected Optional insertMessageInbox(IncomingTextMessage message, long type, long serverTimestamp, boolean runIncrement, boolean runThreadUpdate) { if (message.isSecureMessage()) { type |= Types.SECURE_MESSAGE_BIT; } else if (message.isGroup()) { @@ -440,11 +440,13 @@ public class SmsDatabase extends MessagingDatabase { SQLiteDatabase db = databaseHelper.getWritableDatabase(); long messageId = db.insert(TABLE_NAME, null, values); - if (unread) { + if (unread && runIncrement) { DatabaseComponent.get(context).threadDatabase().incrementUnread(threadId, 1); } - DatabaseComponent.get(context).threadDatabase().update(threadId, true); + if (runThreadUpdate) { + DatabaseComponent.get(context).threadDatabase().update(threadId, true); + } if (message.getSubscriptionId() != -1) { DatabaseComponent.get(context).recipientDatabase().setDefaultSubscriptionId(recipient, message.getSubscriptionId()); @@ -456,23 +458,23 @@ public class SmsDatabase extends MessagingDatabase { } } - public Optional insertMessageInbox(IncomingTextMessage message) { - return insertMessageInbox(message, Types.BASE_INBOX_TYPE, 0); + public Optional insertMessageInbox(IncomingTextMessage message, boolean runIncrement, boolean runThreadUpdate) { + return insertMessageInbox(message, Types.BASE_INBOX_TYPE, 0, runIncrement, runThreadUpdate); } public Optional insertCallMessage(IncomingTextMessage message) { - return insertMessageInbox(message, 0, 0); + return insertMessageInbox(message, 0, 0, true, true); } - public Optional insertMessageInbox(IncomingTextMessage message, long serverTimestamp) { - return insertMessageInbox(message, Types.BASE_INBOX_TYPE, serverTimestamp); + public Optional insertMessageInbox(IncomingTextMessage message, long serverTimestamp, boolean runIncrement, boolean runThreadUpdate) { + return insertMessageInbox(message, Types.BASE_INBOX_TYPE, serverTimestamp, runIncrement, runThreadUpdate); } - public Optional insertMessageOutbox(long threadId, OutgoingTextMessage message, long serverTimestamp) { + public Optional insertMessageOutbox(long threadId, OutgoingTextMessage message, long serverTimestamp, boolean runThreadUpdate) { if (threadId == -1) { threadId = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(message.getRecipient()); } - long messageId = insertMessageOutbox(threadId, message, false, serverTimestamp, null); + long messageId = insertMessageOutbox(threadId, message, false, serverTimestamp, null, runThreadUpdate); if (messageId == -1) { return Optional.absent(); } @@ -481,7 +483,8 @@ public class SmsDatabase extends MessagingDatabase { } public long insertMessageOutbox(long threadId, OutgoingTextMessage message, - boolean forceSms, long date, InsertListener insertListener) + boolean forceSms, long date, InsertListener insertListener, + boolean runThreadUpdate) { long type = Types.BASE_SENDING_TYPE; @@ -517,7 +520,9 @@ public class SmsDatabase extends MessagingDatabase { insertListener.onComplete(); } - DatabaseComponent.get(context).threadDatabase().update(threadId, true); + if (runThreadUpdate) { + DatabaseComponent.get(context).threadDatabase().update(threadId, true); + } DatabaseComponent.get(context).threadDatabase().setLastSeen(threadId); DatabaseComponent.get(context).threadDatabase().setHasSent(threadId, true); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt index 0a3d3c45d8..5762233678 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -11,7 +11,6 @@ import org.session.libsession.messaging.jobs.Job import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.jobs.MessageReceiveJob import org.session.libsession.messaging.jobs.MessageSendJob -import org.session.libsession.messaging.jobs.TrimThreadJob import org.session.libsession.messaging.messages.control.ConfigurationMessage import org.session.libsession.messaging.messages.control.MessageRequestResponse import org.session.libsession.messaging.messages.signal.IncomingEncryptedMessage @@ -102,7 +101,29 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, return database.getAttachmentsForMessage(messageID) } - override fun persist(message: VisibleMessage, quotes: QuoteModel?, linkPreview: List, groupPublicKey: String?, openGroupID: String?, attachments: List): Long? { + override fun markConversationAsRead(threadId: Long, updateLastSeen: Boolean) { + val threadDb = DatabaseComponent.get(context).threadDatabase() + threadDb.setRead(threadId, updateLastSeen) + } + + override fun incrementUnread(threadId: Long, amount: Int) { + val threadDb = DatabaseComponent.get(context).threadDatabase() + threadDb.incrementUnread(threadId, amount) + } + + override fun updateThread(threadId: Long, unarchive: Boolean) { + val threadDb = DatabaseComponent.get(context).threadDatabase() + threadDb.update(threadId, unarchive) + } + + override fun persist(message: VisibleMessage, + quotes: QuoteModel?, + linkPreview: List, + groupPublicKey: String?, + openGroupID: String?, + attachments: List, + runIncrement: Boolean, + runThreadUpdate: Boolean): Long? { var messageID: Long? = null val senderAddress = Address.fromSerialized(message.sender!!) val isUserSender = (message.sender!! == getUserPublicKey()) @@ -139,14 +160,14 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, val mmsDatabase = DatabaseComponent.get(context).mmsDatabase() val insertResult = if (message.sender == getUserPublicKey()) { val mediaMessage = OutgoingMediaMessage.from(message, targetRecipient, pointers, quote.orNull(), linkPreviews.orNull()?.firstOrNull()) - mmsDatabase.insertSecureDecryptedMessageOutbox(mediaMessage, message.threadID ?: -1, message.sentTimestamp!!) + mmsDatabase.insertSecureDecryptedMessageOutbox(mediaMessage, message.threadID ?: -1, message.sentTimestamp!!, runThreadUpdate) } else { // It seems like we have replaced SignalServiceAttachment with SessionServiceAttachment val signalServiceAttachments = attachments.mapNotNull { it.toSignalPointer() } val mediaMessage = IncomingMediaMessage.from(message, senderAddress, targetRecipient.expireMessages * 1000L, group, signalServiceAttachments, quote, linkPreviews) - mmsDatabase.insertSecureDecryptedMessageInbox(mediaMessage, message.threadID ?: -1, message.receivedTimestamp ?: 0) + mmsDatabase.insertSecureDecryptedMessageInbox(mediaMessage, message.threadID ?: -1, message.receivedTimestamp ?: 0, runIncrement, runThreadUpdate) } if (insertResult.isPresent) { messageID = insertResult.get().messageId @@ -158,12 +179,12 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, val insertResult = if (message.sender == getUserPublicKey()) { val textMessage = if (isOpenGroupInvitation) OutgoingTextMessage.fromOpenGroupInvitation(message.openGroupInvitation, targetRecipient, message.sentTimestamp) else OutgoingTextMessage.from(message, targetRecipient) - smsDatabase.insertMessageOutbox(message.threadID ?: -1, textMessage, message.sentTimestamp!!) + smsDatabase.insertMessageOutbox(message.threadID ?: -1, textMessage, message.sentTimestamp!!, runThreadUpdate) } else { val textMessage = if (isOpenGroupInvitation) IncomingTextMessage.fromOpenGroupInvitation(message.openGroupInvitation, senderAddress, message.sentTimestamp) else IncomingTextMessage.from(message, senderAddress, group, targetRecipient.expireMessages * 1000L) val encrypted = IncomingEncryptedMessage(textMessage, textMessage.messageBody) - smsDatabase.insertMessageInbox(encrypted, message.receivedTimestamp ?: 0) + smsDatabase.insertMessageInbox(encrypted, message.receivedTimestamp ?: 0, runIncrement, runThreadUpdate) } insertResult.orNull()?.let { result -> messageID = result.messageId @@ -171,8 +192,8 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, } val threadID = message.threadID // open group trim thread job is scheduled after processing in OpenGroupPollerV2 - if (openGroupID.isNullOrEmpty() && threadID != null && threadID >= 0) { - JobQueue.shared.add(TrimThreadJob(threadID)) + if (openGroupID.isNullOrEmpty() && threadID != null && threadID >= 0 && TextSecurePreferences.isThreadLengthTrimmingEnabled(context)) { + JobQueue.shared.queueThreadForTrim(threadID) } message.serverHash?.let { serverHash -> messageID?.let { id -> @@ -436,7 +457,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, val updateData = UpdateMessageData.buildGroupUpdate(type, name, members)?.toJSON() val infoMessage = IncomingGroupMessage(m, groupID, updateData, true) val smsDB = DatabaseComponent.get(context).smsDatabase() - smsDB.insertMessageInbox(infoMessage) + smsDB.insertMessageInbox(infoMessage, true, true) } override fun insertOutgoingInfoMessage(context: Context, groupID: String, type: SignalServiceGroup.Type, name: String, members: Collection, admins: Collection, threadID: Long, sentTimestamp: Long) { @@ -448,7 +469,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, val mmsDB = DatabaseComponent.get(context).mmsDatabase() val mmsSmsDB = DatabaseComponent.get(context).mmsSmsDatabase() if (mmsSmsDB.getMessageFor(sentTimestamp, userPublicKey) != null) return - val infoMessageID = mmsDB.insertMessageOutbox(infoMessage, threadID, false, null) + val infoMessageID = mmsDB.insertMessageOutbox(infoMessage, threadID, false, null, runThreadUpdate = true) mmsDB.markAsSent(infoMessageID, true) } @@ -519,6 +540,16 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, OpenGroupManager.addOpenGroup(urlAsString, context) } + override fun onOpenGroupAdded(urlAsString: String) { + val server = OpenGroupV2.getServer(urlAsString) + OpenGroupManager.restartPollerForServer(server.toString().removeSuffix("/")) + } + + override fun hasBackgroundGroupAddJob(groupJoinUrl: String): Boolean { + val jobDb = DatabaseComponent.get(context).sessionJobDatabase() + return jobDb.hasBackgroundGroupAddJob(groupJoinUrl) + } + override fun setProfileSharing(address: Address, value: Boolean) { val recipient = Recipient.from(context, address, false) DatabaseComponent.get(context).recipientDatabase().setProfileSharing(recipient, value) @@ -667,7 +698,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, Optional.of(message) ) - database.insertSecureDecryptedMessageInbox(mediaMessage, -1) + database.insertSecureDecryptedMessageInbox(mediaMessage, -1, runIncrement = true, runThreadUpdate = true) } override fun insertMessageRequestResponse(response: MessageRequestResponse) { @@ -705,7 +736,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, Optional.absent() ) val threadId = getOrCreateThreadIdFor(senderAddress) - mmsDb.insertSecureDecryptedMessageInbox(message, threadId) + mmsDb.insertSecureDecryptedMessageInbox(message, threadId, runIncrement = true, runThreadUpdate = true) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java index 9e105025f9..feaa70dd7f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -574,6 +574,17 @@ public class ThreadDatabase extends Database { return getOrCreateThreadIdFor(recipient, DistributionTypes.DEFAULT); } + public void setThreadArchived(long threadId) { + ContentValues contentValues = new ContentValues(1); + contentValues.put(ARCHIVED, 1); + + databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues, ID_WHERE, + new String[] {String.valueOf(threadId)}); + + notifyConversationListListeners(); + notifyConversationListeners(threadId); + } + public long getOrCreateThreadIdFor(Recipient recipient, int distributionType) { SQLiteDatabase db = databaseHelper.getReadableDatabase(); String where = ADDRESS + " = ?"; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java index 94b9cf1598..460b346b7e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -10,6 +10,7 @@ import net.sqlcipher.database.SQLiteDatabase; import net.sqlcipher.database.SQLiteDatabaseHook; import net.sqlcipher.database.SQLiteOpenHelper; +import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.crypto.DatabaseSecret; import org.thoughtcrime.securesms.database.AttachmentDatabase; @@ -86,6 +87,13 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { public void postKey(SQLiteDatabase db) { db.rawExecSQL("PRAGMA kdf_iter = '1';"); db.rawExecSQL("PRAGMA cipher_page_size = 4096;"); + // if not vacuumed in a while, perform that operation + long currentTime = System.currentTimeMillis(); + // 7 days + if (currentTime - TextSecurePreferences.getLastVacuumTime(context) > 604_800_000) { + db.rawExecSQL("VACUUM;"); + TextSecurePreferences.setLastVacuumNow(context); + } } }); @@ -144,7 +152,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { db.execSQL(GroupDatabase.getCreateUpdatedTimestampCommand()); db.execSQL(RecipientDatabase.getCreateApprovedCommand()); db.execSQL(RecipientDatabase.getCreateApprovedMeCommand()); - db.execSQL(MmsDatabase.getCreateMessageRequestResponseCommand()); + db.execSQL(MmsDatabase.createMessageRequestResponseCommand); db.execSQL(LokiAPIDatabase.CREATE_FORK_INFO_TABLE_COMMAND); db.execSQL(LokiAPIDatabase.CREATE_DEFAULT_FORK_INFO_COMMAND); db.execSQL(LokiAPIDatabase.UPDATE_HASHES_INCLUDE_NAMESPACE_COMMAND); @@ -339,7 +347,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { db.execSQL(RecipientDatabase.getCreateApprovedCommand()); db.execSQL(RecipientDatabase.getCreateApprovedMeCommand()); db.execSQL(RecipientDatabase.getUpdateApprovedCommand()); - db.execSQL(MmsDatabase.getCreateMessageRequestResponseCommand()); + db.execSQL(MmsDatabase.createMessageRequestResponseCommand); } if (oldVersion < lokiV32) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/loaders/PagingMediaLoader.java b/app/src/main/java/org/thoughtcrime/securesms/database/loaders/PagingMediaLoader.java index a979da5446..6e861dad0d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/loaders/PagingMediaLoader.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/loaders/PagingMediaLoader.java @@ -46,8 +46,10 @@ public class PagingMediaLoader extends AsyncLoader> { return new Pair<>(cursor, leftIsRecent ? cursor.getPosition() : cursor.getCount() - 1 - cursor.getPosition()); } } - cursor.close(); + if (cursor != null) { + cursor.close(); + } return null; } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsAttachmentInfo.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsAttachmentInfo.kt new file mode 100644 index 0000000000..e1828a0901 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsAttachmentInfo.kt @@ -0,0 +1,17 @@ +package org.thoughtcrime.securesms.database.model + +import org.thoughtcrime.securesms.util.MediaUtil + +data class MmsAttachmentInfo(val dataFile: String?, val thumbnailFile: String?, val contentType: String?) { + companion object { + @JvmStatic + fun List.anyImages() = any { + MediaUtil.isImageType(it.contentType) + } + + @JvmStatic + fun List.anyThumbnailNonNull() = any { + it.thumbnailFile?.isNotEmpty() == true + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java index 86aaf9f120..03022b69cc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java @@ -27,8 +27,8 @@ import android.text.style.StyleSpan; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import org.session.libsession.utilities.recipients.Recipient; import org.session.libsession.utilities.ExpirationUtil; +import org.session.libsession.utilities.recipients.Recipient; import org.thoughtcrime.securesms.database.MmsSmsColumns; import org.thoughtcrime.securesms.database.SmsDatabase; @@ -74,7 +74,6 @@ public class ThreadRecord extends DisplayRecord { @Override public SpannableString getDisplayBody(@NonNull Context context) { - Recipient recipient = getRecipient(); if (isGroupUpdateMessage()) { return emphasisAdded(context.getString(R.string.ThreadRecord_group_updated)); } else if (isOpenGroupInvitation()) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyAdapter.java index 277b2e847c..8972fd8e88 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyAdapter.java @@ -4,18 +4,15 @@ package org.thoughtcrime.securesms.giph.ui; import android.content.Context; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.RecyclerView; - - - -import org.session.libsignal.utilities.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.ProgressBar; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + import com.bumptech.glide.RequestBuilder; import com.bumptech.glide.load.DataSource; import com.bumptech.glide.load.engine.DiskCacheStrategy; @@ -26,19 +23,20 @@ import com.bumptech.glide.request.RequestListener; import com.bumptech.glide.request.target.Target; import com.bumptech.glide.util.ByteBufferUtil; -import network.loki.messenger.R; -import org.thoughtcrime.securesms.giph.model.GiphyImage; -import org.thoughtcrime.securesms.giph.model.ChunkedImageUrl; -import org.thoughtcrime.securesms.mms.GlideApp; -import org.thoughtcrime.securesms.mms.GlideRequests; - import org.session.libsession.utilities.MaterialColor; import org.session.libsession.utilities.Util; import org.session.libsession.utilities.ViewUtil; +import org.session.libsignal.utilities.Log; +import org.thoughtcrime.securesms.giph.model.ChunkedImageUrl; +import org.thoughtcrime.securesms.giph.model.GiphyImage; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.mms.GlideRequests; import java.util.List; import java.util.concurrent.ExecutionException; +import network.loki.messenger.R; + class GiphyAdapter extends RecyclerView.Adapter { @@ -154,12 +152,12 @@ class GiphyAdapter extends RecyclerView.Adapter { RequestBuilder thumbnailRequest = GlideApp.with(context) .load(new ChunkedImageUrl(image.getStillUrl(), image.getStillSize())) - .diskCacheStrategy(DiskCacheStrategy.ALL); + .diskCacheStrategy(DiskCacheStrategy.NONE); if (org.thoughtcrime.securesms.util.Util.isLowMemory(context)) { glideRequests.load(new ChunkedImageUrl(image.getStillUrl(), image.getStillSize())) .placeholder(new ColorDrawable(Util.getRandomElement(MaterialColor.values()).toConversationColor(context))) - .diskCacheStrategy(DiskCacheStrategy.ALL) + .diskCacheStrategy(DiskCacheStrategy.NONE) .transition(DrawableTransitionOptions.withCrossFade()) .listener(holder) .into(holder.thumbnail); @@ -169,7 +167,7 @@ class GiphyAdapter extends RecyclerView.Adapter { glideRequests.load(new ChunkedImageUrl(image.getGifUrl(), image.getGifSize())) .thumbnail(thumbnailRequest) .placeholder(new ColorDrawable(Util.getRandomElement(MaterialColor.values()).toConversationColor(context))) - .diskCacheStrategy(DiskCacheStrategy.ALL) + .diskCacheStrategy(DiskCacheStrategy.NONE) .transition(DrawableTransitionOptions.withCrossFade()) .listener(holder) .into(holder.thumbnail); diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/PlaceholderAvatarFetcher.kt b/app/src/main/java/org/thoughtcrime/securesms/glide/PlaceholderAvatarFetcher.kt new file mode 100644 index 0000000000..38d51877de --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/PlaceholderAvatarFetcher.kt @@ -0,0 +1,34 @@ +package org.thoughtcrime.securesms.glide + +import android.content.Context +import android.graphics.drawable.BitmapDrawable +import com.bumptech.glide.Priority +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.data.DataFetcher +import org.session.libsession.avatars.PlaceholderAvatarPhoto +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.util.AvatarPlaceholderGenerator + +class PlaceholderAvatarFetcher(private val context: Context, + private val photo: PlaceholderAvatarPhoto): DataFetcher { + + override fun loadData(priority: Priority,callback: DataFetcher.DataCallback) { + try { + val avatar = AvatarPlaceholderGenerator.generate(context, 128, photo.hashString, photo.displayName) + callback.onDataReady(avatar) + } catch (e: Exception) { + Log.e("Loki", "Error in fetching avatar") + callback.onLoadFailed(e) + } + } + + override fun cleanup() {} + + override fun cancel() {} + + override fun getDataClass(): Class { + return BitmapDrawable::class.java + } + + override fun getDataSource(): DataSource = DataSource.LOCAL +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/PlaceholderAvatarLoader.kt b/app/src/main/java/org/thoughtcrime/securesms/glide/PlaceholderAvatarLoader.kt new file mode 100644 index 0000000000..ca78b572c5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/PlaceholderAvatarLoader.kt @@ -0,0 +1,31 @@ +package org.thoughtcrime.securesms.glide + +import android.content.Context +import android.graphics.drawable.BitmapDrawable +import com.bumptech.glide.load.Options +import com.bumptech.glide.load.model.ModelLoader +import com.bumptech.glide.load.model.ModelLoader.LoadData +import com.bumptech.glide.load.model.ModelLoaderFactory +import com.bumptech.glide.load.model.MultiModelLoaderFactory +import org.session.libsession.avatars.PlaceholderAvatarPhoto + +class PlaceholderAvatarLoader(private val context: Context): ModelLoader { + + override fun buildLoadData( + model: PlaceholderAvatarPhoto, + width: Int, + height: Int, + options: Options + ): LoadData { + return LoadData(model, PlaceholderAvatarFetcher(context, model)) + } + + override fun handles(model: PlaceholderAvatarPhoto): Boolean = true + + class Factory(private val context: Context) : ModelLoaderFactory { + override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader { + return PlaceholderAvatarLoader(context) + } + override fun teardown() {} + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java index 727c0582ca..a3d0e6d252 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java @@ -29,7 +29,7 @@ public class GroupManager { } public static long getThreadIDFromGroupID(String groupID, @NonNull Context context) { - final Recipient groupRecipient = Recipient.from(context, Address.fromSerialized(groupID), false); + final Recipient groupRecipient = Recipient.from(context, Address.fromSerialized(groupID), true); return DatabaseComponent.get(context).threadDatabase().getThreadIdIfExistsFor(groupRecipient); } @@ -59,6 +59,7 @@ public class GroupManager { long threadID = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor( groupRecipient, DistributionTypes.CONVERSATION); + DatabaseComponent.get(context).threadDatabase().setThreadArchived(threadID); return new GroupActionResult(groupRecipient, threadID); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/JoinPublicChatActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/JoinPublicChatActivity.kt index ba87e8706d..e464d69a0e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/JoinPublicChatActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/JoinPublicChatActivity.kt @@ -25,6 +25,7 @@ import network.loki.messenger.R import network.loki.messenger.databinding.ActivityJoinPublicChatBinding import network.loki.messenger.databinding.FragmentEnterChatUrlBinding import okhttp3.HttpUrl +import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.open_groups.OpenGroupAPIV2.DefaultGroup import org.session.libsession.utilities.Address import org.session.libsession.utilities.GroupUtil @@ -101,6 +102,7 @@ class JoinPublicChatActivity : PassphraseRequiredActionBarActivity(), ScanQRCode val sanitizedServer = server.toString().removeSuffix("/") val openGroupID = "$sanitizedServer.${room!!}" OpenGroupManager.add(sanitizedServer, room, publicKey!!, this@JoinPublicChatActivity) + MessagingModuleConfiguration.shared.storage.onOpenGroupAdded(stringWithExplicitScheme) val threadID = GroupManager.getOpenGroupThreadID(openGroupID, this@JoinPublicChatActivity) val groupID = GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray()) threadID to groupID diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt index e18f1a8e8f..90f287cb1c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt @@ -7,16 +7,15 @@ import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.open_groups.OpenGroupAPIV2 import org.session.libsession.messaging.open_groups.OpenGroupV2 import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPollerV2 -import org.session.libsession.utilities.Util import org.session.libsignal.utilities.ThreadUtils import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import org.thoughtcrime.securesms.util.BitmapUtil import java.util.concurrent.Executors object OpenGroupManager { private val executorService = Executors.newScheduledThreadPool(4) private var pollers = mutableMapOf() // One for each server private var isPolling = false + private val pollUpdaterLock = Any() val isAllCaughtUp: Boolean get() { @@ -49,8 +48,11 @@ object OpenGroupManager { } fun stopPolling() { - pollers.forEach { it.value.stop() } - pollers.clear() + synchronized(pollUpdaterLock) { + pollers.forEach { it.value.stop() } + pollers.clear() + isPolling = false + } } @WorkerThread @@ -67,7 +69,7 @@ object OpenGroupManager { storage.removeLastMessageServerID(room, server) // Store the public key storage.setOpenGroupPublicKey(server,publicKey) - // Get an auth token + // Get group info OpenGroupAPIV2.getAuthToken(room, server).get() // Get group info val info = OpenGroupAPIV2.getInfo(room, server).get() @@ -77,11 +79,17 @@ object OpenGroupManager { } val openGroup = OpenGroupV2(server, room, info.name, publicKey) threadDB.setOpenGroupChat(openGroup, threadID) + } + + fun restartPollerForServer(server: String) { // Start the poller if needed - pollers[server]?.startIfNeeded() ?: run { - val poller = OpenGroupPollerV2(server, executorService) - Util.runOnMain { poller.startIfNeeded() } - pollers[server] = poller + synchronized(pollUpdaterLock) { + pollers[server]?.stop() + pollers[server]?.startIfNeeded() ?: run { + val poller = OpenGroupPollerV2(server, executorService) + pollers[server] = poller + poller.startIfNeeded() + } } } @@ -91,13 +99,16 @@ object OpenGroupManager { val openGroupID = "$server.$room" val threadID = GroupManager.getOpenGroupThreadID(openGroupID, context) val recipient = threadDB.getRecipientForThreadId(threadID) ?: return + threadDB.setThreadArchived(threadID) val groupID = recipient.address.serialize() // Stop the poller if needed val openGroups = storage.getAllV2OpenGroups().filter { it.value.server == server } if (openGroups.count() == 1) { - val poller = pollers[server] - poller?.stop() - pollers.remove(server) + synchronized(pollUpdaterLock) { + val poller = pollers[server] + poller?.stop() + pollers.remove(server) + } } // Delete storage.removeLastDeletionServerID(room, server) @@ -112,12 +123,7 @@ object OpenGroupManager { fun addOpenGroup(urlAsString: String, context: Context) { val url = HttpUrl.parse(urlAsString) ?: return - val builder = HttpUrl.Builder().scheme(url.scheme()).host(url.host()) - if (url.port() != 80 || url.port() != 443) { - // Non-standard port; add to server - builder.port(url.port()) - } - val server = builder.build() + val server = OpenGroupV2.getServer(urlAsString) val room = url.pathSegments().firstOrNull() ?: return val publicKey = url.queryParameter("public_key") ?: return add(server.toString().removeSuffix("/"), room, publicKey, context) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt index 3e4a71200d..261890ba59 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt @@ -15,7 +15,8 @@ import network.loki.messenger.R import network.loki.messenger.databinding.ViewConversationBinding import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities.highlightMentions -import org.thoughtcrime.securesms.database.RecipientDatabase +import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_ALL +import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_NONE import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.util.DateUtils @@ -47,7 +48,7 @@ class ConversationView : LinearLayout { binding.conversationViewDisplayNameTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0) ContextCompat.getDrawable(context, R.drawable.conversation_view_background) } - binding.profilePictureView.glide = glide + binding.profilePictureView.root.glide = glide val unreadCount = thread.unreadCount if (thread.recipient.isBlocked) { binding.accentView.setBackgroundResource(R.color.destructive) @@ -73,15 +74,15 @@ class ConversationView : LinearLayout { binding.conversationViewDisplayNameTextView.text = senderDisplayName binding.timestampTextView.text = DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), thread.date) val recipient = thread.recipient - binding.muteIndicatorImageView.isVisible = recipient.isMuted || recipient.notifyType != RecipientDatabase.NOTIFY_TYPE_ALL - val drawableRes = if (recipient.isMuted || recipient.notifyType == RecipientDatabase.NOTIFY_TYPE_NONE) { + binding.muteIndicatorImageView.isVisible = recipient.isMuted || recipient.notifyType != NOTIFY_TYPE_ALL + val drawableRes = if (recipient.isMuted || recipient.notifyType == NOTIFY_TYPE_NONE) { R.drawable.ic_outline_notifications_off_24 } else { R.drawable.ic_notifications_mentions } binding.muteIndicatorImageView.setImageResource(drawableRes) val rawSnippet = thread.getDisplayBody(context) - val snippet = highlightMentions(rawSnippet, thread.threadId, context) + val snippet = highlightMentions(rawSnippet, recipient.isOpenGroupRecipient, context) binding.snippetTextView.text = snippet binding.snippetTextView.typeface = if (unreadCount > 0 && !thread.isRead) Typeface.DEFAULT_BOLD else Typeface.DEFAULT binding.snippetTextView.visibility = if (isTyping) View.GONE else View.VISIBLE @@ -103,13 +104,11 @@ class ConversationView : LinearLayout { thread.isRead -> binding.statusIndicatorImageView.setImageResource(R.drawable.ic_filled_circle_check) else -> binding.statusIndicatorImageView.setImageResource(R.drawable.ic_circle_check) } - post { - binding.profilePictureView.update(thread.recipient) - } + binding.profilePictureView.root.update(thread.recipient) } fun recycle() { - binding.profilePictureView.recycle() + binding.profilePictureView.root.recycle() } private fun getUserDisplayName(recipient: Recipient): String? { diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index d70653f8a0..3bbe8c8554 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -5,18 +5,15 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.database.Cursor import android.os.Bundle import android.text.SpannableString import android.widget.Toast import androidx.activity.viewModels import androidx.core.os.bundleOf import androidx.core.view.isVisible -import androidx.lifecycle.Observer import androidx.lifecycle.lifecycleScope -import androidx.loader.app.LoaderManager -import androidx.loader.content.Loader import androidx.localbroadcastmanager.content.LocalBroadcastManager +import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers @@ -73,7 +70,6 @@ import org.thoughtcrime.securesms.util.UiModeUtilities import org.thoughtcrime.securesms.util.disableClipping import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.show - import java.io.IOException import java.util.Locale import javax.inject.Inject @@ -83,7 +79,6 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ConversationClickListener, SeedReminderViewDelegate, NewConversationButtonSetViewDelegate, - LoaderManager.LoaderCallbacks, GlobalSearchInputLayout.GlobalSearchInputLayoutListener { private lateinit var binding: ActivityHomeBinding @@ -97,12 +92,13 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), @Inject lateinit var textSecurePreferences: TextSecurePreferences private val globalSearchViewModel by viewModels() + private val homeViewModel by viewModels() private val publicKey: String get() = textSecurePreferences.getLocalNumber()!! - private val homeAdapter: HomeAdapter by lazy { - HomeAdapter(context = this, cursor = threadDb.approvedConversationList, listener = this) + private val homeAdapter: NewHomeAdapter by lazy { + NewHomeAdapter(context = this, listener = this) } private val globalSearchAdapter = GlobalSearchAdapter { model -> @@ -156,8 +152,8 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), // Set up Glide glide = GlideApp.with(this) // Set up toolbar buttons - binding.profileButton.glide = glide - binding.profileButton.setOnClickListener { openSettings() } + binding.profileButton.root.glide = glide + binding.profileButton.root.setOnClickListener { openSettings() } binding.searchViewContainer.setOnClickListener { binding.globalSearchInputLayout.requestFocus() } @@ -184,8 +180,20 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), // Set up empty state view binding.createNewPrivateChatButton.setOnClickListener { createNewPrivateChat() } IP2Country.configureIfNeeded(this@HomeActivity) - // This is a workaround for the fact that CursorRecyclerViewAdapter doesn't actually auto-update (even though it says it will) - LoaderManager.getInstance(this).restartLoader(0, null, this) + homeViewModel.getObservable(this).observe(this) { newData -> + val manager = binding.recyclerView.layoutManager as LinearLayoutManager + val firstPos = manager.findFirstCompletelyVisibleItemPosition() + val offsetTop = if(firstPos >= 0) { + manager.findViewByPosition(firstPos)?.let { view -> + manager.getDecoratedTop(view) - manager.getTopDecorationHeight(view) + } ?: 0 + } else 0 + homeAdapter.data = newData + if(firstPos >= 0) { manager.scrollToPositionWithOffset(firstPos, offsetTop) } + setupMessageRequestsBanner() + updateEmptyState() + } + homeViewModel.tryUpdateChannel() // Set up new conversation button set binding.newConversationButtonSet.delegate = this // Observe blocked contacts changed events @@ -202,17 +210,6 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), // Double check that the long poller is up (applicationContext as ApplicationContext).startPollingIfNeeded() // update things based on TextSecurePrefs (profile info etc) - // Set up typing observer - withContext(Dispatchers.Main) { - ApplicationContext.getInstance(this@HomeActivity).typingStatusRepository.typingThreads.observe(this@HomeActivity, Observer> { threadIDs -> - val adapter = binding.recyclerView.adapter as HomeAdapter - adapter.typingThreadIDs = threadIDs ?: setOf() - }) - updateProfileButton() - TextSecurePreferences.events.filter { it == TextSecurePreferences.PROFILE_NAME_PREF }.collect { - updateProfileButton() - } - } // Set up remaining components if needed val application = ApplicationContext.getInstance(this@HomeActivity) application.registerForFCMIfNeeded(false) @@ -220,6 +217,13 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), OpenGroupManager.startPolling() JobQueue.shared.resumePendingJobs() } + // Set up typing observer + withContext(Dispatchers.Main) { + updateProfileButton() + TextSecurePreferences.events.filter { it == TextSecurePreferences.PROFILE_NAME_PREF }.collect { + updateProfileButton() + } + } } // monitor the global search VM query launch { @@ -293,7 +297,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), binding.searchToolbar.isVisible = isShown binding.sessionToolbar.isVisible = !isShown binding.recyclerView.isVisible = !isShown - binding.emptyStateContainer.isVisible = (binding.recyclerView.adapter as HomeAdapter).itemCount == 0 && binding.recyclerView.isVisible + binding.emptyStateContainer.isVisible = (binding.recyclerView.adapter as NewHomeAdapter).itemCount == 0 && binding.recyclerView.isVisible binding.seedReminderView.isVisible = !TextSecurePreferences.getHasViewedSeed(this) && !isShown binding.gradientView.isVisible = !isShown binding.globalSearchRecycler.isVisible = isShown @@ -314,35 +318,27 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), root.setOnClickListener { showMessageRequests() } root.setOnLongClickListener { hideMessageRequests(); true } root.layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT) - homeAdapter.headerView = root - homeAdapter.notifyItemChanged(0) + val hadHeader = homeAdapter.hasHeaderView() + homeAdapter.header = root + if (hadHeader) homeAdapter.notifyItemChanged(0) + else homeAdapter.notifyItemInserted(0) } } else { - homeAdapter.headerView = null + val hadHeader = homeAdapter.hasHeaderView() + homeAdapter.header = null + if (hadHeader) { + homeAdapter.notifyItemRemoved(0) + } } } - override fun onCreateLoader(id: Int, bundle: Bundle?): Loader { - return HomeLoader(this@HomeActivity) - } - - override fun onLoadFinished(loader: Loader, cursor: Cursor?) { - homeAdapter.changeCursor(cursor) - setupMessageRequestsBanner() - updateEmptyState() - } - - override fun onLoaderReset(cursor: Loader) { - homeAdapter.changeCursor(null) - } - override fun onResume() { super.onResume() ApplicationContext.getInstance(this).messageNotifier.setHomeScreenVisible(true) if (textSecurePreferences.getLocalNumber() == null) { return; } // This can be the case after a secondary device is auto-cleared IdentityKeyUtil.checkUpdate(this) - binding.profileButton.recycle() // clear cached image before update tje profilePictureView - binding.profileButton.update() + binding.profileButton.root.recycle() // clear cached image before update tje profilePictureView + binding.profileButton.root.update() if (textSecurePreferences.getHasViewedSeed()) { binding.seedReminderView.isVisible = false } @@ -377,7 +373,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), // region Updating private fun updateEmptyState() { - val threadCount = (binding.recyclerView.adapter as HomeAdapter).itemCount + val threadCount = (binding.recyclerView.adapter)!!.itemCount binding.emptyStateContainer.isVisible = threadCount == 0 && binding.recyclerView.isVisible } @@ -385,14 +381,16 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), fun onUpdateProfileEvent(event: ProfilePictureModifiedEvent) { if (event.recipient.isLocalNumber) { updateProfileButton() + } else { + homeViewModel.tryUpdateChannel() } } private fun updateProfileButton() { - binding.profileButton.publicKey = publicKey - binding.profileButton.displayName = textSecurePreferences.getProfileName() - binding.profileButton.recycle() - binding.profileButton.update() + binding.profileButton.root.publicKey = publicKey + binding.profileButton.root.displayName = textSecurePreferences.getProfileName() + binding.profileButton.root.recycle() + binding.profileButton.root.update() } // endregion @@ -534,9 +532,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), private fun setConversationPinned(threadId: Long, pinned: Boolean) { lifecycleScope.launch(Dispatchers.IO) { threadDb.setPinned(threadId, pinned) - withContext(Dispatchers.Main) { - LoaderManager.getInstance(this@HomeActivity).restartLoader(0, null, this@HomeActivity) - } + homeViewModel.tryUpdateChannel() } } @@ -608,11 +604,6 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), show(intent, isForResult = true) } - private fun showPath() { - val intent = Intent(this, PathActivity::class.java) - show(intent) - } - private fun showMessageRequests() { val intent = Intent(this, MessageRequestsActivity::class.java) push(intent) @@ -624,7 +615,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), .setPositiveButton(R.string.yes) { _, _ -> textSecurePreferences.setHasHiddenMessageRequests() setupMessageRequestsBanner() - LoaderManager.getInstance(this).restartLoader(0, null, this) + homeViewModel.tryUpdateChannel() } .setNegativeButton(R.string.no) { _, _ -> // Do nothing diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt index c75564d07a..690ee78083 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt @@ -1,51 +1,6 @@ package org.thoughtcrime.securesms.home -import android.content.Context -import android.database.Cursor -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter import org.thoughtcrime.securesms.database.model.ThreadRecord -import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import org.thoughtcrime.securesms.mms.GlideRequests - -class HomeAdapter( - context: Context, - cursor: Cursor?, - val listener: ConversationClickListener -) : CursorRecyclerViewAdapter(context, cursor) { - private val threadDatabase = DatabaseComponent.get(context).threadDatabase() - lateinit var glide: GlideRequests - var typingThreadIDs = setOf() - set(value) { field = value; notifyDataSetChanged() } - - class ViewHolder(val view: ConversationView) : RecyclerView.ViewHolder(view) - - override fun onCreateItemViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - val view = ConversationView(context) - view.setOnClickListener { view.thread?.let { listener.onConversationClick(it) } } - view.setOnLongClickListener { - view.thread?.let { listener.onLongConversationClick(it) } - true - } - return ViewHolder(view) - } - - override fun onBindItemViewHolder(viewHolder: ViewHolder, cursor: Cursor) { - val thread = getThread(cursor)!! - val isTyping = typingThreadIDs.contains(thread.threadId) - viewHolder.view.bind(thread, isTyping, glide) - } - - override fun onItemViewRecycled(holder: ViewHolder?) { - super.onItemViewRecycled(holder) - holder?.view?.recycle() - } - - private fun getThread(cursor: Cursor): ThreadRecord? { - return threadDatabase.readerFor(cursor).current - } -} interface ConversationClickListener { fun onConversationClick(thread: ThreadRecord) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt new file mode 100644 index 0000000000..cb4d6cd746 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt @@ -0,0 +1,42 @@ +package org.thoughtcrime.securesms.home + +import android.content.Context +import androidx.recyclerview.widget.DiffUtil +import org.thoughtcrime.securesms.database.model.ThreadRecord + +class HomeDiffUtil( + private val old: List, + private val new: List, + private val context: Context +): DiffUtil.Callback() { + + override fun getOldListSize(): Int = old.size + + override fun getNewListSize(): Int = new.size + + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = + old[oldItemPosition].threadId == new[newItemPosition].threadId + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + val oldItem = old[oldItemPosition] + val newItem = new[newItemPosition] + + // return early to save getDisplayBody or expensive calls + val sameCount = oldItem.count == newItem.count + 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 sameAvatar = oldItem.recipient.profileAvatar == newItem.recipient.profileAvatar + if (!sameAvatar) return false + val sameUsername = oldItem.recipient.name == newItem.recipient.name + if (!sameUsername) return false + val sameSnippet = oldItem.getDisplayBody(context) == newItem.getDisplayBody(context) + if (!sameSnippet) return false + + // all same + return true + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeLoader.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeLoader.kt index a748189b2e..6935fb24a1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeLoader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeLoader.kt @@ -5,9 +5,14 @@ import android.database.Cursor import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.util.AbstractCursorLoader -class HomeLoader(context: Context) : AbstractCursorLoader(context) { +class HomeLoader(context: Context, val onNewCursor: (Cursor?) -> Unit) : AbstractCursorLoader(context) { override fun getCursor(): Cursor { return DatabaseComponent.get(context).threadDatabase().approvedConversationList } + + override fun deliverResult(newCursor: Cursor?) { + super.deliverResult(newCursor) + onNewCursor(newCursor) + } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt new file mode 100644 index 0000000000..e68b30d6c2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt @@ -0,0 +1,59 @@ +package org.thoughtcrime.securesms.home + +import android.content.Context +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.cash.copper.flow.observeQuery +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus +import kotlinx.coroutines.withContext +import org.thoughtcrime.securesms.database.DatabaseContentProviders +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.database.model.ThreadRecord +import javax.inject.Inject + +@HiltViewModel +class HomeViewModel @Inject constructor(private val threadDb: ThreadDatabase): ViewModel() { + + private val executor = viewModelScope + SupervisorJob() + + private val _conversations = MutableLiveData>() + val conversations: LiveData> = _conversations + + private val listUpdateChannel = Channel(capacity = Channel.CONFLATED) + + fun tryUpdateChannel() = listUpdateChannel.trySend(Unit) + + fun getObservable(context: Context): LiveData> { + executor.launch(Dispatchers.IO) { + context.contentResolver + .observeQuery(DatabaseContentProviders.ConversationList.CONTENT_URI) + .onEach { listUpdateChannel.trySend(Unit) } + .collect() + } + executor.launch(Dispatchers.IO) { + for (update in listUpdateChannel) { + threadDb.approvedConversationList.use { openCursor -> + val reader = threadDb.readerFor(openCursor) + val threads = mutableListOf() + while (true) { + threads += reader.next ?: break + } + withContext(Dispatchers.Main) { + _conversations.value = threads + } + } + } + } + return conversations + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/NewHomeAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/home/NewHomeAdapter.kt new file mode 100644 index 0000000000..850a5de69e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/home/NewHomeAdapter.kt @@ -0,0 +1,114 @@ +package org.thoughtcrime.securesms.home + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListUpdateCallback +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.NO_ID +import org.thoughtcrime.securesms.database.model.ThreadRecord +import org.thoughtcrime.securesms.mms.GlideRequests + +class NewHomeAdapter(private val context: Context, private val listener: ConversationClickListener): + RecyclerView.Adapter(), + ListUpdateCallback { + + companion object { + private const val HEADER = 0 + private const val ITEM = 1 + } + + var header: View? = null + + private var _data: List = emptyList() + var data: List + get() = _data.toList() + set(newData) { + val previousData = _data.toList() + val diff = HomeDiffUtil(previousData, newData, context) + val diffResult = DiffUtil.calculateDiff(diff) + _data = newData + diffResult.dispatchUpdatesTo(this as ListUpdateCallback) + } + + fun hasHeaderView(): Boolean = header != null + + private val headerCount: Int + get() = if (header == null) 0 else 1 + + override fun onInserted(position: Int, count: Int) { + notifyItemRangeInserted(position + headerCount, count) + } + + override fun onRemoved(position: Int, count: Int) { + notifyItemRangeRemoved(position + headerCount, count) + } + + override fun onMoved(fromPosition: Int, toPosition: Int) { + notifyItemMoved(fromPosition + headerCount, toPosition + headerCount) + } + + override fun onChanged(position: Int, count: Int, payload: Any?) { + notifyItemRangeChanged(position + headerCount, count, payload) + } + + override fun getItemId(position: Int): Long { + if (hasHeaderView() && position == 0) return NO_ID + val offsetPosition = if (hasHeaderView()) position-1 else position + return _data[offsetPosition].threadId + } + + lateinit var glide: GlideRequests + var typingThreadIDs = setOf() + set(value) { + field = value + // TODO: replace this with a diffed update or a partial change set with payloads + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder = + when (viewType) { + HEADER -> { + HeaderFooterViewHolder(header!!) + } + ITEM -> { + val view = ConversationView(context) + view.setOnClickListener { view.thread?.let { listener.onConversationClick(it) } } + view.setOnLongClickListener { + view.thread?.let { listener.onLongConversationClick(it) } + true + } + ViewHolder(view) + } + else -> throw Exception("viewType $viewType isn't valid") + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + if (holder is ViewHolder) { + val offset = if (hasHeaderView()) position - 1 else position + val thread = data[offset] + val isTyping = typingThreadIDs.contains(thread.threadId) + holder.view.bind(thread, isTyping, glide) + } + } + + override fun onViewRecycled(holder: RecyclerView.ViewHolder) { + if (holder is ViewHolder) { + holder.view.recycle() + } else { + super.onViewRecycled(holder) + } + } + + override fun getItemViewType(position: Int): Int = + if (hasHeaderView() && position == 0) HEADER + else ITEM + + override fun getItemCount(): Int = data.size + if (hasHeaderView()) 1 else 0 + + class ViewHolder(val view: ConversationView) : RecyclerView.ViewHolder(view) + + class HeaderFooterViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt index 0ac74c0845..3f7e997b54 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt @@ -39,7 +39,7 @@ class UserDetailsBottomSheet : BottomSheetDialogFragment() { const val ARGUMENT_THREAD_ID = "threadId" } - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { binding = FragmentUserDetailsBottomSheetBinding.inflate(inflater, container, false) return binding.root } @@ -51,10 +51,10 @@ class UserDetailsBottomSheet : BottomSheetDialogFragment() { val recipient = Recipient.from(requireContext(), Address.fromSerialized(publicKey), false) val threadRecipient = threadDb.getRecipientForThreadId(threadID) ?: return dismiss() with(binding) { - profilePictureView.publicKey = publicKey - profilePictureView.glide = GlideApp.with(this@UserDetailsBottomSheet) - profilePictureView.isLarge = true - profilePictureView.update(recipient) + profilePictureView.root.publicKey = publicKey + profilePictureView.root.glide = GlideApp.with(this@UserDetailsBottomSheet) + profilePictureView.root.isLarge = true + profilePictureView.root.update(recipient) nameTextViewContainer.visibility = View.VISIBLE nameTextViewContainer.setOnClickListener { nameTextViewContainer.visibility = View.INVISIBLE diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt index 554fb2e11a..fab8bca998 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt @@ -11,7 +11,6 @@ import network.loki.messenger.databinding.ViewGlobalSearchHeaderBinding import network.loki.messenger.databinding.ViewGlobalSearchResultBinding import org.session.libsession.utilities.GroupRecord import org.session.libsession.utilities.recipients.Recipient -import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.search.model.MessageResult import java.security.InvalidParameterException @@ -84,14 +83,14 @@ class GlobalSearchAdapter (private val modelCallback: (Model)->Unit): RecyclerVi override fun onViewRecycled(holder: RecyclerView.ViewHolder) { if (holder is ContentView) { - holder.binding.searchResultProfilePicture.recycle() + holder.binding.searchResultProfilePicture.root.recycle() } } class ContentView(view: View, private val modelCallback: (Model) -> Unit) : RecyclerView.ViewHolder(view) { val binding = ViewGlobalSearchResultBinding.bind(view).apply { - searchResultProfilePicture.glide = GlideApp.with(root) + searchResultProfilePicture.root.glide = GlideApp.with(root) } fun bindPayload(newQuery: String, model: Model) { @@ -99,7 +98,7 @@ class GlobalSearchAdapter (private val modelCallback: (Model)->Unit): RecyclerVi } fun bind(query: String, model: Model) { - binding.searchResultProfilePicture.recycle() + binding.searchResultProfilePicture.root.recycle() when (model) { is Model.GroupConversation -> bindModel(query, model) is Model.Contact -> bindModel(query, model) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt index 2181b7f833..7603d39224 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt @@ -3,9 +3,7 @@ package org.thoughtcrime.securesms.home.search import android.graphics.Typeface import android.text.Spannable import android.text.SpannableStringBuilder -import android.text.style.ForegroundColorSpan import android.text.style.StyleSpan -import android.util.TypedValue import androidx.core.view.isVisible import androidx.recyclerview.widget.DiffUtil import network.loki.messenger.R @@ -86,12 +84,12 @@ private fun getHighlight(query: String?, toSearch: String): Spannable? { } fun ContentView.bindModel(query: String?, model: GroupConversation) { - binding.searchResultProfilePicture.isVisible = true + binding.searchResultProfilePicture.root.isVisible = true binding.searchResultSavedMessages.isVisible = false binding.searchResultSubtitle.isVisible = model.groupRecord.isClosedGroup binding.searchResultTimestamp.isVisible = false val threadRecipient = Recipient.from(binding.root.context, Address.fromSerialized(model.groupRecord.encodedId), false) - binding.searchResultProfilePicture.update(threadRecipient) + binding.searchResultProfilePicture.root.update(threadRecipient) val nameString = model.groupRecord.title binding.searchResultTitle.text = getHighlight(query, nameString) @@ -107,14 +105,14 @@ fun ContentView.bindModel(query: String?, model: GroupConversation) { } fun ContentView.bindModel(query: String?, model: ContactModel) { - binding.searchResultProfilePicture.isVisible = true + binding.searchResultProfilePicture.root.isVisible = true binding.searchResultSavedMessages.isVisible = false binding.searchResultSubtitle.isVisible = false binding.searchResultTimestamp.isVisible = false binding.searchResultSubtitle.text = null val recipient = Recipient.from(binding.root.context, Address.fromSerialized(model.contact.sessionID), false) - binding.searchResultProfilePicture.update(recipient) + binding.searchResultProfilePicture.root.update(recipient) val nameString = model.contact.getSearchName() binding.searchResultTitle.text = getHighlight(query, nameString) } @@ -123,12 +121,12 @@ fun ContentView.bindModel(model: SavedMessages) { binding.searchResultSubtitle.isVisible = false binding.searchResultTimestamp.isVisible = false binding.searchResultTitle.setText(R.string.note_to_self) - binding.searchResultProfilePicture.isVisible = false + binding.searchResultProfilePicture.root.isVisible = false binding.searchResultSavedMessages.isVisible = true } fun ContentView.bindModel(query: String?, model: Message) { - binding.searchResultProfilePicture.isVisible = true + binding.searchResultProfilePicture.root.isVisible = true binding.searchResultSavedMessages.isVisible = false binding.searchResultTimestamp.isVisible = true // val hasUnreads = model.unread > 0 @@ -137,7 +135,7 @@ fun ContentView.bindModel(query: String?, model: Message) { // binding.unreadCountTextView.text = model.unread.toString() // } binding.searchResultTimestamp.text = DateUtils.getDisplayFormattedTimeSpanString(binding.root.context, Locale.getDefault(), model.messageResult.receivedTimestampMs) - binding.searchResultProfilePicture.update(model.messageResult.conversationRecipient) + binding.searchResultProfilePicture.root.update(model.messageResult.conversationRecipient) val textSpannable = SpannableStringBuilder() if (model.messageResult.conversationRecipient != model.messageResult.messageRecipient) { // group chat, bind diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileAvatarJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileAvatarJob.java index 8b24dfc485..39b7753035 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileAvatarJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileAvatarJob.java @@ -48,7 +48,8 @@ public class RetrieveProfileAvatarJob extends BaseJob { .setQueue("RetrieveProfileAvatarJob" + recipient.getAddress().serialize()) .addConstraint(NetworkConstraint.KEY) .setLifespan(TimeUnit.HOURS.toMillis(1)) - .setMaxAttempts(10) + .setMaxAttempts(2) + .setMaxInstances(1) .build(), recipient, profileAvatar); diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt index d8d12938fe..9a8d061297 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt @@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.messagerequests import android.content.Context import android.content.res.Resources -import android.graphics.Typeface import android.util.AttributeSet import android.view.LayoutInflater import android.widget.LinearLayout @@ -35,7 +34,7 @@ class MessageRequestView : LinearLayout { // region Updating fun bind(thread: ThreadRecord, glide: GlideRequests) { this.thread = thread - binding.profilePictureView.glide = glide + binding.profilePictureView.root.glide = glide val senderDisplayName = getUserDisplayName(thread.recipient) ?: thread.recipient.address.toString() binding.displayNameTextView.text = senderDisplayName @@ -45,12 +44,12 @@ class MessageRequestView : LinearLayout { binding.snippetTextView.text = snippet post { - binding.profilePictureView.update(thread.recipient) + binding.profilePictureView.root.update(thread.recipient) } } fun recycle() { - binding.profilePictureView.recycle() + binding.profilePictureView.root.recycle() } private fun getUserDisplayName(recipient: Recipient): String? { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/SignalGlideModule.java b/app/src/main/java/org/thoughtcrime/securesms/mms/SignalGlideModule.java index e49b2333df..02172b7248 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/SignalGlideModule.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/SignalGlideModule.java @@ -2,9 +2,11 @@ package org.thoughtcrime.securesms.mms; import android.content.Context; import android.graphics.Bitmap; -import androidx.annotation.NonNull; +import android.graphics.drawable.BitmapDrawable; import android.util.Log; +import androidx.annotation.NonNull; + import com.bumptech.glide.Glide; import com.bumptech.glide.GlideBuilder; import com.bumptech.glide.Registry; @@ -21,12 +23,14 @@ import com.bumptech.glide.load.resource.gif.StreamGifDecoder; import com.bumptech.glide.module.AppGlideModule; import org.session.libsession.avatars.ContactPhoto; +import org.session.libsession.avatars.PlaceholderAvatarPhoto; import org.thoughtcrime.securesms.crypto.AttachmentSecret; import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider; import org.thoughtcrime.securesms.giph.model.ChunkedImageUrl; import org.thoughtcrime.securesms.glide.ChunkedImageUrlLoader; import org.thoughtcrime.securesms.glide.ContactPhotoLoader; import org.thoughtcrime.securesms.glide.OkHttpUrlLoader; +import org.thoughtcrime.securesms.glide.PlaceholderAvatarLoader; import org.thoughtcrime.securesms.glide.cache.EncryptedBitmapCacheDecoder; import org.thoughtcrime.securesms.glide.cache.EncryptedBitmapResourceEncoder; import org.thoughtcrime.securesms.glide.cache.EncryptedCacheEncoder; @@ -69,6 +73,7 @@ public class SignalGlideModule extends AppGlideModule { registry.append(DecryptableUri.class, InputStream.class, new DecryptableStreamUriLoader.Factory(context)); registry.append(AttachmentModel.class, InputStream.class, new AttachmentStreamUriLoader.Factory()); registry.append(ChunkedImageUrl.class, InputStream.class, new ChunkedImageUrlLoader.Factory()); + registry.append(PlaceholderAvatarPhoto.class, BitmapDrawable.class, new PlaceholderAvatarLoader.Factory(context)); registry.replace(GlideUrl.class, InputStream.class, new OkHttpUrlLoader.Factory()); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java index 3ec26f33cf..884d6d7bed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java @@ -89,14 +89,14 @@ public class AndroidAutoReplyReceiver extends BroadcastReceiver { Log.w("AndroidAutoReplyReceiver", "GroupRecipient, Sending media message"); OutgoingMediaMessage reply = OutgoingMediaMessage.from(message, recipient, Collections.emptyList(), null, null); try { - DatabaseComponent.get(context).mmsDatabase().insertMessageOutbox(reply, replyThreadId, false, null); + DatabaseComponent.get(context).mmsDatabase().insertMessageOutbox(reply, replyThreadId, false, null, true); } catch (MmsException e) { Log.w(TAG, e); } } else { Log.w("AndroidAutoReplyReceiver", "Sending regular message "); OutgoingTextMessage reply = OutgoingTextMessage.from(message, recipient); - DatabaseComponent.get(context).smsDatabase().insertMessageOutbox(replyThreadId, reply, false, System.currentTimeMillis(), null); + DatabaseComponent.get(context).smsDatabase().insertMessageOutbox(replyThreadId, reply, false, System.currentTimeMillis(), null, true); } List messageIds = DatabaseComponent.get(context).threadDatabase().setRead(replyThreadId, true); diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java index 31186e24c6..e6953cabfd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java @@ -229,7 +229,7 @@ public class DefaultMessageNotifier implements MessageNotifier { ThreadDatabase threads = DatabaseComponent.get(context).threadDatabase(); Recipient recipient = threads.getRecipientForThreadId(threadId); - if (!recipient.isGroupRecipient() && threads.getMessageCount(threadId) == 1 && + if (recipient != null && !recipient.isGroupRecipient() && threads.getMessageCount(threadId) == 1 && !(recipient.isApproved() || threads.getLastSeenAndHasSent(threadId).second())) { TextSecurePreferences.removeHasHiddenMessageRequests(context); } @@ -278,10 +278,10 @@ public class DefaultMessageNotifier implements MessageNotifier { try { if (notificationState.hasMultipleThreads()) { + sendMultipleThreadNotification(context, notificationState, signal); for (long threadId : notificationState.getThreads()) { sendSingleThreadNotification(context, new NotificationState(notificationState.getNotificationsForThread(threadId)), false, true); } - sendMultipleThreadNotification(context, notificationState, signal); } else if (notificationState.getMessageCount() > 0){ sendSingleThreadNotification(context, notificationState, signal, false); } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java index c5dd2b9d57..06ea38b79b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java @@ -83,7 +83,7 @@ public class RemoteReplyReceiver extends BroadcastReceiver { case GroupMessage: { OutgoingMediaMessage reply = OutgoingMediaMessage.from(message, recipient, Collections.emptyList(), null, null); try { - DatabaseComponent.get(context).mmsDatabase().insertMessageOutbox(reply, threadId, false, null); + DatabaseComponent.get(context).mmsDatabase().insertMessageOutbox(reply, threadId, false, null, true); MessageSender.send(message, address); } catch (MmsException e) { Log.w(TAG, e); @@ -92,7 +92,7 @@ public class RemoteReplyReceiver extends BroadcastReceiver { } case SecureMessage: { OutgoingTextMessage reply = OutgoingTextMessage.from(message, recipient); - DatabaseComponent.get(context).smsDatabase().insertMessageOutbox(threadId, reply, false, System.currentTimeMillis(), null); + DatabaseComponent.get(context).smsDatabase().insertMessageOutbox(threadId, reply, false, System.currentTimeMillis(), null, true); MessageSender.send(message, address); break; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java index acac47e568..b461081cfe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java @@ -92,7 +92,7 @@ public class SingleRecipientNotificationBuilder extends AbstractNotificationBuil Bitmap iconBitmap = GlideApp.with(context.getApplicationContext()) .asBitmap() .load(contactPhoto) - .diskCacheStrategy(DiskCacheStrategy.ALL) + .diskCacheStrategy(DiskCacheStrategy.NONE) .circleCrop() .submit(context.getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_width), context.getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_height)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LandingActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/LandingActivity.kt index a87b769e67..f67f0fbaa6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LandingActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/LandingActivity.kt @@ -20,7 +20,7 @@ class LandingActivity : BaseActionBarActivity() { with(binding) { fakeChatView.startAnimating() registerButton.setOnClickListener { register() } - restoreButton.setOnClickListener { restore() } + restoreButton.setOnClickListener { link() } linkButton.setOnClickListener { link() } } IdentityKeyUtil.generateIdentityKeyPair(this) @@ -34,11 +34,6 @@ class LandingActivity : BaseActionBarActivity() { push(intent) } - private fun restore() { - val intent = Intent(this, RecoveryPhraseRestoreActivity::class.java) - push(intent) - } - private fun link() { val intent = Intent(this, LinkDeviceActivity::class.java) push(intent) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt index eaac7aaa43..934b9e4ea5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt @@ -77,12 +77,12 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { val displayName = TextSecurePreferences.getProfileName(this) ?: hexEncodedPublicKey glide = GlideApp.with(this) with(binding) { - profilePictureView.glide = glide - profilePictureView.publicKey = hexEncodedPublicKey - profilePictureView.displayName = displayName - profilePictureView.isLarge = true - profilePictureView.update() - profilePictureView.setOnClickListener { showEditProfilePictureUI() } + profilePictureView.root.glide = glide + profilePictureView.root.publicKey = hexEncodedPublicKey + profilePictureView.root.displayName = displayName + profilePictureView.root.isLarge = true + profilePictureView.root.update() + profilePictureView.root.setOnClickListener { showEditProfilePictureUI() } ctnGroupNameSection.setOnClickListener { startActionMode(DisplayNameEditActionModeCallback()) } btnGroupNameDisplay.text = displayName publicKeyTextView.text = hexEncodedPublicKey @@ -214,8 +214,8 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { binding.btnGroupNameDisplay.text = displayName } if (isUpdatingProfilePicture && profilePicture != null) { - binding.profilePictureView.recycle() // Clear the cached image before updating - binding.profilePictureView.update() + binding.profilePictureView.root.recycle() // Clear the cached image before updating + binding.profilePictureView.root.update() } displayNameToBeUploaded = null profilePictureToBeUploaded = null diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/ShareLogsDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/ShareLogsDialog.kt index c167f87429..1bd8373247 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/ShareLogsDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/ShareLogsDialog.kt @@ -22,8 +22,10 @@ import network.loki.messenger.BuildConfig import network.loki.messenger.R import network.loki.messenger.databinding.DialogShareLogsBinding import org.session.libsignal.utilities.ExternalStorageUtil +import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog +import org.thoughtcrime.securesms.util.FileProviderUtil import org.thoughtcrime.securesms.util.StreamUtil import java.io.File import java.io.FileOutputStream @@ -84,18 +86,26 @@ class ShareLogsDialog : BaseDialog() { requireContext().contentResolver.update(mediaUri, updateValues, null, null) } - val shareIntent = Intent().apply { - action = Intent.ACTION_SEND - putExtra(Intent.EXTRA_STREAM, mediaUri) - data = mediaUri - type = "text/plain" + val shareUri = if (mediaUri.scheme == ContentResolver.SCHEME_FILE) { + FileProviderUtil.getUriFor(context, File(mediaUri.path!!)) + } else { + mediaUri + } + + withContext(Main) { + val shareIntent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_STREAM, shareUri) + type = "text/plain" + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + startActivity(Intent.createChooser(shareIntent, getString(R.string.share))) } dismiss() - - startActivity(Intent.createChooser(shareIntent, getString(R.string.share))) } catch (e: Exception) { withContext(Main) { + Log.e("Loki", "Error saving logs", e) Toast.makeText(context,"Error saving logs", Toast.LENGTH_LONG).show() } dismiss() diff --git a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt index 296b4c7875..60b931ca04 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt @@ -33,7 +33,7 @@ import kotlin.coroutines.suspendCoroutine interface ConversationRepository { fun isOxenHostedOpenGroup(threadId: Long): Boolean - fun getRecipientForThreadId(threadId: Long): Recipient + fun maybeGetRecipientForThreadId(threadId: Long): Recipient? fun saveDraft(threadId: Long, text: String) fun getDraft(threadId: Long): String? fun inviteContacts(threadId: Long, contacts: List) @@ -86,12 +86,11 @@ class DefaultConversationRepository @Inject constructor( override fun isOxenHostedOpenGroup(threadId: Long): Boolean { val openGroup = lokiThreadDb.getOpenGroupChat(threadId) - return openGroup?.room == "session" || openGroup?.room == "oxen" - || openGroup?.room == "lokinet" || openGroup?.room == "crypto" + return openGroup?.publicKey == OpenGroupAPIV2.defaultServerPublicKey } - override fun getRecipientForThreadId(threadId: Long): Recipient { - return threadDb.getRecipientForThreadId(threadId)!! + override fun maybeGetRecipientForThreadId(threadId: Long): Recipient? { + return threadDb.getRecipientForThreadId(threadId) } override fun saveDraft(threadId: Long, text: String) { @@ -121,7 +120,7 @@ class DefaultConversationRepository @Inject constructor( contact, message.sentTimestamp ) - smsDb.insertMessageOutbox(-1, outgoingTextMessage, message.sentTimestamp!!) + smsDb.insertMessageOutbox(-1, outgoingTextMessage, message.sentTimestamp!!, true) MessageSender.send(message, contact.address) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.java b/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.java index d571ad1d44..b6d2e2bc57 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.java @@ -119,7 +119,7 @@ public class ExpiringMessageManager implements SSKEnvironment.MessageExpirationM Optional.absent(), Optional.absent()); //insert the timer update message - database.insertSecureDecryptedMessageInbox(mediaMessage, -1); + database.insertSecureDecryptedMessageInbox(mediaMessage, -1, true, true); //set the timer to the conversation DatabaseComponent.get(context).recipientDatabase().setExpireMessages(recipient, duration); @@ -141,7 +141,7 @@ public class ExpiringMessageManager implements SSKEnvironment.MessageExpirationM try { OutgoingExpirationUpdateMessage timerUpdateMessage = new OutgoingExpirationUpdateMessage(recipient, sentTimestamp, duration * 1000L, groupId); - database.insertSecureDecryptedMessageOutbox(timerUpdateMessage, -1, sentTimestamp); + database.insertSecureDecryptedMessageOutbox(timerUpdateMessage, -1, sentTimestamp, true); if (groupId != null) { // we need the group ID as recipient for setExpireMessages below diff --git a/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ProfileManager.kt b/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ProfileManager.kt index ba519fb9c1..c46f75bff8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ProfileManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ProfileManager.kt @@ -41,7 +41,8 @@ class ProfileManager : SSKEnvironment.ProfileManagerProtocol { override fun setProfilePictureURL(context: Context, recipient: Recipient, profilePictureURL: String) { val job = RetrieveProfileAvatarJob(recipient, profilePictureURL) - ApplicationContext.getInstance(context).jobManager.add(job) + val jobManager = ApplicationContext.getInstance(context).jobManager + jobManager.add(job) val sessionID = recipient.address.serialize() val contactDatabase = DatabaseComponent.get(context).sessionContactDatabase() var contact = contactDatabase.getContactWithSessionID(sessionID) diff --git a/app/src/main/res/layout/activity_conversation_v2_action_bar.xml b/app/src/main/res/layout/activity_conversation_v2_action_bar.xml index 12496cf114..f28a0a0748 100644 --- a/app/src/main/res/layout/activity_conversation_v2_action_bar.xml +++ b/app/src/main/res/layout/activity_conversation_v2_action_bar.xml @@ -8,7 +8,7 @@ android:orientation="horizontal" android:gravity="center_vertical"> - diff --git a/app/src/main/res/layout/activity_home.xml b/app/src/main/res/layout/activity_home.xml index 893bf88bcf..c6489295ba 100644 --- a/app/src/main/res/layout/activity_home.xml +++ b/app/src/main/res/layout/activity_home.xml @@ -27,7 +27,7 @@ android:layout_marginLeft="20dp" android:layout_marginRight="20dp"> - - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/view_document.xml b/app/src/main/res/layout/view_document.xml index b4df80d3c9..74330b9fb4 100644 --- a/app/src/main/res/layout/view_document.xml +++ b/app/src/main/res/layout/view_document.xml @@ -1,5 +1,5 @@ - - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/view_global_search_result.xml b/app/src/main/res/layout/view_global_search_result.xml index b784ca559f..327fb27c2a 100644 --- a/app/src/main/res/layout/view_global_search_result.xml +++ b/app/src/main/res/layout/view_global_search_result.xml @@ -18,7 +18,7 @@ android:id="@+id/search_result_profile_picture_parent" android:layout_width="wrap_content" android:layout_height="wrap_content"> - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/view_profile_picture.xml b/app/src/main/res/layout/view_profile_picture.xml index 513d845054..876dfe8eb8 100644 --- a/app/src/main/res/layout/view_profile_picture.xml +++ b/app/src/main/res/layout/view_profile_picture.xml @@ -1,8 +1,8 @@ - + android:layout_width="match_parent" + android:layout_height="match_parent"> - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/view_quote.xml b/app/src/main/res/layout/view_quote.xml index fee333cd48..71ea481e7e 100644 --- a/app/src/main/res/layout/view_quote.xml +++ b/app/src/main/res/layout/view_quote.xml @@ -1,38 +1,38 @@ - + android:minWidth="300dp" + android:minHeight="52dp" + android:paddingVertical="12dp" + android:paddingHorizontal="12dp" + app:quote_mode="regular"> @@ -61,44 +61,45 @@ app:barrierDirection="end" app:constraint_referenced_ids="quoteViewAttachmentPreviewContainer,quoteViewAccentLine" /> - + + - + android:layout_height="wrap_content" + android:layout_marginStart="@dimen/medium_spacing" + android:ellipsize="end" + android:maxLines="3" + android:textColor="@color/text" + android:textSize="@dimen/small_font_size" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintHorizontal_bias="0.5" + app:layout_constraintStart_toEndOf="@+id/quoteStartBarrier" + app:layout_constraintTop_toBottomOf="@+id/quoteViewAuthorTextView" + app:layout_constraintVertical_chainStyle="packed" + android:maxWidth="240dp" + tools:maxLines="1" + tools:text="@tools:sample/lorem/random" /> - - - - + app:tint="@color/text" + tools:visibility="gone" /> - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/view_quote_draft.xml b/app/src/main/res/layout/view_quote_draft.xml new file mode 100644 index 0000000000..0b3ad5eecc --- /dev/null +++ b/app/src/main/res/layout/view_quote_draft.xml @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_untrusted_attachment.xml b/app/src/main/res/layout/view_untrusted_attachment.xml index c019dc1ace..6d6b9ae104 100644 --- a/app/src/main/res/layout/view_untrusted_attachment.xml +++ b/app/src/main/res/layout/view_untrusted_attachment.xml @@ -1,5 +1,5 @@ - - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/view_user.xml b/app/src/main/res/layout/view_user.xml index 47e817c368..4d16c676ac 100644 --- a/app/src/main/res/layout/view_user.xml +++ b/app/src/main/res/layout/view_user.xml @@ -14,7 +14,7 @@ android:gravity="center_vertical" android:padding="@dimen/medium_spacing"> - diff --git a/app/src/main/res/layout/view_visible_message.xml b/app/src/main/res/layout/view_visible_message.xml index 374883e563..f7281c75f1 100644 --- a/app/src/main/res/layout/view_visible_message.xml +++ b/app/src/main/res/layout/view_visible_message.xml @@ -1,6 +1,8 @@ - @@ -8,108 +10,117 @@ + android:textStyle="bold" /> - - + - + - + - - - + android:layout_marginStart="12dp" + android:layout_marginEnd="12dp" + android:ellipsize="end" + android:maxLines="1" + android:textColor="@color/text" + android:textStyle="bold" + tools:text="@tools:sample/full_names" + android:paddingBottom="4dp" + app:layout_constraintStart_toStartOf="@+id/expirationTimerViewContainer" + app:layout_constraintTop_toTopOf="parent"/> - + + + /> - + - - - - - - - - - - - - - + - + - \ No newline at end of file + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_visible_message_content.xml b/app/src/main/res/layout/view_visible_message_content.xml index 06b7eb393c..aa5c936e46 100644 --- a/app/src/main/res/layout/view_visible_message_content.xml +++ b/app/src/main/res/layout/view_visible_message_content.xml @@ -8,7 +8,7 @@ xmlns:app="http://schemas.android.com/apk/res-auto"> - - + + - - + + - - \ No newline at end of file diff --git a/app/src/main/res/layout/view_voice_message.xml b/app/src/main/res/layout/view_voice_message.xml index b319df392d..6a1a6e865d 100644 --- a/app/src/main/res/layout/view_voice_message.xml +++ b/app/src/main/res/layout/view_voice_message.xml @@ -1,5 +1,5 @@ - - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/values-notnight-v21/colors.xml b/app/src/main/res/values-notnight-v21/colors.xml index 2818d14dcb..03eebfb2fe 100644 --- a/app/src/main/res/values-notnight-v21/colors.xml +++ b/app/src/main/res/values-notnight-v21/colors.xml @@ -18,7 +18,7 @@ #4D077C44 #FCFCFC #0D000000 - #FFFFFF + #888888 #FCFCFC #0D000000 #EFEFEF diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index 558e90799a..4ebdfd8632 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -240,13 +240,10 @@ - - - - + + + - - diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 037c4186e0..5ae56b70cd 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -24,7 +24,7 @@ #212121 #FFCE3A #0DFFFFFF - #000000 + #888888 #171717 #0DFFFFFF #232323 diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt index ae6c153cf2..c93a4c0837 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt @@ -34,7 +34,7 @@ class ConversationViewModelTest: BaseViewModelTest() { fun setUp() { recipient = mock(Recipient::class.java) whenever(repository.isOxenHostedOpenGroup(anyLong())).thenReturn(true) - whenever(repository.getRecipientForThreadId(anyLong())).thenReturn(recipient) + whenever(repository.maybeGetRecipientForThreadId(anyLong())).thenReturn(recipient) } @Test diff --git a/libsession/src/main/java/org/session/libsession/avatars/GroupRecordContactPhoto.java b/libsession/src/main/java/org/session/libsession/avatars/GroupRecordContactPhoto.java index bd0edd3449..f03e1ee1e6 100644 --- a/libsession/src/main/java/org/session/libsession/avatars/GroupRecordContactPhoto.java +++ b/libsession/src/main/java/org/session/libsession/avatars/GroupRecordContactPhoto.java @@ -6,11 +6,11 @@ import android.net.Uri; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import org.session.libsession.messaging.MessagingModuleConfiguration; import org.session.libsession.database.StorageProtocol; +import org.session.libsession.messaging.MessagingModuleConfiguration; import org.session.libsession.utilities.Address; -import org.session.libsession.utilities.GroupRecord; import org.session.libsession.utilities.Conversions; +import org.session.libsession.utilities.GroupRecord; import org.session.libsignal.utilities.guava.Optional; import java.io.ByteArrayInputStream; @@ -31,7 +31,7 @@ public class GroupRecordContactPhoto implements ContactPhoto { @Override public InputStream openInputStream(Context context) throws IOException { - StorageProtocol groupDatabase = MessagingModuleConfiguration.shared.getStorage(); + StorageProtocol groupDatabase = MessagingModuleConfiguration.getShared().getStorage(); Optional groupRecord = Optional.of(groupDatabase.getGroup(address.toGroupString())); if (groupRecord.isPresent() && groupRecord.get().getAvatar() != null) { diff --git a/libsession/src/main/java/org/session/libsession/avatars/PlaceholderAvatarPhoto.kt b/libsession/src/main/java/org/session/libsession/avatars/PlaceholderAvatarPhoto.kt new file mode 100644 index 0000000000..0567bad5ad --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/avatars/PlaceholderAvatarPhoto.kt @@ -0,0 +1,13 @@ +package org.session.libsession.avatars + +import com.bumptech.glide.load.Key +import java.security.MessageDigest + +class PlaceholderAvatarPhoto(val hashString: String, + val displayName: String): Key { + + override fun updateDiskCacheKey(messageDigest: MessageDigest) { + messageDigest.update(hashString.encodeToByteArray()) + messageDigest.update(displayName.encodeToByteArray()) + } +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/database/MessageDataProvider.kt b/libsession/src/main/java/org/session/libsession/database/MessageDataProvider.kt index 356307d1dd..f07d8a3538 100644 --- a/libsession/src/main/java/org/session/libsession/database/MessageDataProvider.kt +++ b/libsession/src/main/java/org/session/libsession/database/MessageDataProvider.kt @@ -1,6 +1,11 @@ package org.session.libsession.database -import org.session.libsession.messaging.sending_receiving.attachments.* +import org.session.libsession.messaging.sending_receiving.attachments.Attachment +import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId +import org.session.libsession.messaging.sending_receiving.attachments.AttachmentState +import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment +import org.session.libsession.messaging.sending_receiving.attachments.SessionServiceAttachmentPointer +import org.session.libsession.messaging.sending_receiving.attachments.SessionServiceAttachmentStream import org.session.libsession.utilities.Address import org.session.libsession.utilities.UploadResult import org.session.libsession.utilities.recipients.Recipient @@ -11,6 +16,9 @@ import java.io.InputStream interface MessageDataProvider { fun getMessageID(serverID: Long): Long? + /** + * @return pair of sms or mms table-specific ID and whether it is in SMS table + */ fun getMessageID(serverId: Long, threadId: Long): Pair? fun deleteMessage(messageID: Long, isSms: Boolean) fun updateMessageAsDeleted(timestamp: Long, author: String) diff --git a/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt b/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt index 5aa3423dd3..8f04dfdf09 100644 --- a/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt +++ b/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt @@ -58,6 +58,8 @@ interface StorageProtocol { fun getAllV2OpenGroups(): Map fun getV2OpenGroup(threadId: Long): OpenGroupV2? fun addOpenGroup(urlAsString: String) + fun onOpenGroupAdded(urlAsString: String) + fun hasBackgroundGroupAddJob(groupJoinUrl: String): Boolean fun setOpenGroupServerMessageID(messageID: Long, serverID: Long, threadID: Long, isSms: Boolean) // Open Group Public Keys @@ -155,7 +157,10 @@ interface StorageProtocol { /** * Returns the ID of the `TSIncomingMessage` that was constructed. */ - fun persist(message: VisibleMessage, quotes: QuoteModel?, linkPreview: List, groupPublicKey: String?, openGroupID: String?, attachments: List): Long? + fun persist(message: VisibleMessage, quotes: QuoteModel?, linkPreview: List, groupPublicKey: String?, openGroupID: String?, attachments: List, runIncrement: Boolean, runThreadUpdate: Boolean): Long? + fun markConversationAsRead(threadId: Long, updateLastSeen: Boolean) + fun incrementUnread(threadId: Long, amount: Int) + fun updateThread(threadId: Long, unarchive: Boolean) fun insertDataExtractionNotificationMessage(senderPublicKey: String, message: DataExtractionNotificationInfoMessage, sentTimestamp: Long) fun insertMessageRequestResponse(response: MessageRequestResponse) fun setRecipientApproved(recipient: Recipient, approved: Boolean) diff --git a/libsession/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt b/libsession/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt index eb620bac1c..37c391dfd6 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt @@ -13,13 +13,17 @@ class MessagingModuleConfiguration( ) { companion object { - lateinit var shared: MessagingModuleConfiguration + @JvmStatic + val shared: MessagingModuleConfiguration + get() = context.getSystemService(MESSAGING_MODULE_SERVICE) as MessagingModuleConfiguration - fun configure(context: Context, storage: StorageProtocol, - messageDataProvider: MessageDataProvider, keyPairProvider: () -> KeyPair? - ) { - if (Companion::shared.isInitialized) { return } - shared = MessagingModuleConfiguration(context, storage, messageDataProvider, keyPairProvider) + const val MESSAGING_MODULE_SERVICE: String = "MessagingModuleConfiguration_MESSAGING_MODULE_SERVICE" + + private lateinit var context: Context + + @JvmStatic + fun configure(context: Context) { + this.context = context } } } \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt index 6df64c2d80..70d08db019 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt @@ -17,7 +17,6 @@ import org.session.libsignal.utilities.Log import java.io.File import java.io.FileInputStream import java.io.InputStream -import java.lang.NullPointerException class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long) : Job { override var delegate: JobDelegate? = null @@ -33,7 +32,7 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long) } // Settings - override val maxFailureCount: Int = 100 + override val maxFailureCount: Int = 2 companion object { val KEY: String = "AttachmentDownloadJob" @@ -54,12 +53,23 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long) || exception == Error.NoSender || (exception is OnionRequestAPI.HTTPRequestFailedAtDestinationException && exception.statusCode == 400)) { attachment?.let { id -> + Log.d("AttachmentDownloadJob", "Setting attachment state = failed, have attachment") messageDataProvider.setAttachmentState(AttachmentState.FAILED, id, databaseMessageID) } ?: run { + Log.d("AttachmentDownloadJob", "Setting attachment state = failed, don't have attachment") messageDataProvider.setAttachmentState(AttachmentState.FAILED, AttachmentId(attachmentID,0), databaseMessageID) } this.handlePermanentFailure(exception) } else { + if (failureCount + 1 >= maxFailureCount) { + attachment?.let { id -> + Log.d("AttachmentDownloadJob", "Setting attachment state = failed from max failure count, have attachment") + messageDataProvider.setAttachmentState(AttachmentState.FAILED, id, databaseMessageID) + } ?: run { + Log.d("AttachmentDownloadJob", "Setting attachment state = failed from max failure count, don't have attachment") + messageDataProvider.setAttachmentState(AttachmentState.FAILED, AttachmentId(attachmentID,0), databaseMessageID) + } + } this.handleFailure(exception) } } @@ -98,16 +108,20 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long) tempFile = createTempFile() val openGroupV2 = storage.getV2OpenGroup(threadID) if (openGroupV2 == null) { + Log.d("AttachmentDownloadJob", "downloading normal attachment") DownloadUtilities.downloadFile(tempFile, attachment.url) } else { + Log.d("AttachmentDownloadJob", "downloading open group attachment") val url = HttpUrl.parse(attachment.url)!! val fileID = url.pathSegments().last() OpenGroupAPIV2.download(fileID.toLong(), openGroupV2.room, openGroupV2.server).get().let { tempFile.writeBytes(it) } } + Log.d("AttachmentDownloadJob", "getting input stream") val inputStream = getInputStream(tempFile, attachment) + Log.d("AttachmentDownloadJob", "inserting attachment") messageDataProvider.insertAttachment(databaseMessageID, attachment.attachmentId, inputStream) if (attachment.contentType.startsWith("audio/")) { // process the duration @@ -124,9 +138,12 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long) Log.e("Loki", "Couldn't process audio attachment", e) } } + Log.d("AttachmentDownloadJob", "deleting tempfile") tempFile.delete() + Log.d("AttachmentDownloadJob", "succeeding job") handleSuccess() } catch (e: Exception) { + Log.e("AttachmentDownloadJob", "Error processing attachment download", e) tempFile?.delete() return handleFailure(e,null) } @@ -135,8 +152,10 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long) private fun getInputStream(tempFile: File, attachment: DatabaseAttachment): InputStream { // Assume we're retrieving an attachment for an open group server if the digest is not set return if (attachment.digest?.size ?: 0 == 0 || attachment.key.isNullOrEmpty()) { + Log.d("AttachmentDownloadJob", "getting input stream with no attachment digest") FileInputStream(tempFile) } else { + Log.d("AttachmentDownloadJob", "getting input stream with attachment digest") AttachmentCipherInputStream.createForAttachment(tempFile, attachment.size, Base64.decode(attachment.key), attachment.digest) } } diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/BackgroundGroupAddJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/BackgroundGroupAddJob.kt new file mode 100644 index 0000000000..e5a3c099fd --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/BackgroundGroupAddJob.kt @@ -0,0 +1,79 @@ +package org.session.libsession.messaging.jobs + +import okhttp3.HttpUrl +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.open_groups.OpenGroupAPIV2 +import org.session.libsession.messaging.open_groups.OpenGroupV2 +import org.session.libsession.messaging.utilities.Data +import org.session.libsession.utilities.GroupUtil +import org.session.libsignal.utilities.Log + +class BackgroundGroupAddJob(val joinUrl: String): Job { + + companion object { + const val KEY = "BackgroundGroupAddJob" + + private const val JOIN_URL = "joinUri" + } + + override var delegate: JobDelegate? = null + override var id: String? = null + override var failureCount: Int = 0 + override val maxFailureCount: Int = 1 + + val openGroupId: String? get() { + val url = HttpUrl.parse(joinUrl) ?: return null + val server = OpenGroupV2.getServer(joinUrl)?.toString()?.removeSuffix("/") ?: return null + val room = url.pathSegments().firstOrNull() ?: return null + return "$server.$room" + } + + override fun execute() { + try { + val storage = MessagingModuleConfiguration.shared.storage + val allV2OpenGroups = storage.getAllV2OpenGroups().map { it.value.joinURL } + if (allV2OpenGroups.contains(joinUrl)) { + Log.e("OpenGroupDispatcher", "Failed to add group because",DuplicateGroupException()) + delegate?.handleJobFailed(this, DuplicateGroupException()) + return + } + // get image + val url = HttpUrl.parse(joinUrl) ?: throw Exception("Group joinUrl isn't valid") + val server = OpenGroupV2.getServer(joinUrl) + val serverString = server.toString().removeSuffix("/") + val publicKey = url.queryParameter("public_key") ?: throw Exception("Group public key isn't valid") + val room = url.pathSegments().firstOrNull() ?: throw Exception("Group room isn't valid") + storage.setOpenGroupPublicKey(serverString,publicKey) + val bytes = OpenGroupAPIV2.downloadOpenGroupProfilePicture(url.pathSegments().firstOrNull()!!, serverString).get() + val groupId = GroupUtil.getEncodedOpenGroupID("$server.$room".toByteArray()) + // get info and auth token + storage.addOpenGroup(joinUrl) + storage.updateProfilePicture(groupId, bytes) + storage.updateTimestampUpdated(groupId, System.currentTimeMillis()) + storage.onOpenGroupAdded(joinUrl) + } catch (e: Exception) { + Log.e("OpenGroupDispatcher", "Failed to add group because",e) + delegate?.handleJobFailed(this, e) + return + } + Log.d("Loki", "Group added successfully") + delegate?.handleJobSucceeded(this) + } + + override fun serialize(): Data = Data.Builder() + .putString(JOIN_URL, joinUrl) + .build() + + override fun getFactoryKey(): String = KEY + + class DuplicateGroupException: Exception("Current open groups already contains this group") + + class Factory : Job.Factory { + override fun create(data: Data): BackgroundGroupAddJob { + return BackgroundGroupAddJob( + data.getString(JOIN_URL) + ) + } + } + +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt index 8cc49a1177..78c0c16b8e 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt @@ -1,11 +1,23 @@ package org.session.libsession.messaging.jobs import com.google.protobuf.ByteString +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking import nl.komponents.kovenant.Promise import nl.komponents.kovenant.task +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.messages.Message +import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate +import org.session.libsession.messaging.messages.visible.ParsedMessage +import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.sending_receiving.MessageReceiver import org.session.libsession.messaging.sending_receiving.handle +import org.session.libsession.messaging.sending_receiving.handleVisibleMessage import org.session.libsession.messaging.utilities.Data +import org.session.libsession.utilities.SSKEnvironment import org.session.libsignal.protos.UtilProtos import org.session.libsignal.utilities.Log @@ -23,7 +35,7 @@ class BatchMessageReceiveJob( override var delegate: JobDelegate? = null override var id: String? = null override var failureCount: Int = 0 - override val maxFailureCount: Int = 10 + override val maxFailureCount: Int = 1 // handled in JobQueue onJobFailed // Failure Exceptions must be retryable if they're a MessageReceiver.Error val failures = mutableListOf() @@ -31,7 +43,7 @@ class BatchMessageReceiveJob( const val TAG = "BatchMessageReceiveJob" const val KEY = "BatchMessageReceiveJob" - const val BATCH_DEFAULT_NUMBER = 50 + const val BATCH_DEFAULT_NUMBER = 512 // Keys used for database storage private val NUM_MESSAGES_KEY = "numMessages" @@ -41,18 +53,39 @@ class BatchMessageReceiveJob( private val OPEN_GROUP_ID_KEY = "open_group_id" } + private fun getThreadId(message: Message, storage: StorageProtocol): Long { + val senderOrSync = when (message) { + is VisibleMessage -> message.syncTarget ?: message.sender!! + is ExpirationTimerUpdate -> message.syncTarget ?: message.sender!! + else -> message.sender!! + } + return storage.getOrCreateThreadIdFor(senderOrSync, message.groupPublicKey, openGroupID) + } + override fun execute() { executeAsync().get() } fun executeAsync(): Promise { return task { - messages.iterator().forEach { messageParameters -> + val threadMap = mutableMapOf>() + val storage = MessagingModuleConfiguration.shared.storage + val context = MessagingModuleConfiguration.shared.context + val localUserPublicKey = storage.getUserPublicKey() + + // parse and collect IDs + messages.forEach { messageParameters -> val (data, serverHash, openGroupMessageServerID) = messageParameters try { val (message, proto) = MessageReceiver.parse(data, openGroupMessageServerID) message.serverHash = serverHash - MessageReceiver.handle(message, proto, this.openGroupID) + val threadID = getThreadId(message, storage) + val parsedParams = ParsedMessage(messageParameters, message, proto) + if (!threadMap.containsKey(threadID)) { + threadMap[threadID] = mutableListOf(parsedParams) + } else { + threadMap[threadID]!! += parsedParams + } } catch (e: Exception) { Log.e(TAG, "Couldn't receive message.", e) if (e is MessageReceiver.Error && !e.isRetryable) { @@ -63,6 +96,53 @@ class BatchMessageReceiveJob( } } } + + // iterate over threads and persist them (persistence is the longest constant in the batch process operation) + runBlocking(Dispatchers.IO) { + val deferredThreadMap = threadMap.entries.map { (threadId, messages) -> + async { + val messageIds = mutableListOf>() + messages.forEach { (parameters, message, proto) -> + try { + if (message is VisibleMessage) { + val messageId = MessageReceiver.handleVisibleMessage(message, proto, openGroupID, + runIncrement = false, + runThreadUpdate = false, + runProfileUpdate = true + ) + if (messageId != null) { + messageIds += messageId to (message.sender == localUserPublicKey) + } + } else { + MessageReceiver.handle(message, proto, openGroupID) + } + } catch (e: Exception) { + Log.e(TAG, "Couldn't process message.", e) + if (e is MessageReceiver.Error && !e.isRetryable) { + Log.e(TAG, "Message failed permanently",e) + } else { + Log.e(TAG, "Message failed",e) + failures += parameters + } + } + } + // increment unreads, notify, and update thread + val unreadFromMine = messageIds.indexOfLast { (_,fromMe) -> fromMe } + var trueUnreadCount = messageIds.size + if (unreadFromMine >= 0) { + trueUnreadCount -= (unreadFromMine + 1) + storage.markConversationAsRead(threadId, false) + } + SSKEnvironment.shared.notificationManager.updateNotification(context, threadId) + storage.incrementUnread(threadId, trueUnreadCount) + storage.updateThread(threadId, true) + } + } + + // await all thread processing + deferredThreadMap.awaitAll() + } + if (failures.isEmpty()) { handleSuccess() } else { diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt index 20c09253b0..1cf54d8304 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt @@ -2,7 +2,7 @@ package org.session.libsession.messaging.jobs import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.channels.Channel @@ -25,46 +25,128 @@ class JobQueue : JobDelegate { private var hasResumedPendingJobs = false // Just for debugging private val jobTimestampMap = ConcurrentHashMap() private val rxDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() + private val rxMediaDispatcher = Executors.newFixedThreadPool(4).asCoroutineDispatcher() + private val openGroupDispatcher = Executors.newCachedThreadPool().asCoroutineDispatcher() private val txDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() - private val scope = GlobalScope + SupervisorJob() + private val scope = CoroutineScope(Dispatchers.Default) + SupervisorJob() private val queue = Channel(UNLIMITED) private val pendingJobIds = mutableSetOf() + private val pendingTrimThreadIds = mutableSetOf() + + private val openGroupChannels = mutableMapOf>() val timer = Timer() - private fun CoroutineScope.processWithDispatcher( + private fun CoroutineScope.processWithOpenGroupDispatcher( channel: Channel, - dispatcher: CoroutineDispatcher + dispatcher: CoroutineDispatcher, + name: String ) = launch(dispatcher) { for (job in channel) { if (!isActive) break - job.delegate = this@JobQueue - job.execute() + val openGroupId = when (job) { + is BatchMessageReceiveJob -> job.openGroupID + is OpenGroupDeleteJob -> job.openGroupId + is TrimThreadJob -> job.openGroupId + is BackgroundGroupAddJob -> job.openGroupId + is GroupAvatarDownloadJob -> "${job.server}.${job.room}" + else -> null + } + if (openGroupId.isNullOrEmpty()) { + Log.e("OpenGroupDispatcher", "Open Group ID was null on ${job.javaClass.simpleName}") + handleJobFailedPermanently(job, NullPointerException("Open Group ID was null")) + } else { + val groupChannel = if (!openGroupChannels.containsKey(openGroupId)) { + Log.d("OpenGroupDispatcher", "Creating $openGroupId channel") + val newGroupChannel = Channel(UNLIMITED) + launch(dispatcher) { + for (groupJob in newGroupChannel) { + if (!isActive) break + groupJob.process(name) + } + } + openGroupChannels[openGroupId] = newGroupChannel + newGroupChannel + } else { + Log.d("OpenGroupDispatcher", "Re-using channel") + openGroupChannels[openGroupId]!! + } + Log.d("OpenGroupDispatcher", "Sending to channel $groupChannel") + groupChannel.send(job) + } } } + private fun CoroutineScope.processWithDispatcher( + channel: Channel, + dispatcher: CoroutineDispatcher, + name: String, + asynchronous: Boolean = true + ) = launch(dispatcher) { + for (job in channel) { + if (!isActive) break + if (asynchronous) { + launch(dispatcher) { + job.process(name) + } + } else { + job.process(name) + } + } + } + + private fun Job.process(dispatcherName: String) { + Log.d(dispatcherName,"processJob: ${javaClass.simpleName}") + delegate = this@JobQueue + execute() + } + init { // Process jobs scope.launch { - val rxQueue = Channel(capacity = 4096) - val txQueue = Channel(capacity = 4096) + val rxQueue = Channel(capacity = UNLIMITED) + val txQueue = Channel(capacity = UNLIMITED) + val mediaQueue = Channel(capacity = UNLIMITED) + val openGroupQueue = Channel(capacity = UNLIMITED) - val receiveJob = processWithDispatcher(rxQueue, rxDispatcher) - val txJob = processWithDispatcher(txQueue, txDispatcher) + val receiveJob = processWithDispatcher(rxQueue, rxDispatcher, "rx", asynchronous = false) + val txJob = processWithDispatcher(txQueue, txDispatcher, "tx") + val mediaJob = processWithDispatcher(mediaQueue, rxMediaDispatcher, "media") + val openGroupJob = processWithOpenGroupDispatcher(openGroupQueue, openGroupDispatcher, "openGroup") while (isActive) { - for (job in queue) { - when (job) { - is NotifyPNServerJob, is AttachmentUploadJob, is MessageSendJob -> { - txQueue.send(job) - } - is MessageReceiveJob, is TrimThreadJob, is BatchMessageReceiveJob, - is AttachmentDownloadJob, is GroupAvatarDownloadJob -> { + if (queue.isEmpty && pendingTrimThreadIds.isNotEmpty()) { + // process trim thread jobs + val pendingThreads = pendingTrimThreadIds.toList() + pendingTrimThreadIds.clear() + for (thread in pendingThreads) { + Log.d("Loki", "Trimming thread $thread") + queue.trySend(TrimThreadJob(thread, null)) + } + } + when (val job = queue.receive()) { + is NotifyPNServerJob, is AttachmentUploadJob, is MessageSendJob -> { + txQueue.send(job) + } + is AttachmentDownloadJob -> { + mediaQueue.send(job) + } + is GroupAvatarDownloadJob, + is BackgroundGroupAddJob, + is OpenGroupDeleteJob -> { + openGroupQueue.send(job) + } + is MessageReceiveJob, is TrimThreadJob, + is BatchMessageReceiveJob -> { + if ((job is BatchMessageReceiveJob && !job.openGroupID.isNullOrEmpty()) + || (job is TrimThreadJob && !job.openGroupId.isNullOrEmpty())) { + openGroupQueue.send(job) + } else { rxQueue.send(job) } - else -> { - throw IllegalStateException("Unexpected job type.") - } + } + else -> { + throw IllegalStateException("Unexpected job type.") } } } @@ -72,7 +154,8 @@ class JobQueue : JobDelegate { // The job has been cancelled receiveJob.cancel() txJob.cancel() - + mediaJob.cancel() + openGroupJob.cancel() } } @@ -82,6 +165,10 @@ class JobQueue : JobDelegate { val shared: JobQueue by lazy { JobQueue() } } + fun queueThreadForTrim(threadId: Long) { + pendingTrimThreadIds += threadId + } + fun add(job: Job) { addWithoutExecuting(job) queue.trySend(job) // offer always called on unlimited capacity @@ -141,7 +228,9 @@ class JobQueue : JobDelegate { MessageSendJob.KEY, NotifyPNServerJob.KEY, BatchMessageReceiveJob.KEY, - GroupAvatarDownloadJob.KEY + GroupAvatarDownloadJob.KEY, + BackgroundGroupAddJob.KEY, + OpenGroupDeleteJob.KEY, ) allJobTypes.forEach { type -> resumePendingJobs(type) @@ -165,13 +254,20 @@ class JobQueue : JobDelegate { Log.i("Loki", "Message send job waiting for attachment upload to finish.") return } + // Batch message receive job, re-queue non-permanently failed jobs - if (job is BatchMessageReceiveJob) { - val replacementParameters = job.failures + if (job is BatchMessageReceiveJob && job.failureCount <= 0) { + val replacementParameters = job.failures.toList() + if (replacementParameters.isNotEmpty()) { + val newJob = BatchMessageReceiveJob(replacementParameters, job.openGroupID) + newJob.failureCount = job.failureCount + 1 + add(newJob) + } } // Regular job failure job.failureCount += 1 + if (job.failureCount >= job.maxFailureCount) { handleJobFailedPermanently(job, error) } else { diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/OpenGroupDeleteJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/OpenGroupDeleteJob.kt new file mode 100644 index 0000000000..e08125e58e --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/OpenGroupDeleteJob.kt @@ -0,0 +1,51 @@ +package org.session.libsession.messaging.jobs + +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.utilities.Data +import org.session.libsignal.utilities.Log + +class OpenGroupDeleteJob(private val messageServerIds: LongArray, private val threadId: Long, val openGroupId: String): Job { + + companion object { + private const val TAG = "OpenGroupDeleteJob" + const val KEY = "OpenGroupDeleteJob" + private const val MESSAGE_IDS = "messageIds" + private const val THREAD_ID = "threadId" + private const val OPEN_GROUP_ID = "openGroupId" + } + + override var delegate: JobDelegate? = null + override var id: String? = null + override var failureCount: Int = 0 + override val maxFailureCount: Int = 1 + + override fun execute() { + val dataProvider = MessagingModuleConfiguration.shared.messageDataProvider + val numberToDelete = messageServerIds.size + Log.d(TAG, "Deleting $numberToDelete messages") + messageServerIds.forEach { serverId -> + val (messageId, isSms) = dataProvider.getMessageID(serverId, threadId) ?: return@forEach + dataProvider.deleteMessage(messageId, isSms) + } + Log.d(TAG, "Deleted $numberToDelete messages successfully") + delegate?.handleJobSucceeded(this) + } + + override fun serialize(): Data = Data.Builder() + .putLongArray(MESSAGE_IDS, messageServerIds) + .putLong(THREAD_ID, threadId) + .putString(OPEN_GROUP_ID, openGroupId) + .build() + + override fun getFactoryKey(): String = KEY + + class Factory: Job.Factory { + override fun create(data: Data): OpenGroupDeleteJob { + val messageServerIds = data.getLongArray(MESSAGE_IDS) + val threadId = data.getLong(THREAD_ID) + val openGroupId = data.getString(OPEN_GROUP_ID) + return OpenGroupDeleteJob(messageServerIds, threadId, openGroupId) + } + } + +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt index 9a3a97401c..cfe792274f 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt @@ -13,7 +13,9 @@ class SessionJobManagerFactories { NotifyPNServerJob.KEY to NotifyPNServerJob.Factory(), TrimThreadJob.KEY to TrimThreadJob.Factory(), BatchMessageReceiveJob.KEY to BatchMessageReceiveJob.Factory(), - GroupAvatarDownloadJob.KEY to GroupAvatarDownloadJob.Factory() + GroupAvatarDownloadJob.KEY to GroupAvatarDownloadJob.Factory(), + BackgroundGroupAddJob.KEY to BackgroundGroupAddJob.Factory(), + OpenGroupDeleteJob.KEY to OpenGroupDeleteJob.Factory(), ) } } diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/TrimThreadJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/TrimThreadJob.kt index b113e02543..72aec85931 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/TrimThreadJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/TrimThreadJob.kt @@ -4,7 +4,7 @@ import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.utilities.Data import org.session.libsession.utilities.TextSecurePreferences -class TrimThreadJob(val threadId: Long) : Job { +class TrimThreadJob(val threadId: Long, val openGroupId: String?) : Job { override var delegate: JobDelegate? = null override var id: String? = null override var failureCount: Int = 0 @@ -14,6 +14,7 @@ class TrimThreadJob(val threadId: Long) : Job { companion object { const val KEY: String = "TrimThreadJob" const val THREAD_ID = "thread_id" + const val OPEN_GROUP_ID = "open_group" } override fun execute() { @@ -27,9 +28,12 @@ class TrimThreadJob(val threadId: Long) : Job { } override fun serialize(): Data { - return Data.Builder() + val builder = Data.Builder() .putLong(THREAD_ID, threadId) - .build() + if (!openGroupId.isNullOrEmpty()) { + builder.putString(OPEN_GROUP_ID, openGroupId) + } + return builder.build() } override fun getFactoryKey(): String = "TrimThreadJob" @@ -37,7 +41,7 @@ class TrimThreadJob(val threadId: Long) : Job { class Factory : Job.Factory { override fun create(data: Data): TrimThreadJob { - return TrimThreadJob(data.getLong(THREAD_ID)) + return TrimThreadJob(data.getLong(THREAD_ID), data.getStringOrDefault(OPEN_GROUP_ID, null)) } } diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/visible/ParsedMessage.kt b/libsession/src/main/java/org/session/libsession/messaging/messages/visible/ParsedMessage.kt new file mode 100644 index 0000000000..b0c5ced904 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/visible/ParsedMessage.kt @@ -0,0 +1,11 @@ +package org.session.libsession.messaging.messages.visible + +import org.session.libsession.messaging.jobs.MessageReceiveParameters +import org.session.libsession.messaging.messages.Message +import org.session.libsignal.protos.SignalServiceProtos + +data class ParsedMessage( + val parameters: MessageReceiveParameters, + val message: Message, + val proto: SignalServiceProtos.Content +) \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupAPIV2.kt b/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupAPIV2.kt index 9597c70aa6..6992f69479 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupAPIV2.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupAPIV2.kt @@ -4,7 +4,6 @@ import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.databind.PropertyNamingStrategy import com.fasterxml.jackson.databind.annotation.JsonNaming import com.fasterxml.jackson.databind.type.TypeFactory -import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableSharedFlow import nl.komponents.kovenant.Promise import nl.komponents.kovenant.functional.bind @@ -18,11 +17,22 @@ import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPolle import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.utilities.AESGCM import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsignal.utilities.* -import org.session.libsignal.utilities.Base64.* -import org.session.libsignal.utilities.HTTP.Verb.* +import org.session.libsignal.utilities.Base64.decode +import org.session.libsignal.utilities.Base64.encodeBytes +import org.session.libsignal.utilities.HTTP +import org.session.libsignal.utilities.HTTP.Verb.DELETE +import org.session.libsignal.utilities.HTTP.Verb.GET +import org.session.libsignal.utilities.HTTP.Verb.POST +import org.session.libsignal.utilities.HTTP.Verb.PUT +import org.session.libsignal.utilities.Hex +import org.session.libsignal.utilities.JsonUtil +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.removing05PrefixIfNeeded +import org.session.libsignal.utilities.toHexString import org.whispersystems.curve25519.Curve25519 -import java.util.* +import kotlin.collections.component1 +import kotlin.collections.component2 +import kotlin.collections.set object OpenGroupAPIV2 { private val moderators: HashMap> = hashMapOf() // Server URL to (channel ID to set of moderator IDs) @@ -38,7 +48,7 @@ object OpenGroupAPIV2 { now - lastOpenDate } - private const val defaultServerPublicKey = "a03c383cf63c3c4efe67acc52112a6dd734b3a946b9545f488aaa93da7991238" + const val defaultServerPublicKey = "a03c383cf63c3c4efe67acc52112a6dd734b3a946b9545f488aaa93da7991238" const val defaultServer = "http://116.203.70.33" sealed class Error(message: String) : Exception(message) { @@ -168,6 +178,9 @@ object OpenGroupAPIV2 { .success { authToken -> storage.setAuthToken(room, server, authToken) } + .fail { exception -> + Log.e("Loki", "Failed to get auth token", exception) + } } } diff --git a/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupV2.kt b/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupV2.kt index 6eb964e9ef..886c359c5e 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupV2.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupV2.kt @@ -1,8 +1,9 @@ package org.session.libsession.messaging.open_groups +import okhttp3.HttpUrl import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Log -import java.util.* +import java.util.Locale data class OpenGroupV2( val server: String, @@ -37,6 +38,15 @@ data class OpenGroupV2( } } + fun getServer(urlAsString: String): HttpUrl? { + val url = HttpUrl.parse(urlAsString) ?: return null + val builder = HttpUrl.Builder().scheme(url.scheme()).host(url.host()) + if (url.port() != 80 || url.port() != 443) { + // Non-standard port; add to server + builder.port(url.port()) + } + return builder.build() + } } fun toJson(): Map = mapOf( diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt index 80482202c0..f0ce0265d0 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt @@ -2,10 +2,19 @@ package org.session.libsession.messaging.sending_receiving import org.session.libsession.messaging.MessagingModuleConfiguration 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.VisibleMessage import org.session.libsignal.crypto.PushTransportDetails import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsignal.utilities.Log object MessageReceiver { @@ -38,7 +47,9 @@ object MessageReceiver { // Parse the envelope val envelope = SignalServiceProtos.Envelope.parseFrom(data) // Decrypt the contents - val ciphertext = envelope.content ?: throw Error.NoData + val ciphertext = envelope.content ?: run { + throw Error.NoData + } var plaintext: ByteArray? = null var sender: String? = null var groupPublicKey: String? = null @@ -59,7 +70,9 @@ object MessageReceiver { throw Error.InvalidGroupPublicKey } val encryptionKeyPairs = MessagingModuleConfiguration.shared.storage.getClosedGroupEncryptionKeyPairs(hexEncodedGroupPublicKey) - if (encryptionKeyPairs.isEmpty()) { throw Error.NoGroupKeyPair } + if (encryptionKeyPairs.isEmpty()) { + throw Error.NoGroupKeyPair + } // Loop through all known group key pairs in reverse order (i.e. try the latest key pair first (which'll more than // likely be the one we want) but try older ones in case that didn't work) var encryptionKeyPair = encryptionKeyPairs.removeLast() @@ -73,6 +86,7 @@ object MessageReceiver { encryptionKeyPair = encryptionKeyPairs.removeLast() decrypt() } else { + Log.e("Loki", "Failed to decrypt group message", e) throw e } } @@ -80,11 +94,15 @@ object MessageReceiver { groupPublicKey = envelope.source decrypt() } - else -> throw Error.UnknownEnvelopeType + else -> { + throw Error.UnknownEnvelopeType + } } } // Don't process the envelope any further if the sender is blocked - if (isBlocked(sender!!)) throw Error.SenderBlocked + if (isBlocked(sender!!)) { + throw Error.SenderBlocked + } // Parse the proto val proto = SignalServiceProtos.Content.parseFrom(PushTransportDetails.getStrippedPaddingMessageBody(plaintext)) // Parse the message @@ -97,11 +115,17 @@ object MessageReceiver { UnsendRequest.fromProto(proto) ?: MessageRequestResponse.fromProto(proto) ?: CallMessage.fromProto(proto) ?: - VisibleMessage.fromProto(proto) ?: throw Error.UnknownMessage + VisibleMessage.fromProto(proto) ?: run { + throw Error.UnknownMessage + } // Ignore self send if needed - if (!message.isSelfSendValid && sender == userPublicKey) throw Error.SelfSend + if (!message.isSelfSendValid && sender == userPublicKey) { + throw Error.SelfSend + } // Guard against control messages in open groups - if (isOpenGroupMessage && message !is VisibleMessage) throw Error.InvalidMessage + if (isOpenGroupMessage && message !is VisibleMessage) { + throw Error.InvalidMessage + } // Finish parsing message.sender = sender message.recipient = userPublicKey @@ -112,7 +136,9 @@ object MessageReceiver { // Validate var isValid = message.isValid() if (message is VisibleMessage && !isValid && proto.dataMessage.attachmentsCount != 0) { isValid = true } - if (!isValid) { throw Error.InvalidMessage } + if (!isValid) { + throw Error.InvalidMessage + } // If the message failed to process the first time around we retry it later (if the error is retryable). In this case the timestamp // will already be in the database but we don't want to treat the message as a duplicate. The isRetry flag is a simple workaround // for this issue. diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt index ee2664d02e..b824c55226 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt @@ -3,7 +3,7 @@ package org.session.libsession.messaging.sending_receiving import android.text.TextUtils import org.session.libsession.avatars.AvatarHelper import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.messaging.jobs.AttachmentDownloadJob +import org.session.libsession.messaging.jobs.BackgroundGroupAddJob import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.control.CallMessage @@ -61,7 +61,11 @@ fun MessageReceiver.handle(message: Message, proto: SignalServiceProtos.Content, is ConfigurationMessage -> handleConfigurationMessage(message) is UnsendRequest -> handleUnsendRequest(message) is MessageRequestResponse -> handleMessageRequestResponse(message) - is VisibleMessage -> handleVisibleMessage(message, proto, openGroupID) + is VisibleMessage -> handleVisibleMessage(message, proto, openGroupID, + runIncrement = true, + runThreadUpdate = true, + runProfileUpdate = true + ) is CallMessage -> handleCallMessage(message) } } @@ -138,10 +142,13 @@ private fun handleConfigurationMessage(message: ConfigurationMessage) { TextSecurePreferences.setConfigurationMessageSynced(context, true) TextSecurePreferences.setLastProfileUpdateTime(context, message.sentTimestamp!!) - if (firstTimeSync) { - val allClosedGroupPublicKeys = storage.getAllClosedGroupPublicKeys() - for (closedGroup in message.closedGroups) { - if (allClosedGroupPublicKeys.contains(closedGroup.publicKey)) continue + val allClosedGroupPublicKeys = storage.getAllClosedGroupPublicKeys() + for (closedGroup in message.closedGroups) { + if (allClosedGroupPublicKeys.contains(closedGroup.publicKey)) { + // just handle the closed group encryption key pairs to avoid sync'd devices getting out of sync + storage.addClosedGroupEncryptionKeyPair(closedGroup.encryptionKeyPair!!, closedGroup.publicKey) + } else if (firstTimeSync) { + // only handle new closed group if it's first time sync handleNewClosedGroup(message.sender!!, message.sentTimestamp!!, closedGroup.publicKey, closedGroup.name, closedGroup.encryptionKeyPair!!, closedGroup.members, closedGroup.admins, message.sentTimestamp!!, closedGroup.expirationTimer) } @@ -149,7 +156,11 @@ private fun handleConfigurationMessage(message: ConfigurationMessage) { val allV2OpenGroups = storage.getAllV2OpenGroups().map { it.value.joinURL } for (openGroup in message.openGroups) { if (allV2OpenGroups.contains(openGroup)) continue - storage.addOpenGroup(openGroup) + Log.d("OpenGroup", "All open groups doesn't contain $openGroup") + if (!storage.hasBackgroundGroupAddJob(openGroup)) { + Log.d("OpenGroup", "Doesn't contain background job for $openGroup, adding") + JobQueue.shared.add(BackgroundGroupAddJob(openGroup)) + } } val profileManager = SSKEnvironment.shared.profileManager val recipient = Recipient.from(context, Address.fromSerialized(userPublicKey), false) @@ -192,8 +203,12 @@ fun handleMessageRequestResponse(message: MessageRequestResponse) { } //endregion -// region Visible Messages -fun MessageReceiver.handleVisibleMessage(message: VisibleMessage, proto: SignalServiceProtos.Content, openGroupID: String?) { +fun MessageReceiver.handleVisibleMessage(message: VisibleMessage, + proto: SignalServiceProtos.Content, + openGroupID: String?, + runIncrement: Boolean, + runThreadUpdate: Boolean, + runProfileUpdate: Boolean): Long? { val storage = MessagingModuleConfiguration.shared.storage val context = MessagingModuleConfiguration.shared.context val userPublicKey = storage.getUserPublicKey() @@ -209,23 +224,25 @@ fun MessageReceiver.handleVisibleMessage(message: VisibleMessage, proto: SignalS } // Update profile if needed val recipient = Recipient.from(context, Address.fromSerialized(messageSender!!), false) - val profile = message.profile - if (profile != null && userPublicKey != messageSender) { - val profileManager = SSKEnvironment.shared.profileManager - val name = profile.displayName!! - if (name.isNotEmpty()) { - profileManager.setName(context, recipient, name) - } - val newProfileKey = profile.profileKey + if (runProfileUpdate) { + val profile = message.profile + if (profile != null && userPublicKey != messageSender) { + val profileManager = SSKEnvironment.shared.profileManager + val name = profile.displayName!! + if (name.isNotEmpty()) { + profileManager.setName(context, recipient, name) + } + val newProfileKey = profile.profileKey - val needsProfilePicture = !AvatarHelper.avatarFileExists(context, Address.fromSerialized(messageSender)) - val profileKeyValid = newProfileKey?.isNotEmpty() == true && (newProfileKey.size == 16 || newProfileKey.size == 32) && profile.profilePictureURL?.isNotEmpty() == true - val profileKeyChanged = (recipient.profileKey == null || !MessageDigest.isEqual(recipient.profileKey, newProfileKey)) + val needsProfilePicture = !AvatarHelper.avatarFileExists(context, Address.fromSerialized(messageSender)) + val profileKeyValid = newProfileKey?.isNotEmpty() == true && (newProfileKey.size == 16 || newProfileKey.size == 32) && profile.profilePictureURL?.isNotEmpty() == true + val profileKeyChanged = (recipient.profileKey == null || !MessageDigest.isEqual(recipient.profileKey, newProfileKey)) - if ((profileKeyValid && profileKeyChanged) || (profileKeyValid && needsProfilePicture)) { - profileManager.setProfileKey(context, recipient, newProfileKey!!) - profileManager.setUnidentifiedAccessMode(context, recipient, Recipient.UnidentifiedAccessMode.UNKNOWN) - profileManager.setProfilePictureURL(context, recipient, profile.profilePictureURL!!) + if ((profileKeyValid && profileKeyChanged) || (profileKeyValid && needsProfilePicture)) { + profileManager.setProfileKey(context, recipient, newProfileKey!!) + profileManager.setUnidentifiedAccessMode(context, recipient, Recipient.UnidentifiedAccessMode.UNKNOWN) + profileManager.setProfilePictureURL(context, recipient, profile.profilePictureURL!!) + } } } // Parse quote if needed @@ -259,8 +276,8 @@ fun MessageReceiver.handleVisibleMessage(message: VisibleMessage, proto: SignalS } } // Parse attachments if needed - val attachments = proto.dataMessage.attachmentsList.mapNotNull { proto -> - val attachment = Attachment.fromProto(proto) + val attachments = proto.dataMessage.attachmentsList.mapNotNull { attachmentProto -> + val attachment = Attachment.fromProto(attachmentProto) if (!attachment.isValid()) { return@mapNotNull null } else { @@ -269,15 +286,11 @@ fun MessageReceiver.handleVisibleMessage(message: VisibleMessage, proto: SignalS } // Persist the message message.threadID = threadID - val messageID = storage.persist(message, quoteModel, linkPreviews, message.groupPublicKey, openGroupID, attachments) ?: throw MessageReceiver.Error.DuplicateMessage - // Parse & persist attachments - // Start attachment downloads if needed - storage.getAttachmentsForMessage(messageID).iterator().forEach { attachment -> - attachment.attachmentId?.let { id -> - val downloadJob = AttachmentDownloadJob(id.rowId, messageID) - JobQueue.shared.add(downloadJob) - } - } + val messageID = storage.persist( + message, quoteModel, linkPreviews, + message.groupPublicKey, openGroupID, + attachments, runIncrement, runThreadUpdate + ) ?: return null val openGroupServerID = message.openGroupServerMessageID if (openGroupServerID != null) { val isSms = !(message.isMediaMessage() || attachments.isNotEmpty()) @@ -285,8 +298,7 @@ fun MessageReceiver.handleVisibleMessage(message: VisibleMessage, proto: SignalS } // Cancel any typing indicators if needed cancelTypingIndicatorsIfNeeded(message.sender!!) - // Notify the user if needed - SSKEnvironment.shared.notificationManager.updateNotification(context, threadID) + return messageID } //endregion diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/DatabaseAttachment.java b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/DatabaseAttachment.java index aba155f160..8d0831adf6 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/DatabaseAttachment.java +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/DatabaseAttachment.java @@ -33,7 +33,7 @@ public class DatabaseAttachment extends Attachment { @Nullable public Uri getDataUri() { if (hasData) { - return MessagingModuleConfiguration.shared.getStorage().getAttachmentDataUri(attachmentId); + return MessagingModuleConfiguration.getShared().getStorage().getAttachmentDataUri(attachmentId); } else { return null; } @@ -43,7 +43,7 @@ public class DatabaseAttachment extends Attachment { @Nullable public Uri getThumbnailUri() { if (hasThumbnail) { - return MessagingModuleConfiguration.shared.getStorage().getAttachmentThumbnailUri(attachmentId); + return MessagingModuleConfiguration.getShared().getStorage().getAttachmentThumbnailUri(attachmentId); } else { return null; } diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/ClosedGroupPollerV2.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/ClosedGroupPollerV2.kt index df6b9bb52f..140bf6b9ed 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/ClosedGroupPollerV2.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/ClosedGroupPollerV2.kt @@ -23,7 +23,7 @@ import java.util.concurrent.TimeUnit import kotlin.math.min class ClosedGroupPollerV2 { - private val executorService = Executors.newScheduledThreadPool(4) + private val executorService = Executors.newScheduledThreadPool(1) private var isPolling = mutableMapOf() private var futures = mutableMapOf>() diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPollerV2.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPollerV2.kt index 844c796dfb..55a4db01a1 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPollerV2.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPollerV2.kt @@ -3,7 +3,13 @@ package org.session.libsession.messaging.sending_receiving.pollers import nl.komponents.kovenant.Promise import nl.komponents.kovenant.functional.map import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.messaging.jobs.* +import org.session.libsession.messaging.jobs.BatchMessageReceiveJob +import org.session.libsession.messaging.jobs.GroupAvatarDownloadJob +import org.session.libsession.messaging.jobs.JobQueue +import org.session.libsession.messaging.jobs.MessageReceiveJob +import org.session.libsession.messaging.jobs.MessageReceiveParameters +import org.session.libsession.messaging.jobs.OpenGroupDeleteJob +import org.session.libsession.messaging.jobs.TrimThreadJob import org.session.libsession.messaging.open_groups.OpenGroupAPIV2 import org.session.libsession.messaging.open_groups.OpenGroupMessageV2 import org.session.libsession.utilities.Address @@ -80,24 +86,32 @@ class OpenGroupPollerV2(private val server: String, private val executorService: JobQueue.shared.add(BatchMessageReceiveJob(parameters, openGroupID)) } + if (envelopes.isNotEmpty()) { + JobQueue.shared.add(TrimThreadJob(threadId,openGroupID)) + } + + val indicatedMax = messages.mapNotNull { it.serverID }.maxOrNull() ?: 0 val currentLastMessageServerID = storage.getLastMessageServerID(room, server) ?: 0 - val actualMax = max(messages.mapNotNull { it.serverID }.maxOrNull() ?: 0, currentLastMessageServerID) - if (actualMax > 0) { + val actualMax = max(indicatedMax, currentLastMessageServerID) + if (actualMax > 0 && indicatedMax > currentLastMessageServerID) { storage.setLastMessageServerID(room, server, actualMax) } } private fun handleDeletedMessages(room: String, openGroupID: String, deletions: List) { val storage = MessagingModuleConfiguration.shared.storage - val dataProvider = MessagingModuleConfiguration.shared.messageDataProvider val groupID = GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray()) val threadID = storage.getThreadId(Address.fromSerialized(groupID)) ?: return - val deletedMessageIDs = deletions.mapNotNull { deletion -> - dataProvider.getMessageID(deletion.deletedMessageServerID, threadID) + + val serverIds = deletions.map { deletion -> + deletion.deletedMessageServerID } - deletedMessageIDs.forEach { (messageId, isSms) -> - MessagingModuleConfiguration.shared.messageDataProvider.deleteMessage(messageId, isSms) + + if (serverIds.isNotEmpty()) { + val deleteJob = OpenGroupDeleteJob(serverIds.toLongArray(), threadID, openGroupID) + JobQueue.shared.add(deleteJob) } + val currentMax = storage.getLastDeletionServerID(room, server) ?: 0L val latestMax = deletions.map { it.id }.maxOrNull() ?: 0L if (latestMax > currentMax && latestMax != 0L) { diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt index ffd3bcff47..4a39b70c0f 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt @@ -1,7 +1,11 @@ package org.session.libsession.messaging.sending_receiving.pollers -import nl.komponents.kovenant.* +import nl.komponents.kovenant.Deferred +import nl.komponents.kovenant.Promise +import nl.komponents.kovenant.deferred import nl.komponents.kovenant.functional.bind +import nl.komponents.kovenant.resolve +import nl.komponents.kovenant.task import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.jobs.BatchMessageReceiveJob import org.session.libsession.messaging.jobs.JobQueue @@ -11,7 +15,8 @@ import org.session.libsession.snode.SnodeModule import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Snode import java.security.SecureRandom -import java.util.* +import java.util.Timer +import java.util.TimerTask private class PromiseCanceledException : Exception("Promise canceled.") @@ -23,7 +28,8 @@ class Poller { // region Settings companion object { - private val retryInterval: Long = 1 * 1000 + private const val retryInterval: Long = 2 * 1000 + private const val maxInterval: Long = 15 * 1000 } // endregion @@ -32,7 +38,7 @@ class Poller { if (hasStarted) { return } Log.d("Loki", "Started polling.") hasStarted = true - setUpPolling() + setUpPolling(retryInterval) } fun stopIfNeeded() { @@ -43,7 +49,7 @@ class Poller { // endregion // region Private API - private fun setUpPolling() { + private fun setUpPolling(delay: Long) { if (!hasStarted) { return; } val thread = Thread.currentThread() SnodeAPI.getSwarm(userPublicKey).bind { @@ -51,13 +57,20 @@ class Poller { val deferred = deferred() pollNextSnode(deferred) deferred.promise - }.always { + }.success { + val nextDelay = if (isCaughtUp) retryInterval else 0 Timer().schedule(object : TimerTask() { - override fun run() { - thread.run { setUpPolling() } + thread.run { setUpPolling(retryInterval) } } - }, retryInterval) + }, nextDelay) + }.fail { + val nextDelay = minOf(maxInterval, (delay * 1.2).toLong()) + Timer().schedule(object : TimerTask() { + override fun run() { + thread.run { setUpPolling(nextDelay) } + } + }, nextDelay) } } diff --git a/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt b/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt index e90c06ac12..76d969ae3b 100644 --- a/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt +++ b/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt @@ -140,13 +140,7 @@ object OnionRequestAPI { testSnode(candidate).success { deferred.resolve(candidate) }.fail { - getGuardSnode().success { - deferred.resolve(candidate) - }.fail { exception -> - if (exception is InsufficientSnodesException) { - deferred.reject(exception) - } - } + deferred.reject(it) } return deferred.promise } diff --git a/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt b/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt index ffc04886aa..58b722f57c 100644 --- a/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt +++ b/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt @@ -41,7 +41,7 @@ import kotlin.properties.Delegates.observable object SnodeAPI { private val sodium by lazy { LazySodiumAndroid(SodiumAndroid()) } - private val database: LokiAPIDatabaseProtocol + internal val database: LokiAPIDatabaseProtocol get() = SnodeModule.shared.storage private val broadcaster: Broadcaster get() = SnodeModule.shared.broadcaster @@ -292,7 +292,6 @@ object SnodeAPI { } fun getRawMessages(snode: Snode, publicKey: String, requiresAuth: Boolean = true, namespace: Int = 0): RawResponsePromise { - val userED25519KeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return Promise.ofFail(Error.NoKeyPair) // Get last message hash val lastHashValue = database.getLastMessageHashValue(snode, publicKey, namespace) ?: "" val parameters = mutableMapOf( @@ -301,6 +300,12 @@ object SnodeAPI { ) // Construct signature if (requiresAuth) { + val userED25519KeyPair = try { + MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return Promise.ofFail(Error.NoKeyPair) + } catch (e: Exception) { + Log.e("Loki", "Error getting KeyPair", e) + return Promise.ofFail(Error.NoKeyPair) + } val timestamp = Date().time + SnodeAPI.clockOffset val ed25519PublicKey = userED25519KeyPair.publicKey.asHexString val signature = ByteArray(Sign.BYTES) @@ -477,7 +482,7 @@ object SnodeAPI { return if (messages != null) { updateLastMessageHashValueIfPossible(snode, publicKey, messages, namespace) val newRawMessages = removeDuplicates(publicKey, messages, namespace) - return parseEnvelopes(newRawMessages); + return parseEnvelopes(newRawMessages) } else { listOf() } @@ -494,7 +499,8 @@ object SnodeAPI { } private fun removeDuplicates(publicKey: String, rawMessages: List<*>, namespace: Int): List<*> { - val receivedMessageHashValues = database.getReceivedMessageHashValues(publicKey, namespace)?.toMutableSet() ?: mutableSetOf() + val originalMessageHashValues = database.getReceivedMessageHashValues(publicKey, namespace)?.toMutableSet() ?: mutableSetOf() + val receivedMessageHashValues = originalMessageHashValues.toMutableSet() val result = rawMessages.filter { rawMessage -> val rawMessageAsJSON = rawMessage as? Map<*, *> val hashValue = rawMessageAsJSON?.get("hash") as? String @@ -507,7 +513,9 @@ object SnodeAPI { false } } - database.setReceivedMessageHashValues(publicKey, receivedMessageHashValues, namespace) + if (originalMessageHashValues != receivedMessageHashValues) { + database.setReceivedMessageHashValues(publicKey, receivedMessageHashValues, namespace) + } return result } diff --git a/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt b/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt index c52583df4a..7678c5202f 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.asSharedFlow import org.session.libsession.BuildConfig import org.session.libsession.R import org.session.libsession.utilities.TextSecurePreferences.Companion.CALL_NOTIFICATIONS_ENABLED +import org.session.libsession.utilities.TextSecurePreferences.Companion.LAST_VACUUM_TIME import org.session.libsession.utilities.TextSecurePreferences.Companion.SHOWN_CALL_NOTIFICATION import org.session.libsession.utilities.TextSecurePreferences.Companion.SHOWN_CALL_WARNING import org.session.libsignal.utilities.Log @@ -160,6 +161,8 @@ interface TextSecurePreferences { fun setShownCallWarning(): Boolean fun setShownCallNotification(): Boolean fun isCallNotificationsEnabled(): Boolean + fun getLastVacuum(): Long + fun setLastVacuumNow() fun clearAll() companion object { @@ -240,6 +243,7 @@ interface TextSecurePreferences { const val CALL_NOTIFICATIONS_ENABLED = "pref_call_notifications_enabled" const val SHOWN_CALL_WARNING = "pref_shown_call_warning" // call warning is user-facing warning of enabling calls const val SHOWN_CALL_NOTIFICATION = "pref_shown_call_notification" // call notification is a promp to check privacy settings + const val LAST_VACUUM_TIME = "pref_last_vacuum_time" @JvmStatic fun getLastConfigurationSyncTime(context: Context): Long { @@ -909,6 +913,16 @@ interface TextSecurePreferences { return previousValue != setValue } + @JvmStatic + fun getLastVacuumTime(context: Context): Long { + return getLongPreference(context, LAST_VACUUM_TIME, 0) + } + + @JvmStatic + fun setLastVacuumNow(context: Context) { + setLongPreference(context, LAST_VACUUM_TIME, System.currentTimeMillis()) + } + @JvmStatic fun clearAll(context: Context) { getDefaultSharedPreferences(context).edit().clear().commit() @@ -1469,6 +1483,14 @@ class AppTextSecurePreferences @Inject constructor( return getBooleanPreference(CALL_NOTIFICATIONS_ENABLED, false) } + override fun getLastVacuum(): Long { + return getLongPreference(LAST_VACUUM_TIME, 0) + } + + override fun setLastVacuumNow() { + setLongPreference(LAST_VACUUM_TIME, System.currentTimeMillis()) + } + override fun setShownCallNotification(): Boolean { val previousValue = getBooleanPreference(SHOWN_CALL_NOTIFICATION, false) if (previousValue) return false diff --git a/libsession/src/main/java/org/session/libsession/utilities/WindowDebouncer.kt b/libsession/src/main/java/org/session/libsession/utilities/WindowDebouncer.kt new file mode 100644 index 0000000000..c3850e64e6 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/utilities/WindowDebouncer.kt @@ -0,0 +1,31 @@ +package org.session.libsession.utilities + +import java.util.Timer +import java.util.TimerTask +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference + +/** + * Not really a 'debouncer' but named to be similar to the current Debouncer + * designed to queue tasks on a window (if not already queued) like a timer + */ +class WindowDebouncer(private val window: Long, private val timer: Timer) { + + private val atomicRef: AtomicReference = AtomicReference(null) + private val hasStarted = AtomicBoolean(false) + + private val recursiveRunnable: TimerTask = object:TimerTask() { + override fun run() { + val runnable = atomicRef.getAndSet(null) + runnable?.run() + } + } + + fun publish(runnable: Runnable) { + if (hasStarted.compareAndSet(false, true)) { + timer.scheduleAtFixedRate(recursiveRunnable, 0, window) + } + atomicRef.compareAndSet(null, runnable) + } + +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/utilities/recipients/Recipient.java b/libsession/src/main/java/org/session/libsession/utilities/recipients/Recipient.java index 823554910f..37334f855f 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/recipients/Recipient.java +++ b/libsession/src/main/java/org/session/libsession/utilities/recipients/Recipient.java @@ -302,7 +302,7 @@ public class Recipient implements RecipientModifiedListener { } public synchronized @Nullable String getName() { - StorageProtocol storage = MessagingModuleConfiguration.shared.getStorage(); + StorageProtocol storage = MessagingModuleConfiguration.getShared().getStorage(); String sessionID = this.address.toString(); if (isGroupRecipient()) { if (this.name == null) { diff --git a/libsession/src/main/java/org/session/libsession/utilities/recipients/RecipientProvider.java b/libsession/src/main/java/org/session/libsession/utilities/recipients/RecipientProvider.java index 1cad1c18bd..03c225e207 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/recipients/RecipientProvider.java +++ b/libsession/src/main/java/org/session/libsession/utilities/recipients/RecipientProvider.java @@ -109,7 +109,7 @@ class RecipientProvider { private @NonNull RecipientDetails getIndividualRecipientDetails(Context context, @NonNull Address address, Optional settings) { if (!settings.isPresent()) { - settings = Optional.fromNullable(MessagingModuleConfiguration.shared.getStorage().getRecipientSettings(address)); + settings = Optional.fromNullable(MessagingModuleConfiguration.getShared().getStorage().getRecipientSettings(address)); } boolean systemContact = settings.isPresent() && !TextUtils.isEmpty(settings.get().getSystemDisplayName()); @@ -120,12 +120,12 @@ class RecipientProvider { private @NonNull RecipientDetails getGroupRecipientDetails(Context context, Address groupId, Optional groupRecord, Optional settings, boolean asynchronous) { if (!groupRecord.isPresent()) { - groupRecord = Optional.fromNullable(MessagingModuleConfiguration.shared.getStorage().getGroup(groupId.toGroupString())); + groupRecord = Optional.fromNullable(MessagingModuleConfiguration.getShared().getStorage().getGroup(groupId.toGroupString())); } if (!settings.isPresent()) { - settings = Optional.fromNullable(MessagingModuleConfiguration.shared.getStorage().getRecipientSettings(groupId)); + settings = Optional.fromNullable(MessagingModuleConfiguration.getShared().getStorage().getRecipientSettings(groupId)); } if (groupRecord.isPresent()) { diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/HTTP.kt b/libsignal/src/main/java/org/session/libsignal/utilities/HTTP.kt index 8937bee708..688dcbab74 100644 --- a/libsignal/src/main/java/org/session/libsignal/utilities/HTTP.kt +++ b/libsignal/src/main/java/org/session/libsignal/utilities/HTTP.kt @@ -1,7 +1,10 @@ package org.session.libsignal.utilities -import okhttp3.* -import java.lang.IllegalStateException +import okhttp3.MediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.Response import java.security.SecureRandom import java.security.cert.X509Certificate import java.util.concurrent.TimeUnit @@ -12,6 +15,7 @@ object HTTP { private val seedNodeConnection by lazy { OkHttpClient().newBuilder() + .callTimeout(timeout, TimeUnit.SECONDS) .connectTimeout(timeout, TimeUnit.SECONDS) .readTimeout(timeout, TimeUnit.SECONDS) .writeTimeout(timeout, TimeUnit.SECONDS) @@ -31,6 +35,7 @@ object HTTP { OkHttpClient().newBuilder() .sslSocketFactory(sslContext.socketFactory, trustManager) .hostnameVerifier { _, _ -> true } + .callTimeout(timeout, TimeUnit.SECONDS) .connectTimeout(timeout, TimeUnit.SECONDS) .readTimeout(timeout, TimeUnit.SECONDS) .writeTimeout(timeout, TimeUnit.SECONDS) @@ -50,13 +55,14 @@ object HTTP { return OkHttpClient().newBuilder() .sslSocketFactory(sslContext.socketFactory, trustManager) .hostnameVerifier { _, _ -> true } + .callTimeout(timeout, TimeUnit.SECONDS) .connectTimeout(timeout, TimeUnit.SECONDS) .readTimeout(timeout, TimeUnit.SECONDS) .writeTimeout(timeout, TimeUnit.SECONDS) .build() } - private const val timeout: Long = 10 + private const val timeout: Long = 120 class HTTPRequestFailedException(val statusCode: Int, val json: Map<*, *>?) : kotlin.Exception("HTTP request failed with status code $statusCode.") diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/ThreadUtils.kt b/libsignal/src/main/java/org/session/libsignal/utilities/ThreadUtils.kt index 9436a54832..0700d01f66 100644 --- a/libsignal/src/main/java/org/session/libsignal/utilities/ThreadUtils.kt +++ b/libsignal/src/main/java/org/session/libsignal/utilities/ThreadUtils.kt @@ -1,9 +1,14 @@ package org.session.libsignal.utilities -import java.util.concurrent.* +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit object ThreadUtils { - val executorPool = Executors.newCachedThreadPool() + + val executorPool: ExecutorService = Executors.newCachedThreadPool() @JvmStatic fun queue(target: Runnable) {