diff --git a/app/build.gradle b/app/build.gradle index e97a7436ae..e568b689cb 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -31,8 +31,8 @@ configurations.all { exclude module: "commons-logging" } -def canonicalVersionCode = 371 -def canonicalVersionName = "1.18.2" +def canonicalVersionCode = 373 +def canonicalVersionName = "1.18.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 5b4ef790bb..3708915700 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -57,6 +57,7 @@ import org.signal.aesgcmprovider.AesGcmProvider; import org.thoughtcrime.securesms.components.TypingStatusSender; import org.thoughtcrime.securesms.crypto.KeyPairUtilities; import org.thoughtcrime.securesms.database.EmojiSearchDatabase; +import org.thoughtcrime.securesms.database.LastSentTimestampCache; import org.thoughtcrime.securesms.database.LokiAPIDatabase; import org.thoughtcrime.securesms.database.Storage; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; @@ -149,6 +150,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO @Inject TextSecurePreferences textSecurePreferences; @Inject PushRegistry pushRegistry; @Inject ConfigFactory configFactory; + @Inject LastSentTimestampCache lastSentTimestampCache; CallMessageProcessor callMessageProcessor; MessagingModuleConfiguration messagingModuleConfiguration; @@ -221,7 +223,8 @@ public class ApplicationContext extends Application implements DefaultLifecycleO device, messageDataProvider, ()-> KeyPairUtilities.INSTANCE.getUserED25519KeyPair(this), - configFactory + configFactory, + lastSentTimestampCache ); callMessageProcessor = new CallMessageProcessor(this, textSecurePreferences, ProcessLifecycleOwner.get().getLifecycle(), storage); Log.i(TAG, "onCreate()"); diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java index b74638fec7..2e67becbfd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java @@ -21,6 +21,7 @@ import android.annotation.TargetApi; import android.content.Context; import android.content.Intent; import android.database.Cursor; +import android.database.CursorIndexOutOfBoundsException; import android.net.Uri; import android.os.AsyncTask; import android.os.Build; @@ -145,6 +146,7 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im View.SYSTEM_UI_FLAG_HIDE_NAVIGATION); } }; + private MediaItemAdapter adapter; public static Intent getPreviewIntent(Context context, MediaPreviewArgs args) { return getPreviewIntent(context, args.getSlide(), args.getMmsRecord(), args.getThread()); @@ -217,13 +219,6 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); } - @TargetApi(VERSION_CODES.JELLY_BEAN) - private void setFullscreenIfPossible() { - if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN) { - getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_FULLSCREEN); - } - } - @Override public void onModified(Recipient recipient) { Util.runOnMain(this::updateActionBar); @@ -285,9 +280,6 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im mediaPager = findViewById(R.id.media_pager); mediaPager.setOffscreenPageLimit(1); - viewPagerListener = new ViewPagerListener(); - mediaPager.addOnPageChangeListener(viewPagerListener); - albumRail = findViewById(R.id.media_preview_album_rail); albumRailAdapter = new MediaRailAdapter(GlideApp.with(this), this, false); @@ -378,7 +370,8 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im if (conversationRecipient != null) { getSupportLoaderManager().restartLoader(0, null, this); } else { - mediaPager.setAdapter(new SingleItemPagerAdapter(this, GlideApp.with(this), getWindow(), initialMediaUri, initialMediaType, initialMediaSize)); + adapter = new SingleItemPagerAdapter(this, GlideApp.with(this), getWindow(), initialMediaUri, initialMediaType, initialMediaSize); + mediaPager.setAdapter(adapter); if (initialCaption != null) { detailsContainer.setVisibility(View.VISIBLE); @@ -506,13 +499,8 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im } private @Nullable MediaItem getCurrentMediaItem() { - MediaItemAdapter adapter = (MediaItemAdapter)mediaPager.getAdapter(); - - if (adapter != null) { - return adapter.getMediaItemFor(mediaPager.getCurrentItem()); - } else { - return null; - } + if (adapter == null) return null; + return adapter.getMediaItemFor(mediaPager.getCurrentItem()); } public static boolean isContentTypeSupported(final String contentType) { @@ -526,23 +514,28 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im @Override public void onLoadFinished(@NonNull Loader> loader, @Nullable Pair data) { - if (data != null) { - CursorPagerAdapter adapter = new CursorPagerAdapter(this, GlideApp.with(this), getWindow(), data.first, data.second, leftIsRecent); - mediaPager.setAdapter(adapter); - adapter.setActive(true); + if (data == null) return; - viewModel.setCursor(this, data.first, leftIsRecent); + mediaPager.removeOnPageChangeListener(viewPagerListener); - if (restartItem >= 0 || data.second >= 0) { - int item = restartItem >= 0 ? restartItem : data.second; - mediaPager.setCurrentItem(item); + adapter = new CursorPagerAdapter(this, GlideApp.with(this), getWindow(), data.first, data.second, leftIsRecent); + mediaPager.setAdapter(adapter); - if (item == 0) { - viewPagerListener.onPageSelected(0); - } - } else { - Log.w(TAG, "one of restartItem "+restartItem+" and data.second "+data.second+" would cause OOB exception"); - } + viewModel.setCursor(this, data.first, leftIsRecent); + + int item = restartItem >= 0 && restartItem < adapter.getCount() ? restartItem : Math.max(Math.min(data.second, adapter.getCount() - 1), 0); + + viewPagerListener = new ViewPagerListener(); + mediaPager.addOnPageChangeListener(viewPagerListener); + + try { + mediaPager.setCurrentItem(item); + } catch (CursorIndexOutOfBoundsException e) { + throw new RuntimeException("restartItem = " + restartItem + ", data.second = " + data.second + " leftIsRecent = " + leftIsRecent, e); + } + + if (item == 0) { + viewPagerListener.onPageSelected(0); } } @@ -560,26 +553,26 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im if (currentPage != -1 && currentPage != position) onPageUnselected(currentPage); currentPage = position; - MediaItemAdapter adapter = (MediaItemAdapter)mediaPager.getAdapter(); + if (adapter == null) return; - if (adapter != null) { - MediaItem item = adapter.getMediaItemFor(position); - if (item.recipient != null) item.recipient.addListener(MediaPreviewActivity.this); - viewModel.setActiveAlbumRailItem(MediaPreviewActivity.this, position); - updateActionBar(); - } + MediaItem item = adapter.getMediaItemFor(position); + if (item.recipient != null) item.recipient.addListener(MediaPreviewActivity.this); + viewModel.setActiveAlbumRailItem(MediaPreviewActivity.this, position); + updateActionBar(); } public void onPageUnselected(int position) { - MediaItemAdapter adapter = (MediaItemAdapter)mediaPager.getAdapter(); + if (adapter == null) return; - if (adapter != null) { + try { MediaItem item = adapter.getMediaItemFor(position); if (item.recipient != null) item.recipient.removeListener(MediaPreviewActivity.this); - - adapter.pause(position); + } catch (CursorIndexOutOfBoundsException e) { + throw new RuntimeException("position = " + position + " leftIsRecent = " + leftIsRecent, e); } + + adapter.pause(position); } @Override @@ -593,7 +586,7 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im } } - private static class SingleItemPagerAdapter extends PagerAdapter implements MediaItemAdapter { + private static class SingleItemPagerAdapter extends MediaItemAdapter { private final GlideRequests glideRequests; private final Window window; @@ -665,7 +658,7 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im } } - private static class CursorPagerAdapter extends PagerAdapter implements MediaItemAdapter { + private static class CursorPagerAdapter extends MediaItemAdapter { private final WeakHashMap mediaViews = new WeakHashMap<>(); @@ -675,7 +668,6 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im private final Cursor cursor; private final boolean leftIsRecent; - private boolean active; private int autoPlayPosition; CursorPagerAdapter(@NonNull Context context, @NonNull GlideRequests glideRequests, @@ -690,15 +682,9 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im this.leftIsRecent = leftIsRecent; } - public void setActive(boolean active) { - this.active = active; - notifyDataSetChanged(); - } - @Override public int getCount() { - if (!active) return 0; - else return cursor.getCount(); + return cursor.getCount(); } @Override @@ -771,8 +757,8 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im } private int getCursorPosition(int position) { - if (leftIsRecent) return position; - else return cursor.getCount() - 1 - position; + int unclamped = leftIsRecent ? position : cursor.getCount() - 1 - position; + return Math.max(Math.min(unclamped, cursor.getCount() - 1), 0); } } @@ -800,9 +786,9 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im } } - interface MediaItemAdapter { - MediaItem getMediaItemFor(int position); - void pause(int position); - @Nullable View getPlaybackControls(int position); + abstract static class MediaItemAdapter extends PagerAdapter { + abstract MediaItem getMediaItemFor(int position); + abstract void pause(int position); + @Nullable abstract View getPlaybackControls(int position); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentServer.java b/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentServer.java index e186007ee3..176a8c290f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentServer.java +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentServer.java @@ -50,7 +50,7 @@ public class AttachmentServer implements Runnable { throws IOException { try { - this.context = context; + this.context = context.getApplicationContext(); this.attachment = attachment; this.socket = new ServerSocket(0, 0, InetAddress.getByAddress(new byte[]{127, 0, 0, 1})); this.port = socket.getLocalPort(); 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 20dc2bbade..6445abed3b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt @@ -5,6 +5,8 @@ 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.MessagingModuleConfiguration +import org.session.libsession.messaging.messages.control.UnsendRequest 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 @@ -184,10 +186,15 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper) override fun deleteMessage(messageID: Long, isSms: Boolean) { val messagingDatabase: MessagingDatabase = if (isSms) DatabaseComponent.get(context).smsDatabase() else DatabaseComponent.get(context).mmsDatabase() + val (threadId, timestamp) = runCatching { messagingDatabase.getMessageRecord(messageID).run { threadId to timestamp } }.getOrNull() ?: (null to null) messagingDatabase.deleteMessage(messageID) DatabaseComponent.get(context).lokiMessageDatabase().deleteMessage(messageID, isSms) DatabaseComponent.get(context).lokiMessageDatabase().deleteMessageServerHash(messageID, mms = !isSms) + + threadId ?: return + timestamp ?: return + MessagingModuleConfiguration.shared.lastSentTimestampCache.delete(threadId, timestamp) } override fun deleteMessages(messageIDs: List, threadId: Long, isSms: Boolean) { @@ -195,12 +202,17 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper) val messagingDatabase: MessagingDatabase = if (isSms) DatabaseComponent.get(context).smsDatabase() else DatabaseComponent.get(context).mmsDatabase() + val messages = messageIDs.mapNotNull { runCatching { messagingDatabase.getMessageRecord(it) }.getOrNull() } + // Perform local delete messagingDatabase.deleteMessages(messageIDs.toLongArray(), threadId) // Perform online delete DatabaseComponent.get(context).lokiMessageDatabase().deleteMessages(messageIDs) DatabaseComponent.get(context).lokiMessageDatabase().deleteMessageServerHashes(messageIDs, mms = !isSms) + + val threadId = messages.firstOrNull()?.threadId + threadId?.let{ MessagingModuleConfiguration.shared.lastSentTimestampCache.delete(it, messages.map { it.timestamp }) } } override fun updateMessageAsDeleted(timestamp: Long, author: String): Long? { diff --git a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java index 35cbf16b63..fd265337f9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java @@ -45,7 +45,8 @@ public class AudioRecorder { Log.i(TAG, "Running startRecording() + " + Thread.currentThread().getId()); try { if (audioCodec != null) { - throw new AssertionError("We can only record once at a time."); + Log.e(TAG, "Trying to start recording while another recording is in progress, exiting..."); + return; } ParcelFileDescriptor fds[] = ParcelFileDescriptor.createPipe(); 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 9a5eb730de..52e2d52ab1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt @@ -122,7 +122,7 @@ class ProfilePictureView @JvmOverloads constructor( glide.clear(imageView) - val placeholder = PlaceholderAvatarPhoto(context, publicKey, displayName ?: "${publicKey.take(4)}...${publicKey.takeLast(4)}") + val placeholder = PlaceholderAvatarPhoto(publicKey, displayName ?: "${publicKey.take(4)}...${publicKey.takeLast(4)}") if (signalProfilePicture != null && avatar != "0" && avatar != "") { glide.load(signalProfilePicture) 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 b2f7e2341d..32a120ef82 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 @@ -288,8 +288,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe if (hexEncodedSeed == null) { hexEncodedSeed = IdentityKeyUtil.getIdentityKeyPair(this).hexEncodedPrivateKey // Legacy account } + + val appContext = applicationContext val loadFileContents: (String) -> String = { fileName -> - MnemonicUtilities.loadFileContents(this, fileName) + MnemonicUtilities.loadFileContents(appContext, fileName) } MnemonicCodec(loadFileContents).encode(hexEncodedSeed!!, MnemonicCodec.Language.Configuration.english) } @@ -326,7 +328,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } }, onAttachmentNeedsDownload = { attachmentId, mmsId -> - // Start download (on IO thread) lifecycleScope.launch(Dispatchers.IO) { JobQueue.shared.add(AttachmentDownloadJob(attachmentId, mmsId)) } @@ -336,8 +337,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe ) adapter.visibleMessageViewDelegate = this - // Register an AdapterDataObserver to scroll us to the bottom of the RecyclerView if we're - // already near the the bottom and the data changes. + // Register an AdapterDataObserver to scroll us to the bottom of the RecyclerView for if + // we're already near the the bottom and the data changes. adapter.registerAdapterDataObserver(ConversationAdapterDataObserver(binding?.conversationRecyclerView!!, adapter)) adapter @@ -375,7 +376,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe const val PICK_GIF = 10 const val PICK_FROM_LIBRARY = 12 const val INVITE_CONTACTS = 124 - } // endregion @@ -573,7 +573,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe binding!!.conversationRecyclerView.layoutManager = layoutManager // Workaround for the fact that CursorRecyclerViewAdapter doesn't auto-update automatically (even though it says it will) LoaderManager.getInstance(this).restartLoader(0, null, this) - binding!!.conversationRecyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + binding!!.conversationRecyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { // The unreadCount check is to prevent us scrolling to the bottom when we first enter a conversation @@ -831,6 +831,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe override fun onDestroy() { viewModel.saveDraft(binding?.inputBar?.text?.trim() ?: "") + cancelVoiceMessage() tearDownRecipientObserver() super.onDestroy() binding = null @@ -1019,7 +1020,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } override fun showVoiceMessageUI() { - binding?.inputBarRecordingView?.show() + binding?.inputBarRecordingView?.show(lifecycleScope) binding?.inputBar?.alpha = 0.0f val animation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f) animation.duration = 250L @@ -1881,7 +1882,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe Log.w("ConversationActivityV2", "Asked to delete messages but could not obtain viewModel recipient - aborting.") return } - + val allSentByCurrentUser = messages.all { it.isOutgoing } val allHasHash = messages.all { lokiMessageDb.getMessageServerHash(it.id, it.isMms) != null } 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 371df34565..7b01eba71e 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 @@ -209,20 +209,6 @@ class ConversationAdapter( return messageDB.readerFor(cursor).current } - private fun getLastSentMessageId(cursor: Cursor): Long { - // If we don't move to first (or at least step backwards) we can step off the end of the - // cursor and any query will return an "Index = -1" error. - val cursorHasContent = cursor.moveToFirst() - if (cursorHasContent) { - val thisThreadId = cursor.getLong(4) // Column index 4 is "thread_id" - if (thisThreadId != -1L) { - val thisUsersSessionId = TextSecurePreferences.getLocalNumber(context) - return messageDB.getLastSentMessageFromSender(thisThreadId, thisUsersSessionId) - } - } - return -1L - } - override fun changeCursor(cursor: Cursor?) { super.changeCursor(cursor) @@ -243,11 +229,6 @@ class ConversationAdapter( toDeselect.iterator().forEach { (pos, record) -> onDeselect(record, pos) } - - // This value gets updated here ONLY when the cursor changes, and the value is then passed - // through to `VisibleMessageView.bind` each time we bind via `onBindItemViewHolder`, above. - // If there are no messages then lastSentMessageId is assigned the value -1L. - if (cursor != null) { lastSentMessageId = getLastSentMessageId(cursor) } } fun findLastSeenItemPosition(lastSeenTimestamp: Long): Int? { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationLoader.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationLoader.kt index 4692bf7862..2ac613bf66 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationLoader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationLoader.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.conversation.v2 import android.content.Context import android.database.Cursor +import org.session.libsession.messaging.MessagingModuleConfiguration import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.util.AbstractCursorLoader @@ -12,6 +13,7 @@ class ConversationLoader( ) : AbstractCursorLoader(context) { override fun getCursor(): Cursor { + MessagingModuleConfiguration.shared.lastSentTimestampCache.refresh(threadID) return DatabaseComponent.get(context).mmsSmsDatabase().getConversation(threadID, reverse) } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt index ec45b6ca82..6d7281dc47 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt @@ -4,8 +4,6 @@ import android.animation.FloatEvaluator import android.animation.IntEvaluator import android.animation.ValueAnimator import android.content.Context -import android.os.Handler -import android.os.Looper import android.util.AttributeSet import android.view.LayoutInflater import android.widget.ImageView @@ -14,6 +12,11 @@ import android.widget.RelativeLayout import android.widget.TextView import androidx.core.content.res.ResourcesCompat import androidx.core.view.isVisible +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import network.loki.messenger.R import network.loki.messenger.databinding.ViewInputBarRecordingBinding import org.thoughtcrime.securesms.util.DateUtils @@ -25,10 +28,10 @@ import java.util.Date class InputBarRecordingView : RelativeLayout { private lateinit var binding: ViewInputBarRecordingBinding private var startTimestamp = 0L - private val snHandler = Handler(Looper.getMainLooper()) private var dotViewAnimation: ValueAnimator? = null private var pulseAnimation: ValueAnimator? = null var delegate: InputBarRecordingViewDelegate? = null + private var timerJob: Job? = null val lockView: LinearLayout get() = binding.lockView @@ -50,9 +53,10 @@ class InputBarRecordingView : RelativeLayout { binding = ViewInputBarRecordingBinding.inflate(LayoutInflater.from(context), this, true) binding.inputBarMiddleContentContainer.disableClipping() binding.inputBarCancelButton.setOnClickListener { hide() } + } - fun show() { + fun show(scope: CoroutineScope) { startTimestamp = Date().time binding.recordButtonOverlayImageView.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_microphone, context.theme)) binding.inputBarCancelButton.alpha = 0.0f @@ -69,7 +73,7 @@ class InputBarRecordingView : RelativeLayout { animateDotView() pulse() animateLockViewUp() - updateTimer() + startTimer(scope) } fun hide() { @@ -86,6 +90,24 @@ class InputBarRecordingView : RelativeLayout { } animation.start() delegate?.handleVoiceMessageUIHidden() + stopTimer() + } + + private fun startTimer(scope: CoroutineScope) { + timerJob?.cancel() + timerJob = scope.launch { + while (isActive) { + val duration = (Date().time - startTimestamp) / 1000L + binding.recordingViewDurationTextView.text = DateUtils.formatElapsedTime(duration) + + delay(500) + } + } + } + + private fun stopTimer() { + timerJob?.cancel() + timerJob = null } private fun animateDotView() { @@ -129,12 +151,6 @@ class InputBarRecordingView : RelativeLayout { animation.start() } - private fun updateTimer() { - val duration = (Date().time - startTimestamp) / 1000L - binding.recordingViewDurationTextView.text = DateUtils.formatElapsedTime(duration) - snHandler.postDelayed({ updateTimer() }, 500) - } - fun lock() { val fadeOutAnimation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f) fadeOutAnimation.duration = 250L 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 4e8a079024..64017e2ad9 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 @@ -22,7 +22,6 @@ import androidx.appcompat.app.AppCompatActivity import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.ContextCompat import androidx.core.os.bundleOf -import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.marginBottom import dagger.hilt.android.AndroidEntryPoint @@ -39,6 +38,7 @@ import org.session.libsession.utilities.modifyLayoutParams import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 +import org.thoughtcrime.securesms.database.LastSentTimestampCache import org.thoughtcrime.securesms.database.LokiAPIDatabase import org.thoughtcrime.securesms.database.LokiThreadDatabase import org.thoughtcrime.securesms.database.MmsDatabase @@ -73,6 +73,7 @@ class VisibleMessageView : LinearLayout { @Inject lateinit var mmsSmsDb: MmsSmsDatabase @Inject lateinit var smsDb: SmsDatabase @Inject lateinit var mmsDb: MmsDatabase + @Inject lateinit var lastSentTimestampCache: LastSentTimestampCache private val binding by lazy { ViewVisibleMessageBinding.bind(this) } private val swipeToReplyIcon = ContextCompat.getDrawable(context, R.drawable.ic_baseline_reply_24)!!.mutate() @@ -303,10 +304,6 @@ class VisibleMessageView : LinearLayout { // --- If we got here then we know the message is outgoing --- - val thisUsersSessionId = TextSecurePreferences.getLocalNumber(context) - val lastSentMessageId = mmsSmsDb.getLastSentMessageFromSender(message.threadId, thisUsersSessionId) - val isLastSentMessage = lastSentMessageId == message.id - // ----- Case ii.) Message is outgoing but NOT scheduled to disappear ----- if (!scheduledToDisappear) { // If this isn't a disappearing message then we never show the timer @@ -319,9 +316,11 @@ class VisibleMessageView : LinearLayout { } else { // ..but if the message HAS been successfully sent or read then only display the delivery status // text and image if this is the last sent message. - binding.messageStatusTextView.isVisible = isLastSentMessage - binding.messageStatusImageView.isVisible = isLastSentMessage - if (isLastSentMessage) { binding.messageStatusImageView.bringToFront() } + val lastSentTimestamp = lastSentTimestampCache.getTimestamp(message.threadId) + val isLastSent = lastSentTimestamp == message.timestamp + binding.messageStatusTextView.isVisible = isLastSent + binding.messageStatusImageView.isVisible = isLastSent + if (isLastSent) { binding.messageStatusImageView.bringToFront() } } } else // ----- Case iii.) Message is outgoing AND scheduled to disappear ----- diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ResendMessageUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ResendMessageUtilities.kt index e01a75b30c..c1d6987904 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ResendMessageUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ResendMessageUtilities.kt @@ -40,18 +40,16 @@ object ResendMessageUtilities { message.recipient = messageRecord.recipient.address.serialize() } message.threadID = messageRecord.threadId - if (messageRecord.isMms) { - val mmsMessageRecord = messageRecord as MmsMessageRecord - if (mmsMessageRecord.linkPreviews.isNotEmpty()) { - message.linkPreview = LinkPreview.from(mmsMessageRecord.linkPreviews[0]) - } - if (mmsMessageRecord.quote != null) { - message.quote = Quote.from(mmsMessageRecord.quote!!.quoteModel) - if (userBlindedKey != null && messageRecord.quote!!.author.serialize() == TextSecurePreferences.getLocalNumber(context)) { - message.quote!!.publicKey = userBlindedKey + if (messageRecord.isMms && messageRecord is MmsMessageRecord) { + messageRecord.linkPreviews.firstOrNull()?.let { message.linkPreview = LinkPreview.from(it) } + messageRecord.quote?.quoteModel?.let { + message.quote = Quote.from(it)?.apply { + if (userBlindedKey != null && publicKey == TextSecurePreferences.getLocalNumber(context)) { + publicKey = userBlindedKey + } } } - message.addSignalAttachments(mmsMessageRecord.slideDeck.asAttachments()) + message.addSignalAttachments(messageRecord.slideDeck.asAttachments()) } val sentTimestamp = message.sentTimestamp val sender = MessagingModuleConfiguration.shared.storage.getUserPublicKey() diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/KeyPairUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/crypto/KeyPairUtilities.kt index 652732f081..f4887e1adb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/crypto/KeyPairUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/KeyPairUtilities.kt @@ -1,10 +1,9 @@ package org.thoughtcrime.securesms.crypto import android.content.Context -import com.goterl.lazysodium.LazySodiumAndroid -import com.goterl.lazysodium.SodiumAndroid import com.goterl.lazysodium.utils.Key import com.goterl.lazysodium.utils.KeyPair +import org.session.libsession.messaging.utilities.SodiumUtilities.sodium import org.session.libsignal.crypto.ecc.DjbECPrivateKey import org.session.libsignal.crypto.ecc.DjbECPublicKey import org.session.libsignal.crypto.ecc.ECKeyPair @@ -13,8 +12,6 @@ import org.session.libsignal.utilities.Hex object KeyPairUtilities { - private val sodium by lazy { LazySodiumAndroid(SodiumAndroid()) } - fun generate(): KeyPairGenerationResult { val seed = sodium.randomBytesBuf(16) try { 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 84e1b9b20a..5620814190 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ConversationNotificationDebouncer.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ConversationNotificationDebouncer.kt @@ -5,9 +5,9 @@ import android.content.Context import org.session.libsession.utilities.Debouncer import org.thoughtcrime.securesms.ApplicationContext -class ConversationNotificationDebouncer(private val context: Context) { +class ConversationNotificationDebouncer(private val context: ApplicationContext) { private val threadIDs = mutableSetOf() - private val handler = (context.applicationContext as ApplicationContext).conversationListNotificationHandler + private val handler = context.conversationListNotificationHandler private val debouncer = Debouncer(handler, 100) companion object { @@ -17,20 +17,28 @@ class ConversationNotificationDebouncer(private val context: Context) { @Synchronized fun get(context: Context): ConversationNotificationDebouncer { if (::shared.isInitialized) { return shared } - shared = ConversationNotificationDebouncer(context) + shared = ConversationNotificationDebouncer(context.applicationContext as ApplicationContext) return shared } } fun notify(threadID: Long) { - threadIDs.add(threadID) + synchronized(threadIDs) { + threadIDs.add(threadID) + } + debouncer.publish { publish() } } private fun publish() { - for (threadID in threadIDs.toList()) { + val toNotify = synchronized(threadIDs) { + val copy = threadIDs.toList() + threadIDs.clear() + copy + } + + for (threadID in toNotify) { context.contentResolver.notifyChange(DatabaseContentProviders.Conversation.getUriForThread(threadID), null) } - threadIDs.clear() } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/LastSentTimestampCache.kt b/app/src/main/java/org/thoughtcrime/securesms/database/LastSentTimestampCache.kt new file mode 100644 index 0000000000..46ada7aa9a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/LastSentTimestampCache.kt @@ -0,0 +1,38 @@ +package org.thoughtcrime.securesms.database + +import org.session.libsession.messaging.LastSentTimestampCache +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class LastSentTimestampCache @Inject constructor( + val mmsSmsDatabase: MmsSmsDatabase +): LastSentTimestampCache { + + private val map = mutableMapOf() + + @Synchronized + override fun getTimestamp(threadId: Long): Long? = map[threadId] + + @Synchronized + override fun submitTimestamp(threadId: Long, timestamp: Long) { + if (map[threadId]?.let { timestamp <= it } == true) return + + map[threadId] = timestamp + } + + @Synchronized + override fun delete(threadId: Long, timestamps: List) { + if (map[threadId]?.let { it !in timestamps } == true) return + map.remove(threadId) + refresh(threadId) + } + + @Synchronized + override fun refresh(threadId: Long) { + if (map[threadId]?.let { it > 0 } == true) return + val lastOutgoingTimestamp = mmsSmsDatabase.getLastOutgoingTimestamp(threadId) + if (lastOutgoingTimestamp <= 0) return + map[threadId] = lastOutgoingTimestamp + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt index f2fcefd0aa..5648cdace1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt @@ -1147,13 +1147,9 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa } } - fun readerFor(cursor: Cursor?): Reader { - return Reader(cursor) - } + fun readerFor(cursor: Cursor?, getQuote: Boolean = true) = Reader(cursor, getQuote) - fun readerFor(message: OutgoingMediaMessage?, threadId: Long): OutgoingMessageReader { - return OutgoingMessageReader(message, threadId) - } + fun readerFor(message: OutgoingMediaMessage?, threadId: Long) = OutgoingMessageReader(message, threadId) fun setQuoteMissing(messageId: Long): Int { val contentValues = ContentValues() @@ -1217,7 +1213,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa } - inner class Reader(private val cursor: Cursor?) : Closeable { + inner class Reader(private val cursor: Cursor?, private val getQuote: Boolean = true) : Closeable { val next: MessageRecord? get() = if (cursor == null || !cursor.moveToNext()) null else current val current: MessageRecord @@ -1226,7 +1222,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa return if (mmsType == PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND.toLong()) { getNotificationMmsMessageRecord(cursor) } else { - getMediaMmsMessageRecord(cursor) + getMediaMmsMessageRecord(cursor, getQuote) } } @@ -1253,20 +1249,10 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa DELIVERY_RECEIPT_COUNT ) ) - var readReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(READ_RECEIPT_COUNT)) - val subscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(SUBSCRIPTION_ID)) + val readReceiptCount = if (isReadReceiptsEnabled(context)) cursor.getInt(cursor.getColumnIndexOrThrow(READ_RECEIPT_COUNT)) else 0 val hasMention = (cursor.getInt(cursor.getColumnIndexOrThrow(HAS_MENTION)) == 1) - if (!isReadReceiptsEnabled(context)) { - readReceiptCount = 0 - } - var contentLocationBytes: ByteArray? = null - var transactionIdBytes: ByteArray? = null - if (!contentLocation.isNullOrEmpty()) contentLocationBytes = toIsoBytes( - contentLocation - ) - if (!transactionId.isNullOrEmpty()) transactionIdBytes = toIsoBytes( - transactionId - ) + val contentLocationBytes: ByteArray? = contentLocation?.takeUnless { it.isEmpty() }?.let(::toIsoBytes) + val transactionIdBytes: ByteArray? = transactionId?.takeUnless { it.isEmpty() }?.let(::toIsoBytes) val slideDeck = SlideDeck(context, MmsNotificationAttachment(status, messageSize)) return NotificationMmsMessageRecord( id, recipient, recipient, @@ -1277,7 +1263,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa ) } - private fun getMediaMmsMessageRecord(cursor: Cursor): MediaMmsMessageRecord { + private fun getMediaMmsMessageRecord(cursor: Cursor, getQuote: Boolean): MediaMmsMessageRecord { val id = cursor.getLong(cursor.getColumnIndexOrThrow(ID)) val dateSent = cursor.getLong(cursor.getColumnIndexOrThrow(NORMALIZED_DATE_SENT)) val dateReceived = cursor.getLong( @@ -1328,7 +1314,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa .filterNot { o: DatabaseAttachment? -> o in contactAttachments } .filterNot { o: DatabaseAttachment? -> o in previewAttachments } ) - val quote = getQuote(cursor) + val quote = if (getQuote) getQuote(cursor) else null val reactions = get(context).reactionDatabase().getReactions(cursor) return MediaMmsMessageRecord( id, recipient, recipient, @@ -1381,7 +1367,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa val quoteId = cursor.getLong(cursor.getColumnIndexOrThrow(QUOTE_ID)) val quoteAuthor = cursor.getString(cursor.getColumnIndexOrThrow(QUOTE_AUTHOR)) if (quoteId == 0L || quoteAuthor.isNullOrBlank()) return null - val retrievedQuote = get(context).mmsSmsDatabase().getMessageFor(quoteId, quoteAuthor) + val retrievedQuote = get(context).mmsSmsDatabase().getMessageFor(quoteId, quoteAuthor, false) val quoteText = retrievedQuote?.body val quoteMissing = retrievedQuote == null val quoteDeck = ( diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java index 1e1cc50896..e6bc04e364 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java @@ -9,7 +9,11 @@ public interface MmsSmsColumns { public static final String THREAD_ID = "thread_id"; public static final String READ = "read"; public static final String BODY = "body"; + + // This is the address of the message recipient, which may be a single user, a group, or a community! + // It is NOT the address of the sender of any given message! public static final String ADDRESS = "address"; + public static final String ADDRESS_DEVICE_ID = "address_device_id"; public static final String DELIVERY_RECEIPT_COUNT = "delivery_receipt_count"; public static final String READ_RECEIPT_COUNT = "read_receipt_count"; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java index da4f39f0c1..b737be855e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java @@ -97,9 +97,13 @@ public class MmsSmsDatabase extends Database { } public @Nullable MessageRecord getMessageFor(long timestamp, String serializedAuthor) { + return getMessageFor(timestamp, serializedAuthor, true); + } + + public @Nullable MessageRecord getMessageFor(long timestamp, String serializedAuthor, boolean getQuote) { try (Cursor cursor = queryTables(PROJECTION, MmsSmsColumns.NORMALIZED_DATE_SENT + " = " + timestamp, null, null)) { - MmsSmsDatabase.Reader reader = readerFor(cursor); + MmsSmsDatabase.Reader reader = readerFor(cursor, getQuote); MessageRecord messageRecord; boolean isOwnNumber = Util.isOwnNumber(context, serializedAuthor); @@ -295,15 +299,7 @@ public class MmsSmsDatabase extends Database { return identifiedMessages; } - public long getLastSentMessageFromSender(long threadId, String serializedAuthor) { - - // Early exit - boolean isOwnNumber = Util.isOwnNumber(context, serializedAuthor); - if (!isOwnNumber) { - Log.i(TAG, "Asked to find last sent message but sender isn't us - returning null."); - return -1; - } - + public long getLastOutgoingTimestamp(long threadId) { String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; @@ -311,8 +307,13 @@ public class MmsSmsDatabase extends Database { try (Cursor cursor = queryTables(PROJECTION, selection, order, null)) { try (MmsSmsDatabase.Reader reader = readerFor(cursor)) { MessageRecord messageRecord; + long attempts = 0; + long maxAttempts = 20; while ((messageRecord = reader.getNext()) != null) { - if (messageRecord.isOutgoing()) { return messageRecord.id; } + // Note: We rely on the message order to get us the most recent outgoing message - so we + // take the first outgoing message we find as the last outgoing message. + if (messageRecord.isOutgoing()) return messageRecord.getTimestamp(); + if (attempts++ > maxAttempts) break; } } } @@ -638,7 +639,11 @@ public class MmsSmsDatabase extends Database { } public Reader readerFor(@NonNull Cursor cursor) { - return new Reader(cursor); + return readerFor(cursor, true); + } + + public Reader readerFor(@NonNull Cursor cursor, boolean getQuote) { + return new Reader(cursor, getQuote); } @NotNull @@ -661,11 +666,13 @@ public class MmsSmsDatabase extends Database { public class Reader implements Closeable { private final Cursor cursor; + private final boolean getQuote; private SmsDatabase.Reader smsReader; private MmsDatabase.Reader mmsReader; - public Reader(Cursor cursor) { + public Reader(Cursor cursor, boolean getQuote) { this.cursor = cursor; + this.getQuote = getQuote; } private SmsDatabase.Reader getSmsReader() { @@ -678,7 +685,7 @@ public class MmsSmsDatabase extends Database { private MmsDatabase.Reader getMmsReader() { if (mmsReader == null) { - mmsReader = DatabaseComponent.get(context).mmsDatabase().readerFor(cursor); + mmsReader = DatabaseComponent.get(context).mmsDatabase().readerFor(cursor, getQuote); } return mmsReader; 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 209e7f187d..f5c6da5fb9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -881,6 +881,10 @@ public class ThreadDatabase extends Database { this.cursor = cursor; } + public int getCount() { + return cursor == null ? 0 : cursor.getCount(); + } + public ThreadRecord getNext() { if (cursor == null || !cursor.moveToNext()) return null; diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/PlaceholderAvatarLoader.kt b/app/src/main/java/org/thoughtcrime/securesms/glide/PlaceholderAvatarLoader.kt index 69c9b8c4f5..b163b5ed90 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/glide/PlaceholderAvatarLoader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/PlaceholderAvatarLoader.kt @@ -1,5 +1,6 @@ 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 @@ -8,7 +9,7 @@ import com.bumptech.glide.load.model.ModelLoaderFactory import com.bumptech.glide.load.model.MultiModelLoaderFactory import org.session.libsession.avatars.PlaceholderAvatarPhoto -class PlaceholderAvatarLoader(): ModelLoader { +class PlaceholderAvatarLoader(private val appContext: Context): ModelLoader { override fun buildLoadData( model: PlaceholderAvatarPhoto, @@ -16,14 +17,14 @@ class PlaceholderAvatarLoader(): ModelLoader { - return LoadData(model, PlaceholderAvatarFetcher(model.context, model)) + return LoadData(model, PlaceholderAvatarFetcher(appContext, model)) } override fun handles(model: PlaceholderAvatarPhoto): Boolean = true - class Factory() : ModelLoaderFactory { + class Factory(private val appContext: Context) : ModelLoaderFactory { override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader { - return PlaceholderAvatarLoader() + return PlaceholderAvatarLoader(appContext) } override fun teardown() {} } 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 13ca924e18..46bf599201 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -2,12 +2,9 @@ package org.thoughtcrime.securesms.home import android.Manifest import android.app.NotificationManager -import android.content.BroadcastReceiver import android.content.ClipData import android.content.ClipboardManager -import android.content.Context import android.content.Intent -import android.content.IntentFilter import android.os.Build import android.os.Bundle import android.widget.Toast @@ -43,19 +40,18 @@ import androidx.core.view.isVisible import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle -import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R import network.loki.messenger.databinding.ActivityHomeBinding -import network.loki.messenger.databinding.ViewMessageRequestBannerBinding import network.loki.messenger.libsession_util.ConfigBase import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe @@ -110,15 +106,12 @@ import org.thoughtcrime.securesms.ui.contentDescription import org.thoughtcrime.securesms.ui.h8 import org.thoughtcrime.securesms.ui.small import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities -import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.IP2Country import org.thoughtcrime.securesms.util.disableClipping import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.show import org.thoughtcrime.securesms.util.start -import org.thoughtcrime.securesms.util.themeState import java.io.IOException -import java.util.Locale import javax.inject.Inject @AndroidEntryPoint @@ -133,7 +126,6 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), private lateinit var binding: ActivityHomeBinding private lateinit var glide: GlideRequests - private var broadcastReceiver: BroadcastReceiver? = null @Inject lateinit var threadDb: ThreadDatabase @Inject lateinit var mmsSmsDatabase: MmsSmsDatabase @@ -151,7 +143,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), get() = textSecurePreferences.getLocalNumber()!! private val homeAdapter: HomeAdapter by lazy { - HomeAdapter(context = this, configFactory = configFactory, listener = this) + HomeAdapter(context = this, configFactory = configFactory, listener = this, ::showMessageRequests, ::hideMessageRequests) } private val globalSearchAdapter = GlobalSearchAdapter { model -> @@ -217,7 +209,6 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), if (!textSecurePreferences.getHasViewedSeed()) SeedReminder() } } - setupMessageRequestsBanner() // Set up recycler view binding.globalSearchInputLayout.listener = this homeAdapter.setHasStableIds(true) @@ -234,18 +225,10 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), binding.emptyStateContainer.setContent { EmptyView(ApplicationContext.getInstance(this).newAccount) } IP2Country.configureIfNeeded(this@HomeActivity) - startObservingUpdates() // Set up new conversation button binding.newConversationButton.setOnClickListener { showNewConversation() } // Observe blocked contacts changed events - val broadcastReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - binding.recyclerView.adapter!!.notifyDataSetChanged() - } - } - this.broadcastReceiver = broadcastReceiver - LocalBroadcastManager.getInstance(this).registerReceiver(broadcastReceiver, IntentFilter("blockedContactsChanged")) // subscribe to outdated config updates, this should be removed after long enough time for device migration lifecycleScope.launch { @@ -256,6 +239,26 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } } + // Subscribe to threads and update the UI + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + homeViewModel.data + .filterNotNull() // We don't actually want the null value here as it indicates a loading state (maybe we need a loading state?) + .collectLatest { data -> + 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 = data + if(firstPos >= 0) { manager.scrollToPositionWithOffset(firstPos, offsetTop) } + updateEmptyState() + } + } + } + lifecycleScope.launchWhenStarted { launch(Dispatchers.IO) { // Double check that the long poller is up @@ -448,34 +451,6 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), binding.newConversationButton.isVisible = !isShown } - private fun setupMessageRequestsBanner() { - val messageRequestCount = threadDb.unapprovedConversationCount - // Set up message requests - if (messageRequestCount > 0 && !textSecurePreferences.hasHiddenMessageRequests()) { - with(ViewMessageRequestBannerBinding.inflate(layoutInflater)) { - unreadCountTextView.text = messageRequestCount.toString() - timestampTextView.text = DateUtils.getDisplayFormattedTimeSpanString( - this@HomeActivity, - Locale.getDefault(), - threadDb.latestUnapprovedConversationTimestamp - ) - root.setOnClickListener { push() } - root.setOnLongClickListener { hideMessageRequests(); true } - root.layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT) - val hadHeader = homeAdapter.hasHeaderView() - homeAdapter.header = root - if (hadHeader) homeAdapter.notifyItemChanged(0) - else homeAdapter.notifyItemInserted(0) - } - } else { - val hadHeader = homeAdapter.hasHeaderView() - homeAdapter.header = null - if (hadHeader) { - homeAdapter.notifyItemRemoved(0) - } - } - } - private fun updateLegacyConfigView() { binding.configOutdatedView.isVisible = ConfigBase.isNewConfigEnabled(textSecurePreferences.hasForcedNewConfig(), SnodeAPI.nowWithOffset) && textSecurePreferences.getHasLegacyConfig() @@ -501,52 +476,20 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ConfigurationMessageUtilities.syncConfigurationIfNeeded(this@HomeActivity) } } - - // If the theme hasn't changed then start observing updates again (if it does change then we - // will recreate the activity resulting in it responding to changes multiple times) - if (currentThemeState == textSecurePreferences.themeState() && !homeViewModel.getObservable(this).hasActiveObservers()) { - startObservingUpdates() - } } override fun onPause() { super.onPause() ApplicationContext.getInstance(this).messageNotifier.setHomeScreenVisible(false) - - homeViewModel.getObservable(this).removeObservers(this) } override fun onDestroy() { - val broadcastReceiver = this.broadcastReceiver - if (broadcastReceiver != null) { - LocalBroadcastManager.getInstance(this).unregisterReceiver(broadcastReceiver) - } super.onDestroy() EventBus.getDefault().unregister(this) } // endregion // region Updating - private fun startObservingUpdates() { - 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() - } - - ApplicationContext.getInstance(this@HomeActivity).typingStatusRepository.typingThreads.observe(this) { threadIds -> - homeAdapter.typingThreadIDs = (threadIds ?: setOf()) - } - } - private fun updateEmptyState() { val threadCount = (binding.recyclerView.adapter)!!.itemCount binding.emptyStateContainer.isVisible = threadCount == 0 && binding.recyclerView.isVisible @@ -557,7 +500,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), if (event.recipient.isLocalNumber) { updateProfileButton() } else { - homeViewModel.tryUpdateChannel() + homeViewModel.tryReload() } } @@ -720,7 +663,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), private fun setConversationPinned(threadId: Long, pinned: Boolean) { lifecycleScope.launch(Dispatchers.IO) { storage.setPinned(threadId, pinned) - homeViewModel.tryUpdateChannel() + homeViewModel.tryReload() } } @@ -785,13 +728,17 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), show(intent, isForResult = true) } + private fun showMessageRequests() { + val intent = Intent(this, MessageRequestsActivity::class.java) + push(intent) + } + private fun hideMessageRequests() { showSessionDialog { text(getString(R.string.hide_message_requests)) button(R.string.yes) { textSecurePreferences.setHasHiddenMessageRequests() - setupMessageRequestsBanner() - homeViewModel.tryUpdateChannel() + homeViewModel.tryReload() } button(R.string.no) } 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 eaf242aae3..571adb7358 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt @@ -9,14 +9,18 @@ import androidx.recyclerview.widget.ListUpdateCallback import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.NO_ID import network.loki.messenger.R -import org.thoughtcrime.securesms.database.model.ThreadRecord +import network.loki.messenger.databinding.ViewMessageRequestBannerBinding import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.mms.GlideRequests +import org.thoughtcrime.securesms.util.DateUtils +import java.util.Locale class HomeAdapter( private val context: Context, private val configFactory: ConfigFactory, - private val listener: ConversationClickListener + private val listener: ConversationClickListener, + private val showMessageRequests: () -> Unit, + private val hideMessageRequests: () -> Unit, ) : RecyclerView.Adapter(), ListUpdateCallback { companion object { @@ -24,23 +28,32 @@ class HomeAdapter( private const val ITEM = 1 } - var header: View? = null + var messageRequests: HomeViewModel.MessageRequests? = null + set(value) { + if (field == value) return + val hadHeader = hasHeaderView() + field = value + if (value != null) { + if (hadHeader) notifyItemChanged(0) else notifyItemInserted(0) + } else if (hadHeader) notifyItemRemoved(0) + } - private var _data: List = emptyList() - var data: List - get() = _data.toList() + var data: HomeViewModel.Data = HomeViewModel.Data() set(newData) { - val previousData = _data.toList() - val diff = HomeDiffUtil(previousData, newData, context, configFactory) + if (field === newData) return + + messageRequests = newData.messageRequests + + val diff = HomeDiffUtil(field, newData, context, configFactory) val diffResult = DiffUtil.calculateDiff(diff) - _data = newData + field = newData diffResult.dispatchUpdatesTo(this as ListUpdateCallback) } - fun hasHeaderView(): Boolean = header != null + fun hasHeaderView(): Boolean = messageRequests != null private val headerCount: Int - get() = if (header == null) 0 else 1 + get() = if (messageRequests == null) 0 else 1 override fun onInserted(position: Int, count: Int) { notifyItemRangeInserted(position + headerCount, count) @@ -61,23 +74,19 @@ class HomeAdapter( 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 + return data.threads[offsetPosition].threadId } lateinit var glide: GlideRequests - var typingThreadIDs = setOf() - set(value) { - if (field == value) { return } - - 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!!) + ViewMessageRequestBannerBinding.inflate(LayoutInflater.from(parent.context)).apply { + root.setOnClickListener { showMessageRequests() } + root.setOnLongClickListener { hideMessageRequests(); true } + root.layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT) + }.let(::HeaderFooterViewHolder) } ITEM -> { val conversationView = LayoutInflater.from(parent.context).inflate(R.layout.view_conversation, parent, false) as ConversationView @@ -93,19 +102,27 @@ class HomeAdapter( } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - if (holder is ConversationViewHolder) { - val offset = if (hasHeaderView()) position - 1 else position - val thread = data[offset] - val isTyping = typingThreadIDs.contains(thread.threadId) - holder.view.bind(thread, isTyping, glide) + when (holder) { + is HeaderFooterViewHolder -> { + holder.binding.run { + messageRequests?.let { + unreadCountTextView.text = it.count + timestampTextView.text = it.timestamp + } + } + } + is ConversationViewHolder -> { + val offset = if (hasHeaderView()) position - 1 else position + val thread = data.threads[offset] + val isTyping = data.typingThreadIDs.contains(thread.threadId) + holder.view.bind(thread, isTyping, glide) + } } } override fun onViewRecycled(holder: RecyclerView.ViewHolder) { if (holder is ConversationViewHolder) { holder.view.recycle() - } else { - super.onViewRecycled(holder) } } @@ -113,10 +130,9 @@ class HomeAdapter( if (hasHeaderView() && position == 0) HEADER else ITEM - override fun getItemCount(): Int = data.size + if (hasHeaderView()) 1 else 0 + override fun getItemCount(): Int = data.threads.size + if (hasHeaderView()) 1 else 0 class ConversationViewHolder(val view: ConversationView) : RecyclerView.ViewHolder(view) - class HeaderFooterViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) - -} \ No newline at end of file + class HeaderFooterViewHolder(val binding: ViewMessageRequestBannerBinding) : RecyclerView.ViewHolder(binding.root) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt index 0fe93d41de..89f02ee21a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt @@ -2,27 +2,26 @@ package org.thoughtcrime.securesms.home import android.content.Context import androidx.recyclerview.widget.DiffUtil -import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.util.getConversationUnread class HomeDiffUtil( - private val old: List, - private val new: List, - private val context: Context, - private val configFactory: ConfigFactory + private val old: HomeViewModel.Data, + private val new: HomeViewModel.Data, + private val context: Context, + private val configFactory: ConfigFactory ): DiffUtil.Callback() { - override fun getOldListSize(): Int = old.size + override fun getOldListSize(): Int = old.threads.size - override fun getNewListSize(): Int = new.size + override fun getNewListSize(): Int = new.threads.size override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = - old[oldItemPosition].threadId == new[newItemPosition].threadId + old.threads[oldItemPosition].threadId == new.threads[newItemPosition].threadId override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { - val oldItem = old[oldItemPosition] - val newItem = new[newItemPosition] + val oldItem = old.threads[oldItemPosition] + val newItem = new.threads[newItemPosition] // return early to save getDisplayBody or expensive calls var isSameItem = true @@ -47,7 +46,8 @@ class HomeDiffUtil( oldItem.isSent == newItem.isSent && oldItem.isPending == newItem.isPending && oldItem.lastSeen == newItem.lastSeen && - configFactory.convoVolatile?.getConversationUnread(newItem) != true + configFactory.convoVolatile?.getConversationUnread(newItem) != true && + old.typingThreadIDs.contains(oldItem.threadId) == new.typingThreadIDs.contains(newItem.threadId) ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt index cb3322e039..fa18a995b6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt @@ -1,71 +1,131 @@ package org.thoughtcrime.securesms.home +import android.content.ContentResolver import android.content.Context -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData +import android.util.Log import androidx.lifecycle.ViewModel +import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope -import app.cash.copper.flow.observeQuery import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.* -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import org.session.libsession.utilities.TextSecurePreferences +import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.database.DatabaseContentProviders import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.ThreadRecord -import java.lang.ref.WeakReference +import org.thoughtcrime.securesms.util.DateUtils +import org.thoughtcrime.securesms.util.observeChanges +import java.util.Locale import javax.inject.Inject +import dagger.hilt.android.qualifiers.ApplicationContext as ApplicationContextQualifier @HiltViewModel -class HomeViewModel @Inject constructor(private val threadDb: ThreadDatabase): ViewModel() { +class HomeViewModel @Inject constructor( + private val threadDb: ThreadDatabase, + private val contentResolver: ContentResolver, + private val prefs: TextSecurePreferences, + @ApplicationContextQualifier private val context: Context, +) : ViewModel() { + // SharedFlow that emits whenever the user asks us to reload the conversation + private val manualReloadTrigger = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) - private val executor = viewModelScope + SupervisorJob() - private var lastContext: WeakReference? = null - private var updateJobs: MutableList = mutableListOf() + /** + * A [StateFlow] that emits the list of threads and the typing status of each thread. + * + * This flow will emit whenever the user asks us to reload the conversation list or + * whenever the conversation list changes. + */ + val data: StateFlow = combine( + observeConversationList(), + observeTypingStatus(), + messageRequests(), + ::Data + ) + .stateIn(viewModelScope, SharingStarted.Eagerly, null) - private val _conversations = MutableLiveData>() - val conversations: LiveData> = _conversations + private fun hasHiddenMessageRequests() = TextSecurePreferences.events + .filter { it == TextSecurePreferences.HAS_HIDDEN_MESSAGE_REQUESTS } + .flowOn(Dispatchers.IO) + .map { prefs.hasHiddenMessageRequests() } + .onStart { emit(prefs.hasHiddenMessageRequests()) } - private val listUpdateChannel = Channel(capacity = Channel.CONFLATED) + private fun observeTypingStatus(): Flow> = + ApplicationContext.getInstance(context).typingStatusRepository + .typingThreads + .asFlow() + .onStart { emit(emptySet()) } + .distinctUntilChanged() - fun tryUpdateChannel() = listUpdateChannel.trySend(Unit) + private fun messageRequests() = combine( + unapprovedConversationCount(), + hasHiddenMessageRequests(), + latestUnapprovedConversationTimestamp(), + ::createMessageRequests + ) - fun getObservable(context: Context): LiveData> { - // If the context has changed (eg. the activity gets recreated) then - // we need to cancel the old executors and recreate them to prevent - // the app from triggering extra updates when data changes - if (context != lastContext?.get()) { - lastContext = WeakReference(context) - updateJobs.forEach { it.cancel() } - updateJobs.clear() + private fun unapprovedConversationCount() = reloadTriggersAndContentChanges() + .map { threadDb.unapprovedConversationCount } - updateJobs.add( - executor.launch(Dispatchers.IO) { - context.contentResolver - .observeQuery(DatabaseContentProviders.ConversationList.CONTENT_URI) - .onEach { listUpdateChannel.trySend(Unit) } - .collect() - } - ) - updateJobs.add( - 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 - } - } - } - } - ) + private fun latestUnapprovedConversationTimestamp() = reloadTriggersAndContentChanges() + .map { threadDb.latestUnapprovedConversationTimestamp } + + @Suppress("OPT_IN_USAGE") + private fun observeConversationList(): Flow> = reloadTriggersAndContentChanges() + .mapLatest { _ -> + threadDb.approvedConversationList.use { openCursor -> + threadDb.readerFor(openCursor).run { generateSequence { next }.toList() } + } } - return conversations - } -} \ No newline at end of file + @OptIn(FlowPreview::class) + private fun reloadTriggersAndContentChanges() = merge( + manualReloadTrigger, + contentResolver.observeChanges(DatabaseContentProviders.ConversationList.CONTENT_URI) + ) + .flowOn(Dispatchers.IO) + .debounce(CHANGE_NOTIFICATION_DEBOUNCE_MILLS) + .onStart { emit(Unit) } + + fun tryReload() = manualReloadTrigger.tryEmit(Unit) + + data class Data( + val threads: List = emptyList(), + val typingThreadIDs: Set = emptySet(), + val messageRequests: MessageRequests? = null + ) + + fun createMessageRequests( + count: Int, + hidden: Boolean, + timestamp: Long + ) = if (count > 0 && !hidden) MessageRequests( + count.toString(), + DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), timestamp) + ) else null + + data class MessageRequests(val count: String, val timestamp: String) + + companion object { + private const val CHANGE_NOTIFICATION_DEBOUNCE_MILLS = 100L + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt index 1f3f2ff537..db0c4d11cc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt @@ -6,7 +6,6 @@ import android.content.Intent import android.content.IntentFilter import android.net.Uri import android.os.Bundle -import android.os.Handler import android.util.AttributeSet import android.util.TypedValue import android.view.Gravity @@ -17,11 +16,17 @@ import android.widget.TextView import android.widget.Toast import androidx.annotation.ColorRes import androidx.localbroadcastmanager.content.LocalBroadcastManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import network.loki.messenger.R import network.loki.messenger.databinding.ActivityPathBinding import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.utilities.getColorFromAttr -import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Snode import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.util.GlowViewUtilities @@ -184,6 +189,7 @@ class PathActivity : PassphraseRequiredActionBarActivity() { private lateinit var location: Location private var dotAnimationStartDelay: Long = 0 private var dotAnimationRepeatInterval: Long = 0 + private var job: Job? = null private val dotView by lazy { val result = PathDotView(context) @@ -240,19 +246,38 @@ class PathActivity : PassphraseRequiredActionBarActivity() { dotViewLayoutParams.addRule(CENTER_IN_PARENT) dotView.layoutParams = dotViewLayoutParams addView(dotView) - Handler().postDelayed({ - performAnimation() - }, dotAnimationStartDelay) } - private fun performAnimation() { - expand() - Handler().postDelayed({ - collapse() - Handler().postDelayed({ - performAnimation() - }, dotAnimationRepeatInterval) - }, 1000) + override fun onAttachedToWindow() { + super.onAttachedToWindow() + + startAnimation() + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + + stopAnimation() + } + + private fun startAnimation() { + job?.cancel() + job = GlobalScope.launch { + withContext(Dispatchers.Main) { + while (isActive) { + delay(dotAnimationStartDelay) + expand() + delay(EXPAND_ANIM_DELAY_MILLS) + collapse() + delay(dotAnimationRepeatInterval) + } + } + } + } + + private fun stopAnimation() { + job?.cancel() + job = null } private fun expand() { @@ -270,6 +295,10 @@ class PathActivity : PassphraseRequiredActionBarActivity() { val endColor = context.resources.getColorWithID(endColorID, context.theme) GlowViewUtilities.animateShadowColorChange(dotView, startColor, endColor) } + + companion object { + private const val EXPAND_ANIM_DELAY_MILLS = 1000L + } } // endregion } \ No newline at end of file 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 0a24c26fad..02172b7248 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/SignalGlideModule.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/SignalGlideModule.java @@ -73,7 +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()); + 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/PushReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt index 7fb9c12ab4..5f218a7a9f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt @@ -17,6 +17,7 @@ import org.session.libsession.messaging.jobs.MessageReceiveParameters import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationMetadata import org.session.libsession.messaging.utilities.MessageWrapper import org.session.libsession.messaging.utilities.SodiumUtilities +import org.session.libsession.messaging.utilities.SodiumUtilities.sodium import org.session.libsession.utilities.bencode.Bencode import org.session.libsession.utilities.bencode.BencodeList import org.session.libsession.utilities.bencode.BencodeString @@ -28,7 +29,6 @@ import javax.inject.Inject private const val TAG = "PushHandler" class PushReceiver @Inject constructor(@ApplicationContext val context: Context) { - private val sodium = LazySodiumAndroid(SodiumAndroid()) private val json = Json { ignoreUnknownKeys = true } fun onPush(dataMap: Map?) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt index 4bef45ff97..bf16333b15 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt @@ -18,6 +18,8 @@ import org.session.libsession.messaging.sending_receiving.notifications.Subscrip import org.session.libsession.messaging.sending_receiving.notifications.SubscriptionResponse import org.session.libsession.messaging.sending_receiving.notifications.UnsubscribeResponse import org.session.libsession.messaging.sending_receiving.notifications.UnsubscriptionRequest +import org.session.libsession.messaging.utilities.SodiumUtilities +import org.session.libsession.messaging.utilities.SodiumUtilities.sodium import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.Version @@ -34,8 +36,6 @@ private const val maxRetryCount = 4 @Singleton class PushRegistryV2 @Inject constructor(private val pushReceiver: PushReceiver) { - private val sodium = LazySodiumAndroid(SodiumAndroid()) - fun register( device: Device, token: String, 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 28625493d2..1a138cd165 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LandingActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/LandingActivity.kt @@ -3,20 +3,36 @@ package org.thoughtcrime.securesms.onboarding import android.content.Intent import android.net.Uri import android.os.Bundle +import androidx.annotation.StringRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.slideInVertically import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.res.stringResource @@ -25,6 +41,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay import network.loki.messenger.R import org.session.libsession.utilities.TextSecurePreferences import org.thoughtcrime.securesms.BaseActionBarActivity @@ -33,7 +50,6 @@ import org.thoughtcrime.securesms.onboarding.pickname.startPickDisplayNameActivi import org.thoughtcrime.securesms.service.KeyCachingService import org.thoughtcrime.securesms.showOpenUrlDialog import org.thoughtcrime.securesms.ui.AppTheme -import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.PreviewTheme import org.thoughtcrime.securesms.ui.ThemeResPreviewParameterProvider import org.thoughtcrime.securesms.ui.classicDarkColors @@ -44,6 +60,19 @@ import org.thoughtcrime.securesms.ui.contentDescription import org.thoughtcrime.securesms.ui.session_accent import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo import org.thoughtcrime.securesms.util.start +import kotlin.time.Duration.Companion.milliseconds + +private data class TextData( + @StringRes val stringId: Int, + val isOutgoing: Boolean = false +) + +private val MESSAGES = listOf( + TextData(R.string.onboardingBubbleWelcomeToSession), + TextData(R.string.onboardingBubbleSessionIsEngineered, isOutgoing = true), + TextData(R.string.onboardingBubbleNoPhoneNumber), + TextData(R.string.onboardingBubbleCreatingAnAccountIsEasy, isOutgoing = true) +) class LandingActivity : BaseActionBarActivity() { @@ -78,17 +107,47 @@ class LandingActivity : BaseActionBarActivity() { @Composable private fun LandingScreen() { + var count by remember { mutableStateOf(0) } + val listState = rememberLazyListState() + + LaunchedEffect(Unit) { + delay(500.milliseconds) + while(count < MESSAGES.size) { + count += 1 + listState.animateScrollToItem(0.coerceAtLeast((count - 1))) + delay(1500L) + } + } + Column(modifier = Modifier.padding(horizontal = 36.dp)) { Spacer(modifier = Modifier.weight(1f)) - Text(stringResource(R.string.onboardingBubblePrivacyInYourPocket), modifier = Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.h4, textAlign = TextAlign.Center) + Text( + stringResource(R.string.onboardingBubblePrivacyInYourPocket), + modifier = Modifier.align(Alignment.CenterHorizontally), + style = MaterialTheme.typography.h4, + textAlign = TextAlign.Center + ) Spacer(modifier = Modifier.height(24.dp)) - IncomingText(stringResource(R.string.onboardingBubbleWelcomeToSession)) - Spacer(modifier = Modifier.height(14.dp)) - OutgoingText(stringResource(R.string.onboardingBubbleSessionIsEngineered)) - Spacer(modifier = Modifier.height(14.dp)) - IncomingText(stringResource(R.string.onboardingBubbleNoPhoneNumber)) - Spacer(modifier = Modifier.height(14.dp)) - OutgoingText(stringResource(R.string.onboardingBubbleCreatingAnAccountIsEasy)) + + LazyColumn( + state = listState, + modifier = Modifier + .heightIn(min = 200.dp) + .fillMaxWidth() + .weight(2f), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + items( + MESSAGES.take(count), + key = { it.stringId } + ) { item -> + AnimateMessageText( + stringResource(item.stringId), + item.isOutgoing + ) + } + } + Spacer(modifier = Modifier.weight(1f)) OutlineButton( @@ -141,21 +200,48 @@ class LandingActivity : BaseActionBarActivity() { } @Composable -private fun IncomingText(text: String) { +private fun AnimateMessageText(text: String, isOutgoing: Boolean, modifier: Modifier = Modifier) { + var visible by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { visible = true } + + Box { + // TODO [SES-2077] Use LazyList itemAnimation when we update to compose 1.7 or so. + MessageText(text, isOutgoing, Modifier.alpha(0f)) + + AnimatedVisibility( + visible = visible, + enter = fadeIn(animationSpec = tween(durationMillis = 300)) + + slideInVertically(animationSpec = tween(durationMillis = 300)) { it } + ) { + MessageText(text, isOutgoing, modifier) + } + } +} + +@Composable +private fun MessageText(text: String, isOutgoing: Boolean, modifier: Modifier) { + if (isOutgoing) OutgoingText(text, modifier) else IncomingText(text, modifier) +} + +@Composable +private fun IncomingText(text: String, modifier: Modifier = Modifier) { ChatText( text, - color = classicDarkColors[2] + color = classicDarkColors[2], + modifier = modifier ) } @Composable -private fun ColumnScope.OutgoingText(text: String) { - ChatText( - text, - color = session_accent, - textColor = MaterialTheme.colors.primary, - modifier = Modifier.align(Alignment.End) - ) +private fun OutgoingText(text: String, modifier: Modifier = Modifier) { + Box(modifier = modifier then Modifier.fillMaxWidth()) { + ChatText( + text, + color = session_accent, + textColor = MaterialTheme.colors.primary, + modifier = Modifier.align(Alignment.TopEnd) + ) + } } @Composable @@ -178,4 +264,4 @@ private fun ChatText( ) .padding(horizontal = 16.dp, vertical = 12.dp) ) -} +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ContentResolverUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ContentResolverUtils.kt new file mode 100644 index 0000000000..f228eb57a4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ContentResolverUtils.kt @@ -0,0 +1,31 @@ +package org.thoughtcrime.securesms.util + +import android.content.ContentResolver +import android.database.ContentObserver +import android.net.Uri +import android.os.Handler +import android.os.Looper +import androidx.annotation.CheckResult +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow + +/** + * Observe changes to a content Uri. This function will emit the Uri whenever the content or + * its descendants change, according to the parameter [notifyForDescendants]. + */ +@CheckResult +fun ContentResolver.observeChanges(uri: Uri, notifyForDescendants: Boolean = false): Flow { + return callbackFlow { + val observer = object : ContentObserver(Handler(Looper.getMainLooper())) { + override fun onChange(selfChange: Boolean) { + trySend(uri) + } + } + + registerContentObserver(uri, notifyForDescendants, observer) + awaitClose { + unregisterContentObserver(observer) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/IP2Country.kt b/app/src/main/java/org/thoughtcrime/securesms/util/IP2Country.kt index 479a54fafa..bc76b80f2c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/IP2Country.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/IP2Country.kt @@ -55,7 +55,7 @@ class IP2Country private constructor(private val context: Context) { public fun configureIfNeeded(context: Context) { if (isInitialized) { return; } - shared = IP2Country(context) + shared = IP2Country(context.applicationContext) } } diff --git a/gradle.properties b/gradle.properties index 0948d30e31..6f3a80a7fb 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,7 +16,7 @@ android.enableJetifier=true org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx2048M" org.gradle.unsafe.configuration-cache=true -gradlePluginVersion=7.4.2 +gradlePluginVersion=7.3.1 googleServicesVersion=4.3.12 kotlinVersion=1.8.21 android.useAndroidX=true diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ab85dbb695..cd825d0848 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Thu Dec 30 07:09:53 SAST 2021 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME diff --git a/libsession-util/src/main/cpp/config_base.cpp b/libsession-util/src/main/cpp/config_base.cpp index 1c90b1b81c..5af6483371 100644 --- a/libsession-util/src/main/cpp/config_base.cpp +++ b/libsession-util/src/main/cpp/config_base.cpp @@ -1,5 +1,6 @@ #include "config_base.h" #include "util.h" +#include "jni_utils.h" extern "C" { JNIEXPORT jboolean JNICALL @@ -85,29 +86,34 @@ Java_network_loki_messenger_libsession_1util_ConfigBase_confirmPushed(JNIEnv *en JNIEXPORT jobject JNICALL Java_network_loki_messenger_libsession_1util_ConfigBase_merge___3Lkotlin_Pair_2(JNIEnv *env, jobject thiz, jobjectArray to_merge) { - std::lock_guard lock{util::util_mutex_}; - auto conf = ptrToConfigBase(env, thiz); - size_t number = env->GetArrayLength(to_merge); - std::vector> configs = {}; - for (int i = 0; i < number; i++) { - auto jElement = (jobject) env->GetObjectArrayElement(to_merge, i); - auto pair = extractHashAndData(env, jElement); - configs.push_back(pair); - } - auto returned = conf->merge(configs); - auto string_stack = util::build_string_stack(env, returned); - return string_stack; + return jni_utils::run_catching_cxx_exception_or_throws(env, [=] { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToConfigBase(env, thiz); + size_t number = env->GetArrayLength(to_merge); + std::vector> configs = {}; + for (int i = 0; i < number; i++) { + auto jElement = (jobject) env->GetObjectArrayElement(to_merge, i); + auto pair = extractHashAndData(env, jElement); + configs.push_back(pair); + } + auto returned = conf->merge(configs); + auto string_stack = util::build_string_stack(env, returned); + return string_stack; + }); } JNIEXPORT jobject JNICALL Java_network_loki_messenger_libsession_1util_ConfigBase_merge__Lkotlin_Pair_2(JNIEnv *env, jobject thiz, jobject to_merge) { - std::lock_guard lock{util::util_mutex_}; - auto conf = ptrToConfigBase(env, thiz); - std::vector> configs = {extractHashAndData(env, to_merge)}; - auto returned = conf->merge(configs); - auto string_stack = util::build_string_stack(env, returned); - return string_stack; + return jni_utils::run_catching_cxx_exception_or_throws(env, [=] { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToConfigBase(env, thiz); + std::vector> configs = { + extractHashAndData(env, to_merge)}; + auto returned = conf->merge(configs); + auto string_stack = util::build_string_stack(env, returned); + return string_stack; + }); } #pragma clang diagnostic pop diff --git a/libsession-util/src/main/cpp/contacts.cpp b/libsession-util/src/main/cpp/contacts.cpp index 7d04904802..324d0f0ea8 100644 --- a/libsession-util/src/main/cpp/contacts.cpp +++ b/libsession-util/src/main/cpp/contacts.cpp @@ -1,100 +1,121 @@ #include "contacts.h" #include "util.h" +#include "jni_utils.h" extern "C" JNIEXPORT jobject JNICALL Java_network_loki_messenger_libsession_1util_Contacts_get(JNIEnv *env, jobject thiz, jstring session_id) { - std::lock_guard lock{util::util_mutex_}; - auto contacts = ptrToContacts(env, thiz); - auto session_id_chars = env->GetStringUTFChars(session_id, nullptr); - auto contact = contacts->get(session_id_chars); - env->ReleaseStringUTFChars(session_id, session_id_chars); - if (!contact) return nullptr; - jobject j_contact = serialize_contact(env, contact.value()); - return j_contact; + // If an exception is thrown, return nullptr + return jni_utils::run_catching_cxx_exception_or( + [=]() -> jobject { + std::lock_guard lock{util::util_mutex_}; + auto contacts = ptrToContacts(env, thiz); + auto session_id_chars = env->GetStringUTFChars(session_id, nullptr); + auto contact = contacts->get(session_id_chars); + env->ReleaseStringUTFChars(session_id, session_id_chars); + if (!contact) return nullptr; + jobject j_contact = serialize_contact(env, contact.value()); + return j_contact; + }, + [](const char *) -> jobject { return nullptr; } + ); } extern "C" JNIEXPORT jobject JNICALL Java_network_loki_messenger_libsession_1util_Contacts_getOrConstruct(JNIEnv *env, jobject thiz, jstring session_id) { - std::lock_guard lock{util::util_mutex_}; - auto contacts = ptrToContacts(env, thiz); - auto session_id_chars = env->GetStringUTFChars(session_id, nullptr); - auto contact = contacts->get_or_construct(session_id_chars); - env->ReleaseStringUTFChars(session_id, session_id_chars); - return serialize_contact(env, contact); + return jni_utils::run_catching_cxx_exception_or_throws(env, [=] { + std::lock_guard lock{util::util_mutex_}; + auto contacts = ptrToContacts(env, thiz); + auto session_id_chars = env->GetStringUTFChars(session_id, nullptr); + auto contact = contacts->get_or_construct(session_id_chars); + env->ReleaseStringUTFChars(session_id, session_id_chars); + return serialize_contact(env, contact); + }); } extern "C" JNIEXPORT void JNICALL Java_network_loki_messenger_libsession_1util_Contacts_set(JNIEnv *env, jobject thiz, jobject contact) { - std::lock_guard lock{util::util_mutex_}; - auto contacts = ptrToContacts(env, thiz); - auto contact_info = deserialize_contact(env, contact, contacts); - contacts->set(contact_info); + jni_utils::run_catching_cxx_exception_or_throws(env, [=] { + std::lock_guard lock{util::util_mutex_}; + auto contacts = ptrToContacts(env, thiz); + auto contact_info = deserialize_contact(env, contact, contacts); + contacts->set(contact_info); + }); } extern "C" JNIEXPORT jboolean JNICALL Java_network_loki_messenger_libsession_1util_Contacts_erase(JNIEnv *env, jobject thiz, jstring session_id) { - std::lock_guard lock{util::util_mutex_}; - auto contacts = ptrToContacts(env, thiz); - auto session_id_chars = env->GetStringUTFChars(session_id, nullptr); + return jni_utils::run_catching_cxx_exception_or_throws(env, [=] { + std::lock_guard lock{util::util_mutex_}; + auto contacts = ptrToContacts(env, thiz); + auto session_id_chars = env->GetStringUTFChars(session_id, nullptr); - bool result = contacts->erase(session_id_chars); - env->ReleaseStringUTFChars(session_id, session_id_chars); - return result; + bool result = contacts->erase(session_id_chars); + env->ReleaseStringUTFChars(session_id, session_id_chars); + return result; + }); } extern "C" #pragma clang diagnostic push #pragma ide diagnostic ignored "bugprone-reserved-identifier" JNIEXPORT jobject JNICALL Java_network_loki_messenger_libsession_1util_Contacts_00024Companion_newInstance___3B(JNIEnv *env, - jobject thiz, - jbyteArray ed25519_secret_key) { - std::lock_guard lock{util::util_mutex_}; - auto secret_key = util::ustring_from_bytes(env, ed25519_secret_key); - auto* contacts = new session::config::Contacts(secret_key, std::nullopt); + jobject thiz, + jbyteArray ed25519_secret_key) { + return jni_utils::run_catching_cxx_exception_or_throws(env, [=] { + std::lock_guard lock{util::util_mutex_}; + auto secret_key = util::ustring_from_bytes(env, ed25519_secret_key); + auto *contacts = new session::config::Contacts(secret_key, std::nullopt); - jclass contactsClass = env->FindClass("network/loki/messenger/libsession_util/Contacts"); - jmethodID constructor = env->GetMethodID(contactsClass, "", "(J)V"); - jobject newConfig = env->NewObject(contactsClass, constructor, reinterpret_cast(contacts)); + jclass contactsClass = env->FindClass("network/loki/messenger/libsession_util/Contacts"); + jmethodID constructor = env->GetMethodID(contactsClass, "", "(J)V"); + jobject newConfig = env->NewObject(contactsClass, constructor, + reinterpret_cast(contacts)); - return newConfig; + return newConfig; + }); } extern "C" JNIEXPORT jobject JNICALL Java_network_loki_messenger_libsession_1util_Contacts_00024Companion_newInstance___3B_3B( JNIEnv *env, jobject thiz, jbyteArray ed25519_secret_key, jbyteArray initial_dump) { - std::lock_guard lock{util::util_mutex_}; - auto secret_key = util::ustring_from_bytes(env, ed25519_secret_key); - auto initial = util::ustring_from_bytes(env, initial_dump); + return jni_utils::run_catching_cxx_exception_or_throws(env, [=] { + std::lock_guard lock{util::util_mutex_}; + auto secret_key = util::ustring_from_bytes(env, ed25519_secret_key); + auto initial = util::ustring_from_bytes(env, initial_dump); - auto* contacts = new session::config::Contacts(secret_key, initial); + auto *contacts = new session::config::Contacts(secret_key, initial); - jclass contactsClass = env->FindClass("network/loki/messenger/libsession_util/Contacts"); - jmethodID constructor = env->GetMethodID(contactsClass, "", "(J)V"); - jobject newConfig = env->NewObject(contactsClass, constructor, reinterpret_cast(contacts)); + jclass contactsClass = env->FindClass("network/loki/messenger/libsession_util/Contacts"); + jmethodID constructor = env->GetMethodID(contactsClass, "", "(J)V"); + jobject newConfig = env->NewObject(contactsClass, constructor, + reinterpret_cast(contacts)); - return newConfig; + return newConfig; + }); } #pragma clang diagnostic pop extern "C" JNIEXPORT jobject JNICALL Java_network_loki_messenger_libsession_1util_Contacts_all(JNIEnv *env, jobject thiz) { - std::lock_guard lock{util::util_mutex_}; - auto contacts = ptrToContacts(env, thiz); - jclass stack = env->FindClass("java/util/Stack"); - jmethodID init = env->GetMethodID(stack, "", "()V"); - jobject our_stack = env->NewObject(stack, init); - jmethodID push = env->GetMethodID(stack, "push", "(Ljava/lang/Object;)Ljava/lang/Object;"); - for (const auto& contact : *contacts) { - auto contact_obj = serialize_contact(env, contact); - env->CallObjectMethod(our_stack, push, contact_obj); - } - return our_stack; + return jni_utils::run_catching_cxx_exception_or_throws(env, [=] { + std::lock_guard lock{util::util_mutex_}; + auto contacts = ptrToContacts(env, thiz); + jclass stack = env->FindClass("java/util/Stack"); + jmethodID init = env->GetMethodID(stack, "", "()V"); + jobject our_stack = env->NewObject(stack, init); + jmethodID push = env->GetMethodID(stack, "push", "(Ljava/lang/Object;)Ljava/lang/Object;"); + for (const auto &contact: *contacts) { + auto contact_obj = serialize_contact(env, contact); + env->CallObjectMethod(our_stack, push, contact_obj); + } + return our_stack; + }); } \ No newline at end of file diff --git a/libsession-util/src/main/cpp/jni_utils.h b/libsession-util/src/main/cpp/jni_utils.h new file mode 100644 index 0000000000..c9ccd924a6 --- /dev/null +++ b/libsession-util/src/main/cpp/jni_utils.h @@ -0,0 +1,54 @@ +#ifndef SESSION_ANDROID_JNI_UTILS_H +#define SESSION_ANDROID_JNI_UTILS_H + +#include +#include + +namespace jni_utils { + /** + * Run a C++ function and catch any exceptions, throwing a Java exception if one is caught, + * and returning a default-constructed value of the specified type. + * + * @tparam RetT The return type of the function + * @tparam Func The function type + * @param f The function to run + * @param fallbackRun The function to run if an exception is caught. The optional exception message reference will be passed to this function. + * @return The return value of the function, or the return value of the fallback function if an exception was caught + */ + template + RetT run_catching_cxx_exception_or(Func f, FallbackRun fallbackRun) { + try { + return f(); + } catch (const std::exception &e) { + return fallbackRun(e.what()); + } catch (...) { + return fallbackRun(nullptr); + } + } + + /** + * Run a C++ function and catch any exceptions, throwing a Java exception if one is caught. + * + * @tparam RetT The return type of the function + * @tparam Func The function type + * @param env The JNI environment + * @param f The function to run + * @return The return value of the function, or a default-constructed value of the specified type if an exception was caught + */ + template + RetT run_catching_cxx_exception_or_throws(JNIEnv *env, Func f) { + return run_catching_cxx_exception_or(f, [env](const char *msg) { + jclass exceptionClass = env->FindClass("java/lang/RuntimeException"); + if (msg) { + auto formatted_message = std::string("libsession: C++ exception: ") + msg; + env->ThrowNew(exceptionClass, formatted_message.c_str()); + } else { + env->ThrowNew(exceptionClass, "libsession: Unknown C++ exception"); + } + + return RetT(); + }); + } +} + +#endif //SESSION_ANDROID_JNI_UTILS_H diff --git a/libsession/src/main/java/org/session/libsession/avatars/PlaceholderAvatarPhoto.kt b/libsession/src/main/java/org/session/libsession/avatars/PlaceholderAvatarPhoto.kt index 0fcbe36e90..916e9112de 100644 --- a/libsession/src/main/java/org/session/libsession/avatars/PlaceholderAvatarPhoto.kt +++ b/libsession/src/main/java/org/session/libsession/avatars/PlaceholderAvatarPhoto.kt @@ -1,13 +1,10 @@ package org.session.libsession.avatars -import android.content.Context import com.bumptech.glide.load.Key import java.security.MessageDigest -class PlaceholderAvatarPhoto(val context: Context, - val hashString: String, +class PlaceholderAvatarPhoto(val hashString: String, val displayName: String): Key { - override fun updateDiskCacheKey(messageDigest: MessageDigest) { messageDigest.update(hashString.encodeToByteArray()) messageDigest.update(displayName.encodeToByteArray()) diff --git a/libsession/src/main/java/org/session/libsession/messaging/LastSentTimestampCache.kt b/libsession/src/main/java/org/session/libsession/messaging/LastSentTimestampCache.kt new file mode 100644 index 0000000000..a41ba60c80 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/LastSentTimestampCache.kt @@ -0,0 +1,9 @@ +package org.session.libsession.messaging + +interface LastSentTimestampCache { + fun getTimestamp(threadId: Long): Long? + fun submitTimestamp(threadId: Long, timestamp: Long) + fun delete(threadId: Long, timestamps: List) + fun delete(threadId: Long, timestamp: Long) = delete(threadId, listOf(timestamp)) + fun refresh(threadId: Long) +} 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 3d48325bf7..e4f15b2114 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt @@ -13,7 +13,8 @@ class MessagingModuleConfiguration( val device: Device, val messageDataProvider: MessageDataProvider, val getUserED25519KeyPair: () -> KeyPair?, - val configFactory: ConfigFactoryProtocol + val configFactory: ConfigFactoryProtocol, + val lastSentTimestampCache: LastSentTimestampCache ) { companion object { diff --git a/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt b/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt index 1f23a1cc87..a5203827ea 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt @@ -21,6 +21,7 @@ import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPoller.Companion.maxInactivityPeriod import org.session.libsession.messaging.utilities.SessionId import org.session.libsession.messaging.utilities.SodiumUtilities +import org.session.libsession.messaging.utilities.SodiumUtilities.sodium import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.snode.OnionResponse import org.session.libsession.snode.SnodeAPI @@ -48,7 +49,6 @@ object OpenGroupApi { val defaultRooms = MutableSharedFlow>(replay = 1) private val hasPerformedInitialPoll = mutableMapOf() private var hasUpdatedLastOpenDate = false - private val sodium by lazy { LazySodiumAndroid(SodiumAndroid()) } private val timeSinceLastOpen by lazy { val context = MessagingModuleConfiguration.shared.context val lastOpenDate = TextSecurePreferences.getLastOpenTimeDate(context) diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageDecrypter.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageDecrypter.kt index 53bf12f26e..8043da4b74 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageDecrypter.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageDecrypter.kt @@ -8,6 +8,7 @@ import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.sending_receiving.MessageReceiver.Error import org.session.libsession.messaging.utilities.SessionId import org.session.libsession.messaging.utilities.SodiumUtilities +import org.session.libsession.messaging.utilities.SodiumUtilities.sodium import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.IdPrefix @@ -17,8 +18,6 @@ import org.session.libsignal.utilities.removingIdPrefixIfNeeded object MessageDecrypter { - private val sodium by lazy { LazySodiumAndroid(SodiumAndroid()) } - /** * Decrypts `ciphertext` using the Session protocol and `x25519KeyPair`. * diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageEncrypter.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageEncrypter.kt index 24a620f8d4..361feff9e8 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageEncrypter.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageEncrypter.kt @@ -7,6 +7,7 @@ import com.goterl.lazysodium.interfaces.Sign import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.sending_receiving.MessageSender.Error import org.session.libsession.messaging.utilities.SodiumUtilities +import org.session.libsession.messaging.utilities.SodiumUtilities.sodium import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log @@ -14,8 +15,6 @@ import org.session.libsignal.utilities.removingIdPrefixIfNeeded object MessageEncrypter { - private val sodium by lazy { LazySodiumAndroid(SodiumAndroid()) } - /** * Encrypts `plaintext` using the Session protocol for `hexEncodedX25519PublicKey`. * diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt index b0459de1d6..0968db27e2 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt @@ -73,6 +73,7 @@ object MessageSender { // Convenience fun send(message: Message, destination: Destination, isSyncMessage: Boolean): Promise { + if (message is VisibleMessage) MessagingModuleConfiguration.shared.lastSentTimestampCache.submitTimestamp(message.threadID!!, message.sentTimestamp!!) return if (destination is Destination.LegacyOpenGroup || destination is Destination.OpenGroup || destination is Destination.OpenGroupInbox) { sendToOpenGroupDestination(destination, message) } else { @@ -372,6 +373,7 @@ object MessageSender { // Result Handling private fun handleSuccessfulMessageSend(message: Message, destination: Destination, isSyncMessage: Boolean = false, openGroupSentTimestamp: Long = -1) { + if (message is VisibleMessage) MessagingModuleConfiguration.shared.lastSentTimestampCache.submitTimestamp(message.threadID!!, openGroupSentTimestamp) val storage = MessagingModuleConfiguration.shared.storage val userPublicKey = storage.getUserPublicKey()!! val timestamp = message.sentTimestamp!! 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 6be9c5b058..e65472c1fe 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 @@ -290,6 +290,7 @@ fun MessageReceiver.handleVisibleMessage( ): Long? { val storage = MessagingModuleConfiguration.shared.storage val context = MessagingModuleConfiguration.shared.context + message.takeIf { it.isSenderSelf }?.sentTimestamp?.let { MessagingModuleConfiguration.shared.lastSentTimestampCache.submitTimestamp(threadId, it) } val userPublicKey = storage.getUserPublicKey() val messageSender: String? = message.sender @@ -410,12 +411,7 @@ fun MessageReceiver.handleVisibleMessage( message.hasMention = listOf(userPublicKey, userBlindedKey) .filterNotNull() .any { key -> - return@any ( - messageText != null && - messageText.contains("@$key") - ) || ( - (quoteModel?.author?.serialize() ?: "") == key - ) + messageText?.contains("@$key") == true || key == (quoteModel?.author?.serialize() ?: "") } // Persist the message diff --git a/libsession/src/main/java/org/session/libsession/messaging/utilities/SodiumUtilities.kt b/libsession/src/main/java/org/session/libsession/messaging/utilities/SodiumUtilities.kt index 079caee235..38e6080950 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/utilities/SodiumUtilities.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/utilities/SodiumUtilities.kt @@ -14,7 +14,7 @@ import org.whispersystems.curve25519.Curve25519 import kotlin.experimental.xor object SodiumUtilities { - private val sodium by lazy { LazySodiumAndroid(SodiumAndroid()) } + val sodium by lazy { LazySodiumAndroid(SodiumAndroid()) } private val curve by lazy { Curve25519.getInstance(Curve25519.BEST) } private const val SCALAR_LENGTH: Int = 32 // crypto_core_ed25519_scalarbytes 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 39b6704098..9bfad6d6e7 100644 --- a/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt +++ b/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt @@ -3,8 +3,6 @@ package org.session.libsession.snode import android.os.Build -import com.goterl.lazysodium.LazySodiumAndroid -import com.goterl.lazysodium.SodiumAndroid import com.goterl.lazysodium.exceptions.SodiumException import com.goterl.lazysodium.interfaces.GenericHash import com.goterl.lazysodium.interfaces.PwHash @@ -19,6 +17,7 @@ import nl.komponents.kovenant.functional.map import nl.komponents.kovenant.task import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.utilities.MessageWrapper +import org.session.libsession.messaging.utilities.SodiumUtilities.sodium import org.session.libsignal.crypto.getRandomElement import org.session.libsignal.database.LokiAPIDatabaseProtocol import org.session.libsignal.protos.SignalServiceProtos @@ -41,7 +40,6 @@ import kotlin.collections.set import kotlin.properties.Delegates.observable object SnodeAPI { - private val sodium by lazy { LazySodiumAndroid(SodiumAndroid()) } internal val database: LokiAPIDatabaseProtocol get() = SnodeModule.shared.storage private val broadcaster: Broadcaster 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 094a9fc349..0601f3c1e9 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 @@ -70,7 +70,7 @@ public class Recipient implements RecipientModifiedListener { private final @NonNull Address address; private final @NonNull List participants = new LinkedList<>(); - private Context context; + private final Context context; private @Nullable String name; private @Nullable String customLabel; private boolean resolving; @@ -132,7 +132,7 @@ public class Recipient implements RecipientModifiedListener { @NonNull Optional details, @NonNull ListenableFutureTask future) { - this.context = context; + this.context = context.getApplicationContext(); this.address = address; this.color = null; this.resolving = true; @@ -259,7 +259,7 @@ public class Recipient implements RecipientModifiedListener { } Recipient(@NonNull Context context, @NonNull Address address, @NonNull RecipientDetails details) { - this.context = context; + this.context = context.getApplicationContext(); this.address = address; this.contactUri = details.contactUri; this.name = details.name;