From d57224505be243c80e9dd64e00f0a7c3a1d841f0 Mon Sep 17 00:00:00 2001 From: Treeki Date: Tue, 18 Feb 2014 02:54:06 +0100 Subject: add Android client --- .../main/java/net/brokenfox/vulpirc/BaseConn.java | 611 +++++++++++++++++++++ .../java/net/brokenfox/vulpirc/ChannelData.java | 116 ++++ .../net/brokenfox/vulpirc/ChannelFragment.java | 22 + .../java/net/brokenfox/vulpirc/Connection.java | 240 ++++++++ .../java/net/brokenfox/vulpirc/IRCService.java | 131 +++++ .../java/net/brokenfox/vulpirc/LoginActivity.java | 263 +++++++++ .../java/net/brokenfox/vulpirc/MainActivity.java | 340 ++++++++++++ .../main/java/net/brokenfox/vulpirc/RichText.java | 236 ++++++++ .../src/main/java/net/brokenfox/vulpirc/Util.java | 62 +++ .../java/net/brokenfox/vulpirc/WindowData.java | 106 ++++ .../java/net/brokenfox/vulpirc/WindowFragment.java | 149 +++++ 11 files changed, 2276 insertions(+) create mode 100644 android/VulpIRC/src/main/java/net/brokenfox/vulpirc/BaseConn.java create mode 100644 android/VulpIRC/src/main/java/net/brokenfox/vulpirc/ChannelData.java create mode 100644 android/VulpIRC/src/main/java/net/brokenfox/vulpirc/ChannelFragment.java create mode 100644 android/VulpIRC/src/main/java/net/brokenfox/vulpirc/Connection.java create mode 100644 android/VulpIRC/src/main/java/net/brokenfox/vulpirc/IRCService.java create mode 100644 android/VulpIRC/src/main/java/net/brokenfox/vulpirc/LoginActivity.java create mode 100644 android/VulpIRC/src/main/java/net/brokenfox/vulpirc/MainActivity.java create mode 100644 android/VulpIRC/src/main/java/net/brokenfox/vulpirc/RichText.java create mode 100644 android/VulpIRC/src/main/java/net/brokenfox/vulpirc/Util.java create mode 100644 android/VulpIRC/src/main/java/net/brokenfox/vulpirc/WindowData.java create mode 100644 android/VulpIRC/src/main/java/net/brokenfox/vulpirc/WindowFragment.java (limited to 'android/VulpIRC/src/main/java') diff --git a/android/VulpIRC/src/main/java/net/brokenfox/vulpirc/BaseConn.java b/android/VulpIRC/src/main/java/net/brokenfox/vulpirc/BaseConn.java new file mode 100644 index 0000000..5b6ac83 --- /dev/null +++ b/android/VulpIRC/src/main/java/net/brokenfox/vulpirc/BaseConn.java @@ -0,0 +1,611 @@ +package net.brokenfox.vulpirc; + +import android.os.Handler; +import android.os.Message; +import android.util.Log; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.concurrent.LinkedBlockingQueue; + +/** + * Created by ninji on 1/29/14. + */ +public class BaseConn { + // Listeners + public interface BaseConnListener { + void handleSessionStarted(); + void handleSessionEnded(); + void handleSocketStateChanged(); + void handlePacketReceived(int type, byte[] data); + void handleLoginError(String error); + void handleStatusMessage(String message); + } + + private BaseConnListener mListener = null; + public void setListener(BaseConnListener l) { + mListener = l; + } + + + // Internal communications + private final static int MSG_SESSION_STARTED = 100; + private final static int MSG_SESSION_ENDED = 101; + private final static int MSG_SOCKET_STATE_CHANGED = 102; + private final static int MSG_PACKET_RECEIVED = 103; + private final static int MSG_LOGIN_ERROR = 104; + private final static int MSG_STATUS_MESSAGE = 105; + private final static int MSG_TIMED_RECONNECT = 200; + + private final Handler.Callback mHandlerCallback = new Handler.Callback() { + @Override + public boolean handleMessage(Message message) { + switch (message.what) { + case MSG_SESSION_STARTED: + if (mListener != null) + mListener.handleSessionStarted(); + return true; + case MSG_SESSION_ENDED: + if (mListener != null) + mListener.handleSessionEnded(); + return true; + case MSG_SOCKET_STATE_CHANGED: + if (mListener != null) + mListener.handleSocketStateChanged(); + return true; + case MSG_PACKET_RECEIVED: + if (mListener != null) + mListener.handlePacketReceived(message.arg1, (byte[]) message.obj); + return true; + case MSG_LOGIN_ERROR: + if (mListener != null) + mListener.handleLoginError((String)message.obj); + return true; + case MSG_STATUS_MESSAGE: + if (mListener != null) + mListener.handleStatusMessage((String)message.obj); + return true; + case MSG_TIMED_RECONNECT: + initiateConnection(); + return true; + } + return false; + } + }; + private final Handler mHandler = new Handler(mHandlerCallback); + + + + + // Packet system + public final static int PROTOCOL_VERSION = 1; + private final static int SESSION_KEY_SIZE = 16; + + private static class Packet { + public int id; + public int type; + public byte[] data; + } + + private final Object mPacketLock = new Object(); + + // Anything that touches the following fields must lock on + // mPacketLock first..! + private final ArrayList mPacketCache = + new ArrayList(); + + private byte[] mSessionKey = null; + private int mNextPacketID = 1; + private int mLastReceivedFromServer = 0; + private boolean mSessionActive = false; + private boolean mSessionWillTerminate = false; + + public boolean getSessionActive() { return mSessionActive; } + + // NOTE: This executes on the Socket thread, so locking + // is absolutely necessary! + private void processRawPacket(int packetType, int packetSize, int msgID, int lastReceivedByServer, byte[] buffer, int bufferPos) { + Log.i("VulpIRC", "Packet received: " + packetType + ", " + packetSize); + + if ((packetType & 0x8000) == 0) { + // For in-band packets, handle the caching junk + synchronized (mPacketLock) { + clearCachedPackets(lastReceivedByServer); + + if (msgID > mLastReceivedFromServer) { + // This is a new packet + mLastReceivedFromServer = msgID; + } else { + // We've already seen this packet, so ignore it + return; + } + } + + // Fire off the packet to the Handler for further + // processing by a subclass + mHandler.sendMessage(mHandler.obtainMessage( + MSG_PACKET_RECEIVED, + packetType, 0, + Arrays.copyOfRange(buffer, bufferPos, bufferPos + packetSize))); + } else { + // Out-of-band packets are handled here. + + synchronized (mPacketLock) { + switch (packetType) { + case 0x8001: + // Successful login + Log.i("VulpIRC", "*** Successful login. ***"); + mHandler.sendMessage( + mHandler.obtainMessage(MSG_STATUS_MESSAGE, + "Logged in successfully.")); + + mSessionKey = Arrays.copyOfRange(buffer, bufferPos, bufferPos + packetSize); + mLastReceivedFromServer = 0; + mNextPacketID = 1; + mPacketCache.clear(); + + if (mSessionActive) + mHandler.sendEmptyMessage(MSG_SESSION_ENDED); + mSessionActive = true; + mSessionWillTerminate = false; + mHandler.sendEmptyMessage(MSG_SESSION_STARTED); + + break; + + case 0x8002: + // Login failure. Output this to the UI somewhere? + Log.e("VulpIRC", "*** Login failed! ***"); + + ByteBuffer b = ByteBuffer.wrap(buffer); + b.order(ByteOrder.LITTLE_ENDIAN); + b.position(bufferPos); + int code = b.getInt(); + + mHandler.sendMessage( + mHandler.obtainMessage(MSG_LOGIN_ERROR, + "Login failure: " + code)); + + if (mSessionActive) + mHandler.sendEmptyMessage(MSG_SESSION_ENDED); + mSessionActive = false; + + requestEndSession(); + + break; + + case 0x8003: + // Session resumed. + Log.i("VulpIRC", "*** Session resumed. ***"); + mHandler.sendMessage( + mHandler.obtainMessage(MSG_STATUS_MESSAGE, + "Session resumed.")); + + lastReceivedByServer = buffer[bufferPos] | + (buffer[bufferPos + 1] << 8) | + (buffer[bufferPos + 2] << 16) | + (buffer[bufferPos + 3] << 24); + + clearCachedPackets(lastReceivedByServer); + for (Packet p : mPacketCache) + sendPacketOverWire(p); + break; + } + } + } + } + + + // This function must only be called while mPacketLock is held! + private void clearCachedPackets(int maxID) { + while (!mPacketCache.isEmpty()) { + if (mPacketCache.get(0).id > maxID) + break; + else + mPacketCache.remove(0); + } + } + + + private void sendPacketOverWire(Packet p) { + int headerSize = ((p.type & 0x8000) == 0) ? 16 : 8; + ByteBuffer b = ByteBuffer.allocate(headerSize + p.data.length); + + b.order(ByteOrder.LITTLE_ENDIAN); + b.putShort((short)p.type); + b.putShort((short)0); + b.putInt(p.data.length); + + if ((p.type & 0x8000) == 0) { + b.putInt(p.id); + b.putInt(mLastReceivedFromServer); + } + + b.put(p.data); + + mWriteQueue.offer(b.array()); + } + + + public void sendPacket(int type, byte[] data) { + Packet p = new Packet(); + p.type = type; + + if ((type & 0x8000) == 0) { + p.id = mNextPacketID; + ++mNextPacketID; + } + + p.data = data; + + synchronized (mPacketLock) { + if ((type & 0x8000) == 0) + mPacketCache.add(p); + + synchronized (mSocketStateLock) { + if (mSocketState == SocketState.CONNECTED && mSessionActive) + sendPacketOverWire(p); + } + } + } + + + // Socket junk + private Socket mSocket = null; + private LinkedBlockingQueue mWriteQueue = + new LinkedBlockingQueue(); + + + public enum SocketState { + DISCONNECTED, CONNECTING, CONNECTED, DISCONNECTING + } + // mSocketState can be accessed from multiple threads + // Always synchronise on the lock before doing anything with it! + private final Object mSocketStateLock = new Object(); + private SocketState mSocketState = SocketState.DISCONNECTED; + public SocketState getSocketState() { return mSocketState; } + + private String mHostname, mUsername, mPassword; + private int mPort; + private boolean mUseTls; + + + private class WriterThread implements Runnable { + private OutputStream mOutputStream = null; + public WriterThread(OutputStream os) { + mOutputStream = os; + } + + @Override + public void run() { + // Should I be using a BufferedOutputStream? + // Not sure. + + while (true) { + try { + byte[] data = mWriteQueue.take(); + mOutputStream.write(data); + mOutputStream.flush(); + } catch (IOException e) { + // oops + Log.e("VulpIRC", "WriterThread write failure:"); + Log.e("VulpIRC", e.toString()); + // Should disconnect here? Maybe? + break; + } catch (InterruptedException e) { + // nothing wrong here + break; + } + } + } + } + private Thread mSocketThread = null; + + private class SocketThread implements Runnable { + @Override + public void run() { + synchronized (mSocketStateLock) { + mSocketState = SocketState.CONNECTING; + mHandler.sendEmptyMessage(MSG_SOCKET_STATE_CHANGED); + } + + Log.i("VulpIRC", "SocketThread running"); + + // No SSL just yet, let's simplify things for now + + mSocket = new Socket(); + try { + mHandler.sendMessage( + mHandler.obtainMessage(MSG_STATUS_MESSAGE, + "Connecting...")); + + mSocket.connect(new InetSocketAddress(mHostname, mPort)); + } catch (IOException e) { + mHandler.sendMessage( + mHandler.obtainMessage(MSG_STATUS_MESSAGE, + "Connection failure (1): " + e.toString())); + + mHandler.sendMessage(mHandler.obtainMessage(MSG_LOGIN_ERROR, e.toString())); + cleanUpConnection(); + return; + } + + // We're connected, yay + Log.i("VulpIRC", "Connected!"); + + InputStream inputStream; + OutputStream outputStream; + + try { + inputStream = mSocket.getInputStream(); + } catch (IOException e) { + mHandler.sendMessage( + mHandler.obtainMessage(MSG_STATUS_MESSAGE, + "Connection failure (2): " + e.toString())); + + mHandler.sendMessage(mHandler.obtainMessage(MSG_LOGIN_ERROR, e.toString())); + cleanUpConnection(); + return; + } + + try { + outputStream = mSocket.getOutputStream(); + } catch (IOException e) { + mHandler.sendMessage( + mHandler.obtainMessage(MSG_STATUS_MESSAGE, + "Connection failure (3): " + e.toString())); + + mHandler.sendMessage(mHandler.obtainMessage(MSG_LOGIN_ERROR, e.toString())); + cleanUpConnection(); + return; + } + + // Start up our writer + mWriteQueue.clear(); + + Thread writerThread = new Thread(new WriterThread(outputStream)); + writerThread.start(); + + // Send the initial login packet + byte[] encPW = Util.encodeString(mPassword); + + ByteBuffer loginData = ByteBuffer.allocate(12 + encPW.length + SESSION_KEY_SIZE); + loginData.order(ByteOrder.LITTLE_ENDIAN); + loginData.putInt(PROTOCOL_VERSION); + loginData.putInt(mLastReceivedFromServer); + loginData.putInt(encPW.length); + loginData.put(encPW); + + if (mSessionKey == null) { + for (int i = 0; i < SESSION_KEY_SIZE; i++) + loginData.put((byte)0); + } else { + loginData.put(mSessionKey); + } + + Packet loginPack = new Packet(); + loginPack.type = 0x8001; + loginPack.data = loginData.array(); + sendPacketOverWire(loginPack); + + // Let's go ! + synchronized (mSocketStateLock) { + mSocketState = SocketState.CONNECTED; + mHandler.sendEmptyMessage(MSG_SOCKET_STATE_CHANGED); + } + + Log.i("VulpIRC", "Beginning socket read"); + + byte[] readBuffer = new byte[16384]; + int readBufSize = 0; + + while (true) { + // Do we have enough space to (try and) read 8k? + // If not, make the buffer bigger + if ((readBufSize + 8192) > readBuffer.length) + readBuffer = Arrays.copyOf(readBuffer, readBuffer.length + 16384); + + // OK, now we should have enough! + try { + int amount = inputStream.read(readBuffer, readBufSize, 8192); + readBufSize += amount; + + if (amount == -1) + break; + } catch (IOException e) { + mHandler.sendMessage( + mHandler.obtainMessage(MSG_STATUS_MESSAGE, + "Connection lost (4): " + e.toString())); + + break; + } + + // Try and parse as many packets as we can! + int pos = 0; + + while (true) { + // Do we have enough to parse a packet header? + if ((readBufSize - pos) < 8) + break; + + int headerSize = 8; + int packetType = (0xFF & (int)readBuffer[pos]) | + ((0xFF & readBuffer[pos + 1]) << 8); + int packetSize = (0xFF & (int)readBuffer[pos + 4]) | + ((0xFF & (int)readBuffer[pos + 5]) << 8) | + ((0xFF & (int)readBuffer[pos + 6]) << 16) | + ((0xFF & (int)readBuffer[pos + 7]) << 24); + + int msgID = 0, lastReceivedByServer = 0; + + // In-band packets have extra stuff + if ((packetType & 0x8000) == 0) { + if ((readBufSize - pos) < 16) + break; + + headerSize = 16; + + msgID = (0xFF & (int)readBuffer[pos + 8]) | + ((0xFF & (int)readBuffer[pos + 9]) << 8) | + ((0xFF & (int)readBuffer[pos + 10]) << 16) | + ((0xFF & (int)readBuffer[pos + 11]) << 24); + lastReceivedByServer = (0xFF & (int)readBuffer[pos + 12]) | + ((0xFF & (int)readBuffer[pos + 13]) << 8) | + ((0xFF & (int)readBuffer[pos + 14]) << 16) | + ((0xFF & (int)readBuffer[pos + 15]) << 24); + } + + // Negative packet sizes aren't right + if (packetSize < 0) + break; + + // Enough data? + if ((readBufSize - pos) < (packetSize + headerSize)) + break; + + // OK, this should mean we can parse things now! + processRawPacket( + packetType, packetSize, + msgID, lastReceivedByServer, + readBuffer, pos + headerSize); + + pos += headerSize + packetSize; + } + + if (pos > 0) { + if (pos >= readBufSize) { + // We've read everything, no copying needed, just wipe it all + readBufSize = 0; + } else { + // Move the remainder to the beginning of the buffer + System.arraycopy(readBuffer, pos, readBuffer, 0, readBufSize - pos); + readBufSize -= pos; + } + } + } + + synchronized (mSocketStateLock) { + mSocketState = SocketState.DISCONNECTING; + mHandler.sendEmptyMessage(MSG_SOCKET_STATE_CHANGED); + } + + // Clean up everything + writerThread.interrupt(); + cleanUpConnection(); + } + + private void cleanUpConnection() { + if (mSocket != null) { + try { + mSocket.close(); + } catch (IOException e) { + // don't care + } + mSocket = null; + } + + synchronized (mSocketStateLock) { + synchronized (mPacketLock) { + if (mSessionWillTerminate) { + mHandler.sendMessage( + mHandler.obtainMessage(MSG_STATUS_MESSAGE, + "Session completed.")); + + mSessionWillTerminate = false; + mSessionKey = null; + if (mSessionActive) + mHandler.sendEmptyMessage(MSG_SESSION_ENDED); + mSessionActive = false; + mHandler.removeMessages(MSG_TIMED_RECONNECT); + } else if (mSessionActive) { + mHandler.sendMessage( + mHandler.obtainMessage(MSG_STATUS_MESSAGE, + "Connection closed. Reconnecting in 15 seconds.")); + mHandler.sendEmptyMessageDelayed(MSG_TIMED_RECONNECT, 15 * 1000); + } + } + + mSocketState = SocketState.DISCONNECTED; + mSocketThread = null; + mHandler.sendEmptyMessage(MSG_SOCKET_STATE_CHANGED); + } + + Log.i("VulpIRC", "Connection closed."); + } + } + + + public boolean initiateConnection(String hostname, int port, boolean useTls, String username, String password) { + synchronized (mSocketStateLock) { + if (mSocketState != SocketState.DISCONNECTED) + return false; + + mHostname = hostname; + mPort = port; + mUseTls = useTls; + mUsername = username; + mPassword = password; + + return initiateConnection(); + } + } + + public boolean initiateConnection() { + synchronized (mSocketStateLock) { + if (mSocketState != SocketState.DISCONNECTED) + return false; + + mSocketState = SocketState.CONNECTING; + mHandler.sendEmptyMessage(MSG_SOCKET_STATE_CHANGED); + + mSocketThread = new Thread(new SocketThread()); + mSocketThread.start(); + + return true; + } + } + + public boolean requestDisconnection() { + synchronized (mSocketStateLock) { + if (mSocketState != SocketState.DISCONNECTED) { + try { + if (mSocket != null) + mSocket.close(); + } catch (IOException e) { + Log.w("VulpIRC", "requestDisconnection could not close socket:"); + Log.w("VulpIRC", e.toString()); + } + + mSocketThread.interrupt(); + return true; + } + return false; + } + } + public boolean requestEndSession() { + // Should send an OOB logout packet to the server too, if possible. + + synchronized (mSocketStateLock) { + mHandler.removeMessages(MSG_TIMED_RECONNECT); + + synchronized (mPacketLock) { + if (mSocketState == SocketState.DISCONNECTED) { + if (mSessionActive) { + mSessionActive = false; + mHandler.sendEmptyMessage(MSG_SESSION_ENDED); + } + return true; + } else { + mSessionWillTerminate = true; + return requestDisconnection(); + } + } + } + } +} diff --git a/android/VulpIRC/src/main/java/net/brokenfox/vulpirc/ChannelData.java b/android/VulpIRC/src/main/java/net/brokenfox/vulpirc/ChannelData.java new file mode 100644 index 0000000..d98b261 --- /dev/null +++ b/android/VulpIRC/src/main/java/net/brokenfox/vulpirc/ChannelData.java @@ -0,0 +1,116 @@ +package net.brokenfox.vulpirc; + +import android.support.v4.app.Fragment; + +import java.nio.ByteBuffer; +import java.util.HashMap; + +/** + * Created by ninji on 2/6/14. + */ +public class ChannelData extends WindowData { + public static class UserData { + public int id; + public int modes; + public char prefix; + } + public HashMap users = new HashMap(); + public String topic; + + public interface ChannelListener extends WindowListener { + void handleUsersChanged(); + } + + private int mNextUserID = 1; + + + @Override + protected Fragment instantiateFragmentClass() { + return new ChannelFragment(); + } + + @Override + public void processInitialSync(ByteBuffer p) { + super.processInitialSync(p); + + processAddUsers(p); + processChangeTopic(p); + } + + public void processChannelPacket(int type, ByteBuffer p) { + switch (type) { + case 0x120: processAddUsers(p); break; + case 0x121: processRemoveUsers(p); break; + case 0x122: processRenameUser(p); break; + case 0x123: processChangeUserMode(p); break; + case 0x124: processChangeTopic(p); break; + } + } + + private void processAddUsers(ByteBuffer p) { + int count = p.getInt(); + + for (int i = 0; i < count; i++) { + String nick = Util.readStringFromBuffer(p); + + UserData d = new UserData(); + d.id = ++mNextUserID; + d.modes = p.getInt(); + d.prefix = (char)p.get(); + + users.put(nick, d); + } + + if (count > 0) + for (WindowListener l : mListeners) + ((ChannelListener)l).handleUsersChanged(); + } + + private void processRemoveUsers(ByteBuffer p) { + int count = p.getInt(); + + if (count == 0) { + users.clear(); + } else { + for (int i = 0; i < count; i++) { + String nick = Util.readStringFromBuffer(p); + users.remove(nick); + } + } + + for (WindowListener l : mListeners) + ((ChannelListener)l).handleUsersChanged(); + } + + private void processRenameUser(ByteBuffer p) { + String fromNick = Util.readStringFromBuffer(p); + String toNick = Util.readStringFromBuffer(p); + + if (users.containsKey(fromNick)) { + users.put(toNick, users.get(fromNick)); + users.remove(fromNick); + + for (WindowListener l : mListeners) + ((ChannelListener)l).handleUsersChanged(); + } + } + + private void processChangeUserMode(ByteBuffer p) { + String nick = Util.readStringFromBuffer(p); + int modes = p.getInt(); + char prefix = (char)p.get(); + + if (users.containsKey(nick)) { + UserData d = users.get(nick); + d.modes = modes; + d.prefix = prefix; + + for (WindowListener l : mListeners) + ((ChannelListener)l).handleUsersChanged(); + } + } + + private void processChangeTopic(ByteBuffer p) { + topic = Util.readStringFromBuffer(p); + } +} diff --git a/android/VulpIRC/src/main/java/net/brokenfox/vulpirc/ChannelFragment.java b/android/VulpIRC/src/main/java/net/brokenfox/vulpirc/ChannelFragment.java new file mode 100644 index 0000000..d60fc5f --- /dev/null +++ b/android/VulpIRC/src/main/java/net/brokenfox/vulpirc/ChannelFragment.java @@ -0,0 +1,22 @@ +package net.brokenfox.vulpirc; + +import android.os.Bundle; + +/** + * Created by ninji on 2/6/14. + */ +public class ChannelFragment extends WindowFragment implements ChannelData.ChannelListener { + private ChannelData mChannelData; + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + mChannelData = (ChannelData)mData; + } + + @Override + public void handleUsersChanged() { + // boop + } +} diff --git a/android/VulpIRC/src/main/java/net/brokenfox/vulpirc/Connection.java b/android/VulpIRC/src/main/java/net/brokenfox/vulpirc/Connection.java new file mode 100644 index 0000000..845717c --- /dev/null +++ b/android/VulpIRC/src/main/java/net/brokenfox/vulpirc/Connection.java @@ -0,0 +1,240 @@ +package net.brokenfox.vulpirc; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.ArrayList; + +/** + * Created by ninji on 1/30/14. + */ +public class Connection implements BaseConn.BaseConnListener { + private static Connection mInstance = new Connection(); + public static Connection get() { return mInstance; } + + + + private BaseConn mBaseConn = new BaseConn(); + private Connection() { + mBaseConn.setListener(this); + + statusWindow.id = -1; + statusWindow.title = "Status"; + } + + // Listener junk + public interface ConnectionListener { + void handleWindowsUpdated(); + } + + private ArrayList mListeners = new ArrayList(); + public void registerListener(ConnectionListener l) { + mListeners.add(l); + } + + public void deregisterListener(ConnectionListener l) { + mListeners.remove(l); + } + + + + private ArrayList mLoginStateListeners = new ArrayList(); + public interface LoginStateListener { + void handleLoginStateChanged(); + } + public void registerLoginStateListener(LoginStateListener l) { + mLoginStateListeners.add(l); + } + public void deregisterLoginStateListener(LoginStateListener l) { + mLoginStateListeners.remove(l); + } + + + // Connection control + public void connect(String hostname, int port, boolean useTls, String username, String password) { + clearLoginError(); + mBaseConn.initiateConnection(hostname, port, useTls, username, password); + } + public void breakConn() { + mBaseConn.requestDisconnection(); + } + public void disconnect() { + mBaseConn.requestEndSession(); + } + + public BaseConn.SocketState getSocketState() { + return mBaseConn.getSocketState(); + } + public boolean getSessionActive() { + return mBaseConn.getSessionActive(); + } + + private String mLoginError = null; + public String getLoginError() { + return mLoginError; + } + public void clearLoginError() { + mLoginError = null; + } + + + // BaseConn handlers + @Override + public void handleSessionStarted() { + statusWindow.pushMessage("Session started!"); + + for (LoginStateListener l : mLoginStateListeners) + l.handleLoginStateChanged(); + + windows.clear(); + for (ConnectionListener l : mListeners) + l.handleWindowsUpdated(); + } + + @Override + public void handleSessionEnded() { + statusWindow.pushMessage("Session ended!"); + + for (LoginStateListener l : mLoginStateListeners) + l.handleLoginStateChanged(); + } + + @Override + public void handleSocketStateChanged() { + for (LoginStateListener l : mLoginStateListeners) + l.handleLoginStateChanged(); + } + + @Override + public void handleLoginError(String error) { + mLoginError = error; + } + + @Override + public void handlePacketReceived(int type, byte[] data) { + //statusWindow.pushMessage("Packet received! " + type + " " + data.length); + + ByteBuffer p = ByteBuffer.wrap(data); + p.order(ByteOrder.LITTLE_ENDIAN); + + if (type == 1) { + + statusWindow.pushMessage(Util.readStringFromBuffer(p)); + + } else if (type == 0x100) { + // Add windows! + int windowCount = p.getInt(); + if (windowCount <= 0) + return; + + for (int i = 0; i < windowCount; i++) { + int windowType = p.getInt(); + + WindowData w; + if (windowType == 2) + w = new ChannelData(); + else + w = new WindowData(); + + w.processInitialSync(p); + + windows.add(w); + } + + for (ConnectionListener l : mListeners) + l.handleWindowsUpdated(); + + } else if (type == 0x101) { + // Remove windows + int windowCount = p.getInt(); + if (windowCount <= 0) + return; + + for (int i = 0; i < windowCount; i++) { + int windowID = p.getInt(); + for (int j = 0; j < windows.size(); j++) { + if (windows.get(j).id == windowID) { + windows.remove(j); + break; + } + } + + if ((mActiveWindow != null) && (mActiveWindow.id == windowID)) + mActiveWindow = null; + } + + for (ConnectionListener l : mListeners) + l.handleWindowsUpdated(); + + } else if (type == 0x102) { + // Add message to window + int windowID = p.getInt(); + byte priority = p.get(); + String message = Util.readStringFromBuffer(p); + + WindowData w = findWindowByID(windowID); + if (w != null) { + w.pushMessage(message); + + if (priority > w.unreadLevel && w != mActiveWindow) + w.setUnreadLevel(priority); + } + + } else if (type == 0x103) { + // Rename window + int windowID = p.getInt(); + String newTitle = Util.readStringFromBuffer(p); + + WindowData w = findWindowByID(windowID); + if (w != null) + w.setTitle(newTitle); + + } else if ((type >= 0x120) && (type < 0x124)) { + // Channel packets + int windowID = p.getInt(); + WindowData w = findWindowByID(windowID); + + if (w != null && w instanceof ChannelData) { + ((ChannelData)w).processChannelPacket(type, p); + } + } + } + + public WindowData findWindowByID(int id) { + if (id == -1) + return statusWindow; + + for (WindowData w : windows) + if (w.id == id) + return w; + + return null; + } + + @Override + public void handleStatusMessage(String message) { + statusWindow.pushMessage(message); + } + + + + public void sendPacket(int type, byte[] data) { + mBaseConn.sendPacket(type, data); + } + + + // Windows. + public WindowData statusWindow = new WindowData(); + public ArrayList windows = new ArrayList(); + private WindowData mActiveWindow = null; + + public void notifyWindowsUpdated() { + for (ConnectionListener l : mListeners) + l.handleWindowsUpdated(); + } + + public WindowData getActiveWindow() { return mActiveWindow; } + public void setActiveWindow(WindowData w) { + mActiveWindow = w; + w.setUnreadLevel(0); + } +} diff --git a/android/VulpIRC/src/main/java/net/brokenfox/vulpirc/IRCService.java b/android/VulpIRC/src/main/java/net/brokenfox/vulpirc/IRCService.java new file mode 100644 index 0000000..6f76c1b --- /dev/null +++ b/android/VulpIRC/src/main/java/net/brokenfox/vulpirc/IRCService.java @@ -0,0 +1,131 @@ +package net.brokenfox.vulpirc; + +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Intent; +import android.os.Binder; +import android.os.IBinder; + +/** + * Created by ninji on 1/30/14. + */ +public class IRCService extends Service implements Connection.LoginStateListener { + private final IBinder mBinder = new LocalBinder(); + public class LocalBinder extends Binder { + IRCService getService() { + return IRCService.this; + } + } + + + // Lifecycle junk. + + + @Override + public void onCreate() { + super.onCreate(); + Connection.get().registerLoginStateListener(this); + mNM = (NotificationManager)getSystemService(NOTIFICATION_SERVICE); + } + + @Override + public void onDestroy() { + Connection.get().deregisterLoginStateListener(this); + disableForeground(); + super.onDestroy(); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + // If we're killed, don't automatically restart -- we only want to be + // started if the main VulpIRC app requests it. + + return START_NOT_STICKY; + } + + @Override + public IBinder onBind(Intent intent) { + return mBinder; + } + + + + + private NotificationManager mNM = null; + + private boolean mInForeground = false; + private void enableForeground() { + Notification n = generateNotification(); + + if (mInForeground) { + // Just refresh the notification + mNM.notify(1, n); + } else { + mInForeground = true; + + startForeground(1, n); + } + } + private void disableForeground() { + if (!mInForeground) + return; + + stopForeground(true); + mInForeground = false; + } + + + private Notification generateNotification() { + boolean active = Connection.get().getSessionActive(); + String desc1, desc2 = ""; + desc1 = active ? "Logged in, " : "Not logged in, "; + switch (Connection.get().getSocketState()) { + case DISCONNECTED: + desc2 = "disconnected"; + break; + case DISCONNECTING: + desc2 = "disconnecting"; + break; + case CONNECTING: + desc2 = "connecting..."; + break; + case CONNECTED: + desc2 = "connected"; + break; + } + + Intent i = new Intent(this, active ? MainActivity.class : LoginActivity.class); + i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + //i.setFlags(i.getFlags() | 0x2000); // enables halo intent, maybe? + PendingIntent pi = PendingIntent.getActivity(this, 0, i, 0); + + Notification n = new Notification.Builder(this) + .setContentTitle("VulpIRC") + .setContentText(desc1 + desc2) + .setContentIntent(pi) + .setSmallIcon(R.drawable.ic_launcher) + .setOngoing(true) + .setDefaults(0) + .getNotification(); + + return n; + } + + + // IRC FUNTIMES + + @Override + public void handleLoginStateChanged() { + if (Connection.get().getSessionActive()) { + enableForeground(); + } else { + if (Connection.get().getSocketState() == BaseConn.SocketState.DISCONNECTED) { + disableForeground(); + stopSelf(); + } else + enableForeground(); + } + } +} diff --git a/android/VulpIRC/src/main/java/net/brokenfox/vulpirc/LoginActivity.java b/android/VulpIRC/src/main/java/net/brokenfox/vulpirc/LoginActivity.java new file mode 100644 index 0000000..85092e9 --- /dev/null +++ b/android/VulpIRC/src/main/java/net/brokenfox/vulpirc/LoginActivity.java @@ -0,0 +1,263 @@ +package net.brokenfox.vulpirc; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.app.Activity; +import android.content.ComponentName; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.Bundle; +import android.os.IBinder; +import android.text.TextUtils; +import android.util.Log; +import android.view.Menu; +import android.view.View; +import android.widget.CheckBox; +import android.widget.EditText; +import android.widget.TextView; +import android.widget.Toast; + +public class LoginActivity extends Activity implements Connection.LoginStateListener { + private View mContainerLoginForm; + private View mContainerLoginStatus; + private TextView mLoginStatus; + private EditText mInputUsername, mInputPassword; + private EditText mInputHostname, mInputPort; + private CheckBox mCheckUseTls; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setContentView(R.layout.activity_login); + + mContainerLoginForm = findViewById(R.id.containerLoginForm); + mContainerLoginStatus = findViewById(R.id.containerLoginStatus); + + mLoginStatus = (TextView)findViewById(R.id.loginStatus); + + mInputUsername = (EditText)findViewById(R.id.inputUsername); + mInputPassword = (EditText)findViewById(R.id.inputPassword); + mInputHostname = (EditText)findViewById(R.id.inputHostname); + mInputPort = (EditText)findViewById(R.id.inputPort); + + mCheckUseTls = (CheckBox)findViewById(R.id.checkUseTls); + + findViewById(R.id.buttonConnect).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + tryAndConnect(); + } + }); + + findViewById(R.id.buttonCancel).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + tryAndCancel(); + } + }); + + doBindService(); + } + + @Override + protected void onDestroy() { + doUnbindService(); + super.onDestroy(); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + super.onCreateOptionsMenu(menu); + getMenuInflater().inflate(R.menu.login, menu); + return true; + } + + + + // Junk. + + private boolean mIsConnecting = false; + + private void tryAndConnect() { + if (mIsConnecting) + return; + + mInputUsername.setError(null); + mInputPassword.setError(null); + mInputHostname.setError(null); + mInputPort.setError(null); + + String username = mInputUsername.getText().toString(); + String password = mInputPassword.getText().toString(); + String hostname = mInputHostname.getText().toString(); + String portStr = mInputPort.getText().toString(); + int port = -1; + if (!TextUtils.isEmpty(portStr)) { + port = Integer.parseInt(portStr); + } + + boolean usesTls = mCheckUseTls.isChecked(); + + View errorView = null; + + //if (TextUtils.isEmpty(password)) { + // mInputPassword.setError("Required"); + // errorView = mInputPassword; + //} + if (TextUtils.isEmpty(username)) { + mInputUsername.setError("Required"); + errorView = mInputUsername; + } + + if (port < 0 || port > 65535) { + mInputPort.setError("Invalid"); + errorView = mInputPort; + } + if (TextUtils.isEmpty(hostname)) { + mInputHostname.setError("Required"); + errorView = mInputHostname; + } + + + if (errorView != null) { + errorView.requestFocus(); + } else { + mIsConnecting = true; + + mLoginStatus.setText("Starting up..."); + + showProgress(true); + + // start service so it'll remain around... + Intent i = new Intent(this, IRCService.class); + startService(i); + + Connection.get().connect(hostname, port, usesTls, username, password); + } + } + + private void tryAndCancel() { + Connection.get().disconnect(); + } + + + + public void handleLoginStateChanged() { + switch (Connection.get().getSocketState()) { + case DISCONNECTED: + mLoginStatus.setText("Disconnected."); + String details = Connection.get().getLoginError(); + if (details != null) { + Toast.makeText(this, details, Toast.LENGTH_LONG).show(); + Connection.get().clearLoginError(); + } + showProgress(false); + mIsConnecting = false; + break; + case DISCONNECTING: + mLoginStatus.setText("Disconnecting..."); + break; + case CONNECTING: + mLoginStatus.setText("Connecting..."); + break; + case CONNECTED: + if (Connection.get().getSessionActive()) { + mLoginStatus.setText("Connected!"); + + completeLoginScreen(); + } else { + mLoginStatus.setText("Logging in..."); + } + break; + } + } + + + private void showProgress(final boolean show) { + // Straight from the LoginActivity template! + // .... Almost. + + int shortAnimTime = getResources().getInteger(android.R.integer.config_shortAnimTime); + + mContainerLoginStatus.setVisibility(View.VISIBLE); + mContainerLoginStatus.animate() + .setDuration(shortAnimTime) + .alpha(show ? 1 : 0) + .setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + mContainerLoginStatus.setVisibility(show ? View.VISIBLE : View.INVISIBLE); + } + }); + + mContainerLoginForm.setVisibility(View.VISIBLE); + mContainerLoginForm.animate() + .setDuration(shortAnimTime) + .alpha(show ? 0 : 1) + .setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + mContainerLoginForm.setVisibility(show ? View.INVISIBLE : View.VISIBLE); + } + }); + } + + + private void completeLoginScreen() { + if (!isFinishing()) { + Intent i = new Intent(LoginActivity.this, MainActivity.class); + //i.setFlags(i.getFlags() | 0x2000); // enables halo intent, maybe? + startActivity(i); + finish(); + } + } + + + // SERVICE JUNK + // Not happy about having this both here and in MainActivity. + // Moof. + private IRCService mService = null; + + private final ServiceConnection mServiceConnection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName componentName, IBinder iBinder) { + Log.i("VulpIRC", "[LoginActivity connected to IRCService]"); + + mService = ((IRCService.LocalBinder)iBinder).getService(); + Connection.get().registerLoginStateListener(LoginActivity.this); + + if (Connection.get().getSessionActive()) { + // We have a session, just jump right into the client + completeLoginScreen(); + } else { + handleLoginStateChanged(); + if (Connection.get().getSocketState() == BaseConn.SocketState.DISCONNECTED) { + mContainerLoginForm.setVisibility(View.VISIBLE); + } else { + mContainerLoginStatus.setVisibility(View.VISIBLE); + } + } + } + + @Override + public void onServiceDisconnected(ComponentName componentName) { + Log.i("VulpIRC", "[LoginActivity disconnected from IRCService]"); + + Connection.get().deregisterLoginStateListener(LoginActivity.this); + mService = null; + } + }; + + private void doBindService() { + Log.i("VulpIRC", "[LoginActivity binding to IRCService...]"); + + Intent i = new Intent(this, IRCService.class); + bindService(i, mServiceConnection, BIND_AUTO_CREATE); + } + + private void doUnbindService() { + Log.i("VulpIRC", "[LoginActivity unbinding from IRCService...]"); + + unbindService(mServiceConnection); + }} diff --git a/android/VulpIRC/src/main/java/net/brokenfox/vulpirc/MainActivity.java b/android/VulpIRC/src/main/java/net/brokenfox/vulpirc/MainActivity.java new file mode 100644 index 0000000..0661d85 --- /dev/null +++ b/android/VulpIRC/src/main/java/net/brokenfox/vulpirc/MainActivity.java @@ -0,0 +1,340 @@ +package net.brokenfox.vulpirc; + +import android.content.Context; +import android.content.res.Configuration; +import android.content.res.TypedArray; +import android.support.v4.app.*; +import android.content.ComponentName; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.Bundle; +import android.os.IBinder; +import android.support.v4.view.ViewPager; +import android.support.v4.widget.DrawerLayout; +import android.util.Log; +import android.view.*; +import android.widget.*; + +public class MainActivity extends FragmentActivity implements Connection.ConnectionListener, Connection.LoginStateListener { + + private ListView mWindowList; + private ViewPager mWindowPager; + private WindowListAdapter mWindowListAdapter; + private WindowPagerAdapter mWindowPagerAdapter; + private DrawerLayout mDrawerLayout; + private ActionBarDrawerToggle mDrawerToggle; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + + mDrawerLayout = ((DrawerLayout)findViewById(R.id.drawerLayout)); + mDrawerToggle = new ActionBarDrawerToggle(this, mDrawerLayout, + R.drawable.ic_drawer, + R.string.drawer_open, R.string.drawer_close) { + @Override + public void onDrawerOpened(View drawerView) { + super.onDrawerOpened(drawerView); + getActionBar().setTitle("VulpIRC"); + invalidateOptionsMenu(); + } + + @Override + public void onDrawerClosed(View drawerView) { + super.onDrawerClosed(drawerView); + + fixActionBarTitle(); + invalidateOptionsMenu(); + } + }; + mDrawerLayout.setDrawerListener(mDrawerToggle); + mDrawerLayout.setDrawerShadow(R.drawable.drawer_shadow, Gravity.LEFT); + + getActionBar().setDisplayHomeAsUpEnabled(true); + getActionBar().setHomeButtonEnabled(true); + + mWindowList = ((ListView)findViewById(R.id.windowList)); + mWindowList.setChoiceMode(AbsListView.CHOICE_MODE_SINGLE); + mWindowList.setOnItemClickListener(new WindowListItemClickListener()); + mWindowList.setBackgroundColor(0x60000000); + mWindowListAdapter = new WindowListAdapter(); + + mWindowPager = ((ViewPager)findViewById(R.id.windowPager)); + mWindowPager.setOnPageChangeListener(new WindowPagerPageChangeListener()); + mWindowPagerAdapter = new WindowPagerAdapter(getSupportFragmentManager()); + + doBindService(); + } + + @Override + protected void onPostCreate(Bundle savedInstanceState) { + super.onPostCreate(savedInstanceState); + mDrawerToggle.syncState(); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + mDrawerToggle.onConfigurationChanged(newConfig); + } + + @Override + protected void onDestroy() { + if (mService != null) { + Connection.get().deregisterListener(this); + Connection.get().deregisterLoginStateListener(this); + } + doUnbindService(); + super.onDestroy(); + } + + @Override + public void handleWindowsUpdated() { + mWindowPagerAdapter.notifyDataSetChanged(); + mWindowListAdapter.notifyDataSetChanged(); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.main, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (mDrawerToggle.onOptionsItemSelected(item)) + return true; + + switch (item.getItemId()) { + case R.id.action_settings: + return true; + + case R.id.actionLogOut: + Connection.get().disconnect(); + return true; + } + return super.onOptionsItemSelected(item); + } + + + private void fixActionBarTitle() { + if (mDrawerLayout.isDrawerOpen(mWindowList)) + return; + + WindowData w = Connection.get().getActiveWindow(); + if (w == null) + getActionBar().setTitle("VulpIRC"); + else + getActionBar().setTitle(w.title); + } + + + @Override + public void handleLoginStateChanged() { + BaseConn.SocketState s = Connection.get().getSocketState(); + switch (s) { + case CONNECTED: + getActionBar().setSubtitle("Connected!"); + break; + case CONNECTING: + getActionBar().setSubtitle("Connecting..."); + break; + case DISCONNECTED: + getActionBar().setSubtitle("Disconnected."); + break; + case DISCONNECTING: + getActionBar().setSubtitle("Disconnecting..."); + break; + } + } + + private class WindowListItemClickListener implements AdapterView.OnItemClickListener { + @Override + public void onItemClick(AdapterView adapterView, View view, int position, long id) { + mWindowPager.setCurrentItem(position); + mWindowList.setItemChecked(position, true); + } + } + + private class WindowPagerPageChangeListener extends ViewPager.SimpleOnPageChangeListener { + @Override + public void onPageSelected(int position) { + mWindowList.setItemChecked(position, true); + if (Connection.get().getActiveWindow() != null) + Log.i("VulpIRC", "Boop1 : " + Connection.get().getActiveWindow().title); + + if (position == 0) + Connection.get().setActiveWindow(Connection.get().statusWindow); + else + Connection.get().setActiveWindow(Connection.get().windows.get(position - 1)); + + Log.i("VulpIRC", "Boop2 : " + Connection.get().getActiveWindow().title); + fixActionBarTitle(); + } + } + + + private class WindowListItemView extends LinearLayout { + private ImageView mStatusView; + private TextView mTitleView; + + public WindowListItemView(Context context) { + super(context); + + setOrientation(HORIZONTAL); + + mStatusView = new ImageView(context); + mStatusView.setImageDrawable(getResources().getDrawable(R.drawable.window_status)); + mTitleView = new TextView(context); + mTitleView.setGravity(0x800003|0x10); // start|center_vertical + + float density = getResources().getDisplayMetrics().density; + setLayoutParams(new AbsListView.LayoutParams(LayoutParams.MATCH_PARENT, (int) (36 * density + 0.5f))); + + int statusSize = (int)(8 * density + 0.5f); + LayoutParams statuslp = new LayoutParams(statusSize, LayoutParams.MATCH_PARENT); + + int titlePadding = (int)(16 * density + 0.5f); + mTitleView.setPadding(titlePadding, 0, 0, 0); + + addView(mStatusView, statuslp); + addView(mTitleView, new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT)); + + // I really don't like this. + TypedArray a = getTheme().obtainStyledAttributes(new int[] { android.R.attr.activatedBackgroundIndicator }); + //setBackground(getResources().getDrawable(android.R.attr.activatedBackgroundIndicator)); + setBackgroundDrawable(a.getDrawable(0)); + a.recycle(); + } + + public void setPlaceholder() { + mTitleView.setText("Placeholder"); + } + public void setWindow(WindowData wd) { + mTitleView.setText(wd.title); + mStatusView.setImageLevel(wd.unreadLevel); + } + } + + private class WindowListAdapter extends BaseAdapter implements ListAdapter { + @Override + public int getCount() { + return 1 + Connection.get().windows.size(); + } + + @Override + public Object getItem(int i) { + if (i == 0) + return "boop"; + else + return Connection.get().windows.get(i - 1); + } + + @Override + public long getItemId(int i) { + if (i == 0) + return -1; + else + return Connection.get().windows.get(i - 1).id; + } + + @Override + public View getView(int i, View view, ViewGroup viewGroup) { + WindowListItemView iv; + + if (view != null && view instanceof WindowListItemView) { + iv = (WindowListItemView)view; + } else { + iv = new WindowListItemView(viewGroup.getContext()); + } + + if (i == 0) + iv.setWindow(Connection.get().statusWindow); + else + iv.setWindow(Connection.get().windows.get(i - 1)); + return iv; + } + } + + private class WindowPagerAdapter extends FragmentPagerAdapter { + WindowPagerAdapter(FragmentManager fm) { + super(fm); + } + + @Override + public Fragment getItem(int i) { + WindowData window = null; + if (i == 0) + window = Connection.get().statusWindow; + else + window = Connection.get().windows.get(i - 1); + + return window.createFragment(); + } + + @Override + public long getItemId(int position) { + if (position == 0) + return -1; + else + return Connection.get().windows.get(position - 1).id; + } + + @Override + public CharSequence getPageTitle(int position) { + if (position == 0) + return "Status"; + else + return Connection.get().windows.get(position - 1).title; + } + + @Override + public int getCount() { + return 1 + Connection.get().windows.size(); + } + } + + + + + // SERVICE JUNK + private IRCService mService = null; + + private final ServiceConnection mServiceConnection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName componentName, IBinder iBinder) { + Log.i("VulpIRC", "[MainActivity connected to IRCService]"); + + mService = ((IRCService.LocalBinder)iBinder).getService(); + Connection.get().registerListener(MainActivity.this); + Connection.get().registerLoginStateListener(MainActivity.this); + + mWindowList.setAdapter(mWindowListAdapter); + mWindowPager.setAdapter(mWindowPagerAdapter); + + handleLoginStateChanged(); + } + + @Override + public void onServiceDisconnected(ComponentName componentName) { + Log.i("VulpIRC", "[MainActivity disconnected from IRCService]"); + + mService = null; + } + }; + + private void doBindService() { + Log.i("VulpIRC", "[MainActivity binding to IRCService...]"); + + Intent i = new Intent(this, IRCService.class); + bindService(i, mServiceConnection, BIND_AUTO_CREATE); + } + + private void doUnbindService() { + Log.i("VulpIRC", "[MainActivity unbinding from IRCService...]"); + + unbindService(mServiceConnection); + } + +} diff --git a/android/VulpIRC/src/main/java/net/brokenfox/vulpirc/RichText.java b/android/VulpIRC/src/main/java/net/brokenfox/vulpirc/RichText.java new file mode 100644 index 0000000..dbe6f6b --- /dev/null +++ b/android/VulpIRC/src/main/java/net/brokenfox/vulpirc/RichText.java @@ -0,0 +1,236 @@ +package net.brokenfox.vulpirc; + +import android.graphics.Typeface; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.style.BackgroundColorSpan; +import android.text.style.ForegroundColorSpan; +import android.text.style.StyleSpan; +import android.text.style.UnderlineSpan; + +import java.util.HashMap; + +/** + * Created by ninji on 2/14/14. + */ +public class RichText { + private final static int[] presetColors = new int[] { + // IRC colours + 0xFFFFFF, 0x000000, 0x000080, 0x008000, + 0xFF0000, 0x800000, 0x800080, 0x808000, + 0xFFFF00, 0x00FF00, 0x008080, 0x00FFFF, + 0x0000FF, 0xFF00FF, 0x808080, 0xC0C0C0, + // Default FG, BG + 0xFFFFFF, 0x000000, + // Preset message colours + 0x6600CC, 0x009900, 0x660000, 0x660000, + 0x660000, 0x336699, + }; + + private final static StyleSpan boldSpan = new StyleSpan(Typeface.BOLD); + private final static StyleSpan italicSpan = new StyleSpan(Typeface.ITALIC); + private final static UnderlineSpan underlineSpan = new UnderlineSpan(); + private static ForegroundColorSpan[] presetForegroundSpans = null; + private static BackgroundColorSpan[] presetBackgroundSpans = null; + private static HashMap cachedForegroundSpans = new HashMap(); + private static HashMap cachedBackgroundSpans = new HashMap(); + + private static void setupPresets() { + presetForegroundSpans = new ForegroundColorSpan[presetColors.length]; + presetBackgroundSpans = new BackgroundColorSpan[presetColors.length]; + + for (int i = 0; i < presetColors.length; i++) { + presetForegroundSpans[i] = new ForegroundColorSpan(0xFF000000 | presetColors[i]); + presetBackgroundSpans[i] = new BackgroundColorSpan(0xFF000000 | presetColors[i]); + } + } + + // This is kinda kludgey but... I don't want to make two extra allocations + // every time I process a string. + // So here. + private static ForegroundColorSpan[] currentFgSpans = new ForegroundColorSpan[4]; + private static BackgroundColorSpan[] currentBgSpans = new BackgroundColorSpan[4]; + + public static Spanned process(String source) { + if (presetForegroundSpans == null) + setupPresets(); + + SpannableStringBuilder s = new SpannableStringBuilder(); + + int boldStart = -1; + int italicStart = -1; + int underlineStart = -1; + int fgStart = -1, bgStart = -1; + ForegroundColorSpan fgSpan = null; + BackgroundColorSpan bgSpan = null; + int rawTextStart = -1; + + for (int i = 0; i < 4; i++) { + currentFgSpans[i] = null; + currentBgSpans[i] = null; + } + + int in = 0, out = 0; + + for (in = 0; in < source.length(); in++) { + char c = source.charAt(in); + if (c >= 0 && c <= 0x1F) { + // Control code! + if (rawTextStart != -1) { + s.append(source, rawTextStart, in); + rawTextStart = -1; + } + + // Process the code... + if (c == 1 && boldStart == -1) { + boldStart = out; + } else if (c == 2 && boldStart > -1) { + s.setSpan(boldSpan, boldStart, out, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + boldStart = -1; + + } else if (c == 3 && italicStart == -1) { + italicStart = out; + } else if (c == 4 && italicStart > -1) { + s.setSpan(italicSpan, italicStart, out, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + italicStart = -1; + + } else if (c == 5 && underlineStart == -1) { + underlineStart = out; + } else if (c == 6 && underlineStart > -1) { + s.setSpan(underlineSpan, underlineStart, out, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + underlineStart = -1; + + } else if (c >= 0x10 && c <= 0x1F) { + boolean isBG = ((c & 4) == 4); + boolean isEnd = ((c & 8) == 8); + int layer = (c & 3); + + // Read what comes afterwards, to decide what we're doing + Object chosenSpan = null; + + if (!isEnd && ((in + 1) < source.length())) { + char first = source.charAt(in + 1); + + if ((first & 1) == 1) { + // Preset colour + if (isBG) + chosenSpan = presetBackgroundSpans[first >> 1]; + else + chosenSpan = presetForegroundSpans[first >> 1]; + in++; // Skip the extra + + } else if ((in + 3) < source.length()) { + // RGB colour + int r = first; + int g = source.charAt(in + 2); + int b = source.charAt(in + 3); + int col = 0xFF000000 | (r << 17) | (g << 9) | (b << 1); + + if (isBG) { + if (cachedBackgroundSpans.containsKey(col)) { + chosenSpan = cachedBackgroundSpans.get(col); + } else { + chosenSpan = new BackgroundColorSpan(col); + cachedBackgroundSpans.put(col, (BackgroundColorSpan)chosenSpan); + } + } else { + if (cachedForegroundSpans.containsKey(col)) { + chosenSpan = cachedForegroundSpans.get(col); + } else { + chosenSpan = new ForegroundColorSpan(col); + cachedForegroundSpans.put(col, (ForegroundColorSpan)chosenSpan); + } + } + + in += 3; // Skip the extra + } + } + + // OK, are we actually changing? + Object oldSpan, newSpan = null; + + if (isBG) + oldSpan = bgSpan; + else + oldSpan = fgSpan; + + // Modify the array, and figure out our new active span + if (isBG) { + if (isEnd) + currentBgSpans[layer] = null; + else + currentBgSpans[layer] = (BackgroundColorSpan)chosenSpan; + + for (int i = 0; i < 4; i++) + if (currentBgSpans[i] != null) + newSpan = currentBgSpans[i]; + + } else { + if (isEnd) + currentFgSpans[layer] = null; + else + currentFgSpans[layer] = (ForegroundColorSpan)chosenSpan; + + for (int i = 0; i < 4; i++) + if (currentFgSpans[i] != null) + newSpan = currentFgSpans[i]; + } + + // If we changed spans.... + if (oldSpan != newSpan) { + // End the existing span, no matter what... + if (isBG) { + if (bgStart > -1) { + s.setSpan(bgSpan, bgStart, out, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + bgStart = -1; + } + } else { + if (fgStart > -1) { + s.setSpan(fgSpan, fgStart, out, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + fgStart = -1; + } + } + + // .. and if we have another span, add it! + if (newSpan != null) { + // Preset colour + if (isBG) { + bgSpan = (BackgroundColorSpan)newSpan; + bgStart = out; + } else { + fgSpan = (ForegroundColorSpan)newSpan; + fgStart = out; + } + } + } + } + + } else { + // Regular character. + if (rawTextStart == -1) { + rawTextStart = in; + } + out++; + } + } + + // Anything left? + if (rawTextStart != -1) { + s.append(source, rawTextStart, source.length()); + } + + // Any un-applied spans? + if (boldStart > -1) + s.setSpan(boldSpan, boldStart, out, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + if (italicStart > -1) + s.setSpan(italicSpan, italicStart, out, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + if (underlineStart > -1) + s.setSpan(underlineSpan, underlineStart, out, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + if (bgStart > -1) + s.setSpan(bgSpan, bgStart, out, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + if (fgStart > -1) + s.setSpan(fgSpan, fgStart, out, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + return s; + } +} diff --git a/android/VulpIRC/src/main/java/net/brokenfox/vulpirc/Util.java b/android/VulpIRC/src/main/java/net/brokenfox/vulpirc/Util.java new file mode 100644 index 0000000..c5dbff1 --- /dev/null +++ b/android/VulpIRC/src/main/java/net/brokenfox/vulpirc/Util.java @@ -0,0 +1,62 @@ +package net.brokenfox.vulpirc; + +import android.util.Log; + +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.Charset; +import java.nio.charset.CharsetDecoder; +import java.nio.charset.CharsetEncoder; + +/** + * Created by ninji on 2/3/14. + */ +public class Util { + + private static CharsetDecoder mUTF8Decoder = null; + private static CharsetEncoder mUTF8Encoder = null; + + public static synchronized String readStringFromBuffer(ByteBuffer p) { + if (mUTF8Decoder == null) + mUTF8Decoder = Charset.forName("UTF-8").newDecoder(); + + int size = p.getInt(); + if (size <= 0) + return ""; + + int beginPos = p.position(); + int endPos = beginPos + size; + int saveLimit = p.limit(); + p.limit(endPos); + + String result = ""; + + try { + result = mUTF8Decoder.decode(p).toString(); + } catch (CharacterCodingException e) { + Log.e("VulpIRC", "Utils.readStringFromBuffer caught decode exception:"); + Log.e("VulpIRC", e.toString()); + } + + p.limit(saveLimit); + p.position(endPos); + + return result; + } + + public static synchronized byte[] encodeString(CharSequence s) { + if (mUTF8Encoder == null) + mUTF8Encoder = Charset.forName("UTF-8").newEncoder(); + + try { + return mUTF8Encoder.encode(CharBuffer.wrap(s)).array(); + } catch (CharacterCodingException e) { + Log.e("VulpIRC", "Utils.encodeString caught encode exception:"); + Log.e("VulpIRC", e.toString()); + + return new byte[0]; + } + } + +} diff --git a/android/VulpIRC/src/main/java/net/brokenfox/vulpirc/WindowData.java b/android/VulpIRC/src/main/java/net/brokenfox/vulpirc/WindowData.java new file mode 100644 index 0000000..09c6606 --- /dev/null +++ b/android/VulpIRC/src/main/java/net/brokenfox/vulpirc/WindowData.java @@ -0,0 +1,106 @@ +package net.brokenfox.vulpirc; + +import android.os.Bundle; +import android.support.v4.app.Fragment; +import android.text.format.Time; +import android.util.Log; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.text.*; +import java.util.ArrayList; +import java.util.Date; + +/** + * Created by ninji on 2/3/14. + */ +public class WindowData { + public int id; + public String title; + public int unreadLevel = 0; + + public ArrayList messages = new ArrayList(); + + protected Fragment instantiateFragmentClass() { + return new WindowFragment(); + } + public Fragment createFragment() { + Bundle args = new Bundle(); + args.putInt("winID", id); + + Fragment wf = instantiateFragmentClass(); + wf.setArguments(args); + return wf; + } + + public void setUnreadLevel(int newLevel) { + unreadLevel = newLevel; + Connection.get().notifyWindowsUpdated(); + } + + public void setTitle(String newTitle) { + title = newTitle; + Connection.get().notifyWindowsUpdated(); + } + + + public interface WindowListener { + void handleMessagesChanged(); + } + + protected ArrayList mListeners = new ArrayList(); + + public void registerListener(WindowListener l) { + mListeners.add(l); + } + public void deregisterListener(WindowListener l) { + mListeners.remove(l); + } + + + + // This is a kludge. + private final DateFormat timestampFormat = new SimpleDateFormat("\u0001[\u0002\u0010\u001DHH:mm:ss\u0018\u0001]\u0002 ", new DateFormatSymbols()); + private String generateTimestamp() { + return timestampFormat.format(new Date()); + } + + public void pushMessage(String message) { + messages.add(RichText.process(generateTimestamp() + message)); + for (WindowListener l : mListeners) + l.handleMessagesChanged(); + } + + + public void processInitialSync(ByteBuffer p) { + id = p.getInt(); + title = Util.readStringFromBuffer(p); + + int messageCount = p.getInt(); + Log.i("VulpIRC", "id=" + id + ", title=[" + title + "]"); + + if (messageCount > 0) { + messages.ensureCapacity(messageCount); + for (int j = 0; j < messageCount; j++) { + String msg = Util.readStringFromBuffer(p); + //Log.i("VulpIRC", "msg " + j + ": " + msg); + messages.add(RichText.process(msg)); + } + } + } + + + + public void sendUserInput(CharSequence message) { + byte[] enc = Util.encodeString(message); + + ByteBuffer buf = ByteBuffer.allocate(8 + enc.length); + buf.order(ByteOrder.LITTLE_ENDIAN); + + buf.putInt(id); + buf.putInt(enc.length); + buf.put(enc); + + Connection.get().sendPacket(0x102, buf.array()); + } +} diff --git a/android/VulpIRC/src/main/java/net/brokenfox/vulpirc/WindowFragment.java b/android/VulpIRC/src/main/java/net/brokenfox/vulpirc/WindowFragment.java new file mode 100644 index 0000000..da89e03 --- /dev/null +++ b/android/VulpIRC/src/main/java/net/brokenfox/vulpirc/WindowFragment.java @@ -0,0 +1,149 @@ +package net.brokenfox.vulpirc; + +import android.app.Activity; +import android.content.Context; +import android.support.v4.app.Fragment; +import android.os.Bundle; +import android.text.InputType; +import android.util.Log; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.inputmethod.EditorInfo; +import android.widget.*; + +/** + * Created by ninji on 2/3/14. + */ +public class WindowFragment extends Fragment implements WindowData.WindowListener, TextView.OnEditorActionListener { + private MessagesAdapter mMessagesAdapter; + private ListView mMessagesList; + private EditText mInput; + protected WindowData mData = null; + + public WindowFragment() { + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + Context ctx = container.getContext(); + + LinearLayout l = new LinearLayout(ctx); + l.setOrientation(LinearLayout.VERTICAL); + + float density = getResources().getDisplayMetrics().density; + int padding = (int)(4 * density + 0.5f); + + mMessagesList = new ListView(ctx); + mMessagesList.setStackFromBottom(true); + mMessagesList.setTranscriptMode(AbsListView.TRANSCRIPT_MODE_NORMAL); + mMessagesList.setDivider(null); + mMessagesList.setDividerHeight(0); + mMessagesList.setPadding(padding, padding, padding, padding); + + mInput = new EditText(ctx); + mInput.setImeOptions(EditorInfo.IME_ACTION_SEND | EditorInfo.IME_FLAG_NO_FULLSCREEN); + mInput.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_NORMAL); + mInput.setOnEditorActionListener(this); + + LinearLayout.LayoutParams mlParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT); + mlParams.weight = 1; + + LinearLayout.LayoutParams inputParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT); + + l.addView(mMessagesList, mlParams); + l.addView(mInput, inputParams); + + return l; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setRetainInstance(true); + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + Bundle b = getArguments(); + int winID = b.getInt("winID"); + mData = Connection.get().findWindowByID(winID); + + mMessagesAdapter = new MessagesAdapter(); + mMessagesList.setAdapter(mMessagesAdapter); + + mData.registerListener(this); + } + + @Override + public void onDestroy() { + if (mData != null) + mData.deregisterListener(this); + super.onDestroy(); + } + + + @Override + public void handleMessagesChanged() { + mMessagesAdapter.notifyDataSetChanged(); + } + + @Override + public boolean onEditorAction(TextView textView, int i, KeyEvent keyEvent) { + if (i == EditorInfo.IME_ACTION_SEND) { + sendEnteredThing(); + return true; + } + + return false; + } + + + + private void sendEnteredThing() { + CharSequence text = mInput.getText().toString(); + mInput.getText().clear(); + + mData.sendUserInput(text); + } + + + + private class MessagesAdapter extends BaseAdapter implements ListAdapter { + @Override + public int getCount() { + return mData.messages.size(); + } + + @Override + public Object getItem(int i) { + return mData.messages.get(i); + } + + @Override + public long getItemId(int i) { + return i; + } + + @Override + public View getView(int i, View view, ViewGroup viewGroup) { + TextView tv; + + if (view != null && view instanceof TextView) { + tv = (TextView)view; + } else { + tv = new TextView(viewGroup.getContext()); + } + + tv.setText(mData.messages.get(i)); + return tv; + } + } +} \ No newline at end of file -- cgit v1.2.3