diff --git a/.run/Run Tests.run.xml b/.run/Run Tests.run.xml new file mode 100644 index 0000000000..42b2e07744 --- /dev/null +++ b/.run/Run Tests.run.xml @@ -0,0 +1,24 @@ + + + + + + + true + true + false + false + + + \ No newline at end of file diff --git a/README.md b/README.md index 723d50c758..17eaebf5fe 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Session Android +# Session Android [Download on the Google Play Store](https://getsession.org/android) diff --git a/app/build.gradle b/app/build.gradle index caf22f109a..171c1657f8 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -5,7 +5,7 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:7.4.2' + classpath "com.android.tools.build:gradle:$gradlePluginVersion" classpath files('libs/gradle-witness.jar') classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlinVersion" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 90cbd054b3..4ff3d91f65 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -230,11 +230,13 @@ android:name="org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2" android:screenOrientation="portrait" android:parentActivityName="org.thoughtcrime.securesms.home.HomeActivity" - android:theme="@style/Theme.Session.DayNight.NoActionBar"> + android:theme="@style/Theme.Session.DayNight.NoActionBar" + android:windowSoftInputMode="adjustResize" > + Unit) { text ?: return TextView(context, null, 0, style) @@ -75,6 +74,8 @@ class SessionDialogBuilder(val context: Context) { }.let(topView::addView) } + fun htmlText(@StringRes id: Int, @StyleRes style: Int = 0, modify: TextView.() -> Unit = {}) { text(context.resources.getText(id)) } + fun view(view: View) = contentView.addView(view) fun view(@LayoutRes layout: Int): View = LayoutInflater.from(context).inflate(layout, contentView) 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 3fd91f7f63..20dc2bbade 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt @@ -184,16 +184,21 @@ 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() + messagingDatabase.deleteMessage(messageID) DatabaseComponent.get(context).lokiMessageDatabase().deleteMessage(messageID, isSms) DatabaseComponent.get(context).lokiMessageDatabase().deleteMessageServerHash(messageID, mms = !isSms) } override fun deleteMessages(messageIDs: List, threadId: Long, isSms: Boolean) { + val messagingDatabase: MessagingDatabase = if (isSms) DatabaseComponent.get(context).smsDatabase() else DatabaseComponent.get(context).mmsDatabase() + // 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) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt index b87eac12c4..198fc33372 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt @@ -334,6 +334,10 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() { if (isEnabled) { viewModel.localRenderer?.let { surfaceView -> surfaceView.setZOrderOnTop(true) + + // Mirror the video preview of the person making the call to prevent disorienting them + surfaceView.setMirror(true) + binding.localRenderer.addView(surfaceView) } } 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 7635ad40e3..9a5eb730de 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt @@ -6,7 +6,6 @@ import android.view.LayoutInflater import android.view.View import android.widget.ImageView import android.widget.RelativeLayout -import androidx.annotation.DimenRes import com.bumptech.glide.load.engine.DiskCacheStrategy import network.loki.messenger.R import network.loki.messenger.databinding.ViewProfilePictureBinding @@ -33,13 +32,12 @@ class ProfilePictureView @JvmOverloads constructor( var additionalDisplayName: String? = null var isLarge = false - private val profilePicturesCache = mutableMapOf() + private val profilePicturesCache = mutableMapOf() private val unknownRecipientDrawable by lazy { ResourceContactPhoto(R.drawable.ic_profile_default) .asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false) } private val unknownOpenGroupDrawable by lazy { ResourceContactPhoto(R.drawable.ic_notification) .asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false) } - // endregion constructor(context: Context, sender: Recipient): this(context) { @@ -90,8 +88,8 @@ class ProfilePictureView @JvmOverloads constructor( val publicKey = publicKey ?: return val additionalPublicKey = additionalPublicKey if (additionalPublicKey != null) { - setProfilePictureIfNeeded(binding.doubleModeImageView1, publicKey, displayName, R.dimen.small_profile_picture_size) - setProfilePictureIfNeeded(binding.doubleModeImageView2, additionalPublicKey, additionalDisplayName, R.dimen.small_profile_picture_size) + setProfilePictureIfNeeded(binding.doubleModeImageView1, publicKey, displayName) + setProfilePictureIfNeeded(binding.doubleModeImageView2, additionalPublicKey, additionalDisplayName) binding.doubleModeImageViewContainer.visibility = View.VISIBLE } else { glide.clear(binding.doubleModeImageView1) @@ -99,14 +97,14 @@ class ProfilePictureView @JvmOverloads constructor( binding.doubleModeImageViewContainer.visibility = View.INVISIBLE } if (additionalPublicKey == null && !isLarge) { - setProfilePictureIfNeeded(binding.singleModeImageView, publicKey, displayName, R.dimen.medium_profile_picture_size) + setProfilePictureIfNeeded(binding.singleModeImageView, publicKey, displayName) binding.singleModeImageView.visibility = View.VISIBLE } else { glide.clear(binding.singleModeImageView) binding.singleModeImageView.visibility = View.INVISIBLE } if (additionalPublicKey == null && isLarge) { - setProfilePictureIfNeeded(binding.largeSingleModeImageView, publicKey, displayName, R.dimen.large_profile_picture_size) + setProfilePictureIfNeeded(binding.largeSingleModeImageView, publicKey, displayName) binding.largeSingleModeImageView.visibility = View.VISIBLE } else { glide.clear(binding.largeSingleModeImageView) @@ -114,17 +112,19 @@ class ProfilePictureView @JvmOverloads constructor( } } - private fun setProfilePictureIfNeeded(imageView: ImageView, publicKey: String, displayName: String?, @DimenRes sizeResId: Int) { + private fun setProfilePictureIfNeeded(imageView: ImageView, publicKey: String, displayName: String?) { if (publicKey.isNotEmpty()) { val recipient = Recipient.from(context, Address.fromSerialized(publicKey), false) - if (profilePicturesCache.containsKey(publicKey) && profilePicturesCache[publicKey] == recipient.profileAvatar) return + if (profilePicturesCache[imageView] == recipient) return + profilePicturesCache[imageView] = recipient val signalProfilePicture = recipient.contactPhoto val avatar = (signalProfilePicture as? ProfileContactPhoto)?.avatarObject + glide.clear(imageView) + val placeholder = PlaceholderAvatarPhoto(context, publicKey, displayName ?: "${publicKey.take(4)}...${publicKey.takeLast(4)}") if (signalProfilePicture != null && avatar != "0" && avatar != "") { - glide.clear(imageView) glide.load(signalProfilePicture) .placeholder(unknownRecipientDrawable) .centerCrop() @@ -132,21 +132,19 @@ class ProfilePictureView @JvmOverloads constructor( .diskCacheStrategy(DiskCacheStrategy.NONE) .circleCrop() .into(imageView) - } else if (recipient.isOpenGroupRecipient && recipient.groupAvatarId == null) { + } else if (recipient.isCommunityRecipient && recipient.groupAvatarId == null) { glide.clear(imageView) glide.load(unknownOpenGroupDrawable) .centerCrop() .circleCrop() .into(imageView) } else { - glide.clear(imageView) glide.load(placeholder) .placeholder(unknownRecipientDrawable) .centerCrop() .circleCrop() .diskCacheStrategy(DiskCacheStrategy.NONE).circleCrop().into(imageView) } - profilePicturesCache[publicKey] = recipient.profileAvatar } else { glide.load(unknownRecipientDrawable) .centerCrop() diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/SearchToolbar.java b/app/src/main/java/org/thoughtcrime/securesms/components/SearchToolbar.java index 8a56acd658..9032b26a2b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/SearchToolbar.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/SearchToolbar.java @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.components; - import android.animation.Animator; import android.content.Context; import android.os.Build; @@ -68,9 +67,7 @@ public class SearchToolbar extends LinearLayout { } @Override - public boolean onQueryTextChange(String newText) { - return onQueryTextSubmit(newText); - } + public boolean onQueryTextChange(String newText) { return onQueryTextSubmit(newText); } }); searchItem.setOnActionExpandListener(new MenuItem.OnActionExpandListener() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/RecentEmojiPageModel.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/RecentEmojiPageModel.java index 6cc39c8d14..0e2de9068c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/RecentEmojiPageModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/RecentEmojiPageModel.java @@ -3,133 +3,95 @@ package org.thoughtcrime.securesms.components.emoji; import android.content.Context; import android.content.SharedPreferences; import android.net.Uri; -import android.os.AsyncTask; import android.preference.PreferenceManager; -import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.annimon.stream.Stream; -import com.fasterxml.jackson.databind.type.CollectionType; -import com.fasterxml.jackson.databind.type.TypeFactory; import org.session.libsignal.utilities.JsonUtil; import org.session.libsignal.utilities.Log; -import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; -import java.util.Iterator; -import java.util.LinkedHashSet; +import java.util.LinkedList; import java.util.List; import network.loki.messenger.R; public class RecentEmojiPageModel implements EmojiPageModel { private static final String TAG = RecentEmojiPageModel.class.getSimpleName(); - private static final String EMOJI_LRU_PREFERENCE = "pref_recent_emoji2"; - private static final int EMOJI_LRU_SIZE = 50; - public static final String KEY = "Recents"; - public static final List DEFAULT_REACTIONS_LIST = - Arrays.asList("\ud83d\ude02", "\ud83e\udd70", "\ud83d\ude22", "\ud83d\ude21", "\ud83d\ude2e", "\ud83d\ude08"); + public static final String RECENT_EMOJIS_KEY = "Recents"; - private final SharedPreferences prefs; - private final LinkedHashSet recentlyUsed; + public static final LinkedList DEFAULT_REACTION_EMOJIS_LIST = new LinkedList<>(Arrays.asList( + "\ud83d\ude02", + "\ud83e\udd70", + "\ud83d\ude22", + "\ud83d\ude21", + "\ud83d\ude2e", + "\ud83d\ude08")); + + public static final String DEFAULT_REACTION_EMOJIS_JSON_STRING = JsonUtil.toJson(new LinkedList<>(DEFAULT_REACTION_EMOJIS_LIST)); + private static SharedPreferences prefs; + private static LinkedList recentlyUsed; public RecentEmojiPageModel(Context context) { - this.prefs = PreferenceManager.getDefaultSharedPreferences(context); - this.recentlyUsed = getPersistedCache(); - } + prefs = PreferenceManager.getDefaultSharedPreferences(context); - private LinkedHashSet getPersistedCache() { - String serialized = prefs.getString(EMOJI_LRU_PREFERENCE, "[]"); - try { - CollectionType collectionType = TypeFactory.defaultInstance() - .constructCollectionType(LinkedHashSet.class, String.class); - return JsonUtil.getMapper().readValue(serialized, collectionType); - } catch (IOException e) { - Log.w(TAG, e); - return new LinkedHashSet<>(); - } + // Note: Do NOT try to populate or update the persisted recent emojis in the constructor - the + // `getEmoji` method ends up getting called half-way through in a race-condition manner. } @Override - public String getKey() { - return KEY; - } + public String getKey() { return RECENT_EMOJIS_KEY; } - @Override public int getIconAttr() { - return R.attr.emoji_category_recent; - } + @Override public int getIconAttr() { return R.attr.emoji_category_recent; } @Override public List getEmoji() { - List recent = new ArrayList<>(recentlyUsed); - List out = new ArrayList<>(DEFAULT_REACTIONS_LIST.size()); - - for (int i = 0; i < DEFAULT_REACTIONS_LIST.size(); i++) { - if (recent.size() > i) { - out.add(recent.get(i)); - } else { - out.add(DEFAULT_REACTIONS_LIST.get(i)); + // Populate our recently used list if required (i.e., on first run) + if (recentlyUsed == null) { + try { + String recentlyUsedEmjoiJsonString = prefs.getString(RECENT_EMOJIS_KEY, DEFAULT_REACTION_EMOJIS_JSON_STRING); + recentlyUsed = JsonUtil.fromJson(recentlyUsedEmjoiJsonString, LinkedList.class); + } catch (Exception e) { + Log.w(TAG, e); + Log.d(TAG, "Default reaction emoji data was corrupt (likely via key re-use on app upgrade) - rewriting fresh data."); + boolean writeSuccess = prefs.edit().putString(RECENT_EMOJIS_KEY, DEFAULT_REACTION_EMOJIS_JSON_STRING).commit(); + if (!writeSuccess) { Log.w(TAG, "Failed to update recently used emojis in shared prefs."); } + recentlyUsed = DEFAULT_REACTION_EMOJIS_LIST; } } - - return out; + return new ArrayList<>(recentlyUsed); } @Override public List getDisplayEmoji() { return Stream.of(getEmoji()).map(Emoji::new).toList(); } - @Override public boolean hasSpriteMap() { - return false; - } + @Override public boolean hasSpriteMap() { return false; } @Nullable @Override - public Uri getSpriteUri() { - return null; - } + public Uri getSpriteUri() { return null; } - @Override public boolean isDynamic() { - return true; - } + @Override public boolean isDynamic() { return true; } - public void onCodePointSelected(String emoji) { - recentlyUsed.remove(emoji); - recentlyUsed.add(emoji); + public static void onCodePointSelected(String emoji) { + // If the emoji is already in the recently used list then remove it.. + if (recentlyUsed.contains(emoji)) { recentlyUsed.removeFirstOccurrence(emoji); } - if (recentlyUsed.size() > EMOJI_LRU_SIZE) { - Iterator iterator = recentlyUsed.iterator(); - iterator.next(); - iterator.remove(); - } + // ..and then regardless of whether the emoji used was already in the recently used list or not + // it gets placed as the first element in the list.. + recentlyUsed.addFirst(emoji); - final LinkedHashSet latestRecentlyUsed = new LinkedHashSet<>(recentlyUsed); - new AsyncTask() { + // Ensure that we only ever store data for a maximum of 6 recently used emojis (this code will + // execute if if we did NOT remove any occurrence of a previously used emoji but then added the + // new emoji to the front of the list). + while (recentlyUsed.size() > 6) { recentlyUsed.removeLast(); } - @Override - protected Void doInBackground(Void... params) { - try { - String serialized = JsonUtil.toJsonThrows(latestRecentlyUsed); - prefs.edit() - .putString(EMOJI_LRU_PREFERENCE, serialized) - .apply(); - } catch (IOException e) { - Log.w(TAG, e); - } - - return null; - } - }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } - - private String[] toReversePrimitiveArray(@NonNull LinkedHashSet emojiSet) { - String[] emojis = new String[emojiSet.size()]; - int i = emojiSet.size() - 1; - for (String emoji : emojiSet) { - emojis[i--] = emoji; - } - return emojis; + // ..which we then save to shared prefs. + String recentlyUsedAsJsonString = JsonUtil.toJson(recentlyUsed); + boolean writeSuccess = prefs.edit().putString(RECENT_EMOJIS_KEY, recentlyUsedAsJsonString).commit(); + if (!writeSuccess) { Log.w(TAG, "Failed to update recently used emojis in shared prefs."); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListLoader.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListLoader.kt index 3a2b2cbb5c..90e0ce50f2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListLoader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListLoader.kt @@ -58,7 +58,7 @@ class ContactSelectionListLoader(context: Context, val mode: Int, val filter: St private fun getOpenGroups(contacts: List): List { return getItems(contacts, context.getString(R.string.fragment_contact_selection_open_groups_title)) { - it.address.isOpenGroup + it.address.isCommunity } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt index 4bbcc3e021..184869b9ad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt @@ -116,7 +116,7 @@ class ConversationActionBarView @JvmOverloads constructor( ) } if (recipient.isGroupRecipient) { - val title = if (recipient.isOpenGroupRecipient) { + val title = if (recipient.isCommunityRecipient) { val userCount = openGroup?.let { lokiApiDb.getUserCount(it.room, it.server) } ?: 0 context.getString(R.string.ConversationActivity_active_member_count, userCount) } else { 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 f647e2fe3e..87d5ea4ae7 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 @@ -46,7 +46,6 @@ import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle import androidx.loader.app.LoaderManager import androidx.loader.content.Loader import androidx.recyclerview.widget.LinearLayoutManager @@ -57,8 +56,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.consumeAsFlow -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -106,6 +103,7 @@ import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.attachments.ScreenshotObserver import org.thoughtcrime.securesms.audio.AudioRecorder +import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel import org.thoughtcrime.securesms.contacts.SelectContactsActivity.Companion.selectedContactsKey import org.thoughtcrime.securesms.conversation.ConversationActionBarDelegate import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessagesActivity @@ -175,12 +173,11 @@ import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.SaveAttachmentTask import org.thoughtcrime.securesms.util.SimpleTextWatcher import org.thoughtcrime.securesms.util.isScrolledToBottom +import org.thoughtcrime.securesms.util.isScrolledToWithin30dpOfBottom import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.show import org.thoughtcrime.securesms.util.toPx import java.lang.ref.WeakReference -import java.time.Instant -import java.util.Date import java.util.Locale import java.util.concurrent.ExecutionException import java.util.concurrent.atomic.AtomicBoolean @@ -191,8 +188,6 @@ import kotlin.math.abs import kotlin.math.min import kotlin.math.roundToInt import kotlin.math.sqrt -import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds private const val TAG = "ConversationActivityV2" @@ -281,6 +276,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private val isScrolledToBottom: Boolean get() = binding?.conversationRecyclerView?.isScrolledToBottom ?: true + private val isScrolledToWithin30dpOfBottom: Boolean + get() = binding?.conversationRecyclerView?.isScrolledToWithin30dpOfBottom ?: true + private val layoutManager: LinearLayoutManager? get() { return binding?.conversationRecyclerView?.layoutManager as LinearLayoutManager? } @@ -336,6 +334,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe lifecycleCoroutineScope = lifecycleScope ) 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. + adapter.registerAdapterDataObserver(ConversationAdapterDataObserver(binding?.conversationRecyclerView!!, adapter)) + adapter } @@ -352,6 +355,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private lateinit var reactionDelegate: ConversationReactionDelegate private val reactWithAnyEmojiStartPage = -1 + // Properties for what message indices are visible previously & now, as well as the scroll state + private var previousLastVisibleRecyclerViewIndex: Int = RecyclerView.NO_POSITION + private var currentLastVisibleRecyclerViewIndex: Int = RecyclerView.NO_POSITION + private var recyclerScrollState: Int = RecyclerView.SCROLL_STATE_IDLE + // region Settings companion object { // Extras @@ -375,12 +383,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe super.onCreate(savedInstanceState, isReady) binding = ActivityConversationV2Binding.inflate(layoutInflater) setContentView(binding!!.root) + // messageIdToScroll messageToScrollTimestamp.set(intent.getLongExtra(SCROLL_MESSAGE_ID, -1)) messageToScrollAuthor.set(intent.getParcelableExtra(SCROLL_MESSAGE_AUTHOR)) val recipient = viewModel.recipient val openGroup = recipient.let { viewModel.openGroup } - if (recipient == null || (recipient.isOpenGroupRecipient && openGroup == null)) { + if (recipient == null || (recipient.isCommunityRecipient && openGroup == null)) { Toast.makeText(this, "This thread has been deleted.", Toast.LENGTH_LONG).show() return finish() } @@ -390,6 +399,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe setUpLinkPreviewObserver() restoreDraftIfNeeded() setUpUiStateObserver() + binding!!.scrollToBottomButton.setOnClickListener { val layoutManager = (binding?.conversationRecyclerView?.layoutManager as? LinearLayoutManager) ?: return@setOnClickListener val targetPosition = if (reverseMessageList) 0 else adapter.itemCount @@ -419,9 +429,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe setUpBlockedBanner() binding!!.searchBottomBar.setEventListener(this) updateSendAfterApprovalText() - showOrHideInputIfNeeded() setUpMessageRequestsBar() + // Note: Do not `showOrHideInputIfNeeded` here - we'll never start this activity w/ the + // keyboard visible and have no need to immediately display it. + val weakActivity = WeakReference(this) lifecycleScope.launch(Dispatchers.IO) { @@ -563,17 +575,45 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe binding!!.conversationRecyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + if (recyclerScrollState == RecyclerView.SCROLL_STATE_IDLE) { + scrollToMostRecentMessageIfWeShould() + } handleRecyclerViewScrolled() } override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { - + recyclerScrollState = newState } }) + } - binding!!.conversationRecyclerView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> - showScrollToBottomButtonIfApplicable() + private fun scrollToMostRecentMessageIfWeShould() { + // Grab an initial 'previous' last visible message.. + if (previousLastVisibleRecyclerViewIndex == RecyclerView.NO_POSITION) { + previousLastVisibleRecyclerViewIndex = layoutManager?.findLastVisibleItemPosition()!! } + + // ..and grab the 'current' last visible message. + currentLastVisibleRecyclerViewIndex = layoutManager?.findLastVisibleItemPosition()!! + + // If the current last visible message index is less than the previous one (i.e. we've + // lost visibility of one or more messages due to showing the IME keyboard) AND we're + // at the bottom of the message feed.. + val atBottomAndTrueLastNoLongerVisible = currentLastVisibleRecyclerViewIndex!! <= previousLastVisibleRecyclerViewIndex!! && !binding?.scrollToBottomButton?.isVisible!! + + // ..OR we're at the last message or have received a new message.. + val atLastOrReceivedNewMessage = currentLastVisibleRecyclerViewIndex == (adapter.itemCount - 1) + + // ..then scroll the recycler view to the last message on resize. Note: We cannot just call + // scroll/smoothScroll - we have to `post` it or nothing happens! + if (atBottomAndTrueLastNoLongerVisible || atLastOrReceivedNewMessage) { + binding?.conversationRecyclerView?.post { + binding?.conversationRecyclerView?.smoothScrollToPosition(adapter.itemCount) + } + } + + // Update our previous last visible view index to the current one + previousLastVisibleRecyclerViewIndex = currentLastVisibleRecyclerViewIndex } // called from onCreate @@ -760,13 +800,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // of the first unread message in the middle of the screen if (isFirstLoad && !reverseMessageList) { layoutManager?.scrollToPositionWithOffset(lastSeenItemPosition, ((layoutManager?.height ?: 0) / 2)) - if (shouldHighlight) { highlightViewAtPosition(lastSeenItemPosition) } - return lastSeenItemPosition } if (lastSeenItemPosition <= 3) { return lastSeenItemPosition } + binding?.conversationRecyclerView?.scrollToPosition(lastSeenItemPosition) return lastSeenItemPosition } @@ -931,11 +970,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe view.glide = glide view.onCandidateSelected = { handleMentionSelected(it) } additionalContentContainer.addView(view) - val candidates = MentionsManager.getMentionCandidates(query, viewModel.threadId, recipient.isOpenGroupRecipient) + val candidates = MentionsManager.getMentionCandidates(query, viewModel.threadId, recipient.isCommunityRecipient) this.mentionCandidatesView = view view.show(candidates, viewModel.threadId) } else { - val candidates = MentionsManager.getMentionCandidates(query, viewModel.threadId, recipient.isOpenGroupRecipient) + val candidates = MentionsManager.getMentionCandidates(query, viewModel.threadId, recipient.isCommunityRecipient) this.mentionCandidatesView!!.setMentionCandidates(candidates) } isShowingMentionCandidatesView = true @@ -1040,8 +1079,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private fun handleRecyclerViewScrolled() { val binding = binding ?: return + + // Note: The typing indicate is whether the other person / other people are typing - it has + // nothing to do with the IME keyboard state. val wasTypingIndicatorVisibleBefore = binding.typingIndicatorViewContainer.isVisible binding.typingIndicatorViewContainer.isVisible = wasTypingIndicatorVisibleBefore && isScrolledToBottom + showScrollToBottomButtonIfApplicable() val maybeTargetVisiblePosition = if (reverseMessageList) layoutManager?.findFirstVisibleItemPosition() else layoutManager?.findLastVisibleItemPosition() val targetVisiblePosition = maybeTargetVisiblePosition ?: RecyclerView.NO_POSITION @@ -1069,6 +1112,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val blindedRecipient = viewModel.blindedRecipient val binding = binding ?: return val openGroup = viewModel.openGroup + val (textResource, insertParam) = when { recipient.isLocalNumber -> R.string.activity_conversation_empty_state_note_to_self to null openGroup != null && !openGroup.canWrite -> R.string.activity_conversation_empty_state_read_only to recipient.toShortString() @@ -1148,7 +1192,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } override fun copyOpenGroupUrl(thread: Recipient) { - if (!thread.isOpenGroupRecipient) { return } + if (!thread.isCommunityRecipient) { return } val threadId = threadDb.getThreadIdIfExistsFor(thread) ?: return val openGroup = lokiThreadDb.getOpenGroupChat(threadId) ?: return @@ -1286,6 +1330,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe sendEmojiRemoval(emoji, messageRecord) } else { sendEmojiReaction(emoji, messageRecord) + RecentEmojiPageModel.onCodePointSelected(emoji) // Save to recently used reaction emojis } } @@ -1312,7 +1357,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } else originalMessage.individualRecipient.address // Send it reactionMessage.reaction = Reaction.from(originalMessage.timestamp, originalAuthor.serialize(), emoji, true) - if (recipient.isOpenGroupRecipient) { + if (recipient.isCommunityRecipient) { val messageServerId = lokiMessageDb.getServerID(originalMessage.id, !originalMessage.isMms) ?: return viewModel.openGroup?.let { OpenGroupApi.addReaction(it.room, it.server, messageServerId, emoji) @@ -1336,7 +1381,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } else originalMessage.individualRecipient.address message.reaction = Reaction.from(originalMessage.timestamp, originalAuthor.serialize(), emoji, false) - if (recipient.isOpenGroupRecipient) { + if (recipient.isCommunityRecipient) { val messageServerId = lokiMessageDb.getServerID(originalMessage.id, !originalMessage.isMms) ?: return viewModel.openGroup?.let { OpenGroupApi.deleteReaction(it.room, it.server, messageServerId, emoji) @@ -1731,7 +1776,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe sendAttachments(slideDeck.asAttachments(), body) } INVITE_CONTACTS -> { - if (viewModel.recipient?.isOpenGroupRecipient != true) { return } + if (viewModel.recipient?.isCommunityRecipient != true) { return } val extras = intent?.extras ?: return if (!intent.hasExtra(selectedContactsKey)) { return } val selectedContacts = extras.getStringArray(selectedContactsKey)!! @@ -1797,19 +1842,61 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe handleLongPress(messages.first(), 0) //TODO: begin selection mode } + // The option to "Delete just for me" or "Delete for everyone" + private fun showDeleteOrDeleteForEveryoneInCommunityUI(messages: Set) { + val bottomSheet = DeleteOptionsBottomSheet() + bottomSheet.recipient = viewModel.recipient!! + bottomSheet.onDeleteForMeTapped = { + messages.forEach(viewModel::deleteLocally) + bottomSheet.dismiss() + endActionMode() + } + bottomSheet.onDeleteForEveryoneTapped = { + messages.forEach(viewModel::deleteForEveryone) + bottomSheet.dismiss() + endActionMode() + } + bottomSheet.onCancelTapped = { + bottomSheet.dismiss() + endActionMode() + } + bottomSheet.show(supportFragmentManager, bottomSheet.tag) + } + + private fun showDeleteLocallyUI(messages: Set) { + val messageCount = 1 + showSessionDialog { + title(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount)) + text(resources.getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messageCount, messageCount)) + button(R.string.delete) { messages.forEach(viewModel::deleteLocally); endActionMode() } + cancelButton(::endActionMode) + } + } + + // Note: The messages in the provided set may be a single message, or multiple if there are a + // group of selected messages. override fun deleteMessages(messages: Set) { - val recipient = viewModel.recipient ?: return + val recipient = viewModel.recipient + if (recipient == null) { + 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 } - if (recipient.isOpenGroupRecipient) { - val messageCount = 1 + // If the recipient is a community OR a Note-to-Self then we delete the message for everyone + if (recipient.isCommunityRecipient || recipient.isLocalNumber) { + val messageCount = 1 // Only used for plurals string showSessionDialog { title(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount)) text(resources.getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messageCount, messageCount)) - button(R.string.delete) { messages.forEach(viewModel::deleteForEveryone); endActionMode() } + button(R.string.delete) { + messages.forEach(viewModel::deleteForEveryone); endActionMode() + } cancelButton { endActionMode() } } + // Otherwise if this is a 1-on-1 conversation we may decided to delete just for ourselves or delete for everyone } else if (allSentByCurrentUser && allHasHash) { val bottomSheet = DeleteOptionsBottomSheet() bottomSheet.recipient = recipient @@ -1828,13 +1915,16 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe endActionMode() } bottomSheet.show(supportFragmentManager, bottomSheet.tag) - } else { + } + else // Finally, if this is a closed group and you are deleting someone else's message(s) then we can only delete locally. + { val messageCount = 1 - showSessionDialog { title(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount)) text(resources.getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messageCount, messageCount)) - button(R.string.delete) { messages.forEach(viewModel::deleteLocally); endActionMode() } + button(R.string.delete) { + messages.forEach(viewModel::deleteLocally); endActionMode() + } cancelButton(::endActionMode) } } @@ -1853,7 +1943,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe showSessionDialog { title(R.string.ConversationFragment_ban_selected_user) text("This will ban the selected user from this room and delete all messages sent by them. It won't ban them from other rooms or delete the messages they sent there.") - button(R.string.ban) { viewModel.banAndDeleteAll(messages.first().individualRecipient); endActionMode() } + button(R.string.ban) { viewModel.banAndDeleteAll(messages.first()); endActionMode() } cancelButton(::endActionMode) } } @@ -1935,7 +2025,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val message = messages.first() as MmsMessageRecord // Do not allow the user to download a file attachment before it has finished downloading - // TODO: Localise the msg in this toast! if (message.isMediaPending) { Toast.makeText(this, resources.getString(R.string.conversation_activity__wait_until_attachment_has_finished_downloading), Toast.LENGTH_LONG).show() return @@ -2105,4 +2194,18 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } } + // AdapterDataObserver implementation to scroll us to the bottom of the ConversationRecyclerView + // when we're already near the bottom and we send or receive a message. + inner class ConversationAdapterDataObserver(val recyclerView: ConversationRecyclerView, val adapter: ConversationAdapter) : RecyclerView.AdapterDataObserver() { + override fun onChanged() { + super.onChanged() + if (recyclerView.isScrolledToWithin30dpOfBottom) { + // Note: The adapter itemCount is zero based - so calling this with the itemCount in + // a non-zero based manner scrolls us to the bottom of the last message (including + // to the bottom of long messages as required by Jira SES-789 / GitHub 1364). + recyclerView.scrollToPosition(adapter.itemCount) + } + } + } + } \ No newline at end of file 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 6013af5ba4..371df34565 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 @@ -22,10 +22,12 @@ import kotlinx.coroutines.launch import network.loki.messenger.R import network.loki.messenger.databinding.ViewVisibleMessageBinding import org.session.libsession.messaging.contacts.Contact +import org.session.libsession.utilities.TextSecurePreferences import org.thoughtcrime.securesms.conversation.v2.messages.ControlMessageView import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageViewDelegate import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter +import org.thoughtcrime.securesms.database.MmsSmsColumns import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.mms.GlideRequests @@ -57,6 +59,7 @@ class ConversationAdapter( private val contactCache = SparseArray(100) private val contactLoadedCache = SparseBooleanArray(100) private val lastSeen = AtomicLong(originalLastSeen) + private var lastSentMessageId: Long = -1L init { lifecycleCoroutineScope.launch(IO) { @@ -136,7 +139,8 @@ class ConversationAdapter( senderId, lastSeen.get(), visibleMessageViewDelegate, - onAttachmentNeedsDownload + onAttachmentNeedsDownload, + lastSentMessageId ) if (!message.isDeleted) { @@ -205,8 +209,23 @@ 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) + val toRemove = mutableSetOf() val toDeselect = mutableSetOf>() for (selected in selectedItems) { @@ -224,6 +243,11 @@ 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/ConversationReactionOverlay.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt index 405d2a3c0e..d336c5fcce 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt @@ -39,7 +39,6 @@ import org.thoughtcrime.securesms.components.menu.ActionItem import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuItemHelper.userCanBanSelectedUsers import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuItemHelper.userCanDeleteSelectedItems import org.thoughtcrime.securesms.database.MmsSmsDatabase -import org.thoughtcrime.securesms.database.SessionContactDatabase import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.ReactionRecord @@ -541,7 +540,7 @@ class ConversationReactionOverlay : FrameLayout { items += ActionItem(R.attr.menu_copy_icon, R.string.copy, { handleActionItemClicked(Action.COPY_MESSAGE) }) } // Copy Session ID - if (recipient.isGroupRecipient && !recipient.isOpenGroupRecipient && message.recipient.address.toString() != userPublicKey) { + if (recipient.isGroupRecipient && !recipient.isCommunityRecipient && message.recipient.address.toString() != userPublicKey) { items += ActionItem(R.attr.menu_copy_icon, R.string.activity_conversation_menu_copy_session_id, { handleActionItemClicked(Action.COPY_SESSION_ID) }) } // Delete message diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 677dd22f60..80d6df87fe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -1,18 +1,23 @@ package org.thoughtcrime.securesms.conversation.v2 import android.content.Context + import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope + import com.goterl.lazysodium.utils.KeyPair + import dagger.assisted.Assisted import dagger.assisted.AssistedInject + import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch + import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.open_groups.OpenGroupApi @@ -22,9 +27,12 @@ import org.session.libsession.utilities.Address import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.database.MmsSmsDatabase + import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.repository.ConversationRepository + import java.util.UUID class ConversationViewModel( @@ -144,9 +152,14 @@ class ConversationViewModel( } fun deleteForEveryone(message: MessageRecord) = viewModelScope.launch { - val recipient = recipient ?: return@launch + val recipient = recipient ?: return@launch Log.w("Loki", "Recipient was null for delete for everyone - aborting delete operation.") + repository.deleteForEveryone(threadId, recipient, message) + .onSuccess { + Log.d("Loki", "Deleted message ${message.id} ") + } .onFailure { + Log.w("Loki", "FAILED TO delete message ${message.id} ") showMessage("Couldn't delete message due to error: $it") } } @@ -168,10 +181,15 @@ class ConversationViewModel( } } - fun banAndDeleteAll(recipient: Recipient) = viewModelScope.launch { - repository.banAndDeleteAll(threadId, recipient) + fun banAndDeleteAll(messageRecord: MessageRecord) = viewModelScope.launch { + + repository.banAndDeleteAll(threadId, messageRecord.individualRecipient) .onSuccess { + // At this point the server side messages have been successfully deleted.. showMessage("Successfully banned user and deleted all their messages") + + // ..so we can now delete all their messages in this thread from local storage & remove the views. + repository.deleteAllLocalMessagesInThreadFromSenderOfMessage(messageRecord) } .onFailure { showMessage("Couldn't execute request due to error: $it") diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt index 3746aa52e4..2788d35dd2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt @@ -65,7 +65,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p menu.findItem(R.id.menu_context_copy).isVisible = !containsControlMessage && hasText // Copy Session ID menu.findItem(R.id.menu_context_copy_public_key).isVisible = - (thread.isGroupRecipient && !thread.isOpenGroupRecipient && selectedItems.size == 1 && firstMessage.individualRecipient.address.toString() != userPublicKey) + (thread.isGroupRecipient && !thread.isCommunityRecipient && selectedItems.size == 1 && firstMessage.individualRecipient.address.toString() != userPublicKey) // Message detail menu.findItem(R.id.menu_message_details).isVisible = selectedItems.size == 1 // Resend diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt index dadf138ead..11069937a0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt @@ -50,7 +50,7 @@ object ConversationMenuHelper { ) { // Prepare menu.clear() - val isOpenGroup = thread.isOpenGroupRecipient + val isOpenGroup = thread.isCommunityRecipient // Base menu (options that should always be present) inflater.inflate(R.menu.menu_conversation, menu) // Expiring messages @@ -253,7 +253,7 @@ object ConversationMenuHelper { } private fun copyOpenGroupUrl(context: Context, thread: Recipient) { - if (!thread.isOpenGroupRecipient) { return } + if (!thread.isCommunityRecipient) { return } val listener = context as? ConversationMenuListener ?: return listener.copyOpenGroupUrl(thread) } @@ -300,7 +300,7 @@ object ConversationMenuHelper { } private fun inviteContacts(context: Context, thread: Recipient) { - if (!thread.isOpenGroupRecipient) { return } + if (!thread.isCommunityRecipient) { return } val intent = Intent(context, SelectContactsActivity::class.java) val activity = context as AppCompatActivity activity.startActivityForResult(intent, ConversationActivityV2.INVITE_CONTACTS) 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 df49785deb..a59a102d1c 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 @@ -32,6 +32,7 @@ import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact.ContactContext import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.utilities.Address +import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.ViewUtil import org.session.libsession.utilities.getColorFromAttr import org.session.libsession.utilities.modifyLayoutParams @@ -131,7 +132,8 @@ class VisibleMessageView : LinearLayout { senderSessionID: String, lastSeen: Long, delegate: VisibleMessageViewDelegate? = null, - onAttachmentNeedsDownload: (Long, Long) -> Unit + onAttachmentNeedsDownload: (Long, Long) -> Unit, + lastSentMessageId: Long ) { val threadID = message.threadId val thread = threadDb.getRecipientForThreadId(threadID) ?: return @@ -164,7 +166,7 @@ class VisibleMessageView : LinearLayout { binding.profilePictureView.publicKey = senderSessionID binding.profilePictureView.update(message.individualRecipient) binding.profilePictureView.setOnClickListener { - if (thread.isOpenGroupRecipient) { + if (thread.isCommunityRecipient) { val openGroup = lokiThreadDb.getOpenGroupChat(threadID) if (IdPrefix.fromValue(senderSessionID) == IdPrefix.BLINDED && openGroup?.canWrite == true) { // TODO: support v2 soon @@ -177,7 +179,7 @@ class VisibleMessageView : LinearLayout { maybeShowUserDetails(senderSessionID, threadID) } } - if (thread.isOpenGroupRecipient) { + if (thread.isCommunityRecipient) { val openGroup = lokiThreadDb.getOpenGroupChat(threadID) ?: return var standardPublicKey = "" var blindedPublicKey: String? = null @@ -193,16 +195,20 @@ class VisibleMessageView : LinearLayout { } binding.senderNameTextView.isVisible = !message.isOutgoing && (isStartOfMessageCluster && (isGroupThread || snIsSelected)) val contactContext = - if (thread.isOpenGroupRecipient) ContactContext.OPEN_GROUP else ContactContext.REGULAR + if (thread.isCommunityRecipient) ContactContext.OPEN_GROUP else ContactContext.REGULAR binding.senderNameTextView.text = contact?.displayName(contactContext) ?: senderSessionID + // Unread marker binding.unreadMarkerContainer.isVisible = lastSeen != -1L && message.timestamp > lastSeen && (previous == null || previous.timestamp <= lastSeen) && !message.isOutgoing + // Date break val showDateBreak = isStartOfMessageCluster || snIsSelected binding.dateBreakTextView.text = if (showDateBreak) DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), message.timestamp) else null binding.dateBreakTextView.isVisible = showDateBreak + // Message status indicator showStatusMessage(message) + // Emoji Reactions val emojiLayoutParams = binding.emojiReactionsView.root.layoutParams as ConstraintLayout.LayoutParams emojiLayoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f @@ -238,7 +244,8 @@ class VisibleMessageView : LinearLayout { } private fun showStatusMessage(message: MessageRecord) { - val disappearing = message.expiresIn > 0 + + val scheduledToDisappear = message.expiresIn > 0 binding.messageInnerLayout.modifyLayoutParams { gravity = if (message.isOutgoing) Gravity.END else Gravity.START @@ -250,21 +257,22 @@ class VisibleMessageView : LinearLayout { binding.expirationTimerView.isGone = true - if (message.isOutgoing || disappearing) { + if (message.isOutgoing || scheduledToDisappear) { val (iconID, iconColor, textId) = getMessageStatusImage(message) textId?.let(binding.messageStatusTextView::setText) iconColor?.let(binding.messageStatusTextView::setTextColor) iconID?.let { ContextCompat.getDrawable(context, it) } ?.run { iconColor?.let { mutate().apply { setTint(it) } } ?: this } ?.let(binding.messageStatusImageView::setImageDrawable) - - val lastMessageID = mmsSmsDb.getLastMessageID(message.threadId) - val isLastMessage = message.id == lastMessageID - binding.messageStatusTextView.isVisible = - textId != null && (!message.isSent || isLastMessage || disappearing) - val showTimer = disappearing && !message.isPending - binding.messageStatusImageView.isVisible = - iconID != null && !showTimer && (!message.isSent || isLastMessage) + + // Always show the delivery status of the last sent message + val thisUsersSessionId = TextSecurePreferences.getLocalNumber(context) + val lastSentMessageId = mmsSmsDb.getLastSentMessageFromSender(message.threadId, thisUsersSessionId) + val isLastSentMessage = lastSentMessageId == message.id + + binding.messageStatusTextView.isVisible = textId != null && (isLastSentMessage || scheduledToDisappear) + val showTimer = scheduledToDisappear && !message.isPending + binding.messageStatusImageView.isVisible = iconID != null && !showTimer && (!message.isSent || isLastSentMessage) binding.messageStatusImageView.bringToFront() binding.expirationTimerView.bringToFront() diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Database.java b/app/src/main/java/org/thoughtcrime/securesms/database/Database.java index b6b224589e..e1879d5230 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Database.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Database.java @@ -26,6 +26,7 @@ import androidx.annotation.NonNull; import net.zetetic.database.sqlcipher.SQLiteDatabase; import org.session.libsession.utilities.WindowDebouncer; +import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; @@ -77,11 +78,11 @@ public abstract class Database { notifyConversationListListeners(); } - protected void setNotifyConverationListeners(Cursor cursor, long threadId) { + protected void setNotifyConversationListeners(Cursor cursor, long threadId) { cursor.setNotificationUri(context.getContentResolver(), DatabaseContentProviders.Conversation.getUriForThread(threadId)); } - protected void setNotifyConverationListListeners(Cursor cursor) { + protected void setNotifyConversationListListeners(Cursor cursor) { cursor.setNotificationUri(context.getContentResolver(), DatabaseContentProviders.ConversationList.CONTENT_URI); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ExpirationConfigurationDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ExpirationConfigurationDatabase.kt index a65c22545b..013bbf5cb5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ExpirationConfigurationDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ExpirationConfigurationDatabase.kt @@ -6,8 +6,8 @@ import android.database.Cursor import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.messages.ExpirationDatabaseMetadata import org.session.libsession.utilities.GroupUtil.CLOSED_GROUP_PREFIX -import org.session.libsession.utilities.GroupUtil.OPEN_GROUP_INBOX_PREFIX -import org.session.libsession.utilities.GroupUtil.OPEN_GROUP_PREFIX +import org.session.libsession.utilities.GroupUtil.COMMUNITY_INBOX_PREFIX +import org.session.libsession.utilities.GroupUtil.COMMUNITY_PREFIX import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper class ExpirationConfigurationDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) { @@ -38,8 +38,8 @@ class ExpirationConfigurationDatabase(context: Context, helper: SQLCipherOpenHel INSERT INTO $TABLE_NAME ($THREAD_ID) SELECT ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ID} FROM ${ThreadDatabase.TABLE_NAME}, ${RecipientDatabase.TABLE_NAME} WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$CLOSED_GROUP_PREFIX%' - AND ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$OPEN_GROUP_PREFIX%' - AND ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$OPEN_GROUP_INBOX_PREFIX%' + AND ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$COMMUNITY_PREFIX%' + AND ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$COMMUNITY_INBOX_PREFIX%' AND EXISTS (SELECT ${RecipientDatabase.EXPIRE_MESSAGES} FROM ${RecipientDatabase.TABLE_NAME} WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} = ${RecipientDatabase.TABLE_NAME}.${RecipientDatabase.ADDRESS} AND ${RecipientDatabase.EXPIRE_MESSAGES} > 0) """.trimIndent() diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt index 441c979108..18dd42818d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt @@ -4,6 +4,7 @@ import android.content.ContentValues import android.content.Context import net.zetetic.database.sqlcipher.SQLiteDatabase.CONFLICT_REPLACE import org.session.libsignal.database.LokiMessageDatabaseProtocol +import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper), LokiMessageDatabaseProtocol { @@ -72,7 +73,12 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab "${Companion.messageID} = ? AND $messageType = ?", arrayOf(messageID.toString(), (if (isSms) SMS_TYPE else MMS_TYPE).toString())) { cursor -> cursor.getInt(serverID).toLong() - } ?: return + } + + if (serverID == null) { + Log.w(this::class.simpleName, "Could not get server ID to delete message with ID: $messageID") + return + } database.beginTransaction() diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MediaDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MediaDatabase.java index 1b273de929..63db0c66ba 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MediaDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MediaDatabase.java @@ -68,7 +68,7 @@ public class MediaDatabase extends Database { public Cursor getGalleryMediaForThread(long threadId) { SQLiteDatabase database = databaseHelper.getReadableDatabase(); Cursor cursor = database.rawQuery(GALLERY_MEDIA_QUERY, new String[]{threadId+""}); - setNotifyConverationListeners(cursor, threadId); + setNotifyConversationListeners(cursor, threadId); return cursor; } @@ -83,7 +83,7 @@ public class MediaDatabase extends Database { public Cursor getDocumentMediaForThread(long threadId) { SQLiteDatabase database = databaseHelper.getReadableDatabase(); Cursor cursor = database.rawQuery(DOCUMENT_MEDIA_QUERY, new String[]{threadId+""}); - setNotifyConverationListeners(cursor, threadId); + setNotifyConversationListeners(cursor, threadId); return cursor; } 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 62db50a1ba..f2fcefd0aa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt @@ -19,9 +19,9 @@ package org.thoughtcrime.securesms.database import android.content.ContentValues import android.content.Context import android.database.Cursor -import android.provider.ContactsContract.CommonDataKinds.BaseTypes import com.annimon.stream.Stream import com.google.android.mms.pdu_alt.PduHeaders +import org.apache.commons.lang3.StringUtils import org.json.JSONArray import org.json.JSONException import org.json.JSONObject @@ -214,7 +214,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa fun getMessage(messageId: Long): Cursor { val cursor = rawQuery(RAW_ID_WHERE, arrayOf(messageId.toString())) - setNotifyConverationListeners(cursor, getThreadIdForMessage(messageId)) + setNotifyConversationListeners(cursor, getThreadIdForMessage(messageId)) return cursor } @@ -630,6 +630,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa if (retrieved.isExpirationUpdate) deleteExpirationTimerMessages(threadId, true.takeUnless { retrieved.isGroup }) val messageId = insertMessageOutbox(retrieved, threadId, false, null, serverTimestamp, runThreadUpdate) if (messageId == -1L) { + Log.w(TAG, "insertSecureDecryptedMessageOutbox believes the MmsDatabase insertion failed.") return Optional.absent() } markAsSent(messageId, true) @@ -859,8 +860,10 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa */ private fun deleteMessages(messageIds: Array) { if (messageIds.isEmpty()) { + Log.w(TAG, "No message Ids provided to MmsDatabase.deleteMessages - aborting delete operation!") return } + // don't need thread IDs val queryBuilder = StringBuilder() for (i in messageIds.indices) { @@ -883,6 +886,8 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa notifyStickerPackListeners() } + // Caution: The bool returned from `deleteMessage` is NOT "Was the message successfully deleted?" + // - it is "Was the thread deleted because removing that message resulted in an empty thread"! override fun deleteMessage(messageId: Long): Boolean { val threadId = getThreadIdForMessage(messageId) val attachmentDatabase = get(context).attachmentDatabase() @@ -899,14 +904,15 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa } override fun deleteMessages(messageIds: LongArray, threadId: Long): Boolean { - val attachmentDatabase = get(context).attachmentDatabase() - val groupReceiptDatabase = get(context).groupReceiptDatabase() + val argsArray = messageIds.map { "?" } + val argValues = messageIds.map { it.toString() }.toTypedArray() - queue(Runnable { attachmentDatabase.deleteAttachmentsForMessages(messageIds) }) - groupReceiptDatabase.deleteRowsForMessages(messageIds) - - val database = databaseHelper.writableDatabase - database!!.delete(TABLE_NAME, ID_IN, arrayOf(messageIds.joinToString(","))) + val db = databaseHelper.writableDatabase + db.delete( + TABLE_NAME, + ID + " IN (" + StringUtils.join(argsArray, ',') + ")", + argValues + ) val threadDeleted = get(context).threadDatabase().update(threadId, false, true) notifyConversationListeners(threadId) @@ -1089,8 +1095,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa } val whereString = where.substring(0, where.length - 4) try { - cursor = - db!!.query(TABLE_NAME, arrayOf(ID), whereString, null, null, null, null) + cursor = db!!.query(TABLE_NAME, arrayOf(ID), whereString, null, null, null, null) val toDeleteStringMessageIds = mutableListOf() while (cursor.moveToNext()) { toDeleteStringMessageIds += cursor.getLong(0).toString() 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 0db4dd00e5..1bf1dcdb15 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java @@ -30,6 +30,7 @@ import net.zetetic.database.sqlcipher.SQLiteQueryBuilder; import org.jetbrains.annotations.NotNull; import org.session.libsession.utilities.Address; import org.session.libsession.utilities.Util; +import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.database.MessagingDatabase.SyncMessageId; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.model.MessageRecord; @@ -115,6 +116,53 @@ public class MmsSmsDatabase extends Database { return null; } + public @Nullable MessageRecord getSentMessageFor(long timestamp, String serializedAuthor) { + // Early exit if the author is not us + boolean isOwnNumber = Util.isOwnNumber(context, serializedAuthor); + if (!isOwnNumber) { + Log.i(TAG, "Asked to find sent messages but provided author is not us - returning null."); + return null; + } + + try (Cursor cursor = queryTables(PROJECTION, MmsSmsColumns.NORMALIZED_DATE_SENT + " = " + timestamp, null, null)) { + MmsSmsDatabase.Reader reader = readerFor(cursor); + + MessageRecord messageRecord; + while ((messageRecord = reader.getNext()) != null) { + if (messageRecord.isOutgoing()) + { + return messageRecord; + } + } + } + Log.i(TAG, "Could not find any message sent from us at provided timestamp - returning null."); + return null; + } + + public MessageRecord getLastSentMessageRecordFromSender(long threadId, String serializedAuthor) { + // Early exit if the author is not us + boolean isOwnNumber = Util.isOwnNumber(context, serializedAuthor); + if (!isOwnNumber) { + Log.i(TAG, "Asked to find last sent message but provided author is not us - returning null."); + return null; + } + + String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; + String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; + + // Try everything with resources so that they auto-close on end of scope + try (Cursor cursor = queryTables(PROJECTION, selection, order, null)) { + try (MmsSmsDatabase.Reader reader = readerFor(cursor)) { + MessageRecord messageRecord; + while ((messageRecord = reader.getNext()) != null) { + if (messageRecord.isOutgoing()) { return messageRecord; } + } + } + } + Log.i(TAG, "Could not find last sent message from us in given thread - returning null."); + return null; + } + public @Nullable MessageRecord getMessageFor(long timestamp, Address author) { return getMessageFor(timestamp, author.serialize()); } @@ -183,7 +231,7 @@ public class MmsSmsDatabase extends Database { String limitStr = limit > 0 || offset > 0 ? offset + ", " + limit : null; Cursor cursor = queryTables(PROJECTION, selection, order, limitStr); - setNotifyConverationListeners(cursor, threadId); + setNotifyConversationListeners(cursor, threadId); return cursor; } @@ -209,6 +257,69 @@ public class MmsSmsDatabase extends Database { } } + // Builds up and returns a list of all all the messages sent by this user in the given thread. + // Used to do a pass through our local database to remove records when a user has "Ban & Delete" + // called on them in a Community. + public Set getAllMessageRecordsFromSenderInThread(long threadId, String serializedAuthor) { + String selection = MmsSmsColumns.THREAD_ID + " = " + threadId + " AND " + MmsSmsColumns.ADDRESS + " = \"" + serializedAuthor + "\""; + Set identifiedMessages = new HashSet(); + + // Try everything with resources so that they auto-close on end of scope + try (Cursor cursor = queryTables(PROJECTION, selection, null, null)) { + try (MmsSmsDatabase.Reader reader = readerFor(cursor)) { + MessageRecord messageRecord; + while ((messageRecord = reader.getNext()) != null) { + identifiedMessages.add(messageRecord); + } + } + } + return identifiedMessages; + } + + // Version of the above `getAllMessageRecordsFromSenderInThread` method that returns the message + // Ids rather than the set of MessageRecords - currently unused by potentially useful in the future. + public Set getAllMessageIdsFromSenderInThread(long threadId, String serializedAuthor) { + String selection = MmsSmsColumns.THREAD_ID + " = " + threadId + " AND " + MmsSmsColumns.ADDRESS + " = \"" + serializedAuthor + "\""; + + Set identifiedMessages = new HashSet(); + + // Try everything with resources so that they auto-close on end of scope + try (Cursor cursor = queryTables(PROJECTION, selection, null, null)) { + try (MmsSmsDatabase.Reader reader = readerFor(cursor)) { + MessageRecord messageRecord; + while ((messageRecord = reader.getNext()) != null) { + identifiedMessages.add(messageRecord.id); + } + } + } + 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; + } + + String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; + String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; + + // Try everything with resources so that they auto-close on end of scope + try (Cursor cursor = queryTables(PROJECTION, selection, order, null)) { + try (MmsSmsDatabase.Reader reader = readerFor(cursor)) { + MessageRecord messageRecord; + while ((messageRecord = reader.getNext()) != null) { + if (messageRecord.isOutgoing()) { return messageRecord.id; } + } + } + } + Log.i(TAG, "Could not find last sent message from us - returning -1."); + return -1; + } + public Cursor getUnread() { String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " ASC"; String selection = "(" + MmsSmsColumns.READ + " = 0 OR " + MmsSmsColumns.REACTIONS_UNREAD + " = 1) AND " + MmsSmsColumns.NOTIFIED + " = 0"; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java index 69c9fa87f1..8dbef32017 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java @@ -1,6 +1,6 @@ package org.thoughtcrime.securesms.database; -import static org.session.libsession.utilities.GroupUtil.OPEN_GROUP_PREFIX; +import static org.session.libsession.utilities.GroupUtil.COMMUNITY_PREFIX; import android.content.ContentValues; import android.content.Context; @@ -123,18 +123,18 @@ public class RecipientDatabase extends Database { public static String getUpdateApprovedCommand() { return "UPDATE "+ TABLE_NAME + " " + "SET " + APPROVED + " = 1, " + APPROVED_ME + " = 1 " + - "WHERE " + ADDRESS + " NOT LIKE '" + OPEN_GROUP_PREFIX + "%'"; + "WHERE " + ADDRESS + " NOT LIKE '" + COMMUNITY_PREFIX + "%'"; } public static String getUpdateResetApprovedCommand() { return "UPDATE "+ TABLE_NAME + " " + "SET " + APPROVED + " = 0, " + APPROVED_ME + " = 0 " + - "WHERE " + ADDRESS + " NOT LIKE '" + OPEN_GROUP_PREFIX + "%'"; + "WHERE " + ADDRESS + " NOT LIKE '" + COMMUNITY_PREFIX + "%'"; } public static String getUpdateApprovedSelectConversations() { return "UPDATE "+ TABLE_NAME + " SET "+APPROVED+" = 1, "+APPROVED_ME+" = 1 "+ - "WHERE "+ADDRESS+ " NOT LIKE '"+OPEN_GROUP_PREFIX+"%' " + + "WHERE "+ADDRESS+ " NOT LIKE '"+ COMMUNITY_PREFIX +"%' " + "AND ("+ADDRESS+" IN (SELECT "+ThreadDatabase.TABLE_NAME+"."+ThreadDatabase.ADDRESS+" FROM "+ThreadDatabase.TABLE_NAME+" WHERE ("+ThreadDatabase.MESSAGE_COUNT+" != 0) "+ "OR "+ADDRESS+" IN (SELECT "+GroupDatabase.TABLE_NAME+"."+GroupDatabase.ADMINS+" FROM "+GroupDatabase.TABLE_NAME+")))"; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SearchDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SearchDatabase.java index eac6a5fbc3..106cc86e17 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SearchDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SearchDatabase.java @@ -10,6 +10,7 @@ import com.annimon.stream.Stream; import net.zetetic.database.sqlcipher.SQLiteDatabase; import org.session.libsession.utilities.Util; + import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import java.util.List; @@ -115,11 +116,9 @@ public class SearchDatabase extends Database { public Cursor queryMessages(@NonNull String query) { SQLiteDatabase db = databaseHelper.getReadableDatabase(); String prefixQuery = adjustQuery(query); - int queryLimit = Math.min(query.length()*50,500); - Cursor cursor = db.rawQuery(MESSAGES_QUERY, new String[] { prefixQuery, prefixQuery, String.valueOf(queryLimit) }); - setNotifyConverationListListeners(cursor); + setNotifyConversationListListeners(cursor); return cursor; } @@ -128,7 +127,7 @@ public class SearchDatabase extends Database { String prefixQuery = adjustQuery(query); Cursor cursor = db.rawQuery(MESSAGES_FOR_THREAD_QUERY, new String[] { prefixQuery, String.valueOf(threadId), prefixQuery, String.valueOf(threadId) }); - setNotifyConverationListListeners(cursor); + setNotifyConversationListListeners(cursor); return cursor; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java index 2c0c33dda8..450e7c2d23 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -621,16 +621,19 @@ public class SmsDatabase extends MessagingDatabase { public Cursor getMessageCursor(long messageId) { SQLiteDatabase db = databaseHelper.getReadableDatabase(); Cursor cursor = db.query(TABLE_NAME, MESSAGE_PROJECTION, ID_WHERE, new String[] {messageId + ""}, null, null, null); - setNotifyConverationListeners(cursor, getThreadIdForMessage(messageId)); + setNotifyConversationListeners(cursor, getThreadIdForMessage(messageId)); return cursor; } + // Caution: The bool returned from `deleteMessage` is NOT "Was the message successfully deleted?" + // - it is "Was the thread deleted because removing that message resulted in an empty thread"! @Override public boolean deleteMessage(long messageId) { Log.i("MessageDatabase", "Deleting: " + messageId); SQLiteDatabase db = databaseHelper.getWritableDatabase(); long threadId = getThreadIdForMessage(messageId); db.delete(TABLE_NAME, ID_WHERE, new String[] {messageId+""}); + notifyConversationListeners(threadId); boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false, true); return threadDeleted; } @@ -645,9 +648,6 @@ public class SmsDatabase extends MessagingDatabase { argValues[i] = (messageIds[i] + ""); } - String combinedMessageIdArgss = StringUtils.join(messageIds, ','); - String combinedMessageIds = StringUtils.join(messageIds, ','); - Log.i("MessageDatabase", "Deleting: " + combinedMessageIds); SQLiteDatabase db = databaseHelper.getWritableDatabase(); db.delete( TABLE_NAME, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt index 584394a86c..73dadda2cb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -92,7 +92,6 @@ import org.thoughtcrime.securesms.mms.PartAuthority import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.SessionMetaProtocol import java.security.MessageDigest -import kotlin.time.Duration.Companion.days import network.loki.messenger.libsession_util.util.Contact as LibSessionContact private const val TAG = "Storage" @@ -121,7 +120,7 @@ open class Storage( ) volatile.set(newVolatileParams) } - } else if (address.isOpenGroup) { + } else if (address.isCommunity) { // these should be added on the group join / group info fetch Log.w("Loki", "Thread created called for open group address, not adding any extra information") } @@ -152,7 +151,7 @@ open class Storage( val sessionId = GroupUtil.doubleDecodeGroupId(address.serialize()) volatile.eraseLegacyClosedGroup(sessionId) groups.eraseLegacyGroup(sessionId) - } else if (address.isOpenGroup) { + } else if (address.isCommunity) { // these should be removed in the group leave / handling new configs Log.w("Loki", "Thread delete called for open group address, expecting to be handled elsewhere") } @@ -257,7 +256,7 @@ open class Storage( // recipient closed group recipient.isClosedGroupRecipient -> config.getOrConstructLegacyGroup(GroupUtil.doubleDecodeGroupId(recipient.address.serialize())) // recipient is open group - recipient.isOpenGroupRecipient -> { + recipient.isCommunityRecipient -> { val openGroupJoinUrl = getOpenGroup(threadId)?.joinURL ?: return BaseCommunityInfo.parseFullUrl(openGroupJoinUrl)?.let { (base, room, pubKey) -> config.getOrConstructCommunity(base, room, pubKey) @@ -327,7 +326,7 @@ open class Storage( setRecipientApprovedMe(targetRecipient, true) } } - if (message.threadID == null && !targetRecipient.isOpenGroupRecipient) { + if (message.threadID == null && !targetRecipient.isCommunityRecipient) { // open group recipients should explicitly create threads message.threadID = getOrCreateThreadIdFor(targetAddress) } @@ -767,13 +766,36 @@ open class Storage( override fun markAsSent(timestamp: Long, author: String) { val database = DatabaseComponent.get(context).mmsSmsDatabase() - val messageRecord = database.getMessageFor(timestamp, author) ?: return + val messageRecord = database.getSentMessageFor(timestamp, author) + if (messageRecord == null) { + Log.w(TAG, "Failed to retrieve local message record in Storage.markAsSent - aborting.") + return + } + if (messageRecord.isMms) { - val mmsDatabase = DatabaseComponent.get(context).mmsDatabase() - mmsDatabase.markAsSent(messageRecord.getId(), true) + DatabaseComponent.get(context).mmsDatabase().markAsSent(messageRecord.getId(), true) } else { - val smsDatabase = DatabaseComponent.get(context).smsDatabase() - smsDatabase.markAsSent(messageRecord.getId(), true) + DatabaseComponent.get(context).smsDatabase().markAsSent(messageRecord.getId(), true) + } + } + + // Method that marks a message as sent in Communities (only!) - where the server modifies the + // message timestamp and as such we cannot use that to identify the local message. + override fun markAsSentToCommunity(threadId: Long, messageID: Long) { + val database = DatabaseComponent.get(context).mmsSmsDatabase() + val message = database.getLastSentMessageRecordFromSender(threadId, TextSecurePreferences.getLocalNumber(context)) + + // Ensure we can find the local message.. + if (message == null) { + Log.w(TAG, "Could not find local message in Storage.markAsSentToCommunity - aborting.") + return + } + + // ..and mark as sent if found. + if (message.isMms) { + DatabaseComponent.get(context).mmsDatabase().markAsSent(message.getId(), true) + } else { + DatabaseComponent.get(context).smsDatabase().markAsSent(message.getId(), true) } } @@ -808,7 +830,11 @@ open class Storage( override fun markUnidentified(timestamp: Long, author: String) { val database = DatabaseComponent.get(context).mmsSmsDatabase() - val messageRecord = database.getMessageFor(timestamp, author) ?: return + val messageRecord = database.getMessageFor(timestamp, author) + if (messageRecord == null) { + Log.w(TAG, "Could not identify message with timestamp: $timestamp from author: $author") + return + } if (messageRecord.isMms) { val mmsDatabase = DatabaseComponent.get(context).mmsDatabase() mmsDatabase.markUnidentified(messageRecord.getId(), true) @@ -818,6 +844,26 @@ open class Storage( } } + // Method that marks a message as unidentified in Communities (only!) - where the server + // modifies the message timestamp and as such we cannot use that to identify the local message. + override fun markUnidentifiedInCommunity(threadId: Long, messageId: Long) { + val database = DatabaseComponent.get(context).mmsSmsDatabase() + val message = database.getLastSentMessageRecordFromSender(threadId, TextSecurePreferences.getLocalNumber(context)) + + // Check to ensure the message exists + if (message == null) { + Log.w(TAG, "Could not find local message in Storage.markUnidentifiedInCommunity - aborting.") + return + } + + // Mark it as unidentified if we found the message successfully + if (message.isMms) { + DatabaseComponent.get(context).mmsDatabase().markUnidentified(message.getId(), true) + } else { + DatabaseComponent.get(context).smsDatabase().markUnidentified(message.getId(), true) + } + } + override fun markAsSentFailed(timestamp: Long, author: String, error: Exception) { val database = DatabaseComponent.get(context).mmsSmsDatabase() val messageRecord = database.getMessageFor(timestamp, author) ?: return @@ -971,7 +1017,10 @@ open class Storage( val infoMessage = OutgoingGroupMediaMessage(recipient, updateData, groupID, null, sentTimestamp, 0, 0, true, null, listOf(), listOf()) val mmsDB = DatabaseComponent.get(context).mmsDatabase() val mmsSmsDB = DatabaseComponent.get(context).mmsSmsDatabase() - if (mmsSmsDB.getMessageFor(sentTimestamp, userPublicKey) != null) return + if (mmsSmsDB.getMessageFor(sentTimestamp, userPublicKey) != null) { + Log.w(TAG, "Bailing from insertOutgoingInfoMessage because we believe the message has already been sent!") + return + } val infoMessageID = mmsDB.insertMessageOutbox(infoMessage, threadID, false, null, runThreadUpdate = true) mmsDB.markAsSent(infoMessageID, true) } @@ -1289,7 +1338,7 @@ open class Storage( priority = if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE ) groups.set(newGroupInfo) - } else if (threadRecipient.isOpenGroupRecipient) { + } else if (threadRecipient.isCommunityRecipient) { val openGroup = getOpenGroup(threadID) ?: return val (baseUrl, room, pubKeyHex) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: return val newGroupInfo = groups.getOrConstructCommunityInfo(baseUrl, room, Hex.toStringCondensed(pubKeyHex)).copy ( 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 fd5042086c..507088a0ad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -18,7 +18,7 @@ package org.thoughtcrime.securesms.database; import static org.session.libsession.utilities.GroupUtil.CLOSED_GROUP_PREFIX; -import static org.session.libsession.utilities.GroupUtil.OPEN_GROUP_PREFIX; +import static org.session.libsession.utilities.GroupUtil.COMMUNITY_PREFIX; import static org.thoughtcrime.securesms.database.GroupDatabase.GROUP_ID; import android.content.ContentValues; @@ -427,7 +427,7 @@ public class ThreadDatabase extends Database { } Cursor cursor = cursors.size() > 1 ? new MergeCursor(cursors.toArray(new Cursor[cursors.size()])) : cursors.get(0); - setNotifyConverationListListeners(cursor); + setNotifyConversationListListeners(cursor); return cursor; } @@ -491,7 +491,7 @@ public class ThreadDatabase extends Database { } public Cursor getConversationList() { - String where = "(" + MESSAGE_COUNT + " != 0 OR " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " LIKE '" + OPEN_GROUP_PREFIX + "%') " + + String where = "(" + MESSAGE_COUNT + " != 0 OR " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " LIKE '" + COMMUNITY_PREFIX + "%') " + "AND " + ARCHIVED + " = 0 "; return getConversationList(where); } @@ -502,7 +502,7 @@ public class ThreadDatabase extends Database { } public Cursor getApprovedConversationList() { - String where = "((" + HAS_SENT + " = 1 OR " + RecipientDatabase.APPROVED + " = 1 OR "+ GroupDatabase.TABLE_NAME +"."+GROUP_ID+" LIKE '"+CLOSED_GROUP_PREFIX+"%') OR " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " LIKE '" + OPEN_GROUP_PREFIX + "%') " + + String where = "((" + HAS_SENT + " = 1 OR " + RecipientDatabase.APPROVED + " = 1 OR "+ GroupDatabase.TABLE_NAME +"."+GROUP_ID+" LIKE '"+CLOSED_GROUP_PREFIX+"%') OR " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " LIKE '" + COMMUNITY_PREFIX + "%') " + "AND " + ARCHIVED + " = 0 "; return getConversationList(where); } @@ -515,18 +515,12 @@ public class ThreadDatabase extends Database { return getConversationList(where); } - public Cursor getArchivedConversationList() { - String where = "(" + MESSAGE_COUNT + " != 0 OR " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " LIKE '" + OPEN_GROUP_PREFIX + "%') " + - "AND " + ARCHIVED + " = 1 "; - return getConversationList(where); - } - private Cursor getConversationList(String where) { SQLiteDatabase db = databaseHelper.getReadableDatabase(); String query = createQuery(where, 0); Cursor cursor = db.rawQuery(query, null); - setNotifyConverationListListeners(cursor); + setNotifyConversationListListeners(cursor); return cursor; } @@ -547,7 +541,7 @@ public class ThreadDatabase extends Database { // edge case where we set the last seen time for a conversation before it loads messages (joining community for example) MmsSmsDatabase mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase(); Recipient forThreadId = getRecipientForThreadId(threadId); - if (mmsSmsDatabase.getConversationCount(threadId) <= 0 && forThreadId != null && forThreadId.isOpenGroupRecipient()) return false; + if (mmsSmsDatabase.getConversationCount(threadId) <= 0 && forThreadId != null && forThreadId.isCommunityRecipient()) return false; SQLiteDatabase db = databaseHelper.getWritableDatabase(); @@ -750,10 +744,7 @@ public class ThreadDatabase extends Database { return true; } - MmsSmsDatabase.Reader reader = null; - - try { - reader = mmsSmsDatabase.readerFor(mmsSmsDatabase.getConversationSnippet(threadId)); + try (MmsSmsDatabase.Reader reader = mmsSmsDatabase.readerFor(mmsSmsDatabase.getConversationSnippet(threadId))) { MessageRecord record = null; if (reader != null) { record = reader.getNext(); @@ -771,11 +762,10 @@ public class ThreadDatabase extends Database { deleteThread(threadId); return true; } + // todo: add empty snippet that clears existing data return false; } } finally { - if (reader != null) - reader.close(); notifyConversationListListeners(); notifyConversationListeners(threadId); } @@ -822,7 +812,7 @@ public class ThreadDatabase extends Database { private boolean deleteThreadOnEmpty(long threadId) { Recipient threadRecipient = getRecipientForThreadId(threadId); - return threadRecipient != null && !threadRecipient.isOpenGroupRecipient(); + return threadRecipient != null && !threadRecipient.isCommunityRecipient(); } private @NonNull String getFormattedBodyFor(@NonNull MessageRecord messageRecord) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java index 39fba182aa..605118f3cc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java @@ -22,7 +22,10 @@ import android.text.SpannableString; import androidx.annotation.NonNull; import org.session.libsession.utilities.recipients.Recipient; +import org.session.libsignal.utilities.Log; +import org.thoughtcrime.securesms.database.MmsDatabase; import org.thoughtcrime.securesms.database.MmsSmsColumns; +import org.thoughtcrime.securesms.database.MmsSmsDatabase; import org.thoughtcrime.securesms.database.SmsDatabase; /** @@ -48,6 +51,9 @@ public abstract class DisplayRecord { long dateReceived, long threadId, int deliveryStatus, int deliveryReceiptCount, long type, int readReceiptCount) { + // TODO: This gets hit very, very often and it likely shouldn't - place a Log.d statement in it to see. + //Log.d("[ACL]", "Creating a display record with delivery status of: " + deliveryStatus); + this.threadId = threadId; this.recipient = recipient; this.dateSent = dateSent; @@ -76,9 +82,7 @@ public abstract class DisplayRecord { && deliveryStatus < SmsDatabase.Status.STATUS_PENDING) || deliveryReceiptCount > 0; } - public boolean isSent() { - return !isFailed() && !isPending(); - } + public boolean isSent() { return MmsSmsColumns.Types.isSentType(type); } public boolean isSyncing() { return MmsSmsColumns.Types.isSyncingType(type); @@ -99,9 +103,10 @@ public abstract class DisplayRecord { } public boolean isPending() { - return MmsSmsColumns.Types.isPendingMessageType(type) - && !MmsSmsColumns.Types.isIdentityVerified(type) - && !MmsSmsColumns.Types.isIdentityDefault(type); + boolean isPending = MmsSmsColumns.Types.isPendingMessageType(type) && + !MmsSmsColumns.Types.isIdentityVerified(type) && + !MmsSmsColumns.Types.isIdentityDefault(type); + return isPending; } public boolean isRead() { return readReceiptCount > 0; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt index ecd40938a1..75c7681b1d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt @@ -78,7 +78,7 @@ class CreateGroupFragment : Fragment() { if (name.isEmpty()) { return@setOnClickListener Toast.makeText(context, R.string.activity_create_closed_group_group_name_missing_error, Toast.LENGTH_LONG).show() } - if (name.length >= 30) { + if (name.length > resources.getInteger(R.integer.max_group_and_community_name_length_chars)) { return@setOnClickListener Toast.makeText(context, R.string.activity_create_closed_group_group_name_too_long_error, Toast.LENGTH_LONG).show() } val selectedMembers = adapter.selectedMembers diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt index 702bf33929..82b9f16dcd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt @@ -11,7 +11,6 @@ import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.databinding.FragmentConversationBottomSheetBinding import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.dependencies.ConfigFactory -import org.thoughtcrime.securesms.util.UiModeUtilities import org.thoughtcrime.securesms.util.getConversationUnread import javax.inject.Inject @@ -75,7 +74,7 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto } binding.copyConversationId.visibility = if (!recipient.isGroupRecipient && !recipient.isLocalNumber) View.VISIBLE else View.GONE binding.copyConversationId.setOnClickListener(this) - binding.copyCommunityUrl.visibility = if (recipient.isOpenGroupRecipient) View.VISIBLE else View.GONE + binding.copyCommunityUrl.visibility = if (recipient.isCommunityRecipient) View.VISIBLE else View.GONE binding.copyCommunityUrl.setOnClickListener(this) binding.unMuteNotificationsTextView.isVisible = recipient.isMuted && !recipient.isLocalNumber binding.muteNotificationsTextView.isVisible = !recipient.isMuted && !recipient.isLocalNumber 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 6a7eaa508a..67400a7a81 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -22,7 +22,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredWidth import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.material.Divider import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme @@ -99,11 +98,11 @@ import org.thoughtcrime.securesms.preferences.SettingsActivity import org.thoughtcrime.securesms.showMuteDialog import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.ui.AppTheme +import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.OutlineButton import org.thoughtcrime.securesms.ui.PreviewTheme import org.thoughtcrime.securesms.ui.SessionShieldIcon import org.thoughtcrime.securesms.ui.ThemeResPreviewParameterProvider -import org.thoughtcrime.securesms.ui.contentDescription import org.thoughtcrime.securesms.ui.h8 import org.thoughtcrime.securesms.ui.small import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities @@ -226,7 +225,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } // Set up empty state view - binding.emptyStateContainer.setContent { EmptyView() } + binding.emptyStateContainer.setContent { EmptyView(ApplicationContext.getInstance(this).newAccount) } IP2Country.configureIfNeeded(this@HomeActivity) startObservingUpdates() @@ -317,7 +316,6 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } val newData = contactResults + messageResults - globalSearchAdapter.setNewData(result.query, newData) } } @@ -365,16 +363,17 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ) { Column(Modifier.weight(1f)) { Row { - Text("Save your recovery password", style = MaterialTheme.typography.h8) + Text(stringResource(R.string.save_your_recovery_password), style = MaterialTheme.typography.h8) Spacer(Modifier.requiredWidth(8.dp)) SessionShieldIcon() } - Text("Save your recovery password to make sure you don't lose access to your account.", style = MaterialTheme.typography.small) + Text(stringResource(R.string.save_your_recovery_password_to_make_sure_you_don_t_lose_access_to_your_account), style = MaterialTheme.typography.small) } Spacer(Modifier.width(12.dp)) OutlineButton( stringResource(R.string.continue_2), - Modifier.align(Alignment.CenterVertically) + Modifier.align(Alignment.CenterVertically), + contentDescription = GetString(R.string.AccessibilityId_reveal_recovery_phrase_button) ) { startRecoveryPasswordActivity() } } } @@ -382,7 +381,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } @Composable - private fun EmptyView() { + private fun EmptyView(newAccount: Boolean) { AppTheme { Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -392,18 +391,20 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ) { Spacer(modifier = Modifier.weight(1f)) Icon( - painter = painterResource(id = R.drawable.emoji_tada), + painter = painterResource(id = if (newAccount) R.drawable.emoji_tada_large else R.drawable.ic_logo_large), contentDescription = null, tint = Color.Unspecified ) - Text("Account Created", style = MaterialTheme.typography.h4, textAlign = TextAlign.Center) - Text("Welcome to Session", color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center) + if (newAccount) Text(stringResource(R.string.onboardingAccountCreated), style = MaterialTheme.typography.h4, textAlign = TextAlign.Center) + if (newAccount) Text(stringResource(R.string.welcome_to_session), color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center) + Divider(modifier = Modifier.padding(vertical = 16.dp)) - Text("You don't have any conversations yet", + Text( + stringResource(R.string.conversationsNone), style = MaterialTheme.typography.h8, textAlign = TextAlign.Center, modifier = Modifier.padding(bottom = 12.dp)) - Text("Hit the plus button to start a chat, create a group, or join an official communitiy!", textAlign = TextAlign.Center) + Text(stringResource(R.string.onboardingHitThePlusButton), textAlign = TextAlign.Center) Spacer(modifier = Modifier.weight(2f)) } } @@ -585,7 +586,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), manager.setPrimaryClip(clip) Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() } - else if (thread.recipient.isOpenGroupRecipient) { + else if (thread.recipient.isCommunityRecipient) { val threadId = threadDb.getThreadIdIfExistsFor(thread.recipient) ?: return@onCopyConversationId Unit val openGroup = DatabaseComponent.get(this@HomeActivity).lokiThreadDatabase().getOpenGroupChat(threadId) ?: return@onCopyConversationId Unit @@ -718,7 +719,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), val message = if (recipient.isGroupRecipient) { val group = groupDatabase.getGroup(recipient.address.toString()).orNull() if (group != null && group.admins.map { it.toString() }.contains(textSecurePreferences.getLocalNumber())) { - "Because you are the creator of this group it will be deleted for everyone. This cannot be undone." + getString(R.string.admin_group_leave_warning) } else { resources.getString(R.string.activity_home_leave_group_dialog_message) } @@ -774,7 +775,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), private fun hideMessageRequests() { showSessionDialog { - text("Hide message requests?") + text(getString(R.string.hide_message_requests)) button(R.string.yes) { textSecurePreferences.setHasHiddenMessageRequests() setupMessageRequestsBanner() 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 2922044435..1f3f2ff537 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt @@ -21,6 +21,7 @@ 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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt index ad8f2d0421..bd38d0df86 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt @@ -25,7 +25,6 @@ import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.IdPrefix import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.database.ThreadDatabase -import org.thoughtcrime.securesms.mms.GlideApp import javax.inject.Inject @AndroidEntryPoint @@ -34,6 +33,9 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() { @Inject lateinit var threadDb: ThreadDatabase private lateinit var binding: FragmentUserDetailsBottomSheetBinding + + private var previousContactNickname: String = "" + companion object { const val ARGUMENT_PUBLIC_KEY = "publicKey" const val ARGUMENT_THREAD_ID = "threadId" @@ -89,10 +91,10 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() { && !threadRecipient.isOpenGroupInboxRecipient && !threadRecipient.isOpenGroupOutboxRecipient - publicKeyTextView.isVisible = !threadRecipient.isOpenGroupRecipient + publicKeyTextView.isVisible = !threadRecipient.isCommunityRecipient && !threadRecipient.isOpenGroupInboxRecipient && !threadRecipient.isOpenGroupOutboxRecipient - messageButton.isVisible = !threadRecipient.isOpenGroupRecipient || IdPrefix.fromValue(publicKey)?.isBlinded() == true + messageButton.isVisible = !threadRecipient.isCommunityRecipient || IdPrefix.fromValue(publicKey)?.isBlinded() == true publicKeyTextView.text = publicKey publicKeyTextView.setOnLongClickListener { val clipboard = @@ -130,9 +132,10 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() { nameTextViewContainer.visibility = View.VISIBLE nameEditTextContainer.visibility = View.INVISIBLE var newNickName: String? = null - if (nicknameEditText.text.isNotEmpty()) { + if (nicknameEditText.text.isNotEmpty() && nicknameEditText.text.trim().length != 0) { newNickName = nicknameEditText.text.toString() } + else { newNickName = previousContactNickname } val publicKey = recipient.address.serialize() val storage = MessagingModuleConfiguration.shared.storage val contact = storage.getContactWithSessionID(publicKey) ?: Contact(publicKey) @@ -145,6 +148,9 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() { fun showSoftKeyboard() { val imm = context?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager imm?.showSoftInput(binding.nicknameEditText, 0) + + // Keep track of the original nickname to re-use if an empty / blank nickname is entered + previousContactNickname = binding.nameTextView.text.toString() } fun hideSoftKeyboard() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.java index a4871f0fc9..cad3b6f6c5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.java @@ -53,7 +53,7 @@ public class MultipleRecipientNotificationBuilder extends AbstractNotificationBu public void setMostRecentSender(Recipient recipient, Recipient threadRecipient) { String displayName = recipient.toShortString(); if (threadRecipient.isGroupRecipient()) { - displayName = getGroupDisplayName(recipient, threadRecipient.isOpenGroupRecipient()); + displayName = getGroupDisplayName(recipient, threadRecipient.isCommunityRecipient()); } if (privacy.isDisplayContact()) { setContentText(context.getString(R.string.MessageNotifier_most_recent_from_s, displayName)); @@ -79,7 +79,7 @@ public class MultipleRecipientNotificationBuilder extends AbstractNotificationBu public void addMessageBody(@NonNull Recipient sender, Recipient threadRecipient, @Nullable CharSequence body) { String displayName = sender.toShortString(); if (threadRecipient.isGroupRecipient()) { - displayName = getGroupDisplayName(sender, threadRecipient.isOpenGroupRecipient()); + displayName = getGroupDisplayName(sender, threadRecipient.isCommunityRecipient()); } if (privacy.isDisplayMessage()) { SpannableStringBuilder builder = new SpannableStringBuilder(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java index 891d0bb2df..2aaa593b58 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java @@ -125,7 +125,7 @@ public class SingleRecipientNotificationBuilder extends AbstractNotificationBuil SpannableStringBuilder stringBuilder = new SpannableStringBuilder(); if (privacy.isDisplayContact() && threadRecipient.isGroupRecipient()) { - String displayName = getGroupDisplayName(individualRecipient, threadRecipient.isOpenGroupRecipient()); + String displayName = getGroupDisplayName(individualRecipient, threadRecipient.isCommunityRecipient()); stringBuilder.append(Util.getBoldedString(displayName + ": ")); } @@ -215,7 +215,7 @@ public class SingleRecipientNotificationBuilder extends AbstractNotificationBuil SpannableStringBuilder stringBuilder = new SpannableStringBuilder(); if (privacy.isDisplayContact() && threadRecipient.isGroupRecipient()) { - String displayName = getGroupDisplayName(individualRecipient, threadRecipient.isOpenGroupRecipient()); + String displayName = getGroupDisplayName(individualRecipient, threadRecipient.isCommunityRecipient()); stringBuilder.append(Util.getBoldedString(displayName + ": ")); } 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 dedb0ed85e..8ce485a9b5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LandingActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/LandingActivity.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign 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 network.loki.messenger.R @@ -34,7 +35,10 @@ import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.ui.AppTheme import org.thoughtcrime.securesms.ui.BorderlessButton import org.thoughtcrime.securesms.ui.FilledButton +import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.OutlineButton +import org.thoughtcrime.securesms.ui.PreviewTheme +import org.thoughtcrime.securesms.ui.ThemeResPreviewParameterProvider import org.thoughtcrime.securesms.ui.classicDarkColors import org.thoughtcrime.securesms.ui.contentDescription import org.thoughtcrime.securesms.ui.session_accent @@ -52,7 +56,7 @@ class LandingActivity : BaseActionBarActivity() { setUpActionBarSessionLogo(true) ComposeView(this) - .apply { setContent { LandingScreen() } } + .apply { setContent { AppTheme { LandingScreen() } } } .let(::setContentView) IdentityKeyUtil.generateIdentityKeyPair(this) @@ -63,41 +67,55 @@ class LandingActivity : BaseActionBarActivity() { @Preview @Composable - private fun LandingScreen() { - AppTheme { - 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) - 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)) - Spacer(modifier = Modifier.weight(1f)) + private fun LandingScreen( + @PreviewParameter(ThemeResPreviewParameterProvider::class) themeResId: Int + ) { + PreviewTheme(themeResId) { + LandingScreen() + } + } - OutlineButton( - text = stringResource(R.string.onboardingAccountCreate), - modifier = Modifier - .width(262.dp) - .align(Alignment.CenterHorizontally)) { startPickDisplayNameActivity() } - Spacer(modifier = Modifier.height(14.dp)) - FilledButton(text = stringResource(R.string.onboardingAccountExists), modifier = Modifier + @Composable + private fun LandingScreen() { + 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) + 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)) + Spacer(modifier = Modifier.weight(1f)) + + OutlineButton( + text = stringResource(R.string.onboardingAccountCreate), + modifier = Modifier .width(262.dp) - .align(Alignment.CenterHorizontally)) { startLinkDeviceActivity() } - Spacer(modifier = Modifier.height(8.dp)) - BorderlessButton( - text = stringResource(R.string.onboardingTosPrivacy), - modifier = Modifier - .width(262.dp) - .align(Alignment.CenterHorizontally), - fontSize = 11.sp, - lineHeight = 13.sp - ) { openDialog() } - Spacer(modifier = Modifier.height(8.dp)) - } + .align(Alignment.CenterHorizontally), + contentDescription = GetString(R.string.AccessibilityId_create_account_button) + ) { startPickDisplayNameActivity() } + Spacer(modifier = Modifier.height(14.dp)) + FilledButton( + text = stringResource(R.string.onboardingAccountExists), + modifier = Modifier + .width(262.dp) + .align(Alignment.CenterHorizontally), + contentDescription = GetString(R.string.AccessibilityId_restore_account_button) + ) { startLinkDeviceActivity() } + Spacer(modifier = Modifier.height(8.dp)) + BorderlessButton( + text = stringResource(R.string.onboardingTosPrivacy), + modifier = Modifier + .width(262.dp) + .align(Alignment.CenterHorizontally), + contentDescription = GetString(R.string.AccessibilityId_privacy_policy_link), + fontSize = 11.sp, + lineHeight = 13.sp + ) { openDialog() } + Spacer(modifier = Modifier.height(8.dp)) } } @@ -105,8 +123,14 @@ class LandingActivity : BaseActionBarActivity() { showSessionDialog { title(R.string.urlOpen) text(R.string.urlOpenBrowser) - button(R.string.activity_landing_terms_of_service) { open("https://getsession.org/terms-of-service") } - button(R.string.activity_landing_privacy_policy) { open("https://getsession.org/privacy-policy") } + button( + R.string.activity_landing_terms_of_service, + contentDescriptionRes = R.string.AccessibilityId_terms_of_service_link + ) { open("https://getsession.org/terms-of-service") } + button( + R.string.activity_landing_privacy_policy, + contentDescriptionRes = R.string.AccessibilityId_privacy_policy_link + ) { open("https://getsession.org/privacy-policy") } } } @@ -136,8 +160,8 @@ class LandingActivity : BaseActionBarActivity() { private fun ChatText( text: String, color: Color, - textColor: Color = Color.Unspecified, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + textColor: Color = Color.Unspecified ) { Text( text, diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt index 3ae0ff3253..fc0421edf4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt @@ -45,6 +45,7 @@ import org.thoughtcrime.securesms.ui.baseBold import org.thoughtcrime.securesms.ui.components.MaybeScanQrCode import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField import org.thoughtcrime.securesms.ui.components.SessionTabRow +import org.thoughtcrime.securesms.ui.contentDescription import javax.inject.Inject private const val TAG = "LinkDeviceActivity" @@ -130,15 +131,22 @@ fun RecoveryPassword(state: LinkDeviceState, onChange: (String) -> Unit = {}, on Spacer(Modifier.size(24.dp)) SessionOutlinedTextField( text = state.recoveryPhrase, + modifier = Modifier + .contentDescription(R.string.AccessibilityId_recovery_phrase_input) + .padding(horizontal = 64.dp), placeholder = stringResource(R.string.recoveryPasswordEnter), onChange = onChange, onContinue = onContinue, - error = state.error, - modifier = Modifier.padding(horizontal = 64.dp) + error = state.error ) Spacer(Modifier.size(12.dp)) state.error?.let { - Text(it, style = MaterialTheme.typography.baseBold, color = MaterialTheme.colors.error) + Text( + it, + modifier = Modifier.contentDescription(R.string.AccessibilityId_error_message), + style = MaterialTheme.typography.baseBold, + color = MaterialTheme.colors.error + ) } Spacer(Modifier.weight(2f)) OutlineButton( diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LoadingActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/LoadingActivity.kt index e68fcb541a..b37cdc16d7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LoadingActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/LoadingActivity.kt @@ -18,16 +18,21 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.res.stringResource import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch +import network.loki.messenger.R +import org.session.libsession.utilities.AppTextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences +import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.BaseActionBarActivity import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.onboarding.messagenotifications.startPNModeActivity import org.thoughtcrime.securesms.onboarding.pickname.startPickDisplayNameActivity import org.thoughtcrime.securesms.ui.AppTheme import org.thoughtcrime.securesms.ui.ProgressArc +import org.thoughtcrime.securesms.ui.contentDescription import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo import javax.inject.Inject @@ -63,6 +68,8 @@ class LoadingActivity: BaseActionBarActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + ApplicationContext.getInstance(this).newAccount = false + ComposeView(this) .apply { setContent { LoadingScreen() } } .let(::setContentView) @@ -98,9 +105,9 @@ class LoadingActivity: BaseActionBarActivity() { AppTheme { Column { Spacer(modifier = Modifier.weight(1f)) - ProgressArc(animatable.value, modifier = Modifier.align(Alignment.CenterHorizontally)) - Text("One moment please..", modifier = Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.h6) - Text("Loading your account", modifier = Modifier.align(Alignment.CenterHorizontally)) + ProgressArc(animatable.value, modifier = Modifier.align(Alignment.CenterHorizontally).contentDescription(R.string.AccessibilityId_loading_animation)) + Text(stringResource(R.string.waitOneMoment), modifier = Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.h6) + Text(stringResource(R.string.loadAccountProgressMessage), modifier = Modifier.align(Alignment.CenterHorizontally)) Spacer(modifier = Modifier.weight(2f)) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotificationsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotificationsActivity.kt index 42fc83f05e..71c1786e4c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotificationsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotificationsActivity.kt @@ -38,9 +38,11 @@ import org.thoughtcrime.securesms.BaseActionBarActivity import org.thoughtcrime.securesms.home.HomeActivity import org.thoughtcrime.securesms.notifications.PushRegistry import org.thoughtcrime.securesms.ui.AppTheme +import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.OutlineButton import org.thoughtcrime.securesms.ui.PreviewTheme import org.thoughtcrime.securesms.ui.ThemeResPreviewParameterProvider +import org.thoughtcrime.securesms.ui.contentDescription import org.thoughtcrime.securesms.ui.h8 import org.thoughtcrime.securesms.ui.h9 import org.thoughtcrime.securesms.ui.session_accent @@ -61,16 +63,16 @@ class MessageNotificationsActivity : BaseActionBarActivity() { TextSecurePreferences.setHasSeenWelcomeScreen(this, true) ComposeView(this) - .apply { setContent { MessageNotifications() } } + .apply { setContent { MessageNotificationsScreen() } } .let(::setContentView) } @Composable - private fun MessageNotifications() { + private fun MessageNotificationsScreen() { val state by viewModel.stateFlow.collectAsState() AppTheme { - MessageNotifications(state, viewModel::setEnabled, ::register) + MessageNotificationsScreen(state, viewModel::setEnabled, ::register) } } @@ -87,30 +89,31 @@ class MessageNotificationsActivity : BaseActionBarActivity() { @Preview @Composable -fun MessageNotificationsPreview( +fun MessageNotificationsScreenPreview( @PreviewParameter(ThemeResPreviewParameterProvider::class) themeResId: Int ) { PreviewTheme(themeResId) { - MessageNotifications() + MessageNotificationsScreen() } } @Composable -fun MessageNotifications( +fun MessageNotificationsScreen( state: MessageNotificationsState = MessageNotificationsState(), setEnabled: (Boolean) -> Unit = {}, onContinue: () -> Unit = {} ) { Column(Modifier.padding(horizontal = 32.dp)) { Spacer(Modifier.weight(1f)) - Text("Message notifications", style = MaterialTheme.typography.h4) + Text(stringResource(R.string.notificationsMessage), style = MaterialTheme.typography.h4) Spacer(Modifier.height(16.dp)) - Text("There are two ways Session can notify you of new messages.") + Text(stringResource(R.string.onboardingMessageNotificationExplaination)) Spacer(Modifier.height(16.dp)) NotificationRadioButton( R.string.activity_pn_mode_fast_mode, R.string.activity_pn_mode_fast_mode_explanation, R.string.activity_pn_mode_recommended_option_tag, + contentDescription = R.string.AccessibilityId_fast_mode_notifications_button, selected = state.pushEnabled, onClick = { setEnabled(true) } ) @@ -118,6 +121,7 @@ fun MessageNotifications( NotificationRadioButton( R.string.activity_pn_mode_slow_mode, R.string.activity_pn_mode_slow_mode_explanation, + contentDescription = R.string.AccessibilityId_slow_mode_notifications_button, selected = state.pushDisabled, onClick = { setEnabled(false) } ) @@ -138,13 +142,14 @@ fun NotificationRadioButton( @StringRes title: Int, @StringRes explanation: Int, @StringRes tag: Int? = null, + @StringRes contentDescription: Int? = null, selected: Boolean = false, onClick: () -> Unit = {} ) { Row { OutlinedButton( onClick = onClick, - modifier = Modifier.weight(1f), + modifier = Modifier.weight(1f).contentDescription(contentDescription), colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.background, contentColor = Color.White), border = if (selected) BorderStroke(ButtonDefaults.OutlinedBorderSize, session_accent) else ButtonDefaults.outlinedBorder, shape = RoundedCornerShape(8.dp) diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayNameActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayNameActivity.kt index 07ed9f7d22..e6a0993222 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayNameActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayNameActivity.kt @@ -31,6 +31,8 @@ import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import network.loki.messenger.R +import org.session.libsession.utilities.AppTextSecurePreferences +import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.BaseActionBarActivity import org.thoughtcrime.securesms.onboarding.messagenotifications.startPNModeActivity import org.thoughtcrime.securesms.ui.AppTheme @@ -105,7 +107,7 @@ class PickDisplayNameActivity : BaseActionBarActivity() { OutlinedTextField( value = state.displayName, - modifier = Modifier.contentDescription(R.string.displayNameEnter), + modifier = Modifier.contentDescription(R.string.AccessibilityId_enter_display_name), onValueChange = { onChange(it) }, placeholder = { Text(stringResource(R.string.displayNameEnter)) }, colors = outlinedTextFieldColors(state.error != null), @@ -137,6 +139,8 @@ class PickDisplayNameActivity : BaseActionBarActivity() { } fun Context.startPickDisplayNameActivity(failedToLoad: Boolean = false, flags: Int = 0) { + ApplicationContext.getInstance(this).newAccount = !failedToLoad + Intent(this, PickDisplayNameActivity::class.java) .apply { putExtra(EXTRA_PICK_NEW_NAME, failedToLoad) } .also { it.flags = flags } diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/recoverypassword/RecoveryPasswordActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/recoverypassword/RecoveryPasswordActivity.kt index 6f15f86ce8..bc9f7efbbf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/recoverypassword/RecoveryPasswordActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/recoverypassword/RecoveryPasswordActivity.kt @@ -6,7 +6,6 @@ import android.graphics.Bitmap import android.os.Bundle import androidx.activity.viewModels import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.Crossfade import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -15,7 +14,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width @@ -27,9 +25,13 @@ import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter @@ -42,11 +44,16 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import network.loki.messenger.R import org.thoughtcrime.securesms.BaseActionBarActivity import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.ui.AppTheme import org.thoughtcrime.securesms.ui.CellWithPaddingAndMargin +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.LaunchedEffectAsync import org.thoughtcrime.securesms.ui.LocalExtraColors import org.thoughtcrime.securesms.ui.OutlineButton import org.thoughtcrime.securesms.ui.PreviewTheme @@ -54,8 +61,10 @@ import org.thoughtcrime.securesms.ui.SessionShieldIcon import org.thoughtcrime.securesms.ui.ThemeResPreviewParameterProvider import org.thoughtcrime.securesms.ui.classicDarkColors import org.thoughtcrime.securesms.ui.colorDestructive +import org.thoughtcrime.securesms.ui.contentDescription import org.thoughtcrime.securesms.ui.h8 import org.thoughtcrime.securesms.ui.small +import kotlin.time.Duration.Companion.seconds class RecoveryPasswordActivity : BaseActionBarActivity() { @@ -74,21 +83,22 @@ class RecoveryPasswordActivity : BaseActionBarActivity() { private fun onHide() { showSessionDialog { - title("Hide Recovery Password Permanently") - text("Without your recovery password, you cannot load your account on new devices.\n" + - "\n" + - "We strongly recommend you save your recovery password in a safe and secure place before continuing.") + title(R.string.recoveryPasswordHidePermanently) + htmlText(R.string.recoveryPasswordHidePermanentlyDescription1) destructiveButton(R.string.continue_2) { onHideConfirm() } - button(R.string.cancel) {} + cancelButton() } } private fun onHideConfirm() { showSessionDialog { - title("Hide Recovery Password Permanently") - text("Are you sure you want to permanently hide your recovery password on this device? This cannot be undone.") - button(R.string.cancel) {} - destructiveButton(R.string.yes) { + title(R.string.recoveryPasswordHidePermanently) + text(R.string.recoveryPasswordHidePermanentlyDescription2) + cancelButton() + destructiveButton( + R.string.yes, + contentDescription = R.string.AccessibilityId_confirm_button + ) { viewModel.permanentlyHidePassword() finish() } @@ -98,7 +108,7 @@ class RecoveryPasswordActivity : BaseActionBarActivity() { @Preview @Composable -fun PreviewMessageDetails( +fun PreviewRecoveryPassword( @PreviewParameter(ThemeResPreviewParameterProvider::class) themeResId: Int ) { PreviewTheme(themeResId) { @@ -132,31 +142,28 @@ fun RecoveryPasswordCell(seed: String = "", qrBitmap: Bitmap? = null, copySeed:( mutableStateOf(false) } - val copied = remember { - mutableStateOf(false) - } - CellWithPaddingAndMargin { Column { Row { - Text("Recovery Password") + Text(stringResource(R.string.sessionRecoveryPassword)) Spacer(Modifier.width(8.dp)) SessionShieldIcon() } - Text("Use your recovery password to load your account on new devices.\n\nYour account cannot be recovered without your recovery password. Make sure it's stored somewhere safe and secure β€” and don't share it with anyone.") + Text(stringResource(R.string.recoveryPasswordDescription)) AnimatedVisibility(!showQr.value) { Text( seed, modifier = Modifier - .padding(vertical = 24.dp) - .border( - width = 1.dp, - color = classicDarkColors[3], - shape = RoundedCornerShape(11.dp) - ) - .padding(24.dp), + .contentDescription(R.string.AccessibilityId_hide_recovery_password_button) + .padding(vertical = 24.dp) + .border( + width = 1.dp, + color = classicDarkColors[3], + shape = RoundedCornerShape(11.dp) + ) + .padding(24.dp), style = MaterialTheme.typography.small.copy(fontFamily = FontFamily.Monospace), color = LocalExtraColors.current.prominentButtonColor, ) @@ -181,16 +188,21 @@ fun RecoveryPasswordCell(seed: String = "", qrBitmap: Bitmap? = null, copySeed:( AnimatedVisibility(!showQr.value) { Row(horizontalArrangement = Arrangement.spacedBy(32.dp)) { - Crossfade(targetState = if (copied.value) R.string.copied else R.string.copy, modifier = Modifier.weight(1f), label = "Copy to Copied CrossFade") { - OutlineButton(text = stringResource(it), modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colors.onPrimary) { copySeed(); copied.value = true } + OutlineButton( + modifier = Modifier.weight(1f), + color = MaterialTheme.colors.onPrimary, + onClick = copySeed, + temporaryContent = { Text(stringResource(R.string.copied)) } + ) { + Text(stringResource(R.string.copy)) } - OutlineButton(text = "View QR", modifier = Modifier.weight(1f), color = MaterialTheme.colors.onPrimary) { showQr.toggle() } + OutlineButton(text = stringResource(R.string.qrView), modifier = Modifier.weight(1f), color = MaterialTheme.colors.onPrimary) { showQr.toggle() } } } AnimatedVisibility(showQr.value, modifier = Modifier.align(Alignment.CenterHorizontally)) { OutlineButton( - text = "View Password", + text = stringResource(R.string.recoveryPasswordView), color = MaterialTheme.colors.onPrimary, modifier = Modifier.align(Alignment.CenterHorizontally) ) { showQr.toggle() } @@ -229,11 +241,12 @@ fun HideRecoveryPasswordCell(onHide: () -> Unit = {}) { CellWithPaddingAndMargin { Row { Column(Modifier.weight(1f)) { - Text(text = "Hide Recovery Password", style = MaterialTheme.typography.h8) - Text(text = "Permanently hide your recovery password on this device.") + Text(text = stringResource(R.string.recoveryPasswordHideRecoveryPassword), style = MaterialTheme.typography.h8) + Text(text = stringResource(R.string.recoveryPasswordHideRecoveryPasswordDescription)) } OutlineButton( - "Hide", + stringResource(R.string.hide), + contentDescription = GetString(R.string.AccessibilityId_hide_recovery_password_button), modifier = Modifier.align(Alignment.CenterVertically), color = colorDestructive ) { onHide() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/HelpSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/HelpSettingsActivity.kt index f6efd041cd..e7e5f2d5f6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/HelpSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/HelpSettingsActivity.kt @@ -5,9 +5,14 @@ import android.content.Intent import android.net.Uri import android.os.Build import android.os.Bundle +import android.widget.ProgressBar +import android.widget.TextView import android.widget.Toast +import androidx.core.view.isInvisible import androidx.preference.Preference + import network.loki.messenger.R +import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.permissions.Permissions @@ -67,6 +72,19 @@ class HelpSettingsFragment: CorrectedPreferenceFragment() { } } + private fun updateExportButtonAndProgressBarUI(exportJobRunning: Boolean) { + this.activity?.runOnUiThread(Runnable { + // Change export logs button text + val exportLogsButton = this.activity?.findViewById(R.id.export_logs_button) as TextView? + if (exportLogsButton == null) { Log.w("Loki", "Could not find export logs button view.") } + exportLogsButton?.text = if (exportJobRunning) getString(R.string.cancel) else getString(R.string.activity_help_settings__export_logs) + + // Show progress bar + val exportProgressBar = this.activity?.findViewById(R.id.export_progress_bar) as ProgressBar? + exportProgressBar?.isInvisible = !exportJobRunning + }) + } + private fun shareLogs() { Permissions.with(this) .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) @@ -76,7 +94,7 @@ class HelpSettingsFragment: CorrectedPreferenceFragment() { Toast.makeText(requireActivity(), R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show() } .onAllGranted { - ShareLogsDialog().show(parentFragmentManager,"Share Logs Dialog") + ShareLogsDialog(::updateExportButtonAndProgressBarUI).show(parentFragmentManager,"Share Logs Dialog") } .execute() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt index 1535770a1e..32b7e83cbb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt @@ -17,6 +17,7 @@ import android.view.ActionMode import android.view.Menu import android.view.MenuItem import android.view.View +import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputMethodManager import android.widget.Toast import androidx.core.view.isGone @@ -215,6 +216,21 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { binding.displayNameEditText.selectAll() binding.displayNameEditText.requestFocus() inputMethodManager.showSoftInput(binding.displayNameEditText, 0) + + // Save the updated display name when the user presses enter on the soft keyboard + binding.displayNameEditText.setOnEditorActionListener { v, actionId, event -> + when (actionId) { + // Note: IME_ACTION_DONE is how we've configured the soft keyboard to respond, + // while IME_ACTION_UNSPECIFIED is what triggers when we hit enter on a + // physical keyboard. + EditorInfo.IME_ACTION_DONE, EditorInfo.IME_ACTION_UNSPECIFIED -> { + saveDisplayName() + displayNameEditActionMode?.finish() + true + } + else -> false + } + } } else { inputMethodManager.hideSoftInputFromWindow(binding.displayNameEditText.windowToken, 0) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/ShareLogsDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/ShareLogsDialog.kt index 2dc5e75d98..9bfc1dabf2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/ShareLogsDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/ShareLogsDialog.kt @@ -11,55 +11,73 @@ import android.os.Bundle import android.os.Environment import android.provider.MediaStore import android.webkit.MimeTypeMap +import android.widget.ProgressBar +import android.widget.TextView import android.widget.Toast +import androidx.core.view.isInvisible import androidx.fragment.app.DialogFragment import androidx.lifecycle.lifecycleScope + import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext + import network.loki.messenger.BuildConfig import network.loki.messenger.R + import org.session.libsignal.utilities.ExternalStorageUtil import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.createSessionDialog import org.thoughtcrime.securesms.util.FileProviderUtil import org.thoughtcrime.securesms.util.StreamUtil + import java.io.File import java.io.FileOutputStream import java.io.IOException import java.util.Objects import java.util.concurrent.TimeUnit -class ShareLogsDialog : DialogFragment() { +class ShareLogsDialog(private val updateCallback: (Boolean)->Unit): DialogFragment() { + + private val TAG = "ShareLogsDialog" private var shareJob: Job? = null override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog { title(R.string.dialog_share_logs_title) text(R.string.dialog_share_logs_explanation) - button(R.string.share, dismiss = false) { shareLogs() } - cancelButton { dismiss() } + button(R.string.share, dismiss = false) { runShareLogsJob() } + cancelButton { updateCallback(false) } } - private fun shareLogs() { + // If the share logs dialog loses focus the job gets cancelled so we'll update the UI state + override fun onPause() { + super.onPause() + updateCallback(false) + } + + private fun runShareLogsJob() { + // Cancel any existing share job that might already be running to start anew shareJob?.cancel() + + updateCallback(true) + shareJob = lifecycleScope.launch(Dispatchers.IO) { val persistentLogger = ApplicationContext.getInstance(context).persistentLogger try { + Log.d(TAG, "Starting share logs job...") + val context = requireContext() val outputUri: Uri = ExternalStorageUtil.getDownloadUri() - val mediaUri = getExternalFile() - if (mediaUri == null) { - // show toast saying media saved - dismiss() - return@launch - } + val mediaUri = getExternalFile() ?: return@launch val inputStream = persistentLogger.logs.get().byteInputStream() val updateValues = ContentValues() + + // Add details into the output or media files as appropriate if (outputUri.scheme == ContentResolver.SCHEME_FILE) { FileOutputStream(mediaUri.path).use { outputStream -> StreamUtil.copy(inputStream, outputStream) @@ -73,6 +91,7 @@ class ShareLogsDialog : DialogFragment() { } } } + if (Build.VERSION.SDK_INT > 28) { updateValues.put(MediaStore.MediaColumns.IS_PENDING, 0) } @@ -95,13 +114,35 @@ class ShareLogsDialog : DialogFragment() { } startActivity(Intent.createChooser(shareIntent, getString(R.string.share))) } - - dismiss() } catch (e: Exception) { withContext(Main) { Log.e("Loki", "Error saving logs", e) Toast.makeText(context,"Error saving logs", Toast.LENGTH_LONG).show() } + } + }.also { shareJob -> + shareJob.invokeOnCompletion { handler -> + // Note: Don't show Toasts here directly - use `withContext(Main)` or such if req'd + handler?.message.let { msg -> + if (shareJob.isCancelled) { + if (msg.isNullOrBlank()) { + Log.w(TAG, "Share logs job was cancelled.") + } else { + Log.d(TAG, "Share logs job was cancelled. Reason: $msg") + } + + } + else if (shareJob.isCompleted) { + Log.d(TAG, "Share logs job completed. Msg: $msg") + } + else { + Log.w(TAG, "Share logs job finished while still Active. Msg: $msg") + } + } + + // Regardless of the job's success it has now completed so update the UI + updateCallback(false) + dismiss() } } @@ -158,5 +199,4 @@ class ShareLogsDialog : DialogFragment() { return context.contentResolver.insert(outputUri, contentValues) } - } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt index f6277d1a4a..384f47d4d6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt @@ -1,13 +1,22 @@ package org.thoughtcrime.securesms.repository import network.loki.messenger.libsession_util.util.ExpiryMode + import android.content.ContentResolver import android.content.Context + import app.cash.copper.Query import app.cash.copper.flow.observeQuery + import dagger.hilt.android.qualifiers.ApplicationContext + +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map + import org.session.libsession.database.MessageDataProvider import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.control.MessageRequestResponse @@ -22,7 +31,10 @@ import org.session.libsession.utilities.Address import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.Log + import org.session.libsignal.utilities.toHexString + import org.thoughtcrime.securesms.database.DatabaseContentProviders import org.thoughtcrime.securesms.database.DraftDatabase import org.thoughtcrime.securesms.database.ExpirationConfigurationDatabase @@ -39,10 +51,8 @@ import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.dependencies.DatabaseComponent + import javax.inject.Inject -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException -import kotlin.coroutines.suspendCoroutine interface ConversationRepository { fun maybeGetRecipientForThreadId(threadId: Long): Recipient? @@ -55,37 +65,19 @@ interface ConversationRepository { fun inviteContacts(threadId: Long, contacts: List) fun setBlocked(recipient: Recipient, blocked: Boolean) fun deleteLocally(recipient: Recipient, message: MessageRecord) + fun deleteAllLocalMessagesInThreadFromSenderOfMessage(messageRecord: MessageRecord) fun setApproved(recipient: Recipient, isApproved: Boolean) - - suspend fun deleteForEveryone( - threadId: Long, - recipient: Recipient, - message: MessageRecord - ): ResultOf - + suspend fun deleteForEveryone(threadId: Long, recipient: Recipient, message: MessageRecord): ResultOf fun buildUnsendRequest(recipient: Recipient, message: MessageRecord): UnsendRequest? - - suspend fun deleteMessageWithoutUnsendRequest( - threadId: Long, - messages: Set - ): ResultOf - + suspend fun deleteMessageWithoutUnsendRequest(threadId: Long, messages: Set): ResultOf suspend fun banUser(threadId: Long, recipient: Recipient): ResultOf - suspend fun banAndDeleteAll(threadId: Long, recipient: Recipient): ResultOf - suspend fun deleteThread(threadId: Long): ResultOf - suspend fun deleteMessageRequest(thread: ThreadRecord): ResultOf - suspend fun clearAllMessageRequests(block: Boolean): ResultOf - suspend fun acceptMessageRequest(threadId: Long, recipient: Recipient): ResultOf - fun declineMessageRequest(threadId: Long) - fun hasReceived(threadId: Long): Boolean - } class DefaultConversationRepository @Inject constructor( @@ -184,6 +176,15 @@ class DefaultConversationRepository @Inject constructor( messageDataProvider.deleteMessage(message.id, !message.isMms) } + override fun deleteAllLocalMessagesInThreadFromSenderOfMessage(messageRecord: MessageRecord) { + val threadId = messageRecord.threadId + val senderId = messageRecord.recipient.address.contactIdentifier() + val messageRecordsToRemoveFromLocalStorage = mmsSmsDb.getAllMessageRecordsFromSenderInThread(threadId, senderId) + for (message in messageRecordsToRemoveFromLocalStorage) { + messageDataProvider.deleteMessage(message.id, !message.isMms) + } + } + override fun setApproved(recipient: Recipient, isApproved: Boolean) { storage.setRecipientApproved(recipient, isApproved) } @@ -196,18 +197,38 @@ class DefaultConversationRepository @Inject constructor( buildUnsendRequest(recipient, message)?.let { unsendRequest -> MessageSender.send(unsendRequest, recipient.address) } + val openGroup = lokiThreadDb.getOpenGroupChat(threadId) if (openGroup != null) { - lokiMessageDb.getServerID(message.id, !message.isMms)?.let { messageServerID -> + val serverId = lokiMessageDb.getServerID(message.id, !message.isMms)?.let { messageServerID -> OpenGroupApi.deleteMessage(messageServerID, openGroup.room, openGroup.server) .success { messageDataProvider.deleteMessage(message.id, !message.isMms) continuation.resume(ResultOf.Success(Unit)) }.fail { error -> + Log.w("TAG", "Call to OpenGroupApi.deleteForEveryone failed - attempting to resume..") continuation.resumeWithException(error) } } - } else { + + // If the server ID is null then this message is stuck in limbo (it has likely been + // deleted remotely but that deletion did not occur locally) - so we'll delete the + // message locally to clean up. + if (serverId == null) { + Log.w("ConversationRepository","Found community message without a server ID - deleting locally.") + + // Caution: The bool returned from `deleteMessage` is NOT "Was the message + // successfully deleted?" - it is "Was the thread itself also deleted because + // removing that message resulted in an empty thread?". + if (message.isMms) { + mmsDb.deleteMessage(message.id) + } else { + smsDb.deleteMessage(message.id) + } + } + } + else // If this thread is NOT in a Community + { messageDataProvider.deleteMessage(message.id, !message.isMms) messageDataProvider.getServerHashForMessage(message.id, message.isMms)?.let { serverHash -> var publicKey = recipient.address.serialize() @@ -218,6 +239,7 @@ class DefaultConversationRepository @Inject constructor( .success { continuation.resume(ResultOf.Success(Unit)) }.fail { error -> + Log.w("[onversationRepository", "Call to SnodeAPI.deleteMessage failed - attempting to resume..") continuation.resumeWithException(error) } } @@ -225,7 +247,7 @@ class DefaultConversationRepository @Inject constructor( } override fun buildUnsendRequest(recipient: Recipient, message: MessageRecord): UnsendRequest? { - if (recipient.isOpenGroupRecipient) return null + if (recipient.isCommunityRecipient) return null messageDataProvider.getServerHashForMessage(message.id, message.isMms) ?: return null return UnsendRequest( author = message.takeUnless { it.isOutgoing }?.run { individualRecipient.address.contactIdentifier() } ?: textSecurePreferences.getLocalNumber(), @@ -279,8 +301,10 @@ class DefaultConversationRepository @Inject constructor( override suspend fun banAndDeleteAll(threadId: Long, recipient: Recipient): ResultOf = suspendCoroutine { continuation -> + // Note: This sessionId could be the blinded Id val sessionID = recipient.address.toString() val openGroup = lokiThreadDb.getOpenGroupChat(threadId)!! + OpenGroupApi.banAndDeleteAll(sessionID, openGroup.room, openGroup.server) .success { continuation.resume(ResultOf.Success(Unit)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java index ddfe85515c..a5669aa351 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java @@ -95,6 +95,16 @@ public class SearchRepository { Stopwatch timer = new Stopwatch("FtsQuery"); String cleanQuery = sanitizeQuery(query); + + // If the search is for a single character and it was stripped by `sanitizeQuery` then abort + // the search for an empty string to avoid SQLite error. + if (cleanQuery.length() == 0) + { + Log.d(TAG, "Aborting empty search query."); + timer.stop(TAG); + return; + } + timer.split("clean"); Pair, List> contacts = queryContacts(cleanQuery); @@ -119,10 +129,11 @@ public class SearchRepository { } executor.execute(() -> { - long startTime = System.currentTimeMillis(); - CursorList messages = queryMessages(sanitizeQuery(query), threadId); - Log.d(TAG, "[ConversationQuery] " + (System.currentTimeMillis() - startTime) + " ms"); - + // If the sanitized search query is empty then abort the search to prevent SQLite errors. + String cleanQuery = sanitizeQuery(query).trim(); + if (cleanQuery.isEmpty()) { return; } + + CursorList messages = queryMessages(cleanQuery, threadId); callback.onResult(messages); }); } @@ -215,7 +226,7 @@ public class SearchRepository { out.append(' '); } } - + return out.toString(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index 618c874d5e..19f0b15b9e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -36,6 +36,12 @@ import androidx.compose.material.RadioButton import androidx.compose.material.Text import androidx.compose.material.TextButton 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.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -58,6 +64,9 @@ import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import network.loki.messenger.R import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.runIf @@ -65,17 +74,19 @@ import org.thoughtcrime.securesms.components.ProfilePictureView import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.OptionsCard import kotlin.math.min import kotlin.math.roundToInt +import kotlin.time.Duration.Companion.seconds @Composable fun OutlineButton( text: String, modifier: Modifier = Modifier, + contentDescription: GetString = GetString(text), color: Color = LocalExtraColors.current.prominentButtonColor, loading: Boolean = false, onClick: () -> Unit ) { OutlinedButton( - modifier = modifier.contentDescription(GetString(text)), + modifier = modifier.contentDescription(contentDescription), onClick = onClick, border = BorderStroke(1.dp, color), shape = RoundedCornerShape(50), // = 50% percent @@ -96,7 +107,68 @@ fun OutlineButton( } @Composable -fun FilledButton(text: String, modifier: Modifier = Modifier, onClick: () -> Unit) { +fun OutlineButton( + modifier: Modifier = Modifier, + color: Color = LocalExtraColors.current.prominentButtonColor, + onClick: () -> Unit = {}, + content: @Composable () -> Unit = {} +) { + OutlinedButton( + modifier = modifier, + onClick = onClick, + border = BorderStroke(1.dp, color), + shape = RoundedCornerShape(percent = 50), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = color, + backgroundColor = Color.Unspecified + ) + ) { + content() + } +} + +@Composable +fun OutlineButton( + temporaryContent: @Composable () -> Unit, + modifier: Modifier = Modifier, + color: Color = LocalExtraColors.current.prominentButtonColor, + onClick: () -> Unit = {}, + content: @Composable () -> Unit = {} +) { + var clicked by remember { mutableStateOf(false) } + if (clicked) LaunchedEffectAsync { + delay(2.seconds) + clicked = false + } + + OutlinedButton( + modifier = modifier, + onClick = { + onClick() + clicked = true + }, + border = BorderStroke(1.dp, color), + shape = RoundedCornerShape(percent = 50), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = color, + backgroundColor = Color.Unspecified + ) + ) { + AnimatedVisibility(clicked) { + temporaryContent() + } + AnimatedVisibility(!clicked) { + content() + } + } +} + +@Composable +fun FilledButton( + text: String, + modifier: Modifier = Modifier, + contentDescription: GetString? = GetString(text), + onClick: () -> Unit) { OutlinedButton( modifier = modifier.size(108.dp, 34.dp), onClick = onClick, @@ -126,6 +198,7 @@ fun BorderlessButtonSecondary( fun BorderlessButton( text: String, modifier: Modifier = Modifier, + contentDescription: GetString = GetString(text), fontSize: TextUnit = TextUnit.Unspecified, lineHeight: TextUnit = TextUnit.Unspecified, contentColor: Color = MaterialTheme.colors.onBackground, @@ -134,7 +207,7 @@ fun BorderlessButton( ) { TextButton( onClick = onClick, - modifier = modifier, + modifier = modifier.contentDescription(contentDescription), shape = RoundedCornerShape(percent = 50), colors = ButtonDefaults.outlinedButtonColors( contentColor = contentColor, @@ -284,8 +357,10 @@ fun TitledRadioButton(option: RadioOption, onClick: () -> Unit) { @Composable fun Modifier.contentDescription(text: GetString?): Modifier { - val context = LocalContext.current - return text?.let { semantics { contentDescription = it(context) } } ?: this + return text?.let { + val context = LocalContext.current + semantics { contentDescription = it(context) } + } ?: this } @Composable @@ -435,3 +510,8 @@ fun RowScope.SessionShieldIcon() { .wrapContentSize(unbounded = true) ) } + +@Composable +fun LaunchedEffectAsync(block: suspend CoroutineScope.() -> Unit) { + rememberCoroutineScope().apply { LaunchedEffect(Unit) { launch { block() } } } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt index 297014d86c..9d10cfdab5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt @@ -193,7 +193,7 @@ object ConfigurationMessageUtilities { while (current != null) { val recipient = current.recipient val contact = when { - recipient.isOpenGroupRecipient -> { + recipient.isCommunityRecipient -> { val openGroup = storage.getOpenGroup(current.threadId) ?: continue val (base, room, pubKey) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: continue convoConfig.getOrConstructCommunity(base, room, pubKey) @@ -279,7 +279,7 @@ object ConfigurationMessageUtilities { @JvmField val DELETE_INACTIVE_ONE_TO_ONES: String = """ - DELETE FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.CLOSED_GROUP_PREFIX}%' AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.OPEN_GROUP_PREFIX}%' AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.OPEN_GROUP_INBOX_PREFIX}%'; + DELETE FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.CLOSED_GROUP_PREFIX}%' AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.COMMUNITY_PREFIX}%' AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.COMMUNITY_INBOX_PREFIX}%'; """.trimIndent() } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/GeneralUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/GeneralUtilities.kt index a38c93831e..9124765763 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/GeneralUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/GeneralUtilities.kt @@ -37,3 +37,8 @@ val RecyclerView.isScrolledToBottom: Boolean get() = computeVerticalScrollOffset().coerceAtLeast(0) + computeVerticalScrollExtent() + toPx(50, resources) >= computeVerticalScrollRange() + +val RecyclerView.isScrolledToWithin30dpOfBottom: Boolean + get() = computeVerticalScrollOffset().coerceAtLeast(0) + + computeVerticalScrollExtent() + + toPx(30, resources) >= computeVerticalScrollRange() \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SharedConfigUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/SharedConfigUtils.kt index b15d82a33e..3984f38b51 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SharedConfigUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SharedConfigUtils.kt @@ -14,7 +14,7 @@ fun ConversationVolatileConfig.getConversationUnread(thread: ThreadRecord): Bool return getOneToOne(recipient.address.serialize())?.unread == true } else if (recipient.isClosedGroupRecipient) { return getLegacyClosedGroup(GroupUtil.doubleDecodeGroupId(recipient.address.toGroupString()))?.unread == true - } else if (recipient.isOpenGroupRecipient) { + } else if (recipient.isCommunityRecipient) { val openGroup = MessagingModuleConfiguration.shared.storage.getOpenGroup(thread.threadId) ?: return false return getCommunity(openGroup.server, openGroup.room)?.unread == true } diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt index 272f2c12db..ff5e481895 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt @@ -408,6 +408,10 @@ class CallManager( override fun onCameraSwitchCompleted(newCameraState: CameraState) { localCameraState = newCameraState + + // If the camera we've switched to is the front one then mirror it to match what someone + // would see when looking in the mirror rather than the left<-->right flipped version. + localRenderer?.setMirror(localCameraState.activeDirection == CameraState.Direction.FRONT) } fun onPreOffer(callId: UUID, recipient: Recipient, onSuccess: () -> Unit) { @@ -639,7 +643,11 @@ class CallManager( peerConnection?.let { connection -> connection.flipCamera() localCameraState = connection.getCameraState() - localRenderer?.setMirror(localCameraState.activeDirection == CameraState.Direction.FRONT) + + // Note: We cannot set the mirrored state of the localRenderer here because + // localCameraState.activeDirection is still PENDING (not FRONT or BACK) until the flip + // completes and we hit Camera.onCameraSwitchDone (followed by PeerConnectionWrapper.onCameraSwitchCompleted + // and CallManager.onCameraSwitchCompleted). } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/PeerConnectionWrapper.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/PeerConnectionWrapper.kt index 4835ab0dc1..f78b93d6b9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/PeerConnectionWrapper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/PeerConnectionWrapper.kt @@ -326,8 +326,6 @@ class PeerConnectionWrapper(private val context: Context, } override fun onCameraSwitchCompleted(newCameraState: CameraState) { - // mirror rotation offset - rotationVideoSink.mirrored = newCameraState.activeDirection == CameraState.Direction.FRONT cameraEventListener.onCameraSwitchCompleted(newCameraState) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/video/Camera.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/video/Camera.kt index 421c144199..b76b168977 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/video/Camera.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/video/Camera.kt @@ -54,7 +54,7 @@ class Camera(context: Context, Log.w(TAG, "Tried to flip camera without capturer or less than 2 cameras") return } - activeDirection = PENDING + activeDirection = PENDING // Note: The activeDirection will be PENDING until `onCameraSwitchDone` capturer.switchCamera(this) } diff --git a/app/src/main/res/drawable/cross.xml b/app/src/main/res/drawable/cross.xml new file mode 100644 index 0000000000..5b090de2b3 --- /dev/null +++ b/app/src/main/res/drawable/cross.xml @@ -0,0 +1,12 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/emoji_tada.xml b/app/src/main/res/drawable/emoji_tada.xml deleted file mode 100644 index ce0f30067d..0000000000 --- a/app/src/main/res/drawable/emoji_tada.xml +++ /dev/null @@ -1,80 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/emoji_tada_large.xml b/app/src/main/res/drawable/emoji_tada_large.xml new file mode 100644 index 0000000000..ed802646ff --- /dev/null +++ b/app/src/main/res/drawable/emoji_tada_large.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_logo_large.xml b/app/src/main/res/drawable/ic_logo_large.xml new file mode 100644 index 0000000000..b494b17663 --- /dev/null +++ b/app/src/main/res/drawable/ic_logo_large.xml @@ -0,0 +1,30 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_conversation_v2.xml b/app/src/main/res/layout/activity_conversation_v2.xml index 000b860841..6fe0c4db60 100644 --- a/app/src/main/res/layout/activity_conversation_v2.xml +++ b/app/src/main/res/layout/activity_conversation_v2.xml @@ -23,6 +23,10 @@ + + @@ -49,6 +50,7 @@ android:inputType="text" android:singleLine="true" android:imeOptions="actionDone" + android:maxLength="@integer/max_group_and_community_name_length_chars" android:contentDescription="@string/AccessibilityId_group_name" android:hint="@string/activity_edit_closed_group_edit_text_hint" /> @@ -57,6 +59,7 @@ android:layout_width="24dp" android:layout_height="24dp" android:layout_gravity="center" + android:layout_marginRight="@dimen/medium_spacing" android:contentDescription="@string/AccessibilityId_accept_name_change" android:src="@drawable/ic_baseline_done_24"/> diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index fdd6d0f5a6..a5bac6b069 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -47,7 +47,11 @@ android:paddingTop="12dp" android:paddingBottom="12dp" android:visibility="invisible" - android:hint="@string/activity_settings_display_name_edit_text_hint" /> + android:hint="@string/activity_settings_display_name_edit_text_hint" + android:imeOptions="actionDone" + android:inputType="textCapWords" + android:maxLength="@integer/max_user_nickname_length_chars" + android:maxLines="1" /> @@ -80,7 +86,7 @@ android:textColor="?android:textColorPrimary" android:fontFamily="@font/space_mono_regular" android:textAlignment="center" - android:contentDescription="@string/AccessibilityId_session_id" + android:contentDescription="@string/AccessibilityId_account_id" tools:text="05987d601943c267879be41830888066c6a024cbdc9a548d06813924bf3372ea78" /> + android:contentDescription="@string/AccessibilityId_recovery_password_menu_item"> - + + android:layout_height="wrap_content" /> + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_create_group.xml b/app/src/main/res/layout/fragment_create_group.xml index af685fa9f6..1e901c753c 100644 --- a/app/src/main/res/layout/fragment_create_group.xml +++ b/app/src/main/res/layout/fragment_create_group.xml @@ -62,10 +62,14 @@ android:layout_marginBottom="@dimen/medium_spacing" android:contentDescription="@string/AccessibilityId_group_name_input" android:hint="@string/activity_create_closed_group_edit_text_hint" - android:maxLength="30" + android:imeOptions="actionDone" + android:inputType="textCapWords" + android:maxLength="@integer/max_group_and_community_name_length_chars" + android:maxLines="1" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/titleText" /> + app:layout_constraintTop_toBottomOf="@id/titleText" + tools:ignore="ContentDescription" /> @@ -73,6 +77,7 @@ android:id="@+id/cancelNicknameEditingButton" android:layout_width="24dp" android:layout_height="24dp" + android:layout_marginLeft="@dimen/large_spacing" android:contentDescription="@string/AccessibilityId_cancel" android:src="@drawable/ic_baseline_clear_24" /> @@ -82,12 +87,12 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center" - android:layout_marginHorizontal="@dimen/small_spacing" android:contentDescription="@string/AccessibilityId_username" android:textAlignment="center" android:paddingVertical="12dp" android:inputType="text" - android:singleLine="true" + android:maxLength="@integer/max_user_nickname_length_chars" + android:maxLines="1" android:imeOptions="actionDone" android:textColorHint="?android:textColorSecondary" android:hint="@string/fragment_user_details_bottom_sheet_edit_text_hint" /> @@ -96,6 +101,7 @@ android:id="@+id/saveNicknameButton" android:layout_width="24dp" android:layout_height="24dp" + android:layout_marginRight="@dimen/large_spacing" android:contentDescription="@string/AccessibilityId_apply" android:src="@drawable/ic_baseline_done_24" /> diff --git a/app/src/main/res/layout/preference_widget_progress.xml b/app/src/main/res/layout/preference_widget_progress.xml index 3cce4b9483..4b44b9a55a 100644 --- a/app/src/main/res/layout/preference_widget_progress.xml +++ b/app/src/main/res/layout/preference_widget_progress.xml @@ -1,23 +1,19 @@ - + - - - + \ No newline at end of file diff --git a/app/src/main/res/layout/reactions_pill.xml b/app/src/main/res/layout/reactions_pill.xml index c87f29d82c..a88673cba8 100644 --- a/app/src/main/res/layout/reactions_pill.xml +++ b/app/src/main/res/layout/reactions_pill.xml @@ -15,7 +15,7 @@ android:layout_width="17dp" android:layout_height="17dp" /> - diff --git a/app/src/main/res/layout/view_contact.xml b/app/src/main/res/layout/view_contact.xml index 3053046d8c..ceb4304cc7 100644 --- a/app/src/main/res/layout/view_contact.xml +++ b/app/src/main/res/layout/view_contact.xml @@ -25,6 +25,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginStart="@dimen/medium_spacing" + android:maxLength="@integer/max_user_nickname_length_chars" android:maxLines="1" android:textAlignment="viewStart" android:ellipsize="end" diff --git a/app/src/main/res/layout/view_conversation.xml b/app/src/main/res/layout/view_conversation.xml index 12a7a8ac8c..ed3cc66969 100644 --- a/app/src/main/res/layout/view_conversation.xml +++ b/app/src/main/res/layout/view_conversation.xml @@ -165,7 +165,7 @@ android:maxLines="1" android:textColor="?android:textColorPrimary" android:textSize="@dimen/medium_font_size" - tools:text="Sorry, gotta go fight crime again" /> + tools:text="Sorry, gotta go fight crime again - and more text to make it ellipsize" /> 100 150 10 + + 35 + 35 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f3be517011..a6333792c6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -20,6 +20,7 @@ Link Device Session ID + Account ID Continue @@ -132,7 +133,7 @@ User settings Username Privacy - Show recovery password + Recovery password menu item Edit user nickname Apply Cancel @@ -1059,6 +1060,7 @@ Privacy in your pocket. Welcome to Session πŸ‘‹ + Welcome to Session Session is engineered to protect your privacy. "You don’t even need a phone number to sign up. " Creating an account is \ninstant, free, and \nanonymous πŸ‘‡ @@ -1081,6 +1083,38 @@ Load Account Camera Permission permanently denied. Configure in settings. Enter your recovery password to load your account. If you haven\'t saved it, you can find it in your app settings. + One moment please.. + Loading your account + Message notifications + There are two ways Session can notify you of new messages. + Hide Recovery Password Permanently +
We strongly recommend you save your recovery password in a safe and secure place before continuing.]]>
+ Are you sure you want to permanently hide your recovery password on this device? This cannot be undone. + Use your recovery password to load your account on new devices. Your account cannot be recovered without your recovery password. Make sure it\'s stored somewhere safe and secure β€” and don\'t share it with anyone. + Hide + Hide Recovery Password + View QR + View Password + Permanently hide your recovery password on this device. + You don\'t have any conversations yet + Hit the plus button to start a chat, create a group, or join an official communitiy! + Because you are the creator of this group it will be deleted for everyone. This cannot be undone. + Hide message requests? + Save your recovery password + Save your recovery password to make sure you don\'t lose access to your account. + Account Created + Fast mode notifications button + Slow mode notifications button + Reveal recovery phrase button + Create account button + Restore your session button + Privacy policy link + Terms of service link + Loading animation + Recovery phrase input + Error message + Hide recovery password button + Confirm button This Account ID is invalid. Please check and try again. Enter Account ID diff --git a/app/src/main/res/xml/preferences_app_protection.xml b/app/src/main/res/xml/preferences_app_protection.xml index 6e0cd98c2f..425edaf9ed 100644 --- a/app/src/main/res/xml/preferences_app_protection.xml +++ b/app/src/main/res/xml/preferences_app_protection.xml @@ -45,7 +45,7 @@ diff --git a/app/src/main/res/xml/preferences_help.xml b/app/src/main/res/xml/preferences_help.xml index 0372619ea3..9bc9bd13e1 100644 --- a/app/src/main/res/xml/preferences_help.xml +++ b/app/src/main/res/xml/preferences_help.xml @@ -6,39 +6,38 @@ android:key="export_logs" android:title="@string/activity_help_settings__report_bug_title" android:summary="@string/activity_help_settings__report_bug_summary" - android:widgetLayout="@layout/export_logs_widget"/> + android:widgetLayout="@layout/export_logs_widget" /> + + + + android:widgetLayout="@layout/preference_external_link" /> + android:widgetLayout="@layout/preference_external_link" /> + android:widgetLayout="@layout/preference_external_link" /> + android:widgetLayout="@layout/preference_external_link" /> \ No newline at end of file diff --git a/app/src/sharedTest/java/org/thoughtcrime/securesms/NoOpLogger.kt b/app/src/sharedTest/java/org/thoughtcrime/securesms/NoOpLogger.kt new file mode 100644 index 0000000000..998c3b179d --- /dev/null +++ b/app/src/sharedTest/java/org/thoughtcrime/securesms/NoOpLogger.kt @@ -0,0 +1,19 @@ +package org.thoughtcrime.securesms + +import org.session.libsignal.utilities.Log.Logger + +object NoOpLogger: Logger() { + override fun v(tag: String?, message: String?, t: Throwable?) {} + + override fun d(tag: String?, message: String?, t: Throwable?) {} + + override fun i(tag: String?, message: String?, t: Throwable?) {} + + override fun w(tag: String?, message: String?, t: Throwable?) {} + + override fun e(tag: String?, message: String?, t: Throwable?) {} + + override fun wtf(tag: String?, message: String?, t: Throwable?) {} + + override fun blockUntilAllWritesFinished() {} +} \ No newline at end of file diff --git a/app/src/test/java/org/thoughtcrime/securesms/BaseViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/BaseViewModelTest.kt index d73bc91a6d..a0f67fa699 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/BaseViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/BaseViewModelTest.kt @@ -1,10 +1,20 @@ package org.thoughtcrime.securesms import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import org.junit.BeforeClass import org.junit.Rule +import org.session.libsignal.utilities.Log open class BaseViewModelTest: BaseCoroutineTest() { + companion object { + @BeforeClass + @JvmStatic + fun setupLogger() { + Log.initialize(NoOpLogger) + } + } + @get:Rule var instantExecutorRule = InstantTaskExecutorRule() diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModelTest.kt index 22679f311e..a9df5d946e 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModelTest.kt @@ -39,7 +39,7 @@ import kotlin.time.Duration.Companion.minutes private const val THREAD_ID = 1L private const val LOCAL_NUMBER = "05---local---address" private val LOCAL_ADDRESS = Address.fromSerialized(LOCAL_NUMBER) -private const val GROUP_NUMBER = "${GroupUtil.OPEN_GROUP_PREFIX}4133" +private const val GROUP_NUMBER = "${GroupUtil.COMMUNITY_PREFIX}4133" private val GROUP_ADDRESS = Address.fromSerialized(GROUP_NUMBER) @OptIn(ExperimentalCoroutinesApi::class) diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt index b89c62b6d5..37303e29d5 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt @@ -3,12 +3,14 @@ package org.thoughtcrime.securesms.conversation.v2 import com.goterl.lazysodium.utils.KeyPair import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking import org.hamcrest.CoreMatchers.endsWith import org.hamcrest.CoreMatchers.equalTo import org.hamcrest.CoreMatchers.notNullValue import org.hamcrest.CoreMatchers.nullValue import org.hamcrest.MatcherAssert.assertThat import org.junit.Before +import org.junit.BeforeClass import org.junit.Test import org.mockito.Mockito import org.mockito.Mockito.anyLong @@ -18,7 +20,9 @@ import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.BaseViewModelTest +import org.thoughtcrime.securesms.NoOpLogger import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.repository.ConversationRepository @@ -32,6 +36,7 @@ class ConversationViewModelTest: BaseViewModelTest() { private val threadId = 123L private val edKeyPair = mock() private lateinit var recipient: Recipient + private lateinit var messageRecord: MessageRecord private val viewModel: ConversationViewModel by lazy { ConversationViewModel(threadId, edKeyPair, repository, storage) @@ -40,6 +45,9 @@ class ConversationViewModelTest: BaseViewModelTest() { @Before fun setUp() { recipient = mock() + messageRecord = mock { record -> + whenever(record.individualRecipient).thenReturn(recipient) + } whenever(repository.maybeGetRecipientForThreadId(anyLong())).thenReturn(recipient) whenever(repository.recipientUpdateFlow(anyLong())).thenReturn(emptyFlow()) } @@ -144,7 +152,7 @@ class ConversationViewModelTest: BaseViewModelTest() { val error = Throwable() whenever(repository.banAndDeleteAll(anyLong(), any())).thenReturn(ResultOf.Failure(error)) - viewModel.banAndDeleteAll(recipient) + viewModel.banAndDeleteAll(messageRecord) assertThat(viewModel.uiState.first().uiMessages.first().message, endsWith("$error")) } @@ -153,7 +161,7 @@ class ConversationViewModelTest: BaseViewModelTest() { fun `should emit a message on ban user and delete all success`() = runBlockingTest { whenever(repository.banAndDeleteAll(anyLong(), any())).thenReturn(ResultOf.Success(Unit)) - viewModel.banAndDeleteAll(recipient) + viewModel.banAndDeleteAll(messageRecord) assertThat( viewModel.uiState.first().uiMessages.first().message, @@ -189,7 +197,7 @@ class ConversationViewModelTest: BaseViewModelTest() { @Test fun `open group recipient should have no blinded recipient`() { - whenever(recipient.isOpenGroupRecipient).thenReturn(true) + whenever(recipient.isCommunityRecipient).thenReturn(true) whenever(recipient.isOpenGroupOutboxRecipient).thenReturn(false) whenever(recipient.isOpenGroupInboxRecipient).thenReturn(false) assertThat(viewModel.blindedRecipient, nullValue()) diff --git a/gradle.properties b/gradle.properties index 6f3a80a7fb..0948d30e31 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.3.1 +gradlePluginVersion=7.4.2 googleServicesVersion=4.3.12 kotlinVersion=1.8.21 android.useAndroidX=true diff --git a/libsession/src/main/java/org/session/libsession/avatars/AvatarHelper.java b/libsession/src/main/java/org/session/libsession/avatars/AvatarHelper.java index 1588289017..cc0909a592 100644 --- a/libsession/src/main/java/org/session/libsession/avatars/AvatarHelper.java +++ b/libsession/src/main/java/org/session/libsession/avatars/AvatarHelper.java @@ -8,9 +8,11 @@ import androidx.annotation.Nullable; import com.annimon.stream.Stream; import org.session.libsession.utilities.Address; +import org.session.libsignal.utilities.Log; import java.io.File; import java.io.FileInputStream; +import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; @@ -22,9 +24,9 @@ public class AvatarHelper { private static final String AVATAR_DIRECTORY = "avatars"; public static InputStream getInputStreamFor(@NonNull Context context, @NonNull Address address) - throws IOException + throws FileNotFoundException { - return new FileInputStream(getAvatarFile(context, address)); + return new FileInputStream(getAvatarFile(context, address)); } public static List getAvatarFiles(@NonNull Context context) { diff --git a/libsession/src/main/java/org/session/libsession/avatars/ProfileContactPhoto.java b/libsession/src/main/java/org/session/libsession/avatars/ProfileContactPhoto.java index f8675b031f..76a3449625 100644 --- a/libsession/src/main/java/org/session/libsession/avatars/ProfileContactPhoto.java +++ b/libsession/src/main/java/org/session/libsession/avatars/ProfileContactPhoto.java @@ -8,6 +8,7 @@ import androidx.annotation.Nullable; import org.session.libsession.utilities.Address; +import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.security.MessageDigest; @@ -24,7 +25,7 @@ public class ProfileContactPhoto implements ContactPhoto { } @Override - public InputStream openInputStream(Context context) throws IOException { + public InputStream openInputStream(Context context) throws FileNotFoundException { return AvatarHelper.getInputStreamFor(context, address); } diff --git a/libsession/src/main/java/org/session/libsession/avatars/ResourceContactPhoto.java b/libsession/src/main/java/org/session/libsession/avatars/ResourceContactPhoto.java index f78089e25e..2920b4b1ce 100644 --- a/libsession/src/main/java/org/session/libsession/avatars/ResourceContactPhoto.java +++ b/libsession/src/main/java/org/session/libsession/avatars/ResourceContactPhoto.java @@ -8,6 +8,7 @@ import android.graphics.drawable.LayerDrawable; import android.widget.ImageView; import androidx.annotation.DrawableRes; +import androidx.appcompat.content.res.AppCompatResources; import com.amulyakhare.textdrawable.TextDrawable; import com.makeramen.roundedimageview.RoundedDrawable; @@ -31,7 +32,7 @@ public class ResourceContactPhoto implements FallbackContactPhoto { @Override public Drawable asDrawable(Context context, int color, boolean inverted) { Drawable background = TextDrawable.builder().buildRound(" ", inverted ? Color.WHITE : color); - RoundedDrawable foreground = (RoundedDrawable) RoundedDrawable.fromDrawable(context.getResources().getDrawable(resourceId)); + RoundedDrawable foreground = (RoundedDrawable) RoundedDrawable.fromDrawable(AppCompatResources.getDrawable(context, resourceId)); foreground.setScaleType(ImageView.ScaleType.CENTER_CROP); @@ -39,8 +40,10 @@ public class ResourceContactPhoto implements FallbackContactPhoto { foreground.setColorFilter(color, PorterDuff.Mode.SRC_ATOP); } - Drawable gradient = context.getResources().getDrawable(ThemeUtil.isDarkTheme(context) ? R.drawable.avatar_gradient_dark - : R.drawable.avatar_gradient_light); + Drawable gradient = AppCompatResources.getDrawable( + context, + ThemeUtil.isDarkTheme(context) ? R.drawable.avatar_gradient_dark : R.drawable.avatar_gradient_light + ); return new ExpandingLayerDrawable(new Drawable[] {background, foreground, gradient}); } diff --git a/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt b/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt index 9d6ae18d0c..260e254fe7 100644 --- a/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt +++ b/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt @@ -120,7 +120,9 @@ interface StorageProtocol { fun markAsSyncing(timestamp: Long, author: String) fun markAsSending(timestamp: Long, author: String) fun markAsSent(timestamp: Long, author: String) + fun markAsSentToCommunity(threadID: Long, messageID: Long) fun markUnidentified(timestamp: Long, author: String) + fun markUnidentifiedInCommunity(threadID: Long, messageID: Long) fun markAsSyncFailed(timestamp: Long, author: String, error: Exception) fun markAsSentFailed(timestamp: Long, author: String, error: Exception) fun clearErrorMessage(messageID: Long) diff --git a/libsession/src/main/java/org/session/libsession/messaging/contacts/Contact.kt b/libsession/src/main/java/org/session/libsession/messaging/contacts/Contact.kt index 92ff9190c5..7161d070aa 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/contacts/Contact.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/contacts/Contact.kt @@ -77,7 +77,7 @@ class Contact(val sessionID: String) { companion object { fun contextForRecipient(recipient: Recipient): ContactContext { - return if (recipient.isOpenGroupRecipient) ContactContext.OPEN_GROUP else ContactContext.REGULAR + return if (recipient.isCommunityRecipient) ContactContext.OPEN_GROUP else ContactContext.REGULAR } } } \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/OpenGroupDeleteJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/OpenGroupDeleteJob.kt index 333c87ba78..271549c410 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/OpenGroupDeleteJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/OpenGroupDeleteJob.kt @@ -22,7 +22,7 @@ class OpenGroupDeleteJob(private val messageServerIds: LongArray, private val th override suspend fun execute(dispatcherName: String) { val dataProvider = MessagingModuleConfiguration.shared.messageDataProvider val numberToDelete = messageServerIds.size - Log.d(TAG, "Deleting $numberToDelete messages") + Log.d(TAG, "About to attempt to delete $numberToDelete messages") // FIXME: This entire process should probably run in a transaction (with the attachment deletion happening only if it succeeded) try { @@ -42,6 +42,7 @@ class OpenGroupDeleteJob(private val messageServerIds: LongArray, private val th delegate?.handleJobSucceeded(this, dispatcherName) } catch (e: Exception) { + Log.w(TAG, "OpenGroupDeleteJob failed: $e") delegate?.handleJobFailed(this, dispatcherName, e) } } diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/Destination.kt b/libsession/src/main/java/org/session/libsession/messaging/messages/Destination.kt index f30c1b9168..faad7aeebf 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/Destination.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/Destination.kt @@ -43,14 +43,14 @@ sealed class Destination { val groupPublicKey = GroupUtil.doubleDecodeGroupID(groupID).toHexString() ClosedGroup(groupPublicKey) } - address.isOpenGroup -> { + address.isCommunity -> { val storage = MessagingModuleConfiguration.shared.storage val threadID = storage.getThreadId(address)!! storage.getOpenGroup(threadID)?.let { OpenGroup(roomToken = it.room, server = it.server, fileIds = fileIds) } ?: throw Exception("Missing open group for thread with ID: $threadID.") } - address.isOpenGroupInbox -> { + address.isCommunityInbox -> { val groupInboxId = GroupUtil.getDecodedGroupID(address.serialize()).split("!") OpenGroupInbox( groupInboxId.dropLast(2).joinToString("!"), 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 a7b5051f51..1f23a1cc87 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 @@ -602,8 +602,7 @@ object OpenGroupApi { // region Message Deletion @JvmStatic fun deleteMessage(serverID: Long, room: String, server: String): Promise { - val request = - Request(verb = DELETE, room = room, server = server, endpoint = Endpoint.RoomMessageIndividual(room, serverID)) + val request = Request(verb = DELETE, room = room, server = server, endpoint = Endpoint.RoomMessageIndividual(room, serverID)) return send(request).map { Log.d("Loki", "Message deletion successful.") } @@ -659,7 +658,9 @@ object OpenGroupApi { } fun banAndDeleteAll(publicKey: String, room: String, server: String): Promise { + val requests = mutableListOf>( + // Ban request BatchRequestInfo( request = BatchRequest( method = POST, @@ -669,6 +670,7 @@ object OpenGroupApi { endpoint = Endpoint.UserBan(publicKey), responseType = object: TypeReference(){} ), + // Delete request BatchRequestInfo( request = BatchRequest(DELETE, "/room/$room/all/$publicKey"), endpoint = Endpoint.RoomDeleteMessages(room, publicKey), 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 21df7fcb5d..b0459de1d6 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 @@ -39,6 +39,7 @@ import org.session.libsignal.crypto.PushTransportDetails import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.IdPrefix +import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Namespace import org.session.libsignal.utilities.defaultRequiresAuth import org.session.libsignal.utilities.hasNamespaces @@ -370,7 +371,7 @@ object MessageSender { } // Result Handling - fun handleSuccessfulMessageSend(message: Message, destination: Destination, isSyncMessage: Boolean = false, openGroupSentTimestamp: Long = -1) { + private fun handleSuccessfulMessageSend(message: Message, destination: Destination, isSyncMessage: Boolean = false, openGroupSentTimestamp: Long = -1) { val storage = MessagingModuleConfiguration.shared.storage val userPublicKey = storage.getUserPublicKey()!! val timestamp = message.sentTimestamp!! @@ -392,8 +393,10 @@ object MessageSender { // in case any errors from previous sends storage.clearErrorMessage(messageID) + // Track the open group server message ID - if (message.openGroupServerMessageID != null && (destination is Destination.LegacyOpenGroup || destination is Destination.OpenGroup)) { + val messageIsAddressedToCommunity = message.openGroupServerMessageID != null && (destination is Destination.LegacyOpenGroup || destination is Destination.OpenGroup) + if (messageIsAddressedToCommunity) { val server: String val room: String when (destination) { @@ -415,9 +418,26 @@ object MessageSender { storage.setOpenGroupServerMessageID(messageID, message.openGroupServerMessageID!!, threadID, !(message as VisibleMessage).isMediaMessage()) } } - // Mark the message as sent - storage.markAsSent(timestamp, userPublicKey) - storage.markUnidentified(timestamp, userPublicKey) + + // Mark the message as sent. + // Note: When sending a message to a community the server modifies the message timestamp + // so when we go to look up the message in the local database by timestamp it fails and + // we're left with the message delivery status as "Sending" forever! As such, we use a + // pair of modified "markAsSentToCommunity" and "markUnidentifiedInCommunity" methods + // to retrieve the local message by thread & message ID rather than timestamp when + // handling community messages only so we can tick the delivery status over to 'Sent'. + // Fixed in: https://optf.atlassian.net/browse/SES-1567 + if (messageIsAddressedToCommunity) + { + storage.markAsSentToCommunity(message.threadID!!, message.id!!) + storage.markUnidentifiedInCommunity(message.threadID!!, message.id!!) + } + else + { + storage.markAsSent(timestamp, userPublicKey) + storage.markUnidentified(timestamp, userPublicKey) + } + // Start the disappearing messages timer if needed SSKEnvironment.shared.messageExpirationManager.maybeStartExpiration(message, startDisappearAfterRead = true) } ?: run { diff --git a/libsession/src/main/java/org/session/libsession/utilities/Address.kt b/libsession/src/main/java/org/session/libsession/utilities/Address.kt index c8cd11d4b6..920b466a8b 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/Address.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/Address.kt @@ -8,7 +8,6 @@ import androidx.annotation.VisibleForTesting import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Util import org.session.libsignal.utilities.guava.Optional -import java.util.Collections import java.util.LinkedList import java.util.concurrent.atomic.AtomicReference import java.util.regex.Matcher @@ -23,17 +22,17 @@ class Address private constructor(address: String) : Parcelable, Comparable { val escapedAddresses = DelimiterUtil.split(serialized, delimiter) + val set = escapedAddresses.toSet().sorted() val addresses: MutableList
= LinkedList() - for (escapedAddress in escapedAddresses) { + for (escapedAddress in set) { addresses.add(fromSerialized(DelimiterUtil.unescape(escapedAddress, delimiter))) } return addresses @@ -177,9 +177,9 @@ class Address private constructor(address: String) : Parcelable, Comparable, delimiter: Char): String { - Collections.sort(addresses) + val set = addresses.toSet().sorted() val escapedAddresses: MutableList = LinkedList() - for (address in addresses) { + for (address in set) { escapedAddresses.add(DelimiterUtil.escape(address.serialize(), delimiter)) } return Util.join(escapedAddresses, delimiter.toString() + "") diff --git a/libsession/src/main/java/org/session/libsession/utilities/GroupRecord.kt b/libsession/src/main/java/org/session/libsession/utilities/GroupRecord.kt index a8cf6fd796..34b17f16b9 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/GroupRecord.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/GroupRecord.kt @@ -22,7 +22,7 @@ class GroupRecord( } val isOpenGroup: Boolean - get() = Address.fromSerialized(encodedId).isOpenGroup + get() = Address.fromSerialized(encodedId).isCommunity val isClosedGroup: Boolean get() = Address.fromSerialized(encodedId).isClosedGroup diff --git a/libsession/src/main/java/org/session/libsession/utilities/GroupUtil.kt b/libsession/src/main/java/org/session/libsession/utilities/GroupUtil.kt index 630db9e991..9c30aeb249 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/GroupUtil.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/GroupUtil.kt @@ -8,12 +8,12 @@ import java.io.IOException object GroupUtil { const val CLOSED_GROUP_PREFIX = "__textsecure_group__!" - const val OPEN_GROUP_PREFIX = "__loki_public_chat_group__!" - const val OPEN_GROUP_INBOX_PREFIX = "__open_group_inbox__!" + const val COMMUNITY_PREFIX = "__loki_public_chat_group__!" + const val COMMUNITY_INBOX_PREFIX = "__open_group_inbox__!" @JvmStatic fun getEncodedOpenGroupID(groupID: ByteArray): String { - return OPEN_GROUP_PREFIX + Hex.toStringCondensed(groupID) + return COMMUNITY_PREFIX + Hex.toStringCondensed(groupID) } @JvmStatic @@ -25,7 +25,7 @@ object GroupUtil { @JvmStatic fun getEncodedOpenGroupInboxID(groupInboxID: ByteArray): Address { - return Address.fromSerialized(OPEN_GROUP_INBOX_PREFIX + Hex.toStringCondensed(groupInboxID)) + return Address.fromSerialized(COMMUNITY_INBOX_PREFIX + Hex.toStringCondensed(groupInboxID)) } @JvmStatic @@ -69,17 +69,17 @@ object GroupUtil { } fun isEncodedGroup(groupId: String): Boolean { - return groupId.startsWith(CLOSED_GROUP_PREFIX) || groupId.startsWith(OPEN_GROUP_PREFIX) + return groupId.startsWith(CLOSED_GROUP_PREFIX) || groupId.startsWith(COMMUNITY_PREFIX) } @JvmStatic - fun isOpenGroup(groupId: String): Boolean { - return groupId.startsWith(OPEN_GROUP_PREFIX) + fun isCommunity(groupId: String): Boolean { + return groupId.startsWith(COMMUNITY_PREFIX) } @JvmStatic - fun isOpenGroupInbox(groupId: String): Boolean { - return groupId.startsWith(OPEN_GROUP_INBOX_PREFIX) + fun isCommunityInbox(groupId: String): Boolean { + return groupId.startsWith(COMMUNITY_INBOX_PREFIX) } @JvmStatic 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 720806ef89..094a9fc349 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 @@ -459,16 +459,16 @@ public class Recipient implements RecipientModifiedListener { } public boolean is1on1() { return address.isContact() && !isLocalNumber; } - public boolean isOpenGroupRecipient() { - return address.isOpenGroup(); + public boolean isCommunityRecipient() { + return address.isCommunity(); } public boolean isOpenGroupOutboxRecipient() { - return address.isOpenGroupOutbox(); + return address.isCommunityOutbox(); } public boolean isOpenGroupInboxRecipient() { - return address.isOpenGroupInbox(); + return address.isCommunityInbox(); } public boolean isClosedGroupRecipient() {