/* Yaaic - Yet Another Android IRC Client Copyright 2009-2013 Sebastian Kaspari This file is part of Yaaic. Yaaic is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Yaaic is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Yaaic. If not, see . */ package org.yaaic.activity; import java.util.ArrayList; import java.util.Collection; import java.util.List; import org.yaaic.R; import org.yaaic.Yaaic; import org.yaaic.adapter.ConversationPagerAdapter; import org.yaaic.adapter.MessageListAdapter; import org.yaaic.command.CommandParser; import org.yaaic.indicator.ConversationIndicator; import org.yaaic.indicator.ConversationTitlePageIndicator.IndicatorStyle; import org.yaaic.irc.IRCBinder; import org.yaaic.irc.IRCConnection; import org.yaaic.irc.IRCService; import org.yaaic.listener.ConversationListener; import org.yaaic.listener.ServerListener; import org.yaaic.listener.SpeechClickListener; import org.yaaic.model.Broadcast; import org.yaaic.model.Conversation; import org.yaaic.model.Extra; import org.yaaic.model.Message; import org.yaaic.model.Query; import org.yaaic.model.Scrollback; import org.yaaic.model.Server; import org.yaaic.model.ServerInfo; import org.yaaic.model.Settings; import org.yaaic.model.Status; import org.yaaic.model.User; import org.yaaic.receiver.ConversationReceiver; import org.yaaic.receiver.ServerReceiver; import android.app.AlertDialog; import android.content.ComponentName; import android.content.DialogInterface; import android.content.Intent; import android.content.IntentFilter; import android.content.ServiceConnection; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.content.res.Configuration; import android.graphics.Typeface; import android.os.Bundle; import android.os.Handler; import android.os.IBinder; import android.speech.RecognizerIntent; import android.support.v4.content.LocalBroadcastManager; import android.support.v4.view.ViewPager; import android.text.InputType; import android.text.method.TextKeyListener; import android.view.KeyEvent; import android.view.View; import android.view.View.OnKeyListener; import android.view.inputmethod.EditorInfo; import android.view.inputmethod.InputMethodManager; import android.widget.Button; import android.widget.EditText; import android.widget.Toast; import com.actionbarsherlock.app.ActionBar; import com.actionbarsherlock.app.SherlockActivity; import com.actionbarsherlock.view.Menu; import com.actionbarsherlock.view.MenuInflater; import com.actionbarsherlock.view.MenuItem; /** * The server view with a scrollable list of all channels * * @author Sebastian Kaspari */ public class ConversationActivity extends SherlockActivity implements ServiceConnection, ServerListener, ConversationListener { public static final int REQUEST_CODE_SPEECH = 99; private static final int REQUEST_CODE_JOIN = 1; private static final int REQUEST_CODE_USERS = 2; private static final int REQUEST_CODE_USER = 3; private static final int REQUEST_CODE_NICK_COMPLETION= 4; private int serverId; private Server server; private IRCBinder binder; private ConversationReceiver channelReceiver; private ServerReceiver serverReceiver; private ViewPager pager; private ConversationIndicator indicator; private ConversationPagerAdapter pagerAdapter; private Scrollback scrollback; // XXX: This is ugly. This is a buffer for a channel that should be joined after showing the // JoinActivity. As onActivityResult() is called before onResume() a "channel joined" // broadcast may get lost as the broadcast receivers are registered in onResume() but the // join command would be called in onActivityResult(). joinChannelBuffer will save the // channel name in onActivityResult() and run the join command in onResume(). private String joinChannelBuffer; private int historySize; private boolean reconnectDialogActive = false; private final OnKeyListener inputKeyListener = new OnKeyListener() { /** * On key pressed (input line) */ @Override public boolean onKey(View view, int keyCode, KeyEvent event) { EditText input = (EditText) view; if (event.getAction() != KeyEvent.ACTION_DOWN) { return false; } if (keyCode == KeyEvent.KEYCODE_DPAD_UP) { String message = scrollback.goBack(); if (message != null) { input.setText(message); } return true; } if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN) { String message = scrollback.goForward(); if (message != null) { input.setText(message); } return true; } if (keyCode == KeyEvent.KEYCODE_ENTER) { sendMessage(input.getText().toString()); // Workaround for a race condition in EditText // Instead of calling input.setText(""); // See: // - https://github.com/pocmo/Yaaic/issues/67 // - http://code.google.com/p/android/issues/detail?id=17508 TextKeyListener.clear(input.getText()); return true; } // Nick completion if (keyCode == KeyEvent.KEYCODE_SEARCH) { doNickCompletion(input); return true; } return false; } }; /** * On create */ @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); serverId = getIntent().getExtras().getInt("serverId"); server = Yaaic.getInstance().getServerById(serverId); Settings settings = new Settings(this); // Finish activity if server does not exist anymore - See #55 if (server == null) { this.finish(); } ActionBar actionBar = getSupportActionBar(); actionBar.setDisplayHomeAsUpEnabled(true); setTitle(server.getTitle()); setContentView(R.layout.conversations); boolean isLandscape = (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE); EditText input = (EditText) findViewById(R.id.input); input.setOnKeyListener(inputKeyListener); pager = (ViewPager) findViewById(R.id.pager); pagerAdapter = new ConversationPagerAdapter(this, server); pager.setAdapter(pagerAdapter); final float density = getResources().getDisplayMetrics().density; indicator = (ConversationIndicator) findViewById(R.id.titleIndicator); indicator.setServer(server); indicator.setTypeface(Typeface.MONOSPACE); indicator.setViewPager(pager); indicator.setFooterColor(0xFF31B6E7); indicator.setFooterLineHeight(1 * density); indicator.setFooterIndicatorHeight(3 * density); indicator.setFooterIndicatorStyle(IndicatorStyle.Underline); indicator.setSelectedColor(0xFFFFFFFF); indicator.setSelectedBold(true); indicator.setBackgroundColor(0xFF181818); historySize = settings.getHistorySize(); if (server.getStatus() == Status.PRE_CONNECTING) { server.clearConversations(); pagerAdapter.clearConversations(); server.getConversation(ServerInfo.DEFAULT_NAME).setHistorySize(historySize); } float fontSize = settings.getFontSize(); indicator.setTextSize(fontSize * density); input.setTextSize(settings.getFontSize()); input.setTypeface(Typeface.MONOSPACE); // Optimization : cache field lookups Collection mConversations = server.getConversations(); for (Conversation conversation : mConversations) { // Only scroll to new conversation if it was selected before if (conversation.getStatus() == Conversation.STATUS_SELECTED) { onNewConversation(conversation.getName()); } else { createNewConversation(conversation.getName()); } } int setInputTypeFlags = 0; setInputTypeFlags |= InputType.TYPE_TEXT_FLAG_AUTO_CORRECT; if (settings.autoCapSentences()) { setInputTypeFlags |= InputType.TYPE_TEXT_FLAG_CAP_SENTENCES; } if (isLandscape && settings.imeExtract()) { setInputTypeFlags |= InputType.TYPE_TEXT_VARIATION_SHORT_MESSAGE; } if (!settings.imeExtract()) { input.setImeOptions(input.getImeOptions() | EditorInfo.IME_FLAG_NO_EXTRACT_UI); } input.setInputType(input.getInputType() | setInputTypeFlags); // Create a new scrollback history scrollback = new Scrollback(); } /** * On resume */ @Override public void onResume() { // register the receivers as early as possible, otherwise we may loose a broadcast message channelReceiver = new ConversationReceiver(server.getId(), this); registerReceiver(channelReceiver, new IntentFilter(Broadcast.CONVERSATION_MESSAGE)); registerReceiver(channelReceiver, new IntentFilter(Broadcast.CONVERSATION_NEW)); registerReceiver(channelReceiver, new IntentFilter(Broadcast.CONVERSATION_REMOVE)); registerReceiver(channelReceiver, new IntentFilter(Broadcast.CONVERSATION_TOPIC)); serverReceiver = new ServerReceiver(this); registerReceiver(serverReceiver, new IntentFilter(Broadcast.SERVER_UPDATE)); super.onResume(); // Check if speech recognition is enabled and available if (new Settings(this).isVoiceRecognitionEnabled()) { PackageManager pm = getPackageManager(); Button speechButton = (Button) findViewById(R.id.speech); List activities = pm.queryIntentActivities(new Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH), 0); if (activities.size() != 0) { ((Button) findViewById(R.id.speech)).setOnClickListener(new SpeechClickListener(this)); speechButton.setVisibility(View.VISIBLE); } } // Start service Intent intent = new Intent(this, IRCService.class); intent.setAction(IRCService.ACTION_FOREGROUND); startService(intent); bindService(intent, this, 0); if (!server.isConnected()) { ((EditText) findViewById(R.id.input)).setEnabled(false); } else { ((EditText) findViewById(R.id.input)).setEnabled(true); } // Optimization - cache field lookup Collection mConversations = server.getConversations(); MessageListAdapter mAdapter; // Fill view with messages that have been buffered while paused for (Conversation conversation : mConversations) { String name = conversation.getName(); mAdapter = pagerAdapter.getItemAdapter(name); if (mAdapter != null) { mAdapter.addBulkMessages(conversation.getBuffer()); conversation.clearBuffer(); } else { // Was conversation created while we were paused? if (pagerAdapter.getPositionByName(name) == -1) { onNewConversation(name); } } // Clear new message notifications for the selected conversation if (conversation.getStatus() == Conversation.STATUS_SELECTED && conversation.getNewMentions() > 0) { Intent ackIntent = new Intent(this, IRCService.class); ackIntent.setAction(IRCService.ACTION_ACK_NEW_MENTIONS); ackIntent.putExtra(IRCService.EXTRA_ACK_SERVERID, serverId); ackIntent.putExtra(IRCService.EXTRA_ACK_CONVTITLE, name); startService(ackIntent); } } // Remove views for conversations that ended while we were paused int numViews = pagerAdapter.getCount(); if (numViews > mConversations.size()) { for (int i = 0; i < numViews; ++i) { if (!mConversations.contains(pagerAdapter.getItem(i))) { pagerAdapter.removeConversation(i--); --numViews; } } } // Join channel that has been selected in JoinActivity (onActivityResult()) if (joinChannelBuffer != null) { new Thread() { @Override public void run() { binder.getService().getConnection(serverId).joinChannel(joinChannelBuffer); joinChannelBuffer = null; } }.start(); } server.setIsForeground(true); } /** * On Pause */ @Override public void onPause() { super.onPause(); server.setIsForeground(false); if (binder != null && binder.getService() != null) { binder.getService().checkServiceStatus(); } unbindService(this); unregisterReceiver(channelReceiver); unregisterReceiver(serverReceiver); } /** * On service connected */ @Override public void onServiceConnected(ComponentName name, IBinder service) { this.binder = (IRCBinder) service; // connect to irc server if connect has been requested if (server.getStatus() == Status.PRE_CONNECTING && getIntent().hasExtra("connect")) { server.setStatus(Status.CONNECTING); binder.connect(server); } else { onStatusUpdate(); } } /** * On service disconnected */ @Override public void onServiceDisconnected(ComponentName name) { this.binder = null; } /** * On options menu requested */ @Override public boolean onCreateOptionsMenu(Menu menu) { super.onCreateOptionsMenu(menu); // inflate from xml MenuInflater inflater = new MenuInflater(this); inflater.inflate(R.menu.conversations, menu); return true; } /** * On menu item selected */ @Override public boolean onMenuItemSelected(int featureId, MenuItem item) { switch (item.getItemId()) { case android.R.id.home: finish(); break; case R.id.disconnect: server.setStatus(Status.DISCONNECTED); server.setMayReconnect(false); binder.getService().getConnection(serverId).quitServer(); server.clearConversations(); setResult(RESULT_OK); finish(); break; case R.id.close: Conversation conversationToClose = pagerAdapter.getItem(pager.getCurrentItem()); // Make sure we part a channel when closing the channel conversation if (conversationToClose.getType() == Conversation.TYPE_CHANNEL) { binder.getService().getConnection(serverId).partChannel(conversationToClose.getName()); } else if (conversationToClose.getType() == Conversation.TYPE_QUERY) { server.removeConversation(conversationToClose.getName()); onRemoveConversation(conversationToClose.getName()); } else { Toast.makeText(this, getResources().getString(R.string.close_server_window), Toast.LENGTH_SHORT).show(); } break; case R.id.join: startActivityForResult(new Intent(this, JoinActivity.class), REQUEST_CODE_JOIN); break; case R.id.users: Conversation conversationForUserList = pagerAdapter.getItem(pager.getCurrentItem()); if (conversationForUserList.getType() == Conversation.TYPE_CHANNEL) { Intent intent = new Intent(this, UsersActivity.class); intent.putExtra( Extra.USERS, binder.getService().getConnection(server.getId()).getUsersAsStringArray( conversationForUserList.getName() ) ); startActivityForResult(intent, REQUEST_CODE_USERS); } else { Toast.makeText(this, getResources().getString(R.string.only_usable_from_channel), Toast.LENGTH_SHORT).show(); } break; } return true; } /** * Get server object assigned to this activity * * @return the server object */ public Server getServer() { return server; } /** * On conversation message */ @Override public void onConversationMessage(String target) { Conversation conversation = server.getConversation(target); if (conversation == null) { // In an early state it can happen that the conversation object // is not created yet. return; } MessageListAdapter adapter = pagerAdapter.getItemAdapter(target); while(conversation.hasBufferedMessages()) { Message message = conversation.pollBufferedMessage(); if (adapter != null && message != null) { adapter.addMessage(message); int status; switch (message.getType()) { case Message.TYPE_MISC: status = Conversation.STATUS_MISC; break; default: status = Conversation.STATUS_MESSAGE; break; } conversation.setStatus(status); } } indicator.updateStateColors(); } /** * On new conversation */ @Override public void onNewConversation(String target) { createNewConversation(target); pager.setCurrentItem(pagerAdapter.getCount() - 1); } /** * Create a new conversation in the pager adapter for the * given target conversation. * * @param target */ public void createNewConversation(String target) { pagerAdapter.addConversation(server.getConversation(target)); } /** * On conversation remove */ @Override public void onRemoveConversation(String target) { int position = pagerAdapter.getPositionByName(target); if (position != -1) { pagerAdapter.removeConversation(position); } } /** * On topic change */ @Override public void onTopicChanged(String target) { // No implementation } /** * On server status update */ @Override public void onStatusUpdate() { EditText input = (EditText) findViewById(R.id.input); if (server.isConnected()) { input.setEnabled(true); } else { input.setEnabled(false); if (server.getStatus() == Status.CONNECTING) { return; } // Service is not connected or initialized yet - See #54 if (binder == null || binder.getService() == null || binder.getService().getSettings() == null) { return; } if (!binder.getService().getSettings().isReconnectEnabled() && !reconnectDialogActive) { reconnectDialogActive = true; AlertDialog.Builder builder = new AlertDialog.Builder(this); builder.setMessage(getResources().getString(R.string.reconnect_after_disconnect, server.getTitle())) .setCancelable(false) .setPositiveButton("Yes", new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int id) { if (!server.isDisconnected()) { reconnectDialogActive = false; return; } binder.getService().getConnection(server.getId()).setAutojoinChannels( server.getCurrentChannelNames() ); server.setStatus(Status.CONNECTING); binder.connect(server); reconnectDialogActive = false; } }) .setNegativeButton(getString(R.string.negative_button), new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int id) { server.setMayReconnect(false); reconnectDialogActive = false; dialog.cancel(); } }); AlertDialog alert = builder.create(); alert.show(); } } } /** * On activity result */ @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { if (resultCode != RESULT_OK) { // ignore other result codes return; } switch (requestCode) { case REQUEST_CODE_SPEECH: ArrayList matches = data.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS); if (matches.size() > 0) { ((EditText) findViewById(R.id.input)).setText(matches.get(0)); } break; case REQUEST_CODE_JOIN: joinChannelBuffer = data.getExtras().getString("channel"); break; case REQUEST_CODE_USERS: Intent intent = new Intent(this, UserActivity.class); intent.putExtra(Extra.USER, data.getStringExtra(Extra.USER)); startActivityForResult(intent, REQUEST_CODE_USER); break; case REQUEST_CODE_NICK_COMPLETION: insertNickCompletion((EditText) findViewById(R.id.input), data.getExtras().getString(Extra.USER)); break; case REQUEST_CODE_USER: final int actionId = data.getExtras().getInt(Extra.ACTION); final String nickname = data.getExtras().getString(Extra.USER); final IRCConnection connection = binder.getService().getConnection(server.getId()); final String conversation = server.getSelectedConversation(); final Handler handler = new Handler(); // XXX: Implement me - The action should be handled after onResume() // to catch the broadcasts... now we just wait a second // Yes .. that's very ugly - we need some kind of queue that is handled after onResume() new Thread() { @Override public void run() { try { Thread.sleep(1000); } catch (InterruptedException e) { // Do nothing } String nicknameWithoutPrefix = nickname; while ( nicknameWithoutPrefix.startsWith("@") || nicknameWithoutPrefix.startsWith("+") || nicknameWithoutPrefix.startsWith(".") || nicknameWithoutPrefix.startsWith("%") ) { // Strip prefix(es) now nicknameWithoutPrefix = nicknameWithoutPrefix.substring(1); } switch (actionId) { case User.ACTION_REPLY: final String replyText = nicknameWithoutPrefix + ": "; handler.post(new Runnable() { @Override public void run() { EditText input = (EditText) findViewById(R.id.input); input.setText(replyText); input.setSelection(replyText.length()); } }); break; case User.ACTION_QUERY: Conversation query = server.getConversation(nicknameWithoutPrefix); if (query == null) { // Open a query if there's none yet query = new Query(nicknameWithoutPrefix); query.setHistorySize(binder.getService().getSettings().getHistorySize()); server.addConversation(query); Intent intent = Broadcast.createConversationIntent( Broadcast.CONVERSATION_NEW, server.getId(), nicknameWithoutPrefix ); LocalBroadcastManager.getInstance(binder.getService()).sendBroadcast(intent); } break; case User.ACTION_OP: connection.op(conversation, nicknameWithoutPrefix); break; case User.ACTION_DEOP: connection.deOp(conversation, nicknameWithoutPrefix); break; case User.ACTION_VOICE: connection.voice(conversation, nicknameWithoutPrefix); break; case User.ACTION_DEVOICE: connection.deVoice(conversation, nicknameWithoutPrefix); break; case User.ACTION_KICK: connection.kick(conversation, nicknameWithoutPrefix); break; case User.ACTION_BAN: connection.ban(conversation, nicknameWithoutPrefix + "!*@*"); break; } } }.start(); break; } } /** * Send a message in this conversation * * @param text The text of the message */ private void sendMessage(String text) { if (text.equals("")) { // ignore empty messages return; } if (!server.isConnected()) { Message message = new Message(getString(R.string.message_not_connected)); message.setColor(Message.COLOR_RED); message.setIcon(R.drawable.error); server.getConversation(server.getSelectedConversation()).addMessage(message); onConversationMessage(server.getSelectedConversation()); } scrollback.addMessage(text); Conversation conversation = pagerAdapter.getItem(pager.getCurrentItem()); if (conversation != null) { if (!text.trim().startsWith("/")) { if (conversation.getType() != Conversation.TYPE_SERVER) { String nickname = binder.getService().getConnection(serverId).getNick(); //conversation.addMessage(new Message("<" + nickname + "> " + text)); conversation.addMessage(new Message(text, nickname)); binder.getService().getConnection(serverId).sendMessage(conversation.getName(), text); } else { Message message = new Message(getString(R.string.chat_only_form_channel)); message.setColor(Message.COLOR_YELLOW); message.setIcon(R.drawable.warning); conversation.addMessage(message); } onConversationMessage(conversation.getName()); } else { CommandParser.getInstance().parse(text, server, conversation, binder.getService()); } } } /** * Complete a nick in the input line */ private void doNickCompletion(EditText input) { String text = input.getText().toString(); if (text.length() <= 0) { return; } String[] tokens = text.split("[\\s,.-]+"); if (tokens.length <= 0) { return; } String word = tokens[tokens.length - 1].toLowerCase(); tokens[tokens.length - 1] = null; int begin = input.getSelectionStart(); int end = input.getSelectionEnd(); int cursor = Math.min(begin, end); int sel_end = Math.max(begin, end); boolean in_selection = (cursor != sel_end); if (in_selection) { word = text.substring(cursor, sel_end); } else { // use the word at the curent cursor position while (true) { cursor -= 1; if (cursor <= 0 || text.charAt(cursor) == ' ') { break; } } if (cursor < 0) { cursor = 0; } if (text.charAt(cursor) == ' ') { cursor += 1; } sel_end = text.indexOf(' ', cursor); if (sel_end == -1) { sel_end = text.length(); } word = text.substring(cursor, sel_end); } // Log.d("Yaaic", "Trying to complete nick: " + word); Conversation conversationForUserList = pagerAdapter.getItem(pager.getCurrentItem()); String[] users = null; if (conversationForUserList.getType() == Conversation.TYPE_CHANNEL) { users = binder.getService().getConnection(server.getId()).getUsersAsStringArray( conversationForUserList.getName() ); } // go through users and add matches if (users != null) { List result = new ArrayList(); for (int i = 0; i < users.length; i++) { String nick = removeStatusChar(users[i].toLowerCase()); if (nick.startsWith(word.toLowerCase())) { result.add(Integer.valueOf(i)); } } if (result.size() == 1) { input.setSelection(cursor, sel_end); insertNickCompletion(input, users[result.get(0).intValue()]); } else if (result.size() > 0) { Intent intent = new Intent(this, UsersActivity.class); String[] extra = new String[result.size()]; int i = 0; for (Integer n : result) { extra[i++] = users[n.intValue()]; } input.setSelection(cursor, sel_end); intent.putExtra(Extra.USERS, extra); startActivityForResult(intent, REQUEST_CODE_NICK_COMPLETION); } } } /** * Insert a given nick completion into the input line * * @param input The input line widget, with the incomplete nick selected * @param nick The completed nick */ private void insertNickCompletion(EditText input, String nick) { int start = input.getSelectionStart(); int end = input.getSelectionEnd(); nick = removeStatusChar(nick); if (start == 0) { nick += ":"; } nick += " "; input.getText().replace(start, end, nick, 0, nick.length()); // put cursor after inserted text input.setSelection(start + nick.length()); input.clearComposingText(); input.post(new Runnable() { @Override public void run() { // make the softkeyboard come up again (only if no hw keyboard is attached) EditText input = (EditText) findViewById(R.id.input); openSoftKeyboard(input); } }); input.requestFocus(); } /** * Open the soft keyboard (helper function) */ private void openSoftKeyboard(View view) { ((InputMethodManager) getSystemService(INPUT_METHOD_SERVICE)).showSoftInput(view, InputMethodManager.SHOW_IMPLICIT); } /** * Remove the status char off the front of a nick if one is present * * @param nick * @return nick without statuschar */ private String removeStatusChar(String nick) { /* Discard status characters */ if (nick.startsWith("@") || nick.startsWith("+") || nick.startsWith("%")) { nick = nick.substring(1); } return nick; } }