/* * VoIP.ms SMS * Copyright (C) 2015-2016 Michael Kourlas * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package net.kourlas.voipms_sms.activities; import android.Manifest; import android.annotation.TargetApi; import android.app.NotificationManager; import android.content.ClipData; import android.content.ClipboardManager; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; import android.graphics.Outline; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.support.annotation.NonNull; import android.support.v4.app.ActivityCompat; import android.support.v4.app.NavUtils; import android.support.v4.content.ContextCompat; import android.support.v4.view.ViewCompat; import android.support.v7.app.ActionBar; import android.support.v7.app.AppCompatActivity; import android.support.v7.view.ActionMode; import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.RecyclerView; import android.support.v7.widget.SearchView; import android.support.v7.widget.Toolbar; import android.text.Editable; import android.text.TextWatcher; import android.util.TypedValue; import android.view.*; import android.widget.*; import net.kourlas.voipms_sms.R; import net.kourlas.voipms_sms.adapters.ConversationRecyclerViewAdapter; import net.kourlas.voipms_sms.db.Database; import net.kourlas.voipms_sms.model.Message; import net.kourlas.voipms_sms.notifications.Notifications; import net.kourlas.voipms_sms.preferences.Preferences; import net.kourlas.voipms_sms.utils.Utils; import java.text.DateFormat; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.List; import java.util.Locale; public class ConversationActivity extends AppCompatActivity implements ActionMode.Callback, View.OnLongClickListener, View.OnClickListener, ActivityCompat.OnRequestPermissionsResultCallback { private Database database; private Preferences preferences; private String contact; private Menu menu; private ActionMode actionMode; private boolean actionModeEnabled; private LinearLayoutManager layoutManager; private ConversationRecyclerViewAdapter adapter; private RecyclerView recyclerView; /// /// Accessors /// public String getContact() { return contact; } /// /// Lifecycle methods /// @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.conversation); database = Database.getInstance(getApplicationContext()); preferences = Preferences.getInstance(getApplicationContext()); contact = getIntent() .getStringExtra(getString(R.string.conversation_extra_contact)); if ((contact.length() == 11) && (contact.charAt(0) == '1')) { // Remove the leading one from a North American phone number // (e.g. +1 (123) 555-4567) contact = contact.substring(1); } // Set up toolbar Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); ViewCompat.setElevation(toolbar, getResources() .getDimension(R.dimen.toolbar_elevation)); setSupportActionBar(toolbar); ActionBar actionBar = getSupportActionBar(); if (actionBar != null) { String contactName = Utils.getContactName(this, contact); if (contactName != null) { actionBar.setTitle(contactName); actionBar.setSubtitle(Utils.getFormattedPhoneNumber(contact)); } else { actionBar.setTitle(Utils.getFormattedPhoneNumber(contact)); } actionBar.setHomeButtonEnabled(true); actionBar.setDisplayHomeAsUpEnabled(true); } actionMode = null; actionModeEnabled = false; // Set up recycler view layoutManager = new LinearLayoutManager(this); layoutManager.setOrientation(LinearLayoutManager.VERTICAL); layoutManager.setStackFromEnd(true); adapter = new ConversationRecyclerViewAdapter(this, layoutManager, contact); recyclerView = (RecyclerView) findViewById(R.id.list); recyclerView.setHasFixedSize(true); recyclerView.setLayoutManager(layoutManager); recyclerView.setAdapter(adapter); // Set up container for message text box final RelativeLayout messageSection = (RelativeLayout) findViewById(R.id.message_section); ViewCompat.setElevation( messageSection, TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_DIP, 10, getResources().getDisplayMetrics())); // Set up message text box final EditText messageText = (EditText) findViewById(R.id.message_edit_text); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { messageText.setOutlineProvider(new ViewOutlineProvider() { @TargetApi(Build.VERSION_CODES.LOLLIPOP) @Override public void getOutline(View view, Outline outline) { outline.setRoundRect(0, 0, view.getWidth(), view.getHeight(), 15); } }); messageText.setClipToOutline(true); } messageText.addTextChangedListener(new TextWatcher() { @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) { // Do nothing. } @Override public void onTextChanged(CharSequence s, int start, int before, int count) { // Do nothing. } @Override public void afterTextChanged(Editable s) { onMessageTextChange(s.toString()); } }); messageText.setOnFocusChangeListener((v, hasFocus) -> { if (hasFocus) { adapter.refresh(); } }); String intentMessageText = getIntent().getStringExtra( getString(R.string.conversation_extra_message_text)); if (intentMessageText != null) { messageText.setText(intentMessageText); messageText.setSelection(messageText.getText().length()); } boolean intentFocus = getIntent().getBooleanExtra( getString(R.string.conversation_extra_focus), false); if (intentFocus) { messageText.requestFocus(); } // Set up personal photo QuickContactBadge photo = (QuickContactBadge) findViewById(R.id.photo); Utils.applyCircularMask(photo); photo.assignContactFromPhone(preferences.getDid(), true); String photoUri = Utils.getContactPhotoUri(getApplicationContext(), preferences.getDid()); if (photoUri != null) { photo.setImageURI(Uri.parse(photoUri)); } else { photo.setImageToDefault(); } // Set up send button final ImageButton sendButton = (ImageButton) findViewById(R.id.send_button); Utils.applyCircularMask(sendButton); sendButton.setOnClickListener(v -> preSendMessage()); } @Override protected void onDestroy() { super.onDestroy(); ActivityMonitor.getInstance().deleteReferenceToActivity(this); } /** * Called when the message text box text changes. * * @param str The new text. */ private void onMessageTextChange(String str) { ViewSwitcher viewSwitcher = (ViewSwitcher) findViewById(R.id.view_switcher); if (str.equals("") && viewSwitcher.getDisplayedChild() == 1) { viewSwitcher.setDisplayedChild(0); } else if (viewSwitcher.getDisplayedChild() == 0) { viewSwitcher.setDisplayedChild(1); } Message previousDraftMessage = database.getDraftMessageForConversation( preferences.getDid(), contact); if (str.equals("")) { if (previousDraftMessage != null) { database.removeMessage( previousDraftMessage.getDatabaseId()); } } else { if (previousDraftMessage != null) { previousDraftMessage.setText(str); database.insertMessage(previousDraftMessage); } else { Message newDraftMessage = new Message( preferences.getDid(), contact, str); newDraftMessage.setDraft(true); database.insertMessage(newDraftMessage); } } TextView charsRemainingTextView = (TextView) findViewById(R.id.chars_remaining_text); if (str.length() >= 150 && str.length() <= 160) { charsRemainingTextView.setVisibility(View.VISIBLE); charsRemainingTextView.setText(String.valueOf(160 - str.length())); } else if (str.length() > 160) { charsRemainingTextView.setVisibility(View.VISIBLE); int charsRemaining; if (str.length() % 153 == 0) { charsRemaining = 0; } else { charsRemaining = 153 - (str.length() % 153); } charsRemainingTextView.setText( getString( R.string.conversation_char_rem, String.valueOf(charsRemaining), String.valueOf((int) Math.ceil(str.length() / 153d)))); } else { charsRemainingTextView.setVisibility(View.GONE); } } /** * Called after the user clicks the send message button. */ private void preSendMessage() { EditText messageEditText = (EditText) findViewById(R.id.message_edit_text); String messageText = messageEditText.getText().toString(); // Split up the message to be sent into 153-character chunks // (if character count greater than 160) and add them to the database if (messageText.length() > 160) { while (true) { if (messageText.length() > 153) { long databaseId = database.insertMessage(new Message( preferences.getDid(), contact, messageText.substring(0, 153))); messageText = messageText.substring(153); adapter.refresh(); preSendMessage(databaseId); } else { long databaseId = database.insertMessage(new Message( preferences.getDid(), contact, messageText)); adapter.refresh(); preSendMessage(databaseId); break; } } } else { long databaseId = database.insertMessage(new Message( preferences.getDid(), contact, messageText)); adapter.refresh(); preSendMessage(databaseId); } // Clear the message text box messageEditText.setText(""); } /// /// Options menu /// /** * Called after the message to be sent has been added to the database. */ private void preSendMessage(long databaseId) { database.markMessageAsSending(databaseId); adapter.refresh(); database.sendMessage(this, databaseId); } /** * Creates the standard options menu. */ @Override public boolean onCreateOptionsMenu(final Menu menu) { // Create standard options menu MenuInflater inflater = getMenuInflater(); inflater.inflate(R.menu.conversation, menu); this.menu = menu; // Hide the call button on devices without telephony support if (!getPackageManager() .hasSystemFeature(PackageManager.FEATURE_TELEPHONY)) { MenuItem phoneMenuItem = menu.findItem(R.id.call_button); phoneMenuItem.setVisible(false); } // Configure the search box to trigger adapter filtering when the // text changes SearchView searchView = (SearchView) menu.findItem(R.id.search_button).getActionView(); searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() { @Override public boolean onQueryTextSubmit(String query) { return false; } @Override public boolean onQueryTextChange(String newText) { adapter.refresh(newText); return true; } }); return super.onCreateOptionsMenu(menu); } /** * Dispatcher for options menu items. */ @Override public boolean onOptionsItemSelected(MenuItem item) { ActionBar actionBar = getSupportActionBar(); if (actionBar != null) { switch (item.getItemId()) { case R.id.call_button: return onCallButtonClick(); case R.id.delete_button: return onDeleteAllButtonClick(); } } return super.onOptionsItemSelected(item); } /** * Handler for the call button. */ private boolean onCallButtonClick() { Intent intent = new Intent(Intent.ACTION_CALL); intent.setData(Uri.parse("tel:" + contact)); // Before trying to call the contact's phone number, request the // CALL_PHONE permission if (ContextCompat.checkSelfPermission(this, Manifest.permission.CALL_PHONE) != PackageManager.PERMISSION_GRANTED) { // We don't yet have the permission, so request it; if granted, // this method will be called again ActivityCompat.requestPermissions( this, new String[] {Manifest.permission.CALL_PHONE}, 0); } else { // We have the permission try { startActivity(intent); } catch (SecurityException ignored) { // Do nothing. } } return true; } /// /// Action mode menu /// /** * Handler for the delete all messages button. */ private boolean onDeleteAllButtonClick() { // Show a confirmation prompt; if the user accepts, delete all messages // and return to the previous activity Utils.showAlertDialog( this, getString(R.string.conversation_delete_confirm_title), getString(R.string.conversation_delete_confirm_message), getString(R.string.delete), (dialog, which) -> { database.deleteMessages(preferences.getDid(), contact); // Go back to the previous activity if no messages remain if (!database.conversationHasMessages(preferences.getDid(), contact)) { NavUtils.navigateUpFromSameTask(this); } else { adapter.refresh(); } }, getString(R.string.cancel), null); return true; } /** * Creates the action mode menu. */ @Override public boolean onCreateActionMode(ActionMode actionMode, Menu menu) { MenuInflater inflater = actionMode.getMenuInflater(); inflater.inflate(R.menu.conversation_secondary, menu); return true; } /** * Unused method implemented due to interface requirements. */ @Override public boolean onPrepareActionMode(ActionMode actionMode, Menu menu) { return false; } /** * Dispatcher for action mode items. */ @Override public boolean onActionItemClicked(ActionMode mode, MenuItem item) { switch (item.getItemId()) { case R.id.resend_button: return onResendButtonClick(mode); case R.id.info_button: return onInfoButtonClick(mode); case R.id.copy_button: return onCopyButtonClick(mode); case R.id.share_button: return onShareButtonClick(mode); case R.id.delete_button: return onDeleteButtonClick(mode); default: return false; } } /** * Switches back to the options menu from the action mode menu. */ @Override public void onDestroyActionMode(ActionMode actionMode) { for (int i = 0; i < adapter.getItemCount(); i++) { adapter.setItemChecked(i, false); } actionModeEnabled = false; } /** * Handler for the resend button. */ private boolean onResendButtonClick(ActionMode mode) { // Resends all checked items for (int i = 0; i < adapter.getItemCount(); i++) { if (adapter.isItemChecked(i)) { database.sendMessage(this, adapter.getItem(i).getDatabaseId()); break; } } mode.finish(); return true; } /** * Handler for the info button. */ private boolean onInfoButtonClick(ActionMode mode) { // Get first checked item Message message = null; for (int i = 0; i < adapter.getItemCount(); i++) { if (adapter.isItemChecked(i)) { message = adapter.getItem(i); break; } } // Display info dialog for that item if (message != null) { DateFormat dateFormat = new SimpleDateFormat( "yyyy-MM-dd HH:mm:ss", Locale.getDefault()); String dialogText = ""; if (message.getType() == Message.Type.INCOMING) { if (message.getVoipId() != null) { dialogText += getString(R.string.conversation_info_id) + " " + message.getVoipId() + "\n"; } dialogText += getString(R.string.conversation_info_to) + " " + Utils.getFormattedPhoneNumber( message.getDid()) + "\n"; dialogText += getString(R.string.conversation_info_from) + " " + Utils.getFormattedPhoneNumber( message.getContact()) + "\n"; dialogText += getString(R.string.conversation_info_date) + " " + dateFormat.format(message.getDate()); } else { if (message.getVoipId() != null) { dialogText += getString(R.string.conversation_info_id) + " " + message.getVoipId() + "\n"; } dialogText += getString(R.string.conversation_info_to) + " " + Utils.getFormattedPhoneNumber( message.getContact()) + "\n"; dialogText += getString(R.string.conversation_info_from) + " " + Utils.getFormattedPhoneNumber( message.getDid()) + "\n"; dialogText += getString(R.string.conversation_info_date) + " " + dateFormat.format(message.getDate()); } Utils.showAlertDialog( this, getString(R.string.conversation_info_title), dialogText, getString(R.string.ok), null, null, null); } mode.finish(); return true; } /** * Handler for the copy button. */ private boolean onCopyButtonClick(ActionMode mode) { // Get first checked item Message message = null; for (int i = 0; i < adapter.getItemCount(); i++) { if (adapter.isItemChecked(i)) { message = adapter.getItem(i); break; } } // Copy text of message to clipboard if (message != null) { ClipboardManager clipboard = (ClipboardManager) getSystemService(CLIPBOARD_SERVICE); ClipData clip = ClipData.newPlainText("Text message", message.getText()); clipboard.setPrimaryClip(clip); } mode.finish(); return true; } /** * Handler for the share button. */ private boolean onShareButtonClick(ActionMode mode) { // Get first checked item Message message = null; for (int i = 0; i < adapter.getItemCount(); i++) { if (adapter.isItemChecked(i)) { message = adapter.getItem(i); break; } } // Send a share intent with the text of the message if (message != null) { Intent intent = new Intent(android.content.Intent.ACTION_SEND); intent.setType("text/plain"); intent.putExtra(android.content.Intent.EXTRA_TEXT, message.getText()); startActivity(Intent.createChooser(intent, null)); } mode.finish(); return true; } /// /// Behavioural handlers /// /** * Handler for the delete button. */ private boolean onDeleteButtonClick(ActionMode mode) { // Get the database IDs of the messages that are checked List<Long> databaseIds = new ArrayList<>(); for (int i = 0; i < adapter.getItemCount(); i++) { if (adapter.isItemChecked(i)) { databaseIds.add(adapter.getItem(i).getDatabaseId()); } } // Show a confirmation dialog Utils.showAlertDialog( this, getString(R.string.conversation_delete_confirm_title), getString(R.string.conversation_delete_confirm_message), getString(R.string.delete), (dialog, which) -> { // Delete each message for (Long databaseId : databaseIds) { database.deleteMessage(databaseId); } // Go back to the previous activity if no messages remain if (!database.conversationHasMessages(preferences.getDid(), contact)) { NavUtils.navigateUpFromSameTask(this); } else { adapter.refresh(); } }, getString(R.string.cancel), null); mode.finish(); return true; } /** * Facilitates special back button behaviour, such as when the search * box is visible or when the action mode is enabled. */ @Override public void onBackPressed() { if (actionModeEnabled) { // Close the action mode when enabled actionMode.finish(); } else if (menu != null) { // Close the search box if visible MenuItem searchItem = menu.findItem(R.id.search_button); SearchView searchView = (SearchView) searchItem.getActionView(); if (!searchView.isIconified()) { searchItem.collapseActionView(); } else { // Otherwise, perform normal back button behaviour super.onBackPressed(); } } } @Override protected void onPause() { super.onPause(); ActivityMonitor.getInstance().deleteReferenceToActivity(this); } /// /// Miscellaneous handlers /// @Override protected void onResume() { super.onResume(); ActivityMonitor.getInstance().setCurrentActivity(this); final EditText messageText = (EditText) findViewById(R.id.message_edit_text); Message draftMessage = database.getDraftMessageForConversation( preferences.getDid(), contact); if (draftMessage != null) { messageText.setText(draftMessage.getText()); messageText.requestFocus(); messageText.setSelection(messageText.getText().length()); } // Remove any open notifications related to this conversation Integer id = Notifications.getInstance(getApplicationContext()) .getNotificationIds().get(contact); if (id != null) { NotificationManager notificationManager = (NotificationManager) getApplicationContext().getSystemService( Context.NOTIFICATION_SERVICE); notificationManager.cancel(id); } postUpdate(); } /** * Called after this activity loads or after a database update if this * activity is currently open. */ public void postUpdate() { database.markConversationAsRead(preferences.getDid(), contact); adapter.refresh(); } /** * Handles requests for the CALL_PHONE permission, which is used by the * call button. */ @Override public void onRequestPermissionsResult(int requestCode, @NonNull String permissions[], @NonNull int[] grantResults) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); if (requestCode == 0) { for (int i = 0; i < permissions.length; i++) { if (permissions[i].equals(Manifest.permission.CALL_PHONE)) { if (grantResults[i] == PackageManager.PERMISSION_GRANTED) { // If the permission request was granted, try calling // again onCallButtonClick(); } else { // Otherwise, show a warning Utils.showPermissionSnackbar( this, R.id.coordinator_layout, getString(R.string.conversation_perm_denied_call)); } } } } } /** * Facilitates special click behaviour, such as when the action mode is * enabled or if a message is clicked that has previously failed to send. */ @Override public void onClick(View view) { if (actionModeEnabled) { // Check or uncheck item when action mode is enabled toggleItem(view); } else { // Resend message if has not yet been sent, but only if Message message = adapter.getItem(recyclerView.getChildAdapterPosition(view)); if (!message.isDelivered() && !message.isDeliveryInProgress()) { preSendMessage(message.getDatabaseId()); } } } /** * Toggles the specified view. * * @param view The view to toggle. */ private void toggleItem(View view) { // Inform the adapter that the item should be checked adapter.toggleItemChecked(recyclerView.getChildAdapterPosition(view)); // Turn on or off the action mode depending on how many items are // checked if (adapter.getCheckedItemCount() == 0) { if (actionMode != null) { actionMode.finish(); } actionModeEnabled = false; return; } if (!actionModeEnabled) { actionMode = startSupportActionMode(this); actionModeEnabled = true; } // If the action mode is enabled, update the visible buttons to match // the number of checked items updateButtons(); } /** * Update the visible buttons to match the number of checked items. */ private void updateButtons() { MenuItem resendAction = actionMode.getMenu().findItem( R.id.resend_button); MenuItem copyAction = actionMode.getMenu().findItem(R.id.copy_button); MenuItem shareAction = actionMode.getMenu().findItem(R.id.share_button); MenuItem infoAction = actionMode.getMenu().findItem(R.id.info_button); int count = adapter.getCheckedItemCount(); // The resend button should only be visible if there is a single item // checked and that item is in the failed to deliver state boolean resendVisible = false; if (count == 1) { for (int i = 0; i < adapter.getItemCount(); i++) { if (adapter.isItemChecked(i)) { if (!adapter.getItem(i).isDelivered() && !adapter.getItem(i).isDeliveryInProgress()) { resendVisible = true; break; } } } } resendAction.setVisible(resendVisible); // Certain buttons should not be visible if there is more than one // item visible if (count >= 2) { infoAction.setVisible(false); copyAction.setVisible(false); shareAction.setVisible(false); } else { infoAction.setVisible(true); copyAction.setVisible(true); shareAction.setVisible(true); } } /// /// Miscellaneous helper methods /// /** * Facilitates special double-click behaviour, such as toggling an item. */ @Override public boolean onLongClick(View view) { toggleItem(view); return true; } /** * Called after this activity sends a message. */ public void postSendMessage(boolean success, long databaseId) { database.markConversationAsRead(preferences.getDid(), contact); if (success) { // Since the message in our database does not have all of the // information we need (the VoIP.ms ID, the precise date of sending) // we delete it and retrieve the sent message from VoIP.ms; the // adapter refresh will occur as part of the DB sync database.removeMessage(databaseId); database.synchronize(true, true, this); } else { // Otherwise, mark the message as failed to deliver and refresh // the adapter database.markMessageAsFailedToSend(databaseId); adapter.refresh(); } // Scroll to the bottom of the adapter so that the message is in view if (adapter.getItemCount() > 0) { layoutManager.scrollToPosition(adapter.getItemCount() - 1); } } }