package org.thoughtcrime.securesms.loki.activities 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.net.Uri import android.os.AsyncTask import android.os.Bundle import android.text.Spannable import android.text.SpannableString import android.text.style.ForegroundColorSpan import android.util.DisplayMetrics import android.view.View import android.widget.RelativeLayout import android.widget.Toast 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 kotlinx.android.synthetic.main.activity_home.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.conversation.ConversationActivity import org.thoughtcrime.securesms.database.Address import org.thoughtcrime.securesms.database.DatabaseFactory import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.groups.GroupManager import org.thoughtcrime.securesms.jobs.MultiDeviceBlockedUpdateJob import org.thoughtcrime.securesms.loki.api.ResetThreadSessionJob import org.thoughtcrime.securesms.loki.dialogs.ConversationOptionsBottomSheet import org.thoughtcrime.securesms.loki.dialogs.LightThemeFeatureIntroBottomSheet import org.thoughtcrime.securesms.loki.dialogs.MultiDeviceRemovalBottomSheet import org.thoughtcrime.securesms.loki.dialogs.UserDetailsBottomSheet import org.thoughtcrime.securesms.loki.protocol.ClosedGroupsProtocol import org.thoughtcrime.securesms.loki.protocol.SessionResetImplementation import org.thoughtcrime.securesms.loki.utilities.* import org.thoughtcrime.securesms.loki.views.ConversationView import org.thoughtcrime.securesms.loki.views.NewConversationButtonSetViewDelegate import org.thoughtcrime.securesms.loki.views.SeedReminderViewDelegate import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.util.TextSecurePreferences import org.thoughtcrime.securesms.util.TextSecurePreferences.getBooleanPreference import org.thoughtcrime.securesms.util.TextSecurePreferences.setBooleanPreference import org.thoughtcrime.securesms.util.Util import org.whispersystems.signalservice.loki.api.fileserver.FileServerAPI import org.whispersystems.signalservice.loki.protocol.mentions.MentionsManager import org.whispersystems.signalservice.loki.protocol.meta.SessionMetaProtocol import org.whispersystems.signalservice.loki.protocol.sessionmanagement.SessionManagementProtocol import org.whispersystems.signalservice.loki.protocol.shelved.multidevice.MultiDeviceProtocol import org.whispersystems.signalservice.loki.protocol.shelved.syncmessages.SyncMessagesProtocol import org.whispersystems.signalservice.loki.utilities.toHexString import java.io.IOException class HomeActivity : PassphraseRequiredActionBarActivity, ConversationClickListener, SeedReminderViewDelegate, NewConversationButtonSetViewDelegate { companion object { private const val PREF_RESET_ALL_SESSIONS_ON_START_UP = "pref_reset_all_sessions_on_start_up" @JvmStatic fun requestResetAllSessionsOnStartup(context: Context) { setBooleanPreference(context, PREF_RESET_ALL_SESSIONS_ON_START_UP, true) } @JvmStatic fun scheduleResetAllSessionsIfRequested(context: Context) { if (!getBooleanPreference(context, PREF_RESET_ALL_SESSIONS_ON_START_UP, false)) return setBooleanPreference(context, PREF_RESET_ALL_SESSIONS_ON_START_UP, false) val jobManager = ApplicationContext.getInstance(context).jobManager DatabaseFactory.getThreadDatabase(context).conversationListQuick.forEach { tuple -> val threadId: Long = tuple.first val recipientAddress: String = tuple.second jobManager.add(ResetThreadSessionJob( Address.fromSerialized(recipientAddress), threadId)) } } } private lateinit var glide: GlideRequests private var broadcastReceiver: BroadcastReceiver? = null private val publicKey: String get() { val masterPublicKey = TextSecurePreferences.getMasterHexEncodedPublicKey(this) val userPublicKey = TextSecurePreferences.getLocalNumber(this) return masterPublicKey ?: userPublicKey } // region Lifecycle constructor() : super() override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { super.onCreate(savedInstanceState, isReady) // Double check that the long poller is up (applicationContext as ApplicationContext).startPollingIfNeeded() // Set content view setContentView(R.layout.activity_home) // Set custom toolbar setSupportActionBar(toolbar) // Set up Glide glide = GlideApp.with(this) // Set up toolbar buttons profileButton.glide = glide profileButton.publicKey = publicKey profileButton.displayName = TextSecurePreferences.getProfileName(this) profileButton.update() profileButton.setOnClickListener { openSettings() } pathStatusViewContainer.disableClipping() pathStatusViewContainer.setOnClickListener { showPath() } // Set up seed reminder view val isMasterDevice = (TextSecurePreferences.getMasterHexEncodedPublicKey(this) == null) val hasViewedSeed = TextSecurePreferences.getHasViewedSeed(this) if (!hasViewedSeed && isMasterDevice) { val seedReminderViewTitle = SpannableString("You're almost finished! 80%") // Intentionally not yet translated seedReminderViewTitle.setSpan(ForegroundColorSpan(resources.getColorWithID(R.color.accent, theme)), 24, 27, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) seedReminderView.title = seedReminderViewTitle seedReminderView.subtitle = resources.getString(R.string.view_seed_reminder_subtitle_1) seedReminderView.setProgress(80, false) seedReminderView.delegate = this } else { seedReminderView.visibility = View.GONE } // Set up recycler view val cursor = DatabaseFactory.getThreadDatabase(this).conversationList val homeAdapter = HomeAdapter(this, cursor) homeAdapter.glide = glide homeAdapter.conversationClickListener = this recyclerView.adapter = homeAdapter recyclerView.layoutManager = LinearLayoutManager(this) // Set up empty state view createNewPrivateChatButton.setOnClickListener { createNewPrivateChat() } // 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, object : LoaderManager.LoaderCallbacks { 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) } }) // Set up gradient view val gradientViewLayoutParams = gradientView.layoutParams as RelativeLayout.LayoutParams val displayMetrics = DisplayMetrics() windowManager.defaultDisplay.getMetrics(displayMetrics) val height = displayMetrics.heightPixels gradientViewLayoutParams.topMargin = (0.15 * height.toFloat()).toInt() // Set up new conversation button set newConversationButtonSet.delegate = this // Set up typing observer ApplicationContext.getInstance(this).typingStatusRepository.typingThreads.observe(this, Observer> { threadIDs -> val adapter = recyclerView.adapter as HomeAdapter adapter.typingThreadIDs = threadIDs ?: setOf() }) // Set up remaining components if needed val application = ApplicationContext.getInstance(this) val apiDB = DatabaseFactory.getLokiAPIDatabase(this) val threadDB = DatabaseFactory.getLokiThreadDatabase(this) val userDB = DatabaseFactory.getLokiUserDatabase(this) val sskDatabase = DatabaseFactory.getSSKDatabase(this) val userPublicKey = TextSecurePreferences.getLocalNumber(this) val sessionResetImpl = SessionResetImplementation(this) if (userPublicKey != null) { MentionsManager.configureIfNeeded(userPublicKey, threadDB, userDB) SessionMetaProtocol.configureIfNeeded(apiDB, userPublicKey) SyncMessagesProtocol.configureIfNeeded(apiDB, userPublicKey) application.publicChatManager.startPollersIfNeeded() } SessionManagementProtocol.configureIfNeeded(sessionResetImpl, sskDatabase, application) MultiDeviceProtocol.configureIfNeeded(apiDB) IP2Country.configureIfNeeded(this) application.registerForFCMIfNeeded(false) // Preload device links to make message sending quicker val publicKeys = ContactUtilities.getAllContacts(this).filter { contact -> !contact.recipient.isGroupRecipient && !contact.isOurDevice && !contact.isSlave }.map { it.recipient.address.toPhoneString() }.toSet() FileServerAPI.shared.getDeviceLinks(publicKeys) // Observe blocked contacts changed events val broadcastReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { recyclerView.adapter!!.notifyDataSetChanged() } } this.broadcastReceiver = broadcastReceiver LocalBroadcastManager.getInstance(this).registerReceiver(broadcastReceiver, IntentFilter("blockedContactsChanged")) // Clear all data if this is a secondary device if (TextSecurePreferences.getMasterHexEncodedPublicKey(this) != null) { TextSecurePreferences.setWasUnlinked(this, true) ApplicationContext.getInstance(this).clearData() } // Perform chat sessions reset if requested (usually happens after backup restoration). scheduleResetAllSessionsIfRequested(this) } override fun onResume() { super.onResume() if (TextSecurePreferences.getLocalNumber(this) == null) { return; } // This can be the case after a secondary device is auto-cleared profileButton.update() val isMasterDevice = (TextSecurePreferences.getMasterHexEncodedPublicKey(this) == null) val hasViewedSeed = TextSecurePreferences.getHasViewedSeed(this) if (hasViewedSeed || !isMasterDevice) { seedReminderView.visibility = View.GONE } // Multi device removal sheet if (!TextSecurePreferences.getHasSeenMultiDeviceRemovalSheet(this)) { TextSecurePreferences.setHasSeenMultiDeviceRemovalSheet(this) val userPublicKey = TextSecurePreferences.getLocalNumber(this) val deviceLinks = DatabaseFactory.getLokiAPIDatabase(this).getDeviceLinks(userPublicKey) if (deviceLinks.isNotEmpty()) { val bottomSheet = MultiDeviceRemovalBottomSheet() bottomSheet.onOKTapped = { bottomSheet.dismiss() } bottomSheet.onLinkTapped = { bottomSheet.dismiss() val url = "https://getsession.org/faq" val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) startActivity(intent) } bottomSheet.show(supportFragmentManager, bottomSheet.tag) return } } // Light theme introduction sheet if (!TextSecurePreferences.hasSeenLightThemeIntroSheet(this) && UiModeUtilities.isDayUiMode(this)) { TextSecurePreferences.setHasSeenLightThemeIntroSheet(this) val bottomSheet = LightThemeFeatureIntroBottomSheet() bottomSheet.show(supportFragmentManager, bottomSheet.tag) return } } 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() } // endregion // region Updating private fun updateEmptyState() { val threadCount = (recyclerView.adapter as HomeAdapter).itemCount emptyStateContainer.visibility = if (threadCount == 0) View.VISIBLE else View.GONE } // endregion // region Interaction override fun handleSeedReminderViewContinueButtonTapped() { val intent = Intent(this, SeedActivity::class.java) show(intent) } override fun onConversationClick(view: ConversationView) { val thread = view.thread ?: return openConversation(thread) } override fun onLongConversationClick(view: ConversationView) { val thread = view.thread ?: return val bottomSheet = ConversationOptionsBottomSheet() bottomSheet.recipient = thread.recipient bottomSheet.onViewDetailsTapped = { bottomSheet.dismiss() val userDetailsBottomSheet = UserDetailsBottomSheet() val bundle = Bundle() bundle.putString("publicKey", thread.recipient.address.toPhoneString()) 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.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, _ -> Thread { DatabaseFactory.getRecipientDatabase(this).setBlocked(thread.recipient, true) ApplicationContext.getInstance(this).jobManager.add(MultiDeviceBlockedUpdateJob()) Util.runOnMain { recyclerView.adapter!!.notifyDataSetChanged() dialog.dismiss() } }.start() }.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, _ -> Thread { DatabaseFactory.getRecipientDatabase(this).setBlocked(thread.recipient, false) ApplicationContext.getInstance(this).jobManager.add(MultiDeviceBlockedUpdateJob()) Util.runOnMain { recyclerView.adapter!!.notifyDataSetChanged() dialog.dismiss() } }.start() }.show() } private fun deleteConversation(thread: ThreadRecord) { val threadID = thread.threadId val recipient = thread.recipient val threadDB = DatabaseFactory.getThreadDatabase(this) val dialogMessage = if (recipient.isGroupRecipient) R.string.activity_home_leave_group_dialog_message else R.string.activity_home_delete_conversation_dialog_message val dialog = AlertDialog.Builder(this) dialog.setMessage(dialogMessage) dialog.setPositiveButton(R.string.yes) { _, _ -> lifecycleScope.launch(Dispatchers.Main) { val context = this@HomeActivity as Context val isClosedGroup = recipient.address.isClosedGroup // Send a leave group message if this is an active closed group if (isClosedGroup && DatabaseFactory.getGroupDatabase(context).isActive(recipient.address.toGroupString())) { var isSSKBasedClosedGroup: Boolean var groupPublicKey: String? try { groupPublicKey = ClosedGroupsProtocol.doubleDecodeGroupID(recipient.address.toString()).toHexString() isSSKBasedClosedGroup = DatabaseFactory.getSSKDatabase(context).isSSKBasedClosedGroup(groupPublicKey) } catch (e: IOException) { groupPublicKey = null isSSKBasedClosedGroup = false } if (isSSKBasedClosedGroup) { ClosedGroupsProtocol.leave(context, groupPublicKey!!) } else if (!ClosedGroupsProtocol.leaveLegacyGroup(context, recipient)) { Toast.makeText(context, R.string.activity_home_leaving_group_failed_message, Toast.LENGTH_LONG).show() return@launch } } withContext(Dispatchers.IO) { val publicChat = DatabaseFactory.getLokiThreadDatabase(context).getPublicChat(threadID) //TODO Move open group related logic to OpenGroupUtilities / PublicChatManager / GroupManager if (publicChat != null) { val apiDB = DatabaseFactory.getLokiAPIDatabase(context) apiDB.removeLastMessageServerID(publicChat.channel, publicChat.server) apiDB.removeLastDeletionServerID(publicChat.channel, publicChat.server) apiDB.clearOpenGroupProfilePictureURL(publicChat.channel, publicChat.server) ApplicationContext.getInstance(context).publicChatAPI!! .leave(publicChat.channel, publicChat.server) ApplicationContext.getInstance(context).publicChatManager .removeChat(publicChat.server, publicChat.channel) } else { threadDB.deleteConversation(threadID) } 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 openConversation(thread: ThreadRecord) { val intent = Intent(this, ConversationActivity::class.java) intent.putExtra(ConversationActivity.ADDRESS_EXTRA, thread.recipient.address) intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, thread.threadId) intent.putExtra(ConversationActivity.DISTRIBUTION_TYPE_EXTRA, thread.distributionType) intent.putExtra(ConversationActivity.TIMING_EXTRA, System.currentTimeMillis()) intent.putExtra(ConversationActivity.LAST_SEEN_EXTRA, thread.lastSeen) intent.putExtra(ConversationActivity.STARTING_POSITION_EXTRA, -1) push(intent) } private fun openSettings() { val intent = Intent(this, SettingsActivity::class.java) show(intent) } 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 }