package org.thoughtcrime.securesms.home import android.app.AlertDialog import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.database.Cursor import android.os.Bundle import android.text.SpannableString import android.widget.Toast import androidx.activity.viewModels import androidx.core.os.bundleOf import androidx.core.view.isVisible import androidx.lifecycle.Lifecycle import androidx.lifecycle.Observer import androidx.lifecycle.lifecycleScope import androidx.loader.app.LoaderManager import androidx.loader.content.Loader import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.bottomsheet.BottomSheetDialogFragment import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R import network.loki.messenger.databinding.ActivityHomeBinding import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.utilities.WebRtcUtils import org.session.libsession.utilities.Util import org.session.libsignal.protos.SignalServiceProtos import org.session.libsession.utilities.Address import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.ProfilePictureModifiedEvent import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.ThreadUtils import org.session.libsignal.utilities.toHexString import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.MuteDialog import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.calls.WebRtcCallActivity import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.utilities.NotificationUtils import org.thoughtcrime.securesms.crypto.IdentityKeyUtil import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.RecipientDatabase import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.dms.CreatePrivateChatActivity import org.thoughtcrime.securesms.groups.CreateClosedGroupActivity import org.thoughtcrime.securesms.groups.JoinPublicChatActivity import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter import org.thoughtcrime.securesms.home.search.GlobalSearchInputLayout import org.thoughtcrime.securesms.home.search.GlobalSearchViewModel import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.onboarding.SeedActivity import org.thoughtcrime.securesms.onboarding.SeedReminderViewDelegate import org.thoughtcrime.securesms.preferences.SettingsActivity import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.IP2Country import org.thoughtcrime.securesms.util.UiModeUtilities import org.thoughtcrime.securesms.util.disableClipping import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.show import org.thoughtcrime.securesms.webrtc.CallBottomSheet import java.io.IOException import javax.inject.Inject @AndroidEntryPoint class HomeActivity : PassphraseRequiredActionBarActivity(), ConversationClickListener, SeedReminderViewDelegate, NewConversationButtonSetViewDelegate, LoaderManager.LoaderCallbacks, GlobalSearchInputLayout.GlobalSearchInputLayoutListener { private lateinit var binding: ActivityHomeBinding private lateinit var glide: GlideRequests private var broadcastReceiver: BroadcastReceiver? = null @Inject lateinit var threadDb: ThreadDatabase @Inject lateinit var mmsSmsDatabase: MmsSmsDatabase @Inject lateinit var recipientDatabase: RecipientDatabase @Inject lateinit var groupDatabase: GroupDatabase @Inject lateinit var textSecurePreferences: TextSecurePreferences private val globalSearchViewModel by viewModels() private val publicKey: String get() = TextSecurePreferences.getLocalNumber(this)!! private val homeAdapter: HomeAdapter by lazy { HomeAdapter(context = this, cursor = threadDb.conversationList, listener = this) } private val globalSearchAdapter = GlobalSearchAdapter { model -> when (model) { is GlobalSearchAdapter.Model.Message -> { val threadId = model.messageResult.threadId val timestamp = model.messageResult.receivedTimestampMs val author = model.messageResult.messageRecipient.address val intent = Intent(this, ConversationActivityV2::class.java) intent.putExtra(ConversationActivityV2.THREAD_ID, threadId) intent.putExtra(ConversationActivityV2.SCROLL_MESSAGE_ID, timestamp) intent.putExtra(ConversationActivityV2.SCROLL_MESSAGE_AUTHOR, author) push(intent) } is GlobalSearchAdapter.Model.SavedMessages -> { val intent = Intent(this, ConversationActivityV2::class.java) intent.putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(model.currentUserPublicKey)) push(intent) } is GlobalSearchAdapter.Model.Contact -> { val address = model.contact.sessionID val intent = Intent(this, ConversationActivityV2::class.java) intent.putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(address)) push(intent) } is GlobalSearchAdapter.Model.GroupConversation -> { val groupAddress = Address.fromSerialized(model.groupRecord.encodedId) val threadId = threadDb.getThreadIdIfExistsFor(Recipient.from(this, groupAddress, false)) if (threadId >= 0) { val intent = Intent(this, ConversationActivityV2::class.java) intent.putExtra(ConversationActivityV2.THREAD_ID, threadId) push(intent) } } else -> { Log.d("Loki", "callback with model: $model") } } } // region Lifecycle override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { super.onCreate(savedInstanceState, isReady) // Set content view binding = ActivityHomeBinding.inflate(layoutInflater) setContentView(binding.root) // Set custom toolbar setSupportActionBar(binding.toolbar) // Set up Glide glide = GlideApp.with(this) // Set up toolbar buttons binding.profileButton.glide = glide binding.profileButton.setOnClickListener { openSettings() } binding.searchViewContainer.setOnClickListener { binding.globalSearchInputLayout.requestFocus() } binding.sessionToolbar.disableClipping() // Set up seed reminder view val hasViewedSeed = TextSecurePreferences.getHasViewedSeed(this) if (!hasViewedSeed) { binding.seedReminderView.isVisible = true binding.seedReminderView.title = SpannableString("You're almost finished! 80%") // Intentionally not yet translated binding.seedReminderView.subtitle = resources.getString(R.string.view_seed_reminder_subtitle_1) binding.seedReminderView.setProgress(80, false) binding.seedReminderView.delegate = this@HomeActivity } else { binding.seedReminderView.isVisible = false } setupHeaderImage() // Set up recycler view binding.globalSearchInputLayout.listener = this homeAdapter.setHasStableIds(true) homeAdapter.glide = glide binding.recyclerView.adapter = homeAdapter binding.globalSearchRecycler.adapter = globalSearchAdapter // Set up empty state view binding.createNewPrivateChatButton.setOnClickListener { createNewPrivateChat() } IP2Country.configureIfNeeded(this@HomeActivity) // This is a workaround for the fact that CursorRecyclerViewAdapter doesn't actually auto-update (even though it says it will) LoaderManager.getInstance(this).restartLoader(0, null, this) // Set up new conversation button set binding.newConversationButtonSet.delegate = this // Observe blocked contacts changed events val broadcastReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { binding.recyclerView.adapter!!.notifyDataSetChanged() } } this.broadcastReceiver = broadcastReceiver LocalBroadcastManager.getInstance(this).registerReceiver(broadcastReceiver, IntentFilter("blockedContactsChanged")) lifecycleScope.launchWhenStarted { launch(Dispatchers.IO) { // Double check that the long poller is up (applicationContext as ApplicationContext).startPollingIfNeeded() // update things based on TextSecurePrefs (profile info etc) // Set up typing observer withContext(Dispatchers.Main) { ApplicationContext.getInstance(this@HomeActivity).typingStatusRepository.typingThreads.observe(this@HomeActivity, Observer> { threadIDs -> val adapter = binding.recyclerView.adapter as HomeAdapter adapter.typingThreadIDs = threadIDs ?: setOf() }) updateProfileButton() TextSecurePreferences.events.filter { it == TextSecurePreferences.PROFILE_NAME_PREF }.collect { updateProfileButton() } } // Set up remaining components if needed val application = ApplicationContext.getInstance(this@HomeActivity) application.registerForFCMIfNeeded(false) val userPublicKey = TextSecurePreferences.getLocalNumber(this@HomeActivity) if (userPublicKey != null) { OpenGroupManager.startPolling() JobQueue.shared.resumePendingJobs() } } // monitor the global search VM query launch { binding.globalSearchInputLayout.query .onEach(globalSearchViewModel::postQuery) .collect() } // Get group results and display them launch { globalSearchViewModel.result.collect { result -> val currentUserPublicKey = publicKey val contactAndGroupList = result.contacts.map { GlobalSearchAdapter.Model.Contact(it) } + result.threads.map { GlobalSearchAdapter.Model.GroupConversation(it) } val contactResults = contactAndGroupList.toMutableList() if (contactResults.isEmpty()) { contactResults.add(GlobalSearchAdapter.Model.SavedMessages(currentUserPublicKey)) } val userIndex = contactResults.indexOfFirst { it is GlobalSearchAdapter.Model.Contact && it.contact.sessionID == currentUserPublicKey } if (userIndex >= 0) { contactResults[userIndex] = GlobalSearchAdapter.Model.SavedMessages(currentUserPublicKey) } if (contactResults.isNotEmpty()) { contactResults.add(0, GlobalSearchAdapter.Model.Header(R.string.global_search_contacts_groups)) } val unreadThreadMap = result.messages .groupBy { it.threadId }.keys .map { it to mmsSmsDatabase.getUnreadCount(it) } .toMap() val messageResults: MutableList = result.messages .map { messageResult -> GlobalSearchAdapter.Model.Message( messageResult, unreadThreadMap[messageResult.threadId] ?: 0 ) }.toMutableList() if (messageResults.isNotEmpty()) { messageResults.add(0, GlobalSearchAdapter.Model.Header(R.string.global_search_messages)) } val newData = contactResults + messageResults globalSearchAdapter.setNewData(result.query, newData) } } } EventBus.getDefault().register(this@HomeActivity) } private fun setupHeaderImage() { val isDayUiMode = UiModeUtilities.isDayUiMode(this) val headerTint = if (isDayUiMode) R.color.black else R.color.accent binding.sessionHeaderImage.setColorFilter(getColor(headerTint)) } override fun onInputFocusChanged(hasFocus: Boolean) { if (hasFocus) { setSearchShown(true) } else { setSearchShown(!binding.globalSearchInputLayout.query.value.isNullOrEmpty()) } } private fun setSearchShown(isShown: Boolean) { binding.searchToolbar.isVisible = isShown binding.sessionToolbar.isVisible = !isShown binding.recyclerView.isVisible = !isShown binding.emptyStateContainer.isVisible = (binding.recyclerView.adapter as HomeAdapter).itemCount == 0 && binding.recyclerView.isVisible binding.seedReminderView.isVisible = !TextSecurePreferences.getHasViewedSeed(this) && !isShown binding.gradientView.isVisible = !isShown binding.globalSearchRecycler.isVisible = isShown binding.newConversationButtonSet.isVisible = !isShown } override fun onCreateLoader(id: Int, bundle: Bundle?): Loader { return HomeLoader(this@HomeActivity) } override fun onLoadFinished(loader: Loader, cursor: Cursor?) { homeAdapter.changeCursor(cursor) updateEmptyState() } override fun onLoaderReset(cursor: Loader) { homeAdapter.changeCursor(null) } override fun onResume() { super.onResume() ApplicationContext.getInstance(this).messageNotifier.setHomeScreenVisible(true) if (TextSecurePreferences.getLocalNumber(this) == null) { return; } // This can be the case after a secondary device is auto-cleared IdentityKeyUtil.checkUpdate(this) binding.profileButton.recycle() // clear cached image before update tje profilePictureView binding.profileButton.update() val hasViewedSeed = TextSecurePreferences.getHasViewedSeed(this) if (hasViewedSeed) { binding.seedReminderView.isVisible = false } if (TextSecurePreferences.getConfigurationMessageSynced(this)) { lifecycleScope.launch(Dispatchers.IO) { ConfigurationMessageUtilities.syncConfigurationIfNeeded(this@HomeActivity) } } } override fun onPause() { super.onPause() ApplicationContext.getInstance(this).messageNotifier.setHomeScreenVisible(false) } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (resultCode == CreateClosedGroupActivity.closedGroupCreatedResultCode) { createNewPrivateChat() } } override fun onDestroy() { val broadcastReceiver = this.broadcastReceiver if (broadcastReceiver != null) { LocalBroadcastManager.getInstance(this).unregisterReceiver(broadcastReceiver) } super.onDestroy() EventBus.getDefault().unregister(this) } // endregion // region Updating private fun updateEmptyState() { val threadCount = (binding.recyclerView.adapter as HomeAdapter).itemCount binding.emptyStateContainer.isVisible = threadCount == 0 && binding.recyclerView.isVisible } @Subscribe(threadMode = ThreadMode.MAIN) fun onUpdateProfileEvent(event: ProfilePictureModifiedEvent) { if (event.recipient.isLocalNumber) { updateProfileButton() } } private fun updateProfileButton() { binding.profileButton.publicKey = publicKey binding.profileButton.displayName = TextSecurePreferences.getProfileName(this) binding.profileButton.recycle() binding.profileButton.update() } // endregion // region Interaction override fun onBackPressed() { if (binding.globalSearchRecycler.isVisible) { binding.globalSearchInputLayout.clearSearch(true) return } super.onBackPressed() } override fun handleSeedReminderViewContinueButtonTapped() { val intent = Intent(this, SeedActivity::class.java) show(intent) } override fun onConversationClick(thread: ThreadRecord) { val intent = Intent(this, ConversationActivityV2::class.java) intent.putExtra(ConversationActivityV2.THREAD_ID, thread.threadId) push(intent) } override fun onLongConversationClick(thread: ThreadRecord) { val bottomSheet = ConversationOptionsBottomSheet() bottomSheet.thread = thread bottomSheet.onViewDetailsTapped = { bottomSheet.dismiss() val userDetailsBottomSheet = UserDetailsBottomSheet() val bundle = bundleOf( UserDetailsBottomSheet.ARGUMENT_PUBLIC_KEY to thread.recipient.address.toString(), UserDetailsBottomSheet.ARGUMENT_THREAD_ID to thread.threadId ) userDetailsBottomSheet.arguments = bundle userDetailsBottomSheet.show(supportFragmentManager, userDetailsBottomSheet.tag) } bottomSheet.onBlockTapped = { bottomSheet.dismiss() if (!thread.recipient.isBlocked) { blockConversation(thread) } } bottomSheet.onUnblockTapped = { bottomSheet.dismiss() if (thread.recipient.isBlocked) { unblockConversation(thread) } } bottomSheet.onDeleteTapped = { bottomSheet.dismiss() deleteConversation(thread) } bottomSheet.onSetMuteTapped = { muted -> bottomSheet.dismiss() setConversationMuted(thread, muted) } bottomSheet.onNotificationTapped = { bottomSheet.dismiss() NotificationUtils.showNotifyDialog(this, thread.recipient) { notifyType -> setNotifyType(thread, notifyType) } } bottomSheet.onPinTapped = { bottomSheet.dismiss() setConversationPinned(thread.threadId, true) } bottomSheet.onUnpinTapped = { bottomSheet.dismiss() setConversationPinned(thread.threadId, false) } bottomSheet.onMarkAllAsReadTapped = { bottomSheet.dismiss() markAllAsRead(thread) } bottomSheet.show(supportFragmentManager, bottomSheet.tag) } private fun blockConversation(thread: ThreadRecord) { AlertDialog.Builder(this) .setTitle(R.string.RecipientPreferenceActivity_block_this_contact_question) .setMessage(R.string.RecipientPreferenceActivity_you_will_no_longer_receive_messages_and_calls_from_this_contact) .setNegativeButton(android.R.string.cancel, null) .setPositiveButton(R.string.RecipientPreferenceActivity_block) { dialog, _ -> lifecycleScope.launch(Dispatchers.IO) { recipientDatabase.setBlocked(thread.recipient, true) withContext(Dispatchers.Main) { binding.recyclerView.adapter!!.notifyDataSetChanged() dialog.dismiss() } } }.show() } private fun unblockConversation(thread: ThreadRecord) { AlertDialog.Builder(this) .setTitle(R.string.RecipientPreferenceActivity_unblock_this_contact_question) .setMessage(R.string.RecipientPreferenceActivity_you_will_once_again_be_able_to_receive_messages_and_calls_from_this_contact) .setNegativeButton(android.R.string.cancel, null) .setPositiveButton(R.string.RecipientPreferenceActivity_unblock) { dialog, _ -> lifecycleScope.launch(Dispatchers.IO) { recipientDatabase.setBlocked(thread.recipient, false) withContext(Dispatchers.Main) { binding.recyclerView.adapter!!.notifyDataSetChanged() dialog.dismiss() } } }.show() } private fun setConversationMuted(thread: ThreadRecord, isMuted: Boolean) { if (!isMuted) { lifecycleScope.launch(Dispatchers.IO) { recipientDatabase.setMuted(thread.recipient, 0) withContext(Dispatchers.Main) { binding.recyclerView.adapter!!.notifyDataSetChanged() } } } else { MuteDialog.show(this) { until: Long -> lifecycleScope.launch(Dispatchers.IO) { recipientDatabase.setMuted(thread.recipient, until) withContext(Dispatchers.Main) { binding.recyclerView.adapter!!.notifyDataSetChanged() } } } } } private fun setNotifyType(thread: ThreadRecord, newNotifyType: Int) { lifecycleScope.launch(Dispatchers.IO) { recipientDatabase.setNotifyType(thread.recipient, newNotifyType) withContext(Dispatchers.Main) { binding.recyclerView.adapter!!.notifyDataSetChanged() } } } private fun setConversationPinned(threadId: Long, pinned: Boolean) { lifecycleScope.launch(Dispatchers.IO) { threadDb.setPinned(threadId, pinned) withContext(Dispatchers.Main) { LoaderManager.getInstance(this@HomeActivity).restartLoader(0, null, this@HomeActivity) } } } private fun markAllAsRead(thread: ThreadRecord) { ThreadUtils.queue { threadDb.markAllAsRead(thread.threadId, thread.recipient.isOpenGroupRecipient) } } private fun deleteConversation(thread: ThreadRecord) { val threadID = thread.threadId val recipient = thread.recipient val message = if (recipient.isGroupRecipient) { val group = groupDatabase.getGroup(recipient.address.toString()).orNull() if (group != null && group.admins.map { it.toString() }.contains(TextSecurePreferences.getLocalNumber(this))) { "Because you are the creator of this group it will be deleted for everyone. This cannot be undone." } else { resources.getString(R.string.activity_home_leave_group_dialog_message) } } else { resources.getString(R.string.activity_home_delete_conversation_dialog_message) } val dialog = AlertDialog.Builder(this) dialog.setMessage(message) dialog.setPositiveButton(R.string.yes) { _, _ -> lifecycleScope.launch(Dispatchers.Main) { val context = this@HomeActivity as Context // Cancel any outstanding jobs DatabaseComponent.get(context).sessionJobDatabase().cancelPendingMessageSendJobs(threadID) // Send a leave group message if this is an active closed group if (recipient.address.isClosedGroup && DatabaseComponent.get(context).groupDatabase().isActive(recipient.address.toGroupString())) { var isClosedGroup: Boolean var groupPublicKey: String? try { groupPublicKey = GroupUtil.doubleDecodeGroupID(recipient.address.toString()).toHexString() isClosedGroup = DatabaseComponent.get(context).lokiAPIDatabase().isClosedGroup(groupPublicKey) } catch (e: IOException) { groupPublicKey = null isClosedGroup = false } if (isClosedGroup) { MessageSender.explicitLeave(groupPublicKey!!, false) } } // Delete the conversation val v2OpenGroup = DatabaseComponent.get(this@HomeActivity).lokiThreadDatabase().getOpenGroupChat(threadID) if (v2OpenGroup != null) { OpenGroupManager.delete(v2OpenGroup.server, v2OpenGroup.room, this@HomeActivity) } else { lifecycleScope.launch(Dispatchers.IO) { threadDb.deleteConversation(threadID) } } // Update the badge count ApplicationContext.getInstance(context).messageNotifier.updateNotification(context) // Notify the user val toastMessage = if (recipient.isGroupRecipient) R.string.MessageRecord_left_group else R.string.activity_home_conversation_deleted_message Toast.makeText(context, toastMessage, Toast.LENGTH_LONG).show() } } dialog.setNegativeButton(R.string.no) { _, _ -> // Do nothing } dialog.create().show() } private fun openSettings() { val intent = Intent(this, SettingsActivity::class.java) show(intent, isForResult = true) } private fun showPath() { val intent = Intent(this, PathActivity::class.java) show(intent) } override fun createNewPrivateChat() { val intent = Intent(this, CreatePrivateChatActivity::class.java) show(intent) } override fun createNewClosedGroup() { val intent = Intent(this, CreateClosedGroupActivity::class.java) show(intent, true) } override fun joinOpenGroup() { val intent = Intent(this, JoinPublicChatActivity::class.java) show(intent) } // endregion }